Compare commits
843 Commits
v0.5.9
...
assets/pr-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78a4d3a2c | ||
|
|
6c53ef4eff | ||
|
|
60eb696859 | ||
|
|
e456925c23 | ||
|
|
038ecc8b70 | ||
|
|
3bfcd15dc0 | ||
|
|
6abdb8684f | ||
|
|
080ae0986a | ||
|
|
bfb61c8449 | ||
|
|
23d7511f55 | ||
|
|
2d73bcc6de | ||
|
|
a24f4a2bc1 | ||
|
|
bf51003c66 | ||
|
|
2df9dce78b | ||
|
|
3ed45fab41 | ||
|
|
7c9cf95698 | ||
|
|
af2c358822 | ||
|
|
1226e544ec | ||
|
|
faa7ed1ae2 | ||
|
|
22cdeb677b | ||
|
|
b210d078d0 | ||
|
|
ce30de20b6 | ||
|
|
8dde4c3c6d | ||
|
|
bdb60a656a | ||
|
|
8a56367d02 | ||
|
|
aebe9bab54 | ||
|
|
9ab31a7614 | ||
|
|
f6556f25d5 | ||
|
|
16a8a763f4 | ||
|
|
4b1cd1b727 | ||
|
|
37ccaf7743 | ||
|
|
06a984f39d | ||
|
|
6d1c034052 | ||
|
|
672d05d124 | ||
|
|
322d0a7cb8 | ||
|
|
5725e78931 | ||
|
|
69b6072e37 | ||
|
|
1a9d417c0a | ||
|
|
8a552c4cb3 | ||
|
|
9da9a36cf3 | ||
|
|
d08ab62f92 | ||
|
|
1cb112bcdf | ||
|
|
2fb4851d1f | ||
|
|
32c3ba017e | ||
|
|
d09b968cc0 | ||
|
|
90d84da849 | ||
|
|
d2c4160c6d | ||
|
|
2b4beae9df | ||
|
|
7dab6f2e33 | ||
|
|
1aab48783d | ||
|
|
5089e6b294 | ||
|
|
3be08cb6cd | ||
|
|
6c4833dcd1 | ||
|
|
ce17629a8d | ||
|
|
5e15c4dd2d | ||
|
|
c04d82ee30 | ||
|
|
7962aea008 | ||
|
|
5493b62bb9 | ||
|
|
86c39ac36f | ||
|
|
6bf05c9ed7 | ||
|
|
a2c1b4a7d8 | ||
|
|
07b3b908f9 | ||
|
|
71989af586 | ||
|
|
adacf0b5c5 | ||
|
|
09ae3d74c4 | ||
|
|
839fdd7d66 | ||
|
|
b0a9a995fb | ||
|
|
3205d131c9 | ||
|
|
52d193b2e8 | ||
|
|
d9e52c734a | ||
|
|
3ce85617da | ||
|
|
0ba984b277 | ||
|
|
8da8cc7f91 | ||
|
|
bc63311003 | ||
|
|
3a00ae1f44 | ||
|
|
e8cad189be | ||
|
|
495a985ae1 | ||
|
|
8f1e6cf379 | ||
|
|
05e8dab710 | ||
|
|
0ba2bfe645 | ||
|
|
f282da3bcb | ||
|
|
eba689754c | ||
|
|
d13c153f5e | ||
|
|
4999fd544d | ||
|
|
36233ba9aa | ||
|
|
4408bce159 | ||
|
|
e7b8e78f9c | ||
|
|
29e7e365f1 | ||
|
|
8ea7ecc477 | ||
|
|
99b75378c3 | ||
|
|
5f56859898 | ||
|
|
7f2445a6f5 | ||
|
|
b3a54b3ff8 | ||
|
|
c8c8497a2f | ||
|
|
04019135a0 | ||
|
|
e7935db84b | ||
|
|
0f67305100 | ||
|
|
6179c3fbd9 | ||
|
|
13705f9098 | ||
|
|
1dea343aa2 | ||
|
|
293a8ff3e9 | ||
|
|
6e422aea33 | ||
|
|
39e52469f2 | ||
|
|
3ff5141184 | ||
|
|
db31513c0b | ||
|
|
5da2c7ff1a | ||
|
|
d109f2891f | ||
|
|
f946951580 | ||
|
|
f6ebfc2e44 | ||
|
|
540dbc2a28 | ||
|
|
2c8128724e | ||
|
|
528f3c51a0 | ||
|
|
a4d94624cd | ||
|
|
87bd16c4ba | ||
|
|
dc54d24d2b | ||
|
|
8457f6c4b7 | ||
|
|
a2d83744b5 | ||
|
|
946450874f | ||
|
|
577a417292 | ||
|
|
a74065bdbb | ||
|
|
0c320234fd | ||
|
|
f5ae2e51f9 | ||
|
|
8e24e40fdd | ||
|
|
85648b1e5a | ||
|
|
b997788437 | ||
|
|
542bafe6c4 | ||
|
|
98965a56e1 | ||
|
|
21c427bc39 | ||
|
|
6b67bb24b4 | ||
|
|
06dd9507ee | ||
|
|
b588235b62 | ||
|
|
f457f6aaca | ||
|
|
c8fe90cbee | ||
|
|
6bd87fa568 | ||
|
|
293fc6e0fe | ||
|
|
2a8ae05363 | ||
|
|
9613f6b624 | ||
|
|
ee78b9b57c | ||
|
|
5ce4dddd7a | ||
|
|
e67285fde1 | ||
|
|
4e31d47936 | ||
|
|
954d126a8f | ||
|
|
5b31ab7435 | ||
|
|
b3c321be67 | ||
|
|
d7ad83f0d5 | ||
|
|
3006429a9a | ||
|
|
9364c48ef0 | ||
|
|
3e140c1bc6 | ||
|
|
7ff3e00759 | ||
|
|
0632c5242c | ||
|
|
76b0163bd3 | ||
|
|
6421662f5d | ||
|
|
54195e0591 | ||
|
|
5fc29a6fd3 | ||
|
|
f41a15c7b8 | ||
|
|
938bc53966 | ||
|
|
093b3cae1f | ||
|
|
0816702084 | ||
|
|
c70eb7157f | ||
|
|
23f95d7dc8 | ||
|
|
682017ba96 | ||
|
|
891c8c1200 | ||
|
|
a611c1c04b | ||
|
|
558966a129 | ||
|
|
2f354d2267 | ||
|
|
eca9601ab0 | ||
|
|
0b9f0448c8 | ||
|
|
675aae16e9 | ||
|
|
9b0e7937f9 | ||
|
|
03eb26d999 | ||
|
|
f2ffeeaf45 | ||
|
|
d7632e29a6 | ||
|
|
a750266e1c | ||
|
|
5f892d29c8 | ||
|
|
f3e11961dc | ||
|
|
9e224d0067 | ||
|
|
3da3a3fb13 | ||
|
|
70b469d349 | ||
|
|
5310ec7c44 | ||
|
|
6cb5998cd6 | ||
|
|
6bbe5ad30d | ||
|
|
cd6d034d6c | ||
|
|
df3a49becf | ||
|
|
ca9eb65fdd | ||
|
|
8d5a24992a | ||
|
|
7a85c30752 | ||
|
|
0fa8afd517 | ||
|
|
d805f288ae | ||
|
|
12fbc7ecf4 | ||
|
|
0ff17dc27c | ||
|
|
d2f68acae8 | ||
|
|
f3dfffb8d1 | ||
|
|
c805b16fcd | ||
|
|
56126e22f2 | ||
|
|
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 |
BIN
.github/pr-597-screenshots/current-dev-sidebar-hover-card-no-native-title.jpg
vendored
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
.github/pr-597-screenshots/current-dev-sidebar-hover-card.jpg
vendored
Normal file
|
After Width: | Height: | Size: 41 KiB |
1206
.github/workflows/dev-build.yml
vendored
Normal file
25
.github/workflows/release-winget.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: Publish to WinGet
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
required: true
|
||||
description: 'Tag of release you want to publish'
|
||||
type: string
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: windows-latest
|
||||
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 }}
|
||||
719
.github/workflows/release.yml
vendored
@@ -8,13 +8,211 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
name: Build frontend
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Cache frontend node_modules
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: frontend/node_modules
|
||||
key: ${{ runner.os }}-node20-frontend-${{ hashFiles('frontend/package-lock.json') }}
|
||||
|
||||
- name: Install Wails
|
||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
|
||||
- name: Build frontend dist
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p frontend/dist
|
||||
printf '<!doctype html><title>GoNavi</title>\n' > frontend/dist/index.html
|
||||
wails generate module
|
||||
node frontend/scripts/wails-frontend-install.mjs
|
||||
npm --prefix frontend run build
|
||||
|
||||
- name: Pack frontend dist
|
||||
shell: bash
|
||||
run: tar -cf frontend-dist.tar -C frontend/dist .
|
||||
|
||||
- name: Upload frontend dist
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend-dist.tar
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
driver_agents:
|
||||
name: Detect changed driver agents
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
drivers: ${{ steps.detect.outputs.drivers }}
|
||||
has_changes: ${{ steps.detect.outputs.has_changes }}
|
||||
release_source: ${{ steps.detect.outputs.release_source }}
|
||||
compare_base: ${{ steps.detect.outputs.compare_base }}
|
||||
force_global_driver_builds: ${{ steps.detect.outputs.force_global_driver_builds }}
|
||||
published_source_commit: ${{ steps.published_source.outputs.source_commit }}
|
||||
has_manifest: ${{ steps.published_source.outputs.has_manifest }}
|
||||
manifest_valid: ${{ steps.published_source.outputs.manifest_valid }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve published driver release source
|
||||
id: published_source
|
||||
env:
|
||||
DRIVER_RELEASE_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${GITHUB_SHA}^" 2>/dev/null || true)"
|
||||
manifest_path="$RUNNER_TEMP/published-driver-manifest.json"
|
||||
if [[ -z "$PREV_TAG" ]]; then
|
||||
echo "source_commit=" >> "$GITHUB_OUTPUT"
|
||||
echo "has_manifest=false" >> "$GITHUB_OUTPUT"
|
||||
echo "manifest_valid=false" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 No previous stable tag found; full rebuild will be used"
|
||||
exit 0
|
||||
fi
|
||||
SOURCE_COMMIT="$(python3 tools/resolve-driver-release-source.py --repo Syngnat/GoNavi-DriverAgents --tag "$PREV_TAG" --manifest-output "$manifest_path")"
|
||||
echo "source_commit=${SOURCE_COMMIT}" >> "$GITHUB_OUTPUT"
|
||||
if [[ -s "$manifest_path" ]]; then
|
||||
echo "has_manifest=true" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 Published driver release exposes revision manifest"
|
||||
else
|
||||
echo "has_manifest=false" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 Published driver release has no revision manifest"
|
||||
fi
|
||||
if [[ -n "$SOURCE_COMMIT" && -s "$manifest_path" ]]; then
|
||||
if bash ./tools/validate-driver-release-manifest.sh --commit "$SOURCE_COMMIT" --manifest "$manifest_path"; then
|
||||
if python3 tools/validate-driver-release-assets.py --repo Syngnat/GoNavi-DriverAgents --tag "$PREV_TAG"; then
|
||||
echo "manifest_valid=true" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 Published driver release manifest and actual assets are consistent with its source commit"
|
||||
else
|
||||
echo "manifest_valid=false" >> "$GITHUB_OUTPUT"
|
||||
echo "⚠️ Published driver release assets do not match manifest; forcing full rebuild"
|
||||
fi
|
||||
else
|
||||
echo "manifest_valid=false" >> "$GITHUB_OUTPUT"
|
||||
echo "⚠️ Published driver release manifest is stale; forcing full rebuild"
|
||||
fi
|
||||
else
|
||||
echo "manifest_valid=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
if [[ -n "$SOURCE_COMMIT" ]]; then
|
||||
echo "🧭 Last published release source commit (${PREV_TAG}): $SOURCE_COMMIT"
|
||||
else
|
||||
echo "🧭 Unable to resolve published release source commit for ${PREV_TAG}; fallback to previous tag diff"
|
||||
fi
|
||||
|
||||
- name: Detect changed driver agents
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
merge_csv() {
|
||||
local current="$1"
|
||||
local incoming="$2"
|
||||
local merged="$current"
|
||||
local seen=",${current},"
|
||||
local item
|
||||
IFS=',' read -r -a items <<< "$incoming"
|
||||
for item in "${items[@]}"; do
|
||||
item="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
if [[ -z "$item" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$seen" == *",$item,"* ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ -n "$merged" ]]; then
|
||||
merged="${merged},${item}"
|
||||
else
|
||||
merged="$item"
|
||||
fi
|
||||
seen="${seen}${item},"
|
||||
done
|
||||
printf '%s\n' "$merged"
|
||||
}
|
||||
|
||||
git fetch --force --tags
|
||||
PREV_TAG="$(git describe --tags --match 'v*' --abbrev=0 "${GITHUB_SHA}^" 2>/dev/null || true)"
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
BASE_REF="$PREV_TAG"
|
||||
RELEASE_SOURCE="$PREV_TAG"
|
||||
else
|
||||
BASE_REF="all"
|
||||
RELEASE_SOURCE="all"
|
||||
fi
|
||||
HAS_MANIFEST="${{ steps.published_source.outputs.has_manifest }}"
|
||||
MANIFEST_VALID="${{ steps.published_source.outputs.manifest_valid }}"
|
||||
if [[ "$RELEASE_SOURCE" != "all" && "$HAS_MANIFEST" != "true" ]]; then
|
||||
echo "⚠️ Published driver release lacks revision manifest; forcing full rebuild to self-heal old assets"
|
||||
BASE_REF="all"
|
||||
RELEASE_SOURCE="all"
|
||||
elif [[ "$RELEASE_SOURCE" != "all" && "$MANIFEST_VALID" != "true" ]]; then
|
||||
echo "⚠️ Published driver release manifest is stale; forcing full rebuild to self-heal old assets"
|
||||
BASE_REF="all"
|
||||
RELEASE_SOURCE="all"
|
||||
fi
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
|
||||
if [[ "$BASE_REF" != "all" ]]; then
|
||||
REVISION_DRIVERS=""
|
||||
for PLATFORM in darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 linux/amd64; do
|
||||
PLATFORM_DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "$PLATFORM")" || {
|
||||
echo "⚠️ 平台 revision 差异对比失败(${PLATFORM}),保守回退为全量驱动重建"
|
||||
DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
|
||||
REVISION_DRIVERS=""
|
||||
break
|
||||
}
|
||||
REVISION_DRIVERS="$(merge_csv "$REVISION_DRIVERS" "$PLATFORM_DRIVERS")"
|
||||
done
|
||||
if [[ -n "$REVISION_DRIVERS" ]]; then
|
||||
echo "🧭 Revision diff union drivers: $REVISION_DRIVERS"
|
||||
DRIVERS="$(merge_csv "$DRIVERS" "$REVISION_DRIVERS")"
|
||||
fi
|
||||
fi
|
||||
FORCE_GLOBAL_DRIVER_BUILDS="$(bash ./tools/should-force-global-driver-builds.sh --base "$BASE_REF" --head "$GITHUB_SHA")"
|
||||
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_source=${RELEASE_SOURCE}" >> "$GITHUB_OUTPUT"
|
||||
echo "compare_base=${BASE_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "force_global_driver_builds=${FORCE_GLOBAL_DRIVER_BUILDS}" >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$DRIVERS" ]; then
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 Changed driver agents since ${BASE_REF}: $DRIVERS"
|
||||
else
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
echo "🧭 No driver-agent changes since ${BASE_REF}"
|
||||
fi
|
||||
if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then
|
||||
echo "🧭 Driver build/release plumbing changed; preserve global driver rebuild set on every platform"
|
||||
fi
|
||||
|
||||
# Phase 1: Build in parallel and output artifacts
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
needs:
|
||||
- frontend
|
||||
- driver_agents
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -38,7 +236,7 @@ jobs:
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
- os: windows-2025-vs2026
|
||||
platform: windows/amd64
|
||||
os_name: Windows
|
||||
arch_name: Amd64
|
||||
@@ -47,7 +245,7 @@ jobs:
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
- os: windows-2025-vs2026
|
||||
platform: windows/arm64
|
||||
os_name: Windows
|
||||
arch_name: Arm64
|
||||
@@ -78,21 +276,46 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: '1.24'
|
||||
check-latest: true
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
- name: Download frontend dist
|
||||
id: download_frontend_dist
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
name: frontend-dist
|
||||
path: frontend-artifact
|
||||
|
||||
- name: Reset failed frontend dist download
|
||||
if: steps.download_frontend_dist.outcome != 'success'
|
||||
shell: bash
|
||||
run: rm -rf frontend-artifact
|
||||
|
||||
- name: Retry frontend dist download
|
||||
if: steps.download_frontend_dist.outcome != 'success'
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend-artifact
|
||||
|
||||
- name: Extract frontend dist
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p frontend/dist
|
||||
test -s frontend-artifact/frontend-dist.tar
|
||||
tar -xf frontend-artifact/frontend-dist.tar -C frontend/dist
|
||||
test -s frontend/dist/index.html
|
||||
|
||||
- name: Install UPX (Windows)
|
||||
if: contains(matrix.platform, 'windows')
|
||||
if: matrix.platform == 'windows/amd64'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$UPX_VERSION = "4.2.4"
|
||||
@@ -155,11 +378,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Install Wails
|
||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@latest
|
||||
run: go install -v github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
|
||||
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
|
||||
id: msys2_duckdb
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
if: ${{ matrix.platform == 'windows/amd64' }}
|
||||
continue-on-error: true
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
@@ -167,9 +390,10 @@ jobs:
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
mingw-w64-ucrt-x86_64-binutils
|
||||
|
||||
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
if: ${{ matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
function Find-MingwBin([string[]]$candidates) {
|
||||
@@ -227,7 +451,7 @@ jobs:
|
||||
Write-Host "✅ 已配置 DuckDB cgo 编译器: gcc=$gcc g++=$gxx"
|
||||
|
||||
- name: Verify DuckDB CGO Toolchain (Windows AMD64)
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
if: ${{ matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
& "$env:CC" --version
|
||||
@@ -237,23 +461,134 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "🧭 为 ${{ matrix.platform }} 全量生成 driver-agent revision 指纹,避免跨平台沿用旧 revision"
|
||||
./tools/generate-driver-agent-revisions.sh --platform "${{ matrix.platform }}"
|
||||
REVISION_HASH="$(python3 - <<'PY'
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
print(hashlib.sha256(Path("internal/db/driver_agent_revisions_gen.go").read_bytes()).hexdigest())
|
||||
PY
|
||||
)"
|
||||
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
|
||||
mkdir -p "$GOCACHE"
|
||||
echo "🧭 使用隔离 GOCACHE:$GOCACHE"
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
wails build -s -skipbindings -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
else
|
||||
wails build -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
wails build -s -skipbindings -platform ${{ matrix.platform }} -clean -o ${{ matrix.build_name }} -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
- name: Resolve Platform Driver Revision Diff
|
||||
id: revision_diff
|
||||
if: ${{ matrix.build_optional_agents && needs.driver_agents.outputs.has_changes == 'true' }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_REF="${{ needs.driver_agents.outputs.compare_base }}"
|
||||
FALLBACK_DRIVERS="${{ needs.driver_agents.outputs.drivers }}"
|
||||
FORCE_GLOBAL_DRIVER_BUILDS="${{ needs.driver_agents.outputs.force_global_driver_builds }}"
|
||||
|
||||
if [[ -z "$BASE_REF" || "$BASE_REF" == "all" || "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" ]]; then
|
||||
if [[ "$FORCE_GLOBAL_DRIVER_BUILDS" == "true" && -n "$BASE_REF" && "$BASE_REF" != "all" ]]; then
|
||||
echo "⚠️ 当前提交涉及 driver 构建/发布链路,保留全局驱动重建结果:${FALLBACK_DRIVERS}"
|
||||
else
|
||||
echo "⚠️ 未拿到可比对的历史 release 基线,回退使用全局检测结果:${FALLBACK_DRIVERS}"
|
||||
fi
|
||||
echo "drivers=${FALLBACK_DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "🧭 对比当前平台 revision:base=${BASE_REF} head=${GITHUB_SHA} platform=${{ matrix.platform }}"
|
||||
if DRIVERS="$(bash ./tools/diff-driver-agent-revisions.sh --base "$BASE_REF" --head "$GITHUB_SHA" --platform "${{ matrix.platform }}")"; then
|
||||
echo "🧭 当前平台实际需要重建的 driver agents: ${DRIVERS:-<empty>}"
|
||||
echo "drivers=${DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "⚠️ revision 差异对比失败,保守回退为全量重建"
|
||||
ALL_DRIVERS="$(bash ./tools/detect-changed-driver-agents.sh --base all --head "$GITHUB_SHA")"
|
||||
echo "drivers=${ALL_DRIVERS}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
|
||||
DRIVERS="$(sed -n 's/^drivers=//p' "$GITHUB_OUTPUT" | tail -n 1)"
|
||||
if [[ -n "$DRIVERS" ]]; then
|
||||
echo "has_changes=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
if: ${{ matrix.build_optional_agents && steps.revision_diff.outputs.has_changes == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
CHANGED_DRIVER_AGENTS: ${{ steps.revision_diff.outputs.drivers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
REVISION_HASH="$(python3 - <<'PY'
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
print(hashlib.sha256(Path("internal/db/driver_agent_revisions_gen.go").read_bytes()).hexdigest())
|
||||
PY
|
||||
)"
|
||||
export GOCACHE="${RUNNER_TEMP}/go-build-${{ matrix.os_name }}-${{ matrix.arch_name }}-${REVISION_HASH}"
|
||||
mkdir -p "$GOCACHE"
|
||||
echo "🧭 可选驱动使用隔离 GOCACHE:$GOCACHE"
|
||||
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
DUCKDB_WINDOWS_LIBRARY_VERSION="v1.4.4"
|
||||
DUCKDB_WINDOWS_LIBRARY_URL="https://github.com/duckdb/duckdb/releases/download/${DUCKDB_WINDOWS_LIBRARY_VERSION}/libduckdb-windows-amd64.zip"
|
||||
|
||||
prepare_duckdb_windows_library() {
|
||||
local lib_dir="$RUNNER_TEMP/duckdb-windows-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
|
||||
local extract_dir="$RUNNER_TEMP/duckdb-windows-extract-${DUCKDB_WINDOWS_LIBRARY_VERSION}"
|
||||
local zip_path="$RUNNER_TEMP/libduckdb-windows-amd64.zip"
|
||||
if [ -f "$lib_dir/duckdb.dll" ] && [ -f "$lib_dir/libduckdb.dll.a" ] && [ -f "$lib_dir/libduckdb.a" ]; then
|
||||
echo "$lib_dir"
|
||||
return 0
|
||||
fi
|
||||
mkdir -p "$lib_dir"
|
||||
rm -rf "$extract_dir"
|
||||
rm -f "$zip_path"
|
||||
local attempt dll_path
|
||||
for attempt in 1 2 3; do
|
||||
echo "📥 下载 DuckDB Windows 动态库 (${attempt}/3): ${DUCKDB_WINDOWS_LIBRARY_URL}" >&2
|
||||
if curl --retry 3 --retry-delay 2 --retry-all-errors --connect-timeout 20 --max-time 300 -fsSL "$DUCKDB_WINDOWS_LIBRARY_URL" -o "$zip_path"; then
|
||||
if unzip -tq "$zip_path" >/dev/null 2>&1; then
|
||||
rm -rf "$lib_dir"
|
||||
rm -rf "$extract_dir"
|
||||
mkdir -p "$lib_dir"
|
||||
mkdir -p "$extract_dir"
|
||||
unzip -qo "$zip_path" -d "$extract_dir"
|
||||
dll_path="$(find "$extract_dir" -type f -name duckdb.dll | head -n 1 || true)"
|
||||
if [ -n "$dll_path" ]; then
|
||||
cp "$dll_path" "$lib_dir/duckdb.dll"
|
||||
go run ./cmd/mingw-import-lib \
|
||||
--dll "$lib_dir/duckdb.dll" \
|
||||
--output-lib "$lib_dir/libduckdb.dll.a"
|
||||
cp "$lib_dir/libduckdb.dll.a" "$lib_dir/libduckdb.a"
|
||||
rm -rf "$extract_dir"
|
||||
echo "$lib_dir"
|
||||
return 0
|
||||
fi
|
||||
echo "⚠️ DuckDB Windows 动态库压缩包缺少 duckdb.dll,准备重试" >&2
|
||||
else
|
||||
echo "⚠️ DuckDB Windows 动态库压缩包校验失败,准备重试" >&2
|
||||
fi
|
||||
else
|
||||
echo "⚠️ DuckDB Windows 动态库下载失败,准备重试" >&2
|
||||
fi
|
||||
rm -f "$zip_path"
|
||||
rm -rf "$lib_dir"
|
||||
rm -rf "$extract_dir"
|
||||
mkdir -p "$lib_dir"
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
echo "❌ 无法准备 DuckDB Windows 动态库:${DUCKDB_WINDOWS_LIBRARY_URL}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
@@ -264,30 +599,98 @@ jobs:
|
||||
echo "⚠️ 跳过 DuckDB driver(当前平台 ${GOOS}/${GOARCH} 不受支持,仅支持 windows/amd64)"
|
||||
continue
|
||||
fi
|
||||
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tag=${TAG})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${TAG}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
DRIVER_VARIANTS=("")
|
||||
if [ "$DRIVER" = "mongodb" ]; then
|
||||
DRIVER_VARIANTS=("v1" "v2" "")
|
||||
fi
|
||||
for DRIVER_VARIANT in "${DRIVER_VARIANTS[@]}"; do
|
||||
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||
BUILD_TAGS="$TAG"
|
||||
OUTPUT_STEM="${DRIVER}-driver-agent"
|
||||
if [ "$DRIVER" = "mongodb" ]; then
|
||||
if [ "$DRIVER_VARIANT" = "v1" ]; then
|
||||
BUILD_TAGS="gonavi_mongodb_driver_v1"
|
||||
OUTPUT_STEM="mongodb-driver-agent-v1"
|
||||
elif [ "$DRIVER_VARIANT" = "v2" ]; then
|
||||
BUILD_TAGS="gonavi_mongodb_driver"
|
||||
OUTPUT_STEM="mongodb-driver-agent-v2"
|
||||
else
|
||||
BUILD_TAGS="gonavi_mongodb_driver"
|
||||
fi
|
||||
fi
|
||||
OUTPUT="${OUTPUT_STEM}-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
DUCKDB_LIB_DIR=""
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" = "amd64" ]; then
|
||||
DUCKDB_LIB_DIR="$(prepare_duckdb_windows_library)"
|
||||
BUILD_TAGS="${BUILD_TAGS} duckdb_use_lib"
|
||||
fi
|
||||
echo "🔧 构建 ${OUTPUT_PATH} (tags=${BUILD_TAGS})"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
if [ -n "$DUCKDB_LIB_DIR" ]; then
|
||||
DUCKDB_LIB_DIR_GCC="$(cygpath -m "$DUCKDB_LIB_DIR")"
|
||||
DUCKDB_LIB_DIR_PATH="$(cygpath -u "$DUCKDB_LIB_DIR")"
|
||||
# cgo 会把每个 CGO_LDFLAGS 片段转成 //go:cgo_ldflag,-L 参数不能再额外包引号。
|
||||
DUCKDB_WINDOWS_CGO_LDFLAGS="-L${DUCKDB_LIB_DIR_GCC} -lduckdb -lstdc++ -lm -lws2_32 -lwsock32 -lrstrtmgr"
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" CGO_LDFLAGS="${DUCKDB_WINDOWS_CGO_LDFLAGS}" PATH="${DUCKDB_LIB_DIR_PATH}:$PATH" go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
cp "$DUCKDB_LIB_DIR/duckdb.dll" "$OUTDIR/duckdb.dll"
|
||||
bash ./tools/compress-driver-artifact.sh "$OUTDIR/duckdb.dll" "$TARGET_PLATFORM" "${{ matrix.os_name }}/duckdb.dll"
|
||||
else
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
fi
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o "${OUTPUT_PATH}" \
|
||||
./cmd/optional-driver-agent
|
||||
fi
|
||||
bash ./tools/compress-driver-artifact.sh "${OUTPUT_PATH}" "$TARGET_PLATFORM" "${{ matrix.os_name }}/${OUTPUT}"
|
||||
if [ "$DRIVER" = "duckdb" ] && [ -n "$DUCKDB_LIB_DIR" ]; then
|
||||
DUCKDB_ZIP_PATH="${OUTDIR}/duckdb-driver.zip"
|
||||
export DUCKDB_ZIP_PATH
|
||||
export DUCKDB_AGENT_PATH="${OUTPUT_PATH}"
|
||||
export DUCKDB_DLL_PATH="${OUTDIR}/duckdb.dll"
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
zip_path = os.environ["DUCKDB_ZIP_PATH"]
|
||||
entries = [
|
||||
("Windows/duckdb-driver-agent-windows-amd64.exe", os.environ["DUCKDB_AGENT_PATH"]),
|
||||
("Windows/duckdb.dll", os.environ["DUCKDB_DLL_PATH"]),
|
||||
]
|
||||
|
||||
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
|
||||
echo "📦 已生成 DuckDB Windows 专属驱动包: ${DUCKDB_ZIP_PATH}"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
bash ./tools/verify-driver-agent-revisions.sh \
|
||||
--assets-dir drivers \
|
||||
--platform "$TARGET_PLATFORM" \
|
||||
--drivers "$CHANGED_DRIVER_AGENTS"
|
||||
|
||||
# macOS Packaging
|
||||
- name: Package macOS DMG
|
||||
if: contains(matrix.platform, 'darwin')
|
||||
@@ -314,6 +717,9 @@ jobs:
|
||||
echo "🔏 正在进行 Ad-hoc 签名..."
|
||||
# 注意:Ad-hoc + hardened runtime(--options runtime)在未配置 entitlements 时,
|
||||
# 可能导致部分 macOS 机型上应用双击无响应。这里保持 Ad-hoc 深签名但禁用 runtime hardened。
|
||||
if command -v xattr >/dev/null 2>&1; then
|
||||
xattr -cr "$APP_NAME" || true
|
||||
fi
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
|
||||
DMG_NAME="${{ matrix.build_name }}.dmg"
|
||||
@@ -330,6 +736,17 @@ jobs:
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
|
||||
VERIFY_MOUNT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gonavi-release-verify.XXXXXX")
|
||||
hdiutil attach -nobrowse -readonly -mountpoint "$VERIFY_MOUNT_DIR" "$DMG_NAME" >/dev/null
|
||||
PACKAGED_APP=$(find "$VERIFY_MOUNT_DIR" -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$PACKAGED_APP" ]; then
|
||||
echo "❌ DMG 内未找到 .app 应用包!"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
codesign --verify --deep --strict --verbose=4 "$PACKAGED_APP"
|
||||
hdiutil detach "$VERIFY_MOUNT_DIR" -quiet >/dev/null 2>&1 || true
|
||||
|
||||
mv "$DMG_NAME" "../../$FINAL_NAME"
|
||||
|
||||
@@ -484,7 +901,7 @@ jobs:
|
||||
|
||||
# Upload to Actions Artifacts (Temporary Storage)
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: build-artifacts-${{ strategy.job-index }} # Unique name per job
|
||||
path: |
|
||||
@@ -498,11 +915,16 @@ jobs:
|
||||
# Phase 2: Collect all artifacts and Publish Release (Single Job)
|
||||
release:
|
||||
name: Publish Release
|
||||
needs: build
|
||||
needs:
|
||||
- build
|
||||
- driver_agents
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Download All Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
path: release-assets
|
||||
pattern: build-artifacts-*
|
||||
@@ -511,31 +933,49 @@ jobs:
|
||||
- name: List Assets
|
||||
run: ls -R release-assets
|
||||
|
||||
- name: Complete Driver Agent Assets
|
||||
if: needs.driver_agents.outputs.has_changes == 'true' && needs.driver_agents.outputs.release_source != 'all'
|
||||
env:
|
||||
DRIVER_RELEASE_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }}
|
||||
run: |
|
||||
python3 tools/complete-driver-release-assets.py \
|
||||
--assets-dir release-assets \
|
||||
--source "${{ needs.driver_agents.outputs.release_source }}" \
|
||||
--require-complete
|
||||
|
||||
- name: Verify Optional Driver Assets
|
||||
if: needs.driver_agents.outputs.has_changes == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
CHANGED_DRIVER_AGENTS: ${{ needs.driver_agents.outputs.drivers }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
|
||||
REQUIRED_FILES=(
|
||||
"drivers/Windows/duckdb-driver-agent-windows-amd64.exe"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/duckdb-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/duckdb-driver-agent-linux-amd64"
|
||||
"drivers/Windows/clickhouse-driver-agent-windows-amd64.exe"
|
||||
"drivers/MacOS/clickhouse-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/clickhouse-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/clickhouse-driver-agent-linux-amd64"
|
||||
)
|
||||
|
||||
missing=0
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ 缺少驱动资产:$file"
|
||||
missing=1
|
||||
IFS=',' read -r -a DRIVERS <<< "$CHANGED_DRIVER_AGENTS"
|
||||
for driver in "${DRIVERS[@]}"; do
|
||||
REQUIRED_FILES=(
|
||||
"drivers/Windows/${driver}-driver-agent-windows-amd64.exe"
|
||||
"drivers/MacOS/${driver}-driver-agent-darwin-amd64"
|
||||
"drivers/MacOS/${driver}-driver-agent-darwin-arm64"
|
||||
"drivers/Linux/${driver}-driver-agent-linux-amd64"
|
||||
)
|
||||
if [ "$driver" != "duckdb" ]; then
|
||||
REQUIRED_FILES+=("drivers/Windows/${driver}-driver-agent-windows-arm64.exe")
|
||||
else
|
||||
echo "✅ 已找到驱动资产:$file"
|
||||
REQUIRED_FILES+=("drivers/Windows/duckdb.dll")
|
||||
REQUIRED_FILES+=("drivers/Windows/duckdb-driver.zip")
|
||||
fi
|
||||
|
||||
for file in "${REQUIRED_FILES[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "❌ 缺少驱动资产:$file"
|
||||
missing=1
|
||||
else
|
||||
echo "✅ 已找到驱动资产:$file"
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$missing" -ne 0 ]; then
|
||||
@@ -544,57 +984,35 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Package Driver Agents Bundle
|
||||
id: driver_assets
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd release-assets
|
||||
if [ ! -d drivers ]; then
|
||||
echo "⚠️ 未找到 drivers 目录,跳过驱动总包打包"
|
||||
echo "has_driver_assets=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$(find drivers -type f 2>/dev/null | head -n 1)" ]; then
|
||||
echo "⚠️ drivers 目录为空,跳过驱动总包打包"
|
||||
rm -rf drivers
|
||||
echo "has_driver_assets=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "📦 打包驱动总包:GoNavi-DriverAgents.zip"
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
python3 ../tools/package-driver-release-assets.py \
|
||||
drivers \
|
||||
../driver-release-assets
|
||||
|
||||
out_name = "GoNavi-DriverAgents.zip"
|
||||
index_name = "GoNavi-DriverAgents-Index.json"
|
||||
base = Path("drivers")
|
||||
out_path = Path(out_name)
|
||||
index_path = Path(index_name)
|
||||
if out_path.exists():
|
||||
out_path.unlink()
|
||||
if index_path.exists():
|
||||
index_path.unlink()
|
||||
python3 ../tools/generate-driver-release-manifest.py \
|
||||
--assets-dir drivers \
|
||||
--output ../driver-release-assets/GoNavi-DriverAgents-Manifest.json
|
||||
|
||||
size_index = {}
|
||||
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for p in base.rglob("*"):
|
||||
if not p.is_file():
|
||||
continue
|
||||
arcname = p.relative_to(base).as_posix()
|
||||
zf.write(p, arcname)
|
||||
size_index[p.name] = p.stat().st_size
|
||||
|
||||
index_path.write_text(
|
||||
json.dumps({"assets": size_index}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"created {out_name} size={out_path.stat().st_size} bytes")
|
||||
print(f"created {index_name} entries={len(size_index)}")
|
||||
PY
|
||||
|
||||
# Release 只发布一个驱动总包,避免大量平铺资产污染 Release 页面
|
||||
# GoNavi 主仓库只保留主程序包;驱动资产发布到独立仓库。
|
||||
rm -rf drivers
|
||||
echo "has_driver_assets=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
shell: bash
|
||||
@@ -613,13 +1031,122 @@ jobs:
|
||||
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||
fi
|
||||
|
||||
- name: Generate Driver SHA256SUMS
|
||||
if: steps.driver_assets.outputs.has_driver_assets == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cd driver-release-assets
|
||||
FILES=()
|
||||
while IFS= read -r file; do
|
||||
if [ -n "$file" ]; then
|
||||
FILES+=("$file")
|
||||
fi
|
||||
done < <(find . -maxdepth 1 -type f ! -name SHA256SUMS -exec basename {} \; | sort)
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "❌ 未找到驱动发布资产"
|
||||
exit 1
|
||||
fi
|
||||
sha256sum "${FILES[@]}" > SHA256SUMS
|
||||
|
||||
- name: Create Driver Agents Release
|
||||
if: startsWith(github.ref, 'refs/tags/') && steps.driver_assets.outputs.has_driver_assets == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.DRIVER_RELEASE_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t DRIVER_RELEASE_ASSETS < <(find driver-release-assets -maxdepth 1 -type f | sort)
|
||||
if [ ${#DRIVER_RELEASE_ASSETS[@]} -eq 0 ]; then
|
||||
echo "未找到 driver release assets"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NOTES_FILE="$RUNNER_TEMP/driver-release-notes.md"
|
||||
cat > "$NOTES_FILE" <<'EOF'
|
||||
GoNavi driver-agent assets for `${{ github.ref_name }}`.
|
||||
EOF
|
||||
|
||||
gh release create "${{ github.ref_name }}" "${DRIVER_RELEASE_ASSETS[@]}" \
|
||||
--repo Syngnat/GoNavi-DriverAgents \
|
||||
--title "GoNavi Driver Agents ${{ github.ref_name }}" \
|
||||
--notes-file "$NOTES_FILE" \
|
||||
--latest
|
||||
|
||||
- name: Checkout code for changelog
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: repo-for-changelog
|
||||
|
||||
- name: Generate Changelog
|
||||
id: changelog
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd repo-for-changelog
|
||||
TAG="${{ github.ref_name }}"
|
||||
# 获取上一个 tag
|
||||
PREV_TAG=$(git tag --sort=-creatordate | grep -E '^v' | sed -n '2p' || true)
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "⚠️ 未找到上一个 tag,使用全部 commit"
|
||||
RANGE="$TAG"
|
||||
else
|
||||
RANGE="${PREV_TAG}..${TAG}"
|
||||
fi
|
||||
|
||||
echo "📋 生成更新日志:$RANGE"
|
||||
|
||||
# 提取 commit 消息(排除 merge commit)
|
||||
COMMITS=$(git log "$RANGE" --no-merges --pretty=format:'%s' 2>/dev/null || true)
|
||||
if [ -z "$COMMITS" ]; then
|
||||
BODY="暂无提交记录。"
|
||||
else
|
||||
CAT_FEAT=""
|
||||
CAT_FIX=""
|
||||
CAT_PERF=""
|
||||
CAT_REFACTOR=""
|
||||
CAT_I18N=""
|
||||
CAT_OTHER=""
|
||||
|
||||
while IFS= read -r line; do
|
||||
[ -z "$line" ] && continue
|
||||
case "$line" in
|
||||
✨*|*feat*) CAT_FEAT="${CAT_FEAT}\n- ${line}" ;;
|
||||
🐛*|*fix*) CAT_FIX="${CAT_FIX}\n- ${line}" ;;
|
||||
⚡*|*perf*) CAT_PERF="${CAT_PERF}\n- ${line}" ;;
|
||||
♻️*|*refactor*) CAT_REFACTOR="${CAT_REFACTOR}\n- ${line}" ;;
|
||||
🌐*) CAT_I18N="${CAT_I18N}\n- ${line}" ;;
|
||||
🔧*|🔨*|*chore*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||
*) CAT_OTHER="${CAT_OTHER}\n- ${line}" ;;
|
||||
esac
|
||||
done <<< "$COMMITS"
|
||||
|
||||
BODY=""
|
||||
[ -n "$CAT_FEAT" ] && BODY="${BODY}## ✨ 新功能\n${CAT_FEAT}\n\n"
|
||||
[ -n "$CAT_FIX" ] && BODY="${BODY}## 🐛 问题修复\n${CAT_FIX}\n\n"
|
||||
[ -n "$CAT_PERF" ] && BODY="${BODY}## ⚡ 性能优化\n${CAT_PERF}\n\n"
|
||||
[ -n "$CAT_REFACTOR" ] && BODY="${BODY}## ♻️ 重构\n${CAT_REFACTOR}\n\n"
|
||||
[ -n "$CAT_I18N" ] && BODY="${BODY}## 🌐 国际化\n${CAT_I18N}\n\n"
|
||||
[ -n "$CAT_OTHER" ] && BODY="${BODY}## 🔧 其他变更\n${CAT_OTHER}\n\n"
|
||||
|
||||
# 附加 compare 链接
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
REPO_URL="${{ github.server_url }}/${{ github.repository }}"
|
||||
BODY="${BODY}---\n**完整变更**: [${PREV_TAG}...${TAG}](${REPO_URL}/compare/${PREV_TAG}...${TAG})\n"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 写入到文件避免多行环境变量问题
|
||||
printf '%b' "$BODY" > /tmp/changelog.md
|
||||
echo "changelog_file=/tmp/changelog.md" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
files: release-assets/*
|
||||
draft: true
|
||||
make_latest: true
|
||||
generate_release_notes: true
|
||||
body_path: ${{ steps.changelog.outputs.changelog_file }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
412
.github/workflows/test-build-all-platforms.yml
vendored
@@ -1,412 +0,0 @@
|
||||
name: Test Build All Platforms (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_label:
|
||||
description: "测试包标识(仅用于文件名)"
|
||||
required: false
|
||||
default: "test"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: test-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin/amd64
|
||||
os_name: MacOS
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-darwin-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: macos-latest
|
||||
platform: darwin/arm64
|
||||
os_name: MacOS
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-test-darwin-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/amd64
|
||||
os_name: Windows
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-windows-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: windows-latest
|
||||
platform: windows/arm64
|
||||
os_name: Windows
|
||||
arch_name: Arm64
|
||||
build_name: gonavi-test-windows-arm64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: ""
|
||||
- os: ubuntu-22.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-linux-amd64
|
||||
wails_tags: ""
|
||||
artifact_suffix: ""
|
||||
build_optional_agents: true
|
||||
linux_webkit: "4.0"
|
||||
- os: ubuntu-24.04
|
||||
platform: linux/amd64
|
||||
os_name: Linux
|
||||
arch_name: Amd64
|
||||
build_name: gonavi-test-linux-amd64-webkit41
|
||||
wails_tags: "webkit2_41"
|
||||
artifact_suffix: "-WebKit41"
|
||||
build_optional_agents: false
|
||||
linux_webkit: "4.1"
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install UPX (Windows)
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$UPX_VERSION = "4.2.4"
|
||||
$url = "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/upx-${UPX_VERSION}-win64.zip"
|
||||
$zipPath = "$env:RUNNER_TEMP\upx.zip"
|
||||
$extractPath = "$env:RUNNER_TEMP\upx"
|
||||
Write-Host "📥 从 GitHub Releases 下载 UPX v${UPX_VERSION} ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing
|
||||
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
|
||||
$upxDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1
|
||||
"$($upxDir.FullName)" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
$upxCmd = Join-Path $upxDir.FullName "upx.exe"
|
||||
if (!(Test-Path $upxCmd)) {
|
||||
Write-Error "❌ 未检测到 upx,无法保证 Windows 测试产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
& $upxCmd --version
|
||||
|
||||
- name: Install Linux Dependencies
|
||||
if: contains(matrix.platform, 'linux')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-dev
|
||||
|
||||
if [ "${{ matrix.linux_webkit }}" = "4.1" ]; then
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libsoup-3.0-dev
|
||||
else
|
||||
sudo apt-get install -y libwebkit2gtk-4.0-dev
|
||||
fi
|
||||
|
||||
sudo apt-get install -y upx-ucl || sudo apt-get install -y upx
|
||||
upx --version
|
||||
|
||||
sudo apt-get install -y libfuse2 || sudo apt-get install -y libfuse2t64 || true
|
||||
|
||||
LINUXDEPLOY_URL="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage"
|
||||
PLUGIN_URL="https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/releases/download/continuous/linuxdeploy-plugin-gtk-x86_64.AppImage"
|
||||
|
||||
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy "$LINUXDEPLOY_URL" || {
|
||||
echo "skip-appimage=true" >> "$GITHUB_ENV"
|
||||
}
|
||||
wget --retry-connrefused --waitretry=1 --read-timeout=20 --timeout=15 --tries=3 -O /tmp/linuxdeploy-plugin-gtk "$PLUGIN_URL" || {
|
||||
echo "skip-appimage=true" >> "$GITHUB_ENV"
|
||||
}
|
||||
|
||||
if [ "${skip-appimage:-false}" != "true" ]; then
|
||||
chmod +x /tmp/linuxdeploy /tmp/linuxdeploy-plugin-gtk
|
||||
fi
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
|
||||
- name: Setup MSYS2 Toolchain For DuckDB (Windows AMD64)
|
||||
id: msys2_duckdb
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
continue-on-error: true
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: UCRT64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-ucrt-x86_64-gcc
|
||||
|
||||
- name: Configure DuckDB CGO Toolchain (Windows AMD64)
|
||||
if: ${{ matrix.build_optional_agents && matrix.platform == 'windows/amd64' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
function Find-MingwBin([string[]]$candidates) {
|
||||
foreach ($bin in $candidates) {
|
||||
if ([string]::IsNullOrWhiteSpace($bin)) {
|
||||
continue
|
||||
}
|
||||
$gcc = Join-Path $bin 'gcc.exe'
|
||||
$gxx = Join-Path $bin 'g++.exe'
|
||||
if ((Test-Path $gcc) -and (Test-Path $gxx)) {
|
||||
return $bin
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
$msys2Location = "${{ steps.msys2_duckdb.outputs['msys2-location'] }}"
|
||||
$candidateBins = @()
|
||||
if (-not [string]::IsNullOrWhiteSpace($msys2Location)) {
|
||||
$candidateBins += Join-Path $msys2Location 'ucrt64\bin'
|
||||
}
|
||||
$candidateBins += @(
|
||||
'C:\msys64\ucrt64\bin',
|
||||
'D:\a\_temp\msys64\ucrt64\bin'
|
||||
)
|
||||
$candidateBins = @($candidateBins | Select-Object -Unique)
|
||||
|
||||
$mingwBin = Find-MingwBin $candidateBins
|
||||
if (-not $mingwBin) {
|
||||
Write-Error "❌ 未找到可用的 DuckDB UCRT64 编译器。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$gcc = Join-Path $mingwBin 'gcc.exe'
|
||||
$gxx = Join-Path $mingwBin 'g++.exe'
|
||||
"$mingwBin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
"CC=$gcc" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
"CXX=$gxx" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
|
||||
- name: Build App
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BUILD_LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$BUILD_LABEL" ]; then
|
||||
BUILD_LABEL="test"
|
||||
fi
|
||||
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
|
||||
if [ -n "${{ matrix.wails_tags }}" ]; then
|
||||
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -tags "${{ matrix.wails_tags }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||
else
|
||||
wails build -platform "${{ matrix.platform }}" -clean -o "${{ matrix.build_name }}" -ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Build Optional Driver Agents
|
||||
if: ${{ matrix.build_optional_agents }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARGET_PLATFORM="${{ matrix.platform }}"
|
||||
GOOS="${TARGET_PLATFORM%%/*}"
|
||||
GOARCH="${TARGET_PLATFORM##*/}"
|
||||
DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
OUTDIR="drivers/${{ matrix.os_name }}"
|
||||
mkdir -p "$OUTDIR"
|
||||
|
||||
for DRIVER in "${DRIVERS[@]}"; do
|
||||
BUILD_DRIVER="$DRIVER"
|
||||
if [ "$DRIVER" = "doris" ]; then
|
||||
BUILD_DRIVER="diros"
|
||||
fi
|
||||
if [ "$DRIVER" = "duckdb" ] && [ "$GOOS" = "windows" ] && [ "$GOARCH" != "amd64" ]; then
|
||||
echo "跳过 DuckDB driver: ${GOOS}/${GOARCH}"
|
||||
continue
|
||||
fi
|
||||
TAG="gonavi_${BUILD_DRIVER}_driver"
|
||||
OUTPUT="${DRIVER}-driver-agent-${GOOS}-${GOARCH}"
|
||||
if [ "$GOOS" = "windows" ]; then
|
||||
OUTPUT="${OUTPUT}.exe"
|
||||
fi
|
||||
OUTPUT_PATH="${OUTDIR}/${OUTPUT}"
|
||||
if [ "$DRIVER" = "duckdb" ]; then
|
||||
CGO_ENABLED=1 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
|
||||
else
|
||||
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" go build -tags "$TAG" -trimpath -ldflags "-s -w" -o "$OUTPUT_PATH" ./cmd/optional-driver-agent
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Package macOS
|
||||
if: contains(matrix.platform, 'darwin')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
brew install create-dmg
|
||||
LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$LABEL" ]; then
|
||||
LABEL="test"
|
||||
fi
|
||||
cd build/bin
|
||||
APP_PATH=$(find . -maxdepth 1 -name "*.app" | head -n 1)
|
||||
if [ -z "$APP_PATH" ]; then
|
||||
echo "未找到 .app 应用包"
|
||||
exit 1
|
||||
fi
|
||||
APP_NAME=$(basename "$APP_PATH")
|
||||
APP_BIN=$(find "$APP_PATH/Contents/MacOS" -maxdepth 1 -type f | head -n 1)
|
||||
if [ -z "$APP_BIN" ]; then
|
||||
echo "未找到 macOS 应用主程序"
|
||||
exit 1
|
||||
fi
|
||||
echo "ℹ️ macOS 产物不执行 UPX 压缩,保留原始主程序。"
|
||||
codesign --force --deep --sign - "$APP_NAME"
|
||||
ZIP_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.zip"
|
||||
DMG_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}-run${GITHUB_RUN_NUMBER}.dmg"
|
||||
mkdir -p ../../artifacts
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_NAME" "../../artifacts/$ZIP_NAME"
|
||||
create-dmg \
|
||||
--volname "GoNavi Test Installer" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "$APP_NAME" 200 190 \
|
||||
--hide-extension "$APP_NAME" \
|
||||
--app-drop-link 600 185 \
|
||||
"$DMG_NAME" \
|
||||
"$APP_NAME"
|
||||
mv "$DMG_NAME" "../../artifacts/$DMG_NAME"
|
||||
shasum -a 256 "../../artifacts/$ZIP_NAME" > "../../artifacts/$ZIP_NAME.sha256"
|
||||
shasum -a 256 "../../artifacts/$DMG_NAME" > "../../artifacts/$DMG_NAME.sha256"
|
||||
|
||||
- name: Package Windows
|
||||
if: contains(matrix.platform, 'windows')
|
||||
shell: pwsh
|
||||
run: |
|
||||
$label = "${{ inputs.build_label }}"
|
||||
if ([string]::IsNullOrWhiteSpace($label)) { $label = 'test' }
|
||||
Set-Location build/bin
|
||||
$target = "${{ matrix.build_name }}"
|
||||
$finalExeName = "GoNavi-$label-${{ matrix.os_name }}-${{ matrix.arch_name }}-run$env:GITHUB_RUN_NUMBER.exe"
|
||||
if (Test-Path "$target.exe") {
|
||||
$finalExe = "$target.exe"
|
||||
} elseif (Test-Path "$target") {
|
||||
Rename-Item -Path "$target" -NewName "$target.exe"
|
||||
$finalExe = "$target.exe"
|
||||
} else {
|
||||
Write-Error "未找到构建产物 '$target'"
|
||||
exit 1
|
||||
}
|
||||
$isArm64Target = "${{ matrix.arch_name }}".ToLowerInvariant() -eq "arm64"
|
||||
if ($isArm64Target) {
|
||||
Write-Warning "⚠️ UPX 当前不支持 win64/arm64,跳过压缩并保留原始 EXE。"
|
||||
$LASTEXITCODE = 0
|
||||
} else {
|
||||
$upxCmd = Get-Command upx -ErrorAction SilentlyContinue
|
||||
if ($null -eq $upxCmd) {
|
||||
Write-Error "❌ 未找到 upx,无法保证 Windows 测试产物经过压缩"
|
||||
exit 1
|
||||
}
|
||||
$beforeBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||
Write-Host "🗜️ 使用 UPX 压缩 $finalExe ..."
|
||||
& upx --best --lzma --force $finalExe | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "❌ UPX 压缩失败($LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
& upx -t $finalExe | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "❌ UPX 校验失败($LASTEXITCODE)"
|
||||
exit 1
|
||||
}
|
||||
$afterBytes = (Get-Item -LiteralPath $finalExe).Length
|
||||
if ($afterBytes -lt $beforeBytes) {
|
||||
$savedBytes = $beforeBytes - $afterBytes
|
||||
Write-Host ("✅ UPX 压缩完成:{0:N2}MB -> {1:N2}MB,减少 {2:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB), ($savedBytes / 1MB))
|
||||
} else {
|
||||
Write-Host ("ℹ️ UPX 压缩完成:{0:N2}MB -> {1:N2}MB" -f ($beforeBytes / 1MB), ($afterBytes / 1MB))
|
||||
}
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path ..\..\artifacts | Out-Null
|
||||
Copy-Item -LiteralPath $finalExe -Destination "..\..\artifacts\$finalExeName" -Force
|
||||
Get-FileHash "..\..\artifacts\$finalExeName" -Algorithm SHA256 | ForEach-Object { "{0} *{1}" -f $_.Hash.ToLower(), (Split-Path $_.Path -Leaf) } | Out-File "..\..\artifacts\$finalExeName.sha256" -Encoding ascii
|
||||
|
||||
- name: Package Linux
|
||||
if: contains(matrix.platform, 'linux')
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$LABEL" ]; then
|
||||
LABEL="test"
|
||||
fi
|
||||
cd build/bin
|
||||
TARGET="${{ matrix.build_name }}"
|
||||
TAR_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.tar.gz"
|
||||
APPIMAGE_NAME="GoNavi-${LABEL}-${{ matrix.os_name }}-${{ matrix.arch_name }}${{ matrix.artifact_suffix }}-run${GITHUB_RUN_NUMBER}.AppImage"
|
||||
mkdir -p ../../artifacts
|
||||
|
||||
if [ ! -f "$TARGET" ]; then
|
||||
echo "未找到构建产物 '$TARGET'"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x "$TARGET"
|
||||
BEFORE_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||
echo "🗜️ 使用 UPX 压缩 Linux 可执行文件: $TARGET ..."
|
||||
upx --best --lzma --force "$TARGET"
|
||||
upx -t "$TARGET"
|
||||
AFTER_BYTES=$(wc -c <"$TARGET" | tr -d '[:space:]')
|
||||
if [ "$AFTER_BYTES" -lt "$BEFORE_BYTES" ]; then
|
||||
SAVED_BYTES=$((BEFORE_BYTES - AFTER_BYTES))
|
||||
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" -v s="$SAVED_BYTES" 'BEGIN { printf "✅ Linux UPX 压缩完成:%.2fMB -> %.2fMB,减少 %.2fMB\n", b/1024/1024, a/1024/1024, s/1024/1024 }'
|
||||
else
|
||||
awk -v b="$BEFORE_BYTES" -v a="$AFTER_BYTES" 'BEGIN { printf "ℹ️ Linux UPX 压缩完成:%.2fMB -> %.2fMB\n", b/1024/1024, a/1024/1024 }'
|
||||
fi
|
||||
tar -czvf "../../artifacts/$TAR_NAME" "$TARGET"
|
||||
sha256sum "../../artifacts/$TAR_NAME" > "../../artifacts/$TAR_NAME.sha256"
|
||||
|
||||
if [ "${skip-appimage:-false}" = "true" ]; then
|
||||
echo "跳过 AppImage 打包"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p AppDir/usr/bin AppDir/usr/share/applications AppDir/usr/share/icons/hicolor/256x256/apps
|
||||
cp "$TARGET" AppDir/usr/bin/gonavi
|
||||
printf '%s\n' '[Desktop Entry]' 'Name=GoNavi' 'Exec=gonavi' 'Icon=gonavi' 'Type=Application' 'Categories=Development;Database;' 'Comment=Database Management Tool' > AppDir/usr/share/applications/gonavi.desktop
|
||||
cp AppDir/usr/share/applications/gonavi.desktop AppDir/gonavi.desktop
|
||||
if [ -f "../../build/appicon.png" ]; then
|
||||
cp "../../build/appicon.png" AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||
cp "../../build/appicon.png" AppDir/gonavi.png
|
||||
else
|
||||
touch AppDir/gonavi.png
|
||||
cp AppDir/gonavi.png AppDir/usr/share/icons/hicolor/256x256/apps/gonavi.png
|
||||
fi
|
||||
export DEPLOY_GTK_VERSION=3
|
||||
/tmp/linuxdeploy --appdir AppDir --plugin gtk --output appimage || exit 0
|
||||
mv GoNavi*.AppImage "$APPIMAGE_NAME" 2>/dev/null || exit 0
|
||||
mv "$APPIMAGE_NAME" "../../artifacts/$APPIMAGE_NAME"
|
||||
sha256sum "../../artifacts/$APPIMAGE_NAME" > "../../artifacts/$APPIMAGE_NAME.sha256"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-build-${{ matrix.build_name }}-run${{ github.run_number }}
|
||||
path: |
|
||||
artifacts/*
|
||||
drivers/**
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
94
.github/workflows/test-macos-build.yml
vendored
@@ -1,94 +0,0 @@
|
||||
name: Test Build macOS (Manual)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_label:
|
||||
description: "测试包标识(仅用于文件名)"
|
||||
required: false
|
||||
default: "test"
|
||||
push:
|
||||
branches:
|
||||
- feature/kingbase_opt
|
||||
paths:
|
||||
- ".github/workflows/test-macos-build.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
name: Build macOS ${{ matrix.arch }}
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: darwin/amd64
|
||||
arch: amd64
|
||||
- platform: darwin/arm64
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24.3"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OUTPUT_NAME="gonavi-test-${{ matrix.arch }}"
|
||||
BUILD_LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$BUILD_LABEL" ]; then
|
||||
BUILD_LABEL="test"
|
||||
fi
|
||||
APP_VERSION="${BUILD_LABEL}-${GITHUB_RUN_NUMBER}"
|
||||
wails build \
|
||||
-platform "${{ matrix.platform }}" \
|
||||
-clean \
|
||||
-o "$OUTPUT_NAME" \
|
||||
-ldflags "-s -w -X GoNavi-Wails/internal/app.AppVersion=${APP_VERSION}"
|
||||
|
||||
- name: Package Zip
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_PATH="build/bin/gonavi-test-${{ matrix.arch }}.app"
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
APP_PATH=$(find build/bin -maxdepth 1 -name "*.app" | head -n 1 || true)
|
||||
fi
|
||||
if [ -z "$APP_PATH" ] || [ ! -d "$APP_PATH" ]; then
|
||||
echo "未找到 .app 产物"
|
||||
ls -la build/bin || true
|
||||
exit 1
|
||||
fi
|
||||
LABEL="${{ inputs.build_label }}"
|
||||
if [ -z "$LABEL" ]; then
|
||||
LABEL="test"
|
||||
fi
|
||||
ZIP_NAME="GoNavi-${LABEL}-macos-${{ matrix.arch }}-run${GITHUB_RUN_NUMBER}.zip"
|
||||
mkdir -p artifacts
|
||||
ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "artifacts/$ZIP_NAME"
|
||||
shasum -a 256 "artifacts/$ZIP_NAME" > "artifacts/$ZIP_NAME.sha256"
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: gonavi-macos-${{ matrix.arch }}-run${{ github.run_number }}
|
||||
path: artifacts/*
|
||||
if-no-files-found: error
|
||||
11
.gitignore
vendored
@@ -1,7 +1,7 @@
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
.gitignore
|
||||
# build / release artifacts
|
||||
frontend/release/
|
||||
**/release/
|
||||
@@ -10,17 +10,26 @@ build/bin/
|
||||
|
||||
# wails / node artifacts (按需)
|
||||
node_modules/
|
||||
frontend/wailsjs/tsconfig.json
|
||||
|
||||
dist/
|
||||
.DS_Store
|
||||
.gemini-clipboard
|
||||
GoNavi-Wails
|
||||
GoNavi-Wails.exe
|
||||
optional-driver-agent
|
||||
optional-driver-agent.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 包”的分发能力。
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
Thank you for contributing to this project.
|
||||
|
||||
This repository follows a release-first workflow: `main` is the default public branch, while releases are prepared through `release/*` branches.
|
||||
This repository uses `dev` as the default integration branch, while stable releases are published from `main` through `release/*` branches.
|
||||
|
||||
---
|
||||
|
||||
## Branch Model
|
||||
|
||||
- `main`: stable release branch and default branch
|
||||
- `dev`: day-to-day integration branch for maintainers
|
||||
- `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
|
||||
@@ -25,21 +25,21 @@ 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 `main`**.
|
||||
Whether your branch is `fix/*` or `feature/*`, external contributors should **open pull requests directly against `dev`**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- `main` is the default branch, so the PR entry point is clearer
|
||||
- merged contributions are immediately visible on the default branch
|
||||
- maintainers can handle downstream sync and release preparation in one place
|
||||
- `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. Create a branch in your fork (`fix/*` or `feature/*` is recommended)
|
||||
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 `main` branch of this repository
|
||||
5. Open a pull request against the `dev` branch of this repository
|
||||
|
||||
---
|
||||
|
||||
@@ -63,33 +63,21 @@ Recommended expectations:
|
||||
|
||||
## Merge Strategy for Maintainers
|
||||
|
||||
Pull requests merged into `main` should generally use **Squash and merge**.
|
||||
Pull requests merged into `dev` should generally use **Squash and merge**.
|
||||
|
||||
Reasons:
|
||||
|
||||
- keeps `main` history clean and linear
|
||||
- maps each PR to a single commit on `main`
|
||||
- reduces release, audit, and rollback complexity
|
||||
- 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 `main`, maintainers must sync `main` back to development and release branches to avoid branch drift.
|
||||
Because external pull requests are merged directly into `dev`, maintainers should treat `dev` as the source branch for daily collaboration and release preparation.
|
||||
|
||||
### 1. Sync `main` -> `dev` (required)
|
||||
|
||||
The automatic GitHub Actions sync workflow has been removed.
|
||||
Maintainers should sync `main` back to `dev` manually when needed:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. Create `release/*` from `dev`
|
||||
### 1. Create `release/*` from `dev`
|
||||
|
||||
Before a release, create a release branch from `dev`, for example:
|
||||
|
||||
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. Release from `release/*` back to `main`
|
||||
### 2. Release from `release/*` back to `main`
|
||||
|
||||
When release preparation is complete, merge the release branch back into `main` and create a tag:
|
||||
|
||||
@@ -113,9 +101,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. Sync `main` back to `dev` after release
|
||||
### 3. Sync `main` back to `dev` after release
|
||||
|
||||
After the release, the same automation still applies. If needed, you can run the workflow manually (`workflow_dispatch`) or execute the fallback commands:
|
||||
After the release, sync `main` back into `dev` so the next iteration starts from the released code line:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
感谢你对本项目的贡献。
|
||||
|
||||
本项目采用“发布优先(`main` 为默认分支)+ `release/*` 分支发版”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
本项目当前采用“`dev` 作为默认集成分支,`main` 作为稳定发布分支,`release/*` 负责发版准备”的协作模型。为减少分支漂移与 PR 处理成本,请在提交贡献前先阅读本指南。
|
||||
|
||||
---
|
||||
|
||||
## 分支模型
|
||||
|
||||
- `main`:稳定发布分支,也是仓库默认分支
|
||||
- `dev`:日常开发集成分支,主要供维护者使用
|
||||
- `dev`:默认分支,也是日常开发集成分支
|
||||
- `main`:稳定发布分支
|
||||
- `release/*`:发布准备分支,主要供维护者使用
|
||||
- 外部贡献者建议使用以下分支命名:
|
||||
- `fix/*`:问题修复
|
||||
@@ -25,21 +25,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## 外部贡献者如何提 Pull Request
|
||||
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `main` 发起 Pull Request**。
|
||||
无论是 `fix/*` 还是 `feature/*`,**外部贡献者统一直接向 `dev` 发起 Pull Request**。
|
||||
|
||||
这样做的原因:
|
||||
|
||||
- `main` 是默认分支,PR 入口更直观
|
||||
- 合并后贡献会直接体现在默认分支
|
||||
- 便于维护者统一做后续同步与发版整理
|
||||
- `dev` 是当前日常集成分支,评审与合入路径和维护者开发流程一致
|
||||
- 外部贡献会直接进入触发日常校验和 dev 构建的分支
|
||||
- 维护者可以直接从 `dev` 切 `release/*`,减少额外同步步骤
|
||||
|
||||
建议流程:
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 从你自己的仓库创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
2. 先同步你 fork 中的 `dev`,再从 `dev` 创建分支(建议命名为 `fix/*` 或 `feature/*`)
|
||||
3. 完成代码修改,并进行必要自检
|
||||
4. 推送到你的远程分支
|
||||
5. 向本仓库的 `main` 分支发起 Pull Request
|
||||
5. 向本仓库的 `dev` 分支发起 Pull Request
|
||||
|
||||
---
|
||||
|
||||
@@ -63,33 +63,21 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z)
|
||||
|
||||
## PR 合并策略(维护者)
|
||||
|
||||
`main` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
`dev` 分支上的 PR 建议使用 **Squash and merge**。
|
||||
|
||||
原因:
|
||||
|
||||
- 保持 `main` 历史干净、线性
|
||||
- 每个 PR 在 `main` 上对应一个清晰提交
|
||||
- 降低发布排查与回滚成本
|
||||
- 保持 `dev` 集成历史清晰、便于审查
|
||||
- 每个 PR 在 `dev` 上对应一个明确的集成提交
|
||||
- 降低发版前整理与冲突处理成本
|
||||
|
||||
---
|
||||
|
||||
## 维护者同步规则
|
||||
|
||||
由于外部 PR 会直接合入 `main`,维护者必须及时将 `main` 的变更同步到开发与发布分支,避免分支漂移。
|
||||
由于外部 PR 会直接合入 `dev`,维护者应将 `dev` 作为日常协作与发版准备的主线分支。
|
||||
|
||||
### 1. main → dev 同步(必做)
|
||||
|
||||
仓库已移除 GitHub Actions 自动回灌 workflow。
|
||||
当前统一采用手动方式将 `main` 同步回 `dev`:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
git pull
|
||||
git merge main
|
||||
git push
|
||||
```
|
||||
|
||||
### 2. 发版前从 dev 切 release/*
|
||||
### 1. 发版前从 dev 切 release/*
|
||||
|
||||
发布前由维护者基于 `dev` 创建发布分支,例如:
|
||||
|
||||
@@ -100,7 +88,7 @@ git checkout -b release/v0.6.0
|
||||
git push -u origin release/v0.6.0
|
||||
```
|
||||
|
||||
### 3. release/* → main 发版
|
||||
### 2. release/* → main 发版
|
||||
|
||||
发布准备完成后,将 `release/*` 合并回 `main`,并打标签发布:
|
||||
|
||||
@@ -113,9 +101,9 @@ git tag v0.6.0
|
||||
git push origin v0.6.0
|
||||
```
|
||||
|
||||
### 4. main 回流到 dev(发版后必做)
|
||||
### 3. main 回流到 dev(发版后必做)
|
||||
|
||||
发布完成后,仍沿用同一套自动化流程;如有需要,也可以手动触发 `workflow_dispatch`,或执行以下兜底命令,确保开发线与发布线一致:
|
||||
发布完成后,需要将 `main` 回流到 `dev`,确保下一轮开发从已发布代码线继续推进:
|
||||
|
||||
```bash
|
||||
git checkout dev
|
||||
|
||||
61
README.md
@@ -5,6 +5,8 @@
|
||||
[](https://reactjs.org/)
|
||||
[](LICENSE)
|
||||
[](https://github.com/Syngnat/GoNavi/actions)
|
||||
[](https://github.com/Syngnat/GoNavi/stargazers)
|
||||
[](https://github.com/Syngnat/GoNavi/releases)
|
||||
|
||||
**Language**: English | [简体中文](README.zh-CN.md)
|
||||
|
||||
@@ -37,6 +39,7 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
| 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 |
|
||||
@@ -48,24 +51,31 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
| 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 |
|
||||
| Federated Query | Trino | Optional driver agent | Cross-source SQL via multiple catalogs, `catalog.schema` browsing, SQL execution |
|
||||
| Search | Elasticsearch | Optional driver agent | Index browsing, mapping inspection, JSON DSL / query_string search |
|
||||
| Extensibility | Custom Driver/DSN | Custom | Extend to more data sources via Driver + DSN |
|
||||
|
||||
<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
|
||||
|
||||
### 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.
|
||||
|
||||
### Performance
|
||||
- **Smooth interaction under load**: optimized table interaction (including column resize workflow on large datasets).
|
||||
- **Virtualized rendering**: keeps large result sets responsive.
|
||||
@@ -125,17 +135,26 @@ GoNavi is designed for developers and DBAs who need a unified desktop experience
|
||||
- [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`
|
||||
`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
|
||||
@@ -195,6 +214,16 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
|
||||
|
||||
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
|
||||
@@ -205,7 +234,21 @@ For the full workflow, branch model, and maintainer sync rules, see:
|
||||
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
External contributors should open pull requests directly against `main`.
|
||||
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
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
[](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) | 简体中文
|
||||
|
||||
@@ -36,6 +38,7 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
| 缓存 | Redis | 内置 | Key 浏览、命令执行、编码/视图切换 |
|
||||
| 关系型 | MariaDB | 可选驱动代理 | 连接查询、对象管理、数据编辑 |
|
||||
| 关系型 | Doris | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
| 列式分析 | StarRocks | 可选驱动代理 | 连接查询、对象浏览、SQL 执行 |
|
||||
| 搜索 | Sphinx | 可选驱动代理 | SphinxQL 查询与对象浏览 |
|
||||
| 关系型 | SQL Server | 可选驱动代理 | 库表浏览、SQL 查询、对象管理 |
|
||||
| 文件型 | SQLite | 可选驱动代理 | 本地文件库浏览、编辑、导出 |
|
||||
@@ -47,24 +50,31 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
| 文档型 | MongoDB | 可选驱动代理 | 文档查询、集合浏览、连接管理 |
|
||||
| 时序 | TDengine | 可选驱动代理 | 时序库表浏览、查询分析 |
|
||||
| 列式分析 | ClickHouse | 可选驱动代理 | 分析查询、对象浏览、SQL 执行 |
|
||||
| 联邦查询 | Trino | 可选驱动代理 | 跨多数据源联邦 SQL、`catalog.schema` 浏览、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/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>
|
||||
|
||||
---
|
||||
|
||||
## 核心特性
|
||||
|
||||
### AI 智能助手 (New)
|
||||
- **多模型服务商支持**:内置跨平台接入 OpenAI, Google Gemini, Anthropic Claude,同时支持任意自定义兼容 OpenAI 格式的 API。
|
||||
- **关联表结构上下文**:原生支持将当前数据库表结构直接提取作为上下文发送给 AI,让 SQL 生成、分析变得更精准。
|
||||
- **快捷指令**:内置多种快捷对话指(如一键生成 SQL、解释执行逻辑、分析性能优化、表字段代码评审等)。
|
||||
|
||||
### 性能与交互
|
||||
- 大数据场景下保持流畅交互(含 DataGrid 列宽拖拽、批量编辑流程优化)。
|
||||
- 虚拟滚动渲染,降低大结果集卡顿风险。
|
||||
@@ -119,17 +129,26 @@ GoNavi 面向开发者与 DBA,核心目标是让数据库操作在桌面端做
|
||||
- [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`
|
||||
`go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0`
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
```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
|
||||
```
|
||||
|
||||
### 编译构建
|
||||
@@ -178,6 +197,16 @@ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 贡献指南
|
||||
@@ -188,7 +217,22 @@ sudo apt-get install -y libgtk-3-0 libwebkit2gtk-4.0-37 libjavascriptcoregtk-4.0
|
||||
|
||||
- [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md)
|
||||
|
||||
外部贡献者统一直接向 `main` 发起 Pull Request。
|
||||
外部贡献者应从 `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/)
|
||||
|
||||
## 开源协议
|
||||
|
||||
|
||||
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
|
||||
@@ -5,7 +5,11 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
DEFAULT_DRIVERS=(mariadb doris sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase mongodb tdengine clickhouse)
|
||||
DEFAULT_DRIVERS=(mariadb oceanbase doris starrocks sphinx sqlserver sqlite duckdb dameng kingbase highgo vastbase opengauss gaussdb iris mongodb tdengine iotdb 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'
|
||||
@@ -14,17 +18,21 @@ usage() {
|
||||
|
||||
选项:
|
||||
--drivers <列表> 指定驱动列表(逗号分隔),例如:kingbase,mongodb
|
||||
--platform <GOOS/GOARCH>
|
||||
目标平台,默认使用当前 Go 环境(go env GOOS/GOARCH)
|
||||
--platform <目标> 目标平台:current、all、GOOS/GOARCH,或逗号分隔列表
|
||||
默认 current(当前 Go 环境)
|
||||
--out-dir <目录> 输出目录根路径,默认:dist/driver-agents
|
||||
--bundle-name <文件名> 驱动总包 zip 名称,默认:GoNavi-DriverAgents.zip
|
||||
--strict 任一驱动构建失败即中断(默认失败后继续,最后汇总)
|
||||
--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
|
||||
}
|
||||
|
||||
@@ -33,7 +41,10 @@ normalize_driver() {
|
||||
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]' | xargs)"
|
||||
case "$name" in
|
||||
doris|diros) echo "doris" ;;
|
||||
mariadb|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|mongodb|tdengine|clickhouse)
|
||||
open_gauss|open-gauss) echo "opengauss" ;;
|
||||
gaussdb|gauss_db|gauss-db) echo "gaussdb" ;;
|
||||
elasticsearch|elastic) echo "elasticsearch" ;;
|
||||
mariadb|oceanbase|starrocks|sphinx|sqlserver|sqlite|duckdb|dameng|kingbase|highgo|vastbase|opengauss|gaussdb|iris|mongodb|tdengine|iotdb|clickhouse)
|
||||
echo "$name"
|
||||
;;
|
||||
*)
|
||||
@@ -58,11 +69,177 @@ platform_dir_name() {
|
||||
esac
|
||||
}
|
||||
|
||||
current_platform() {
|
||||
echo "$(go env GOOS)/$(go env GOARCH)"
|
||||
}
|
||||
|
||||
append_platform() {
|
||||
local candidate
|
||||
candidate="$1"
|
||||
if [[ "$platform_seen" == *"|$candidate|"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
platforms+=("$candidate")
|
||||
platform_seen="${platform_seen}${candidate}|"
|
||||
}
|
||||
|
||||
normalize_platform() {
|
||||
local value goos goarch platform_dir
|
||||
value="$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
case "$value" in
|
||||
current|"")
|
||||
current_platform
|
||||
;;
|
||||
*/*)
|
||||
goos="${value%%/*}"
|
||||
goarch="${value##*/}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
if [[ -z "$goos" || -z "$goarch" || "$platform_dir" == "Unknown" ]]; then
|
||||
return 1
|
||||
fi
|
||||
echo "$goos/$goarch"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
zip_bundle() {
|
||||
local bundle_zip_path="$1"
|
||||
local bundle_stage_dir="$2"
|
||||
local -a bundle_dirs=()
|
||||
local dir
|
||||
|
||||
for dir in "$bundle_stage_dir"/*; do
|
||||
[[ -d "$dir" ]] || continue
|
||||
bundle_dirs+=("$(basename "$dir")")
|
||||
done
|
||||
|
||||
if [[ ${#bundle_dirs[@]} -eq 0 ]]; then
|
||||
echo "❌ 驱动总包 staging 目录为空。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$bundle_zip_path"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
zip -qry "$bundle_zip_path" "${bundle_dirs[@]}"
|
||||
)
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
BUNDLE_STAGE_DIR="$bundle_stage_dir" BUNDLE_ZIP_PATH="$bundle_zip_path" python3 - <<'PY'
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
stage = Path(os.environ["BUNDLE_STAGE_DIR"])
|
||||
target = Path(os.environ["BUNDLE_ZIP_PATH"])
|
||||
with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in stage.rglob("*"):
|
||||
if path.is_file():
|
||||
zf.write(path, path.relative_to(stage).as_posix())
|
||||
PY
|
||||
else
|
||||
echo "❌ 未找到 zip 或 python3,无法生成驱动总包 zip。"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
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
|
||||
@@ -86,6 +263,14 @@ while [[ $# -gt 0 ]]; do
|
||||
strict_mode="true"
|
||||
shift
|
||||
;;
|
||||
--upx)
|
||||
upx_mode="required"
|
||||
shift
|
||||
;;
|
||||
--no-upx)
|
||||
upx_mode="off"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
@@ -103,20 +288,6 @@ if ! command -v go >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$target_platform" ]]; then
|
||||
target_platform="$(go env GOOS)/$(go env GOARCH)"
|
||||
fi
|
||||
|
||||
if [[ "$target_platform" != */* ]]; then
|
||||
echo "❌ --platform 参数格式错误,应为 GOOS/GOARCH,例如 darwin/arm64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
goos="${target_platform%%/*}"
|
||||
goarch="${target_platform##*/}"
|
||||
platform_key="${goos}-${goarch}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
|
||||
declare -a drivers=()
|
||||
if [[ -n "$driver_csv" ]]; then
|
||||
IFS=',' read -r -a raw_drivers <<<"$driver_csv"
|
||||
@@ -130,67 +301,135 @@ if [[ -n "$driver_csv" ]]; then
|
||||
else
|
||||
drivers=("${DEFAULT_DRIVERS[@]}")
|
||||
fi
|
||||
revision_driver_csv="$(join_by_comma "${drivers[@]}")"
|
||||
|
||||
output_dir="${out_root%/}/${platform_key}"
|
||||
declare -a platforms=()
|
||||
platform_seen="|"
|
||||
if [[ -z "$target_platform" ]]; then
|
||||
target_platform="current"
|
||||
fi
|
||||
IFS=',' read -r -a raw_platforms <<<"$target_platform"
|
||||
for item in "${raw_platforms[@]}"; do
|
||||
normalized_platform="$(printf '%s' "$item" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
if [[ "$normalized_platform" == "all" ]]; then
|
||||
for default_platform in "${DEFAULT_PLATFORMS[@]}"; do
|
||||
append_platform "$default_platform"
|
||||
done
|
||||
continue
|
||||
fi
|
||||
normalized_platform="$(normalize_platform "$item")" || {
|
||||
echo "❌ --platform 参数格式错误,应为 current、all、GOOS/GOARCH 或逗号分隔列表,例如 darwin/arm64,windows/amd64"
|
||||
exit 1
|
||||
}
|
||||
append_platform "$normalized_platform"
|
||||
done
|
||||
|
||||
if [[ ${#platforms[@]} -eq 0 ]]; then
|
||||
echo "❌ 未指定有效目标平台。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out_root"
|
||||
out_root_abs="$(cd "$out_root" && pwd)"
|
||||
bundle_stage_dir="$(mktemp -d "${TMPDIR:-/tmp}/gonavi-driver-bundle.XXXXXX")"
|
||||
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$bundle_stage_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$output_dir" "$bundle_platform_dir"
|
||||
output_dir_abs="$(cd "$output_dir" && pwd)"
|
||||
bundle_zip_path="$output_dir_abs/$bundle_name"
|
||||
if [[ ${#platforms[@]} -eq 1 ]]; then
|
||||
single_platform="${platforms[0]}"
|
||||
single_platform_key="${single_platform/\//-}"
|
||||
single_output_dir="${out_root%/}/$single_platform_key"
|
||||
mkdir -p "$single_output_dir"
|
||||
bundle_zip_path="$(cd "$single_output_dir" && pwd)/$bundle_name"
|
||||
else
|
||||
bundle_zip_path="$out_root_abs/$bundle_name"
|
||||
fi
|
||||
|
||||
declare -a built_assets=()
|
||||
declare -a failed_drivers=()
|
||||
declare -a skipped_drivers=()
|
||||
|
||||
echo "🚀 开始构建 optional-driver-agent"
|
||||
echo " 平台:$goos/$goarch"
|
||||
echo " 输出目录:$output_dir_abs"
|
||||
echo " 平台:${platforms[*]}"
|
||||
echo " 输出根目录:$out_root_abs"
|
||||
echo " 驱动列表:${drivers[*]}"
|
||||
|
||||
for driver in "${drivers[@]}"; do
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||
echo "⚠️ 跳过 duckdb(仅支持 windows/amd64)"
|
||||
skipped_drivers+=("$driver")
|
||||
continue
|
||||
fi
|
||||
for platform in "${platforms[@]}"; do
|
||||
goos="${platform%%/*}"
|
||||
goarch="${platform##*/}"
|
||||
platform_key="${goos}-${goarch}"
|
||||
platform_dir="$(platform_dir_name "$goos")"
|
||||
output_dir="${out_root%/}/${platform_key}"
|
||||
bundle_platform_dir="$bundle_stage_dir/$platform_dir"
|
||||
|
||||
build_driver="$(build_driver_name "$driver")"
|
||||
tag="gonavi_${build_driver}_driver"
|
||||
asset_name="${driver}-driver-agent-${goos}-${goarch}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
asset_name="${asset_name}.exe"
|
||||
fi
|
||||
output_path="$output_dir_abs/$asset_name"
|
||||
mkdir -p "$output_dir" "$bundle_platform_dir"
|
||||
output_dir_abs="$(cd "$output_dir" && pwd)"
|
||||
|
||||
cgo_enabled=0
|
||||
if [[ "$driver" == "duckdb" ]]; then
|
||||
cgo_enabled=1
|
||||
fi
|
||||
echo ""
|
||||
echo "🧭 生成 driver-agent revision 指纹:$platform"
|
||||
"$SCRIPT_DIR/tools/generate-driver-agent-revisions.sh" --platform "$platform" --drivers "$revision_driver_csv"
|
||||
|
||||
echo "🔧 构建 $driver -> $asset_name (tag=$tag, CGO_ENABLED=$cgo_enabled)"
|
||||
set +e
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
go build -tags "$tag" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
build_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ $build_exit -ne 0 ]]; then
|
||||
echo "❌ 构建失败:$driver"
|
||||
failed_drivers+=("$driver")
|
||||
if [[ "$strict_mode" == "true" ]]; then
|
||||
exit $build_exit
|
||||
for driver in "${drivers[@]}"; do
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" != "amd64" ]]; then
|
||||
echo "⚠️ 跳过 duckdb($platform 仅支持 windows/amd64)"
|
||||
skipped_drivers+=("duckdb($platform)")
|
||||
continue
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
cp "$output_path" "$bundle_platform_dir/$asset_name"
|
||||
built_assets+=("$asset_name")
|
||||
build_driver="$(build_driver_name "$driver")"
|
||||
tag="gonavi_${build_driver}_driver"
|
||||
build_tags="$tag"
|
||||
asset_name="${driver}-driver-agent-${goos}-${goarch}"
|
||||
if [[ "$goos" == "windows" ]]; then
|
||||
asset_name="${asset_name}.exe"
|
||||
fi
|
||||
output_path="$output_dir_abs/$asset_name"
|
||||
|
||||
cgo_enabled=0
|
||||
if [[ "$driver" == "duckdb" ]]; then
|
||||
cgo_enabled=1
|
||||
fi
|
||||
duckdb_lib_dir=""
|
||||
if [[ "$driver" == "duckdb" && "$goos" == "windows" && "$goarch" == "amd64" ]]; then
|
||||
duckdb_lib_dir="$(prepare_duckdb_windows_library "$bundle_stage_dir")"
|
||||
build_tags="$build_tags duckdb_use_lib"
|
||||
fi
|
||||
|
||||
echo "🔧 构建 $driver -> $asset_name (platform=$platform, tags=$build_tags, CGO_ENABLED=$cgo_enabled)"
|
||||
set +e
|
||||
if [[ -n "$duckdb_lib_dir" ]]; then
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
CGO_LDFLAGS="-L${duckdb_lib_dir} -lduckdb" PATH="${duckdb_lib_dir}:$PATH" \
|
||||
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
else
|
||||
CGO_ENABLED="$cgo_enabled" GOOS="$goos" GOARCH="$goarch" GOTOOLCHAIN=auto \
|
||||
go build -tags "$build_tags" -trimpath -ldflags "-s -w" -o "$output_path" ./cmd/optional-driver-agent
|
||||
fi
|
||||
build_exit=$?
|
||||
set -e
|
||||
|
||||
if [[ $build_exit -ne 0 ]]; then
|
||||
echo "❌ 构建失败:$driver ($platform)"
|
||||
failed_drivers+=("$driver($platform)")
|
||||
if [[ "$strict_mode" == "true" ]]; then
|
||||
exit $build_exit
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
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
|
||||
@@ -198,25 +437,19 @@ if [[ ${#built_assets[@]} -eq 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -f "$bundle_zip_path"
|
||||
if command -v zip >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
zip -qry "$bundle_zip_path" "$platform_dir"
|
||||
)
|
||||
elif command -v ditto >/dev/null 2>&1; then
|
||||
(
|
||||
cd "$bundle_stage_dir"
|
||||
ditto -c -k --sequesterRsrc --keepParent "$platform_dir" "$bundle_zip_path"
|
||||
)
|
||||
else
|
||||
echo "❌ 未找到 zip/ditto,无法生成驱动总包 zip。"
|
||||
exit 1
|
||||
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 " 单文件输出目录:$output_dir_abs"
|
||||
echo " 单文件输出根目录:$out_root_abs"
|
||||
echo " 驱动总包:$bundle_zip_path"
|
||||
echo " 已构建:${built_assets[*]}"
|
||||
if [[ ${#skipped_drivers[@]} -gt 0 ]]; then
|
||||
|
||||
368
build-release.sh
@@ -1,16 +1,42 @@
|
||||
#!/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"
|
||||
|
||||
@@ -20,6 +46,13 @@ RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
BUILD_FAILURES=()
|
||||
|
||||
record_build_failure() {
|
||||
local target="$1"
|
||||
BUILD_FAILURES+=("$target")
|
||||
}
|
||||
|
||||
get_file_size_bytes() {
|
||||
local target="$1"
|
||||
if [ ! -f "$target" ]; then
|
||||
@@ -84,229 +117,102 @@ try_compress_binary_with_upx() {
|
||||
fi
|
||||
}
|
||||
|
||||
MAC_VOLICON_PATH="build/darwin/icon.icns"
|
||||
if [ ! -f "$MAC_VOLICON_PATH" ]; then
|
||||
MAC_VOLICON_PATH=""
|
||||
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 -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-arm64.app"
|
||||
DMG_NAME="${APP_NAME}-${VERSION}-mac-arm64.dmg"
|
||||
|
||||
# 移动 .app 到 dist
|
||||
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
|
||||
echo -e "${YELLOW} ⚠️ macOS arm64 不再执行 UPX 压缩,保留原始主程序。${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS arm64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名(无 Apple Developer 账号时防止 Gatekeeper 报已损坏)
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (arm64)..."
|
||||
codesign --force --deep --sign - "$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 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-arm64.XXXXXX")
|
||||
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
|
||||
else
|
||||
if command -v ditto &> /dev/null; then
|
||||
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
else
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
|
||||
fi
|
||||
|
||||
create-dmg "${CREATE_DMG_ARGS[@]}" \
|
||||
--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" \
|
||||
"$STAGE_DIR"
|
||||
|
||||
CREATE_DMG_EXIT_CODE=$?
|
||||
rm -rf "$STAGE_DIR"
|
||||
|
||||
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
|
||||
else
|
||||
# create-dmg 可能会在失败时遗留 rw.*.dmg 中间产物;不要直接当作最终产物使用
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
|
||||
if [ -n "$RW_FILE" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
|
||||
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
rm -f "$RW_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 防御性:即使生成了目标文件,也要确保不是 UDRW(UDRW 在 Finder 下可能表现为“已损坏/无法打开”)
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
|
||||
if [ "$DMG_FORMAT" = "UDRW" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}"
|
||||
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
|
||||
rm -f "$TMP_UDZO"
|
||||
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
else
|
||||
# 删除中间的 .app 文件,保持目录整洁
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
echo -e "${RED} ❌ DMG 生成失败,请检查 create-dmg 输出。${NC}"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 create-dmg 工具,跳过 DMG 打包,仅保留 .app。${NC}"
|
||||
echo " 安装命令: brew install create-dmg"
|
||||
fi
|
||||
else
|
||||
echo -e "${RED} ❌ macOS arm64 构建失败。${NC}"
|
||||
fi
|
||||
|
||||
# --- macOS AMD64 构建 ---
|
||||
echo -e "${GREEN}🍎 正在构建 macOS (amd64)...${NC}"
|
||||
wails build -platform darwin/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
APP_SRC="$BUILD_BIN_DIR/$DEFAULT_BINARY_NAME.app"
|
||||
APP_DEST_NAME="${APP_NAME}-${VERSION}-mac-amd64.app"
|
||||
DMG_NAME="${APP_NAME}-${VERSION}-mac-amd64.dmg"
|
||||
|
||||
mv "$APP_SRC" "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
APP_BIN_PATH=$(find "$DIST_DIR/$APP_DEST_NAME/Contents/MacOS" -maxdepth 1 -type f -print -quit)
|
||||
if [ -n "$APP_BIN_PATH" ] && [ -f "$APP_BIN_PATH" ]; then
|
||||
echo -e "${YELLOW} ⚠️ macOS amd64 不再执行 UPX 压缩,保留原始主程序。${NC}"
|
||||
else
|
||||
echo -e "${RED} ❌ 未找到 macOS amd64 主程序文件。${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ad-hoc 代码签名
|
||||
echo " 🔏 正在对 .app 进行 ad-hoc 签名 (amd64)..."
|
||||
codesign --force --deep --sign - "$DIST_DIR/$APP_DEST_NAME"
|
||||
|
||||
if command -v create-dmg &> /dev/null; then
|
||||
echo " 📦 正在打包 DMG (amd64)..."
|
||||
rm -f "$DIST_DIR/$DMG_NAME"
|
||||
# create-dmg 的 source 需要是“包含 .app 的目录”,不能直接传 .app 路径。
|
||||
STAGE_DIR=$(mktemp -d "$DIST_DIR/.dmg-stage-${APP_NAME}-${VERSION}-amd64.XXXXXX")
|
||||
if [ -z "$STAGE_DIR" ] || [ ! -d "$STAGE_DIR" ]; then
|
||||
echo -e "${RED} ❌ 创建 DMG 临时目录失败,跳过 DMG 打包。${NC}"
|
||||
else
|
||||
if command -v ditto &> /dev/null; then
|
||||
ditto "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
else
|
||||
cp -R "$DIST_DIR/$APP_DEST_NAME" "$STAGE_DIR/$APP_DEST_NAME"
|
||||
fi
|
||||
|
||||
# --sandbox-safe 会跳过 Finder 的 AppleScript 排版,避免打包过程中弹出/打开挂载窗口(CI/本地静默打包更友好)。
|
||||
CREATE_DMG_ARGS=(--volname "${APP_NAME} ${VERSION}" --format UDZO --sandbox-safe)
|
||||
if [ -n "$MAC_VOLICON_PATH" ]; then
|
||||
CREATE_DMG_ARGS+=(--volicon "$MAC_VOLICON_PATH")
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 macOS 卷图标 (build/darwin/icon.icns),跳过 --volicon。${NC}"
|
||||
fi
|
||||
|
||||
create-dmg "${CREATE_DMG_ARGS[@]}" \
|
||||
--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" \
|
||||
"$STAGE_DIR"
|
||||
|
||||
CREATE_DMG_EXIT_CODE=$?
|
||||
rm -rf "$STAGE_DIR"
|
||||
|
||||
if [ $CREATE_DMG_EXIT_CODE -ne 0 ]; then
|
||||
echo -e "${RED} ❌ create-dmg 执行失败 (exit=$CREATE_DMG_EXIT_CODE),保留 .app 以便排查。${NC}"
|
||||
else
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
RW_FILE=$(find "$DIST_DIR" -maxdepth 1 -name "rw.*.dmg" -print -quit)
|
||||
if [ -n "$RW_FILE" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 create-dmg 中间产物: $(basename "$RW_FILE"),正在转换为可分发 DMG...${NC}"
|
||||
hdiutil convert "$RW_FILE" -format UDZO -o "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
rm -f "$RW_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
DMG_FORMAT=$(hdiutil imageinfo "$DIST_DIR/$DMG_NAME" 2>/dev/null | awk -F': ' '/^Format:/{print $2; exit}')
|
||||
if [ "$DMG_FORMAT" = "UDRW" ]; then
|
||||
echo -e "${YELLOW} ⚠️ 检测到 UDRW(可写原始映像),正在转换为 UDZO...${NC}"
|
||||
TMP_UDZO="$DIST_DIR/.tmp.$DMG_NAME"
|
||||
rm -f "$TMP_UDZO"
|
||||
hdiutil convert "$DIST_DIR/$DMG_NAME" -format UDZO -o "$TMP_UDZO" >/dev/null 2>&1 && mv "$TMP_UDZO" "$DIST_DIR/$DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -f "$DIST_DIR/$DMG_NAME" ] && command -v hdiutil &> /dev/null; then
|
||||
hdiutil verify "$DIST_DIR/$DMG_NAME" >/dev/null 2>&1
|
||||
if [ $? -ne 0 ]; then
|
||||
echo -e "${RED} ❌ DMG 校验失败,保留 .app 以便排查。${NC}"
|
||||
else
|
||||
rm -rf "$DIST_DIR/$APP_DEST_NAME"
|
||||
echo " ✅ 已生成 $DMG_NAME"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "$DIST_DIR/$DMG_NAME" ]; then
|
||||
echo -e "${RED} ❌ DMG 生成失败。${NC}"
|
||||
fi
|
||||
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
|
||||
generate_driver_agent_revisions "windows/amd64"
|
||||
wails build -platform windows/amd64 -clean -ldflags "$LDFLAGS"
|
||||
if [ $? -eq 0 ]; then
|
||||
TARGET_EXE="$DIST_DIR/${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
@@ -315,6 +221,7 @@ if command -v x86_64-w64-mingw32-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-amd64.exe"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows amd64 构建失败。${NC}"
|
||||
record_build_failure "Windows amd64"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW 工具 (x86_64-w64-mingw32-gcc),跳过 Windows amd64 构建。${NC}"
|
||||
@@ -323,6 +230,7 @@ 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"
|
||||
@@ -331,6 +239,7 @@ if command -v aarch64-w64-mingw32-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-windows-arm64.exe"
|
||||
else
|
||||
echo -e "${RED} ❌ Windows arm64 构建失败。${NC}"
|
||||
record_build_failure "Windows arm64"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW} ⚠️ 未找到 MinGW ARM64 工具 (aarch64-w64-mingw32-gcc),跳过 Windows arm64 构建。${NC}"
|
||||
@@ -345,6 +254,7 @@ 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"
|
||||
@@ -359,12 +269,14 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "x86_64" ]; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux amd64 构建失败。${NC}"
|
||||
record_build_failure "Linux amd64"
|
||||
fi
|
||||
elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||
# macOS 或其他系统,尝试交叉编译
|
||||
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"
|
||||
@@ -378,6 +290,7 @@ elif command -v x86_64-linux-gnu-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-amd64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux amd64 交叉编译失败。${NC}"
|
||||
record_build_failure "Linux amd64"
|
||||
fi
|
||||
unset CC CXX CGO_ENABLED
|
||||
else
|
||||
@@ -389,6 +302,7 @@ fi
|
||||
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"
|
||||
@@ -402,12 +316,14 @@ if [ "$CURRENT_OS" = "Linux" ] && [ "$CURRENT_ARCH" = "aarch64" ]; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux arm64 构建失败。${NC}"
|
||||
record_build_failure "Linux arm64"
|
||||
fi
|
||||
elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
||||
# 交叉编译
|
||||
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"
|
||||
@@ -421,6 +337,7 @@ elif command -v aarch64-linux-gnu-gcc &> /dev/null; then
|
||||
echo " ✅ 已生成 ${APP_NAME}-${VERSION}-linux-arm64.tar.gz"
|
||||
else
|
||||
echo -e "${RED} ❌ Linux arm64 交叉编译失败。${NC}"
|
||||
record_build_failure "Linux arm64"
|
||||
fi
|
||||
unset CC CXX CGO_ENABLED
|
||||
else
|
||||
@@ -454,12 +371,21 @@ else
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
|
||||
echo -e "${RED}❌ 构建未完全成功,失败平台:${BUILD_FAILURES[*]}${NC}"
|
||||
echo -e "${YELLOW}📦 已成功生成的产物在 'dist/' 目录下:${NC}"
|
||||
else
|
||||
echo -e "${GREEN}🎉 所有任务完成!构建产物在 'dist/' 目录下:${NC}"
|
||||
fi
|
||||
ls -lh "$DIST_DIR"
|
||||
echo ""
|
||||
echo -e "${GREEN}📋 支持的平台:${NC}"
|
||||
echo " • macOS (Intel/Apple Silicon): .dmg"
|
||||
echo " • macOS (Intel/Apple Silicon): .zip"
|
||||
echo " • Windows (x64/ARM64): .exe"
|
||||
echo " • Linux (x64/ARM64): .tar.gz"
|
||||
echo ""
|
||||
echo -e "${YELLOW}💡 提示:Linux AppImage 包请使用 GitHub Actions CI/CD 构建。${NC}"
|
||||
|
||||
if [ "${#BUILD_FAILURES[@]}" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
@@ -17,6 +21,7 @@ import (
|
||||
type agentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Config *connection.ConnectionConfig `json:"config,omitempty"`
|
||||
Query string `json:"query,omitempty"`
|
||||
TimeoutMs int64 `json:"timeoutMs,omitempty"`
|
||||
@@ -31,14 +36,21 @@ type agentResponse struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
Messages []string `json:"messages,omitempty"`
|
||||
ChunkType string `json:"chunkType,omitempty"`
|
||||
RowsAffected int64 `json:"rowsAffected,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
agentMethodConnect = "connect"
|
||||
agentMethodClose = "close"
|
||||
agentMethodMetadata = "metadata"
|
||||
agentMethodPing = "ping"
|
||||
agentMethodOpenSession = "openSession"
|
||||
agentMethodCloseSession = "closeSession"
|
||||
agentMethodQuery = "query"
|
||||
agentMethodQueryMulti = "queryMulti"
|
||||
agentMethodStreamQuery = "streamQuery"
|
||||
agentMethodExec = "exec"
|
||||
agentMethodGetDatabases = "getDatabases"
|
||||
agentMethodGetTables = "getTables"
|
||||
@@ -53,23 +65,67 @@ const (
|
||||
|
||||
const legacyClickHouseDefaultTimeout = 2 * time.Hour
|
||||
|
||||
var (
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
const (
|
||||
agentChunkColumns = "columns"
|
||||
agentChunkRows = "rows"
|
||||
agentChunkDone = "done"
|
||||
// agentStreamBatchSize 控制 driver-agent 向主进程发送 row chunk 的批次大小。
|
||||
// 调小到 64:单批 JSON 编码 + 主进程解码的瞬时内存峰值降为原来的 1/4,
|
||||
// 代价是 IPC 次数变为 4 倍,但每批仅一次 stdin/stdout 行读写,整体影响可忽略。
|
||||
// 重要:减小批次不能根除内存峰值,仍需配合 SetGCPercent + 周期 GC(见 main)。
|
||||
agentStreamBatchSize = 64
|
||||
agentMemoryTrimRowsThreshold = 100000
|
||||
agentMemoryTrimMinInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
agentDriverType string
|
||||
agentDatabaseFactory func() db.Database
|
||||
agentMemoryTrimRunning atomic.Bool
|
||||
agentMemoryTrimLastAt atomic.Int64
|
||||
runAgentMemoryTrimAsync = func(fn func()) {
|
||||
go fn()
|
||||
}
|
||||
agentMemoryTrimFn = func() {
|
||||
runtime.GC()
|
||||
debug.FreeOSMemory()
|
||||
}
|
||||
)
|
||||
|
||||
type agentRuntime struct {
|
||||
inst db.Database
|
||||
sessions map[string]db.StatementExecer
|
||||
nextSessionID int64
|
||||
}
|
||||
|
||||
func main() {
|
||||
if agentDatabaseFactory == nil || strings.TrimSpace(agentDriverType) == "" {
|
||||
fmt.Fprintf(os.Stderr, "未配置驱动代理 provider,请使用 gonavi_<driver>_driver 标签构建\n")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// driver-agent 是独立进程,主进程无法控制其 GC 行为。
|
||||
// 大结果集(88W+ 行)通过 JSON-lines 跨进程传输时,每行有 5-8 倍内存副本;
|
||||
// Go 默认 GOGC=100 + Windows MADV_FREE 不归还 RSS,会导致 driver-agent 进程
|
||||
// 内存峰值达到数据总量的 10+ 倍(用户实测 88W 普通业务表撑到 8G+)。
|
||||
//
|
||||
// GC 策略组合:
|
||||
// - SetGCPercent(50):堆增长 50% 即触发 GC,比默认 100 更早收敛
|
||||
// - InitMemorySoftLimit:起始 2GB,运行时由 MaybeGrowMemoryLimit 自适应抬升到最多 8GB
|
||||
// (起步保守 + 按需扩张,避免静态 2GB 限制在大表场景触发 GC 硬模式降速 15-25%)
|
||||
//
|
||||
// 代价:CPU 开销增加约 5-10%。导出场景是 I/O 密集型,可忽略。
|
||||
debug.SetGCPercent(50)
|
||||
db.InitMemorySoftLimit(db.MemorySoftLimitInitialBytes)
|
||||
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Buffer(make([]byte, 0, 16<<10), 8<<20)
|
||||
writer := bufio.NewWriter(os.Stdout)
|
||||
defer writer.Flush()
|
||||
|
||||
var inst db.Database
|
||||
runtimeState := &agentRuntime{
|
||||
sessions: make(map[string]db.StatementExecer),
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
@@ -86,23 +142,32 @@ func main() {
|
||||
continue
|
||||
}
|
||||
|
||||
resp := handleRequest(&inst, req)
|
||||
if strings.TrimSpace(req.Method) == agentMethodStreamQuery {
|
||||
if err := handleStreamRequest(runtimeState, req, writer); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入流式响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
resp := handleRequest(runtimeState, req)
|
||||
if err := writeResponse(writer, resp); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "写入响应失败:%v\n", err)
|
||||
break
|
||||
}
|
||||
if strings.TrimSpace(req.Method) == agentMethodQuery {
|
||||
maybeReleaseAgentMemory("query-response", countAgentResponseRows(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
if inst != nil {
|
||||
_ = inst.Close()
|
||||
}
|
||||
runtimeState.close()
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "读取请求失败:%v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
func handleRequest(runtimeState *agentRuntime, req agentRequest) agentResponse {
|
||||
resp := agentResponse{ID: req.ID, Success: true}
|
||||
method := strings.TrimSpace(req.Method)
|
||||
|
||||
@@ -111,9 +176,7 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
if req.Config == nil {
|
||||
return fail(resp, "连接配置为空")
|
||||
}
|
||||
if *inst != nil {
|
||||
_ = (*inst).Close()
|
||||
}
|
||||
runtimeState.close()
|
||||
next := agentDatabaseFactory()
|
||||
if next == nil {
|
||||
return fail(resp, "驱动代理初始化失败")
|
||||
@@ -121,84 +184,162 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
if err := next.Connect(*req.Config); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
*inst = next
|
||||
runtimeState.inst = next
|
||||
return resp
|
||||
case agentMethodClose:
|
||||
if *inst != nil {
|
||||
if err := (*inst).Close(); err != nil {
|
||||
if runtimeState.inst != nil {
|
||||
if err := runtimeState.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
|
||||
case agentMethodOpenSession:
|
||||
if runtimeState.inst == nil {
|
||||
return fail(resp, "connection not open")
|
||||
}
|
||||
provider, ok := runtimeState.inst.(db.SessionExecerProvider)
|
||||
if !ok {
|
||||
return fail(resp, fmt.Sprintf("当前数据源(%s)不支持 SQL 编辑器托管事务", strings.TrimSpace(agentDriverType)))
|
||||
}
|
||||
openCtx := context.Background()
|
||||
var cancel context.CancelFunc
|
||||
if req.TimeoutMs > 0 {
|
||||
openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(req.TimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
session, err := provider.OpenSessionExecer(openCtx)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
sessionID := runtimeState.nextID()
|
||||
runtimeState.sessions[sessionID] = session
|
||||
resp.Data = sessionID
|
||||
return resp
|
||||
case agentMethodCloseSession:
|
||||
if err := runtimeState.closeSession(req.SessionID); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
if *inst == nil {
|
||||
if runtimeState.inst == nil {
|
||||
return fail(resp, "connection not open")
|
||||
}
|
||||
|
||||
if session, ok, err := runtimeState.session(req.SessionID); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
} else if ok {
|
||||
switch method {
|
||||
case agentMethodQuery:
|
||||
data, fields, messages, err := queryStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
resp.Messages = messages
|
||||
case agentMethodQueryMulti:
|
||||
data, messages, supported, err := queryMultiStatementWithMessagesOptionalTimeout(session, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
if !supported {
|
||||
return fail(resp, "当前事务会话不支持多结果集查询")
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Messages = messages
|
||||
case agentMethodExec:
|
||||
affected, err := execStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.RowsAffected = affected
|
||||
default:
|
||||
return fail(resp, "当前事务会话不支持该方法")
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
switch method {
|
||||
case agentMethodPing:
|
||||
if err := (*inst).Ping(); err != nil {
|
||||
if err := runtimeState.inst.Ping(); err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
case agentMethodQuery:
|
||||
data, fields, err := queryWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
data, fields, messages, err := queryWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Fields = fields
|
||||
resp.Messages = messages
|
||||
case agentMethodQueryMulti:
|
||||
data, messages, supported, err := queryMultiWithMessagesOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
if !supported {
|
||||
return fail(resp, "当前驱动不支持原生多结果集查询")
|
||||
}
|
||||
resp.Data = data
|
||||
resp.Messages = messages
|
||||
case agentMethodExec:
|
||||
affected, err := execWithOptionalTimeout(*inst, req.Query, req.TimeoutMs)
|
||||
affected, err := execWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.RowsAffected = affected
|
||||
case agentMethodGetDatabases:
|
||||
data, err := (*inst).GetDatabases()
|
||||
data, err := runtimeState.inst.GetDatabases()
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
resp.Data = data
|
||||
case agentMethodGetTables:
|
||||
data, err := (*inst).GetTables(req.DBName)
|
||||
data, err := runtimeState.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)
|
||||
data, err := runtimeState.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)
|
||||
data, err := runtimeState.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)
|
||||
data, err := runtimeState.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)
|
||||
data, err := runtimeState.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)
|
||||
data, err := runtimeState.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)
|
||||
data, err := runtimeState.inst.GetTriggers(req.DBName, req.TableName)
|
||||
if err != nil {
|
||||
return fail(resp, err.Error())
|
||||
}
|
||||
@@ -207,7 +348,7 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
if req.Changes == nil {
|
||||
return fail(resp, "变更集为空")
|
||||
}
|
||||
applier, ok := (*inst).(interface {
|
||||
applier, ok := runtimeState.inst.(interface {
|
||||
ApplyChanges(tableName string, changes connection.ChangeSet) error
|
||||
})
|
||||
if !ok {
|
||||
@@ -223,6 +364,169 @@ func handleRequest(inst *db.Database, req agentRequest) agentResponse {
|
||||
return resp
|
||||
}
|
||||
|
||||
type agentStreamResponseWriter struct {
|
||||
writer *bufio.Writer
|
||||
requestID int64
|
||||
columns []string
|
||||
rows [][]interface{}
|
||||
rowCount int64
|
||||
}
|
||||
|
||||
func newAgentStreamResponseWriter(writer *bufio.Writer, requestID int64) *agentStreamResponseWriter {
|
||||
return &agentStreamResponseWriter{
|
||||
writer: writer,
|
||||
requestID: requestID,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) SetColumns(columns []string) error {
|
||||
w.columns = append([]string(nil), columns...)
|
||||
return writeResponse(w.writer, agentResponse{
|
||||
ID: w.requestID,
|
||||
Success: true,
|
||||
ChunkType: agentChunkColumns,
|
||||
Fields: w.columns,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) ConsumeRow(row map[string]interface{}) error {
|
||||
if len(w.columns) == 0 {
|
||||
return fmt.Errorf("流式查询缺少列定义")
|
||||
}
|
||||
values := make([]interface{}, len(w.columns))
|
||||
for idx, column := range w.columns {
|
||||
values[idx] = row[column]
|
||||
}
|
||||
return w.ConsumeRowValues(values)
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) ConsumeRowValues(values []interface{}) error {
|
||||
row := append([]interface{}(nil), values...)
|
||||
w.rows = append(w.rows, row)
|
||||
w.rowCount++
|
||||
if len(w.rows) < agentStreamBatchSize {
|
||||
return nil
|
||||
}
|
||||
return w.flushRows()
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) flushRows() error {
|
||||
if len(w.rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
rows := w.rows
|
||||
w.rows = nil
|
||||
return writeResponse(w.writer, agentResponse{
|
||||
ID: w.requestID,
|
||||
Success: true,
|
||||
ChunkType: agentChunkRows,
|
||||
Data: rows,
|
||||
})
|
||||
}
|
||||
|
||||
func (w *agentStreamResponseWriter) finish() error {
|
||||
return w.flushRows()
|
||||
}
|
||||
|
||||
func handleStreamRequest(runtimeState *agentRuntime, req agentRequest, writer *bufio.Writer) error {
|
||||
resp := agentResponse{ID: req.ID, Success: true}
|
||||
if runtimeState.inst == nil {
|
||||
return writeResponse(writer, fail(resp, "connection not open"))
|
||||
}
|
||||
|
||||
streamWriter := newAgentStreamResponseWriter(writer, req.ID)
|
||||
if session, ok, err := runtimeState.session(req.SessionID); err != nil {
|
||||
return writeResponse(writer, fail(resp, err.Error()))
|
||||
} else if ok {
|
||||
if err := streamStatementWithOptionalTimeout(session, req.Query, req.TimeoutMs, streamWriter); err != nil {
|
||||
_ = streamWriter.finish()
|
||||
return writeResponse(writer, fail(resp, err.Error()))
|
||||
}
|
||||
if err := streamWriter.finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
|
||||
return err
|
||||
}
|
||||
maybeReleaseAgentMemory("stream-query-session", streamWriter.rowCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := streamDatabaseWithOptionalTimeout(runtimeState.inst, req.Query, req.TimeoutMs, streamWriter); err != nil {
|
||||
_ = streamWriter.finish()
|
||||
return writeResponse(writer, fail(resp, err.Error()))
|
||||
}
|
||||
if err := streamWriter.finish(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeResponse(writer, agentResponse{ID: req.ID, Success: true, ChunkType: agentChunkDone}); err != nil {
|
||||
return err
|
||||
}
|
||||
maybeReleaseAgentMemory("stream-query-db", streamWriter.rowCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *agentRuntime) nextID() string {
|
||||
r.ensureSessionMap()
|
||||
r.nextSessionID++
|
||||
return "session-" + strconv.FormatInt(r.nextSessionID, 10)
|
||||
}
|
||||
|
||||
func (r *agentRuntime) session(sessionID string) (db.StatementExecer, bool, error) {
|
||||
r.ensureSessionMap()
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
session, ok := r.sessions[sessionID]
|
||||
if !ok || session == nil {
|
||||
return nil, false, fmt.Errorf("事务会话不存在或已结束")
|
||||
}
|
||||
return session, true, nil
|
||||
}
|
||||
|
||||
func (r *agentRuntime) closeSession(sessionID string) error {
|
||||
r.ensureSessionMap()
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if sessionID == "" {
|
||||
return fmt.Errorf("事务会话 ID 不能为空")
|
||||
}
|
||||
session, ok := r.sessions[sessionID]
|
||||
if ok {
|
||||
delete(r.sessions, sessionID)
|
||||
}
|
||||
if !ok || session == nil {
|
||||
return fmt.Errorf("事务会话不存在或已结束")
|
||||
}
|
||||
return session.Close()
|
||||
}
|
||||
|
||||
func (r *agentRuntime) close() error {
|
||||
var closeErr error
|
||||
r.ensureSessionMap()
|
||||
for sessionID, session := range r.sessions {
|
||||
delete(r.sessions, sessionID)
|
||||
if session != nil {
|
||||
if err := session.Close(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.inst != nil {
|
||||
if err := r.inst.Close(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
}
|
||||
r.inst = nil
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (r *agentRuntime) ensureSessionMap() {
|
||||
if r.sessions == nil {
|
||||
r.sessions = make(map[string]db.StatementExecer)
|
||||
}
|
||||
}
|
||||
|
||||
func writeResponse(writer *bufio.Writer, resp agentResponse) error {
|
||||
// 对响应数据做统一 JSON 安全归一化:
|
||||
// 将 map[any]any(如 duckdb.Map)递归转换为 map[string]any,避免序列化失败导致代理进程退出。
|
||||
@@ -293,25 +597,234 @@ func normalizeAgentResponseData(v interface{}) interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func queryWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
type agentQueryRunner interface {
|
||||
Query(string) ([]map[string]interface{}, []string, error)
|
||||
}
|
||||
|
||||
type agentQueryContextRunner interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}
|
||||
|
||||
type agentQueryMessageRunner interface {
|
||||
QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error)
|
||||
}
|
||||
|
||||
type agentQueryMessageContextRunner interface {
|
||||
QueryContextWithMessages(context.Context, string) ([]map[string]interface{}, []string, []string, error)
|
||||
}
|
||||
|
||||
type agentMultiResultMessageRunner interface {
|
||||
QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error)
|
||||
}
|
||||
|
||||
type agentMultiResultMessageContextRunner interface {
|
||||
QueryMultiContextWithMessages(context.Context, string) ([]connection.ResultSetData, []string, error)
|
||||
}
|
||||
|
||||
type agentMultiResultRunner interface {
|
||||
QueryMulti(query string) ([]connection.ResultSetData, error)
|
||||
}
|
||||
|
||||
type agentMultiResultContextRunner interface {
|
||||
QueryMultiContext(context.Context, string) ([]connection.ResultSetData, error)
|
||||
}
|
||||
|
||||
type agentExecRunner interface {
|
||||
Exec(string) (int64, error)
|
||||
}
|
||||
|
||||
type agentExecContextRunner interface {
|
||||
ExecContext(context.Context, string) (int64, error)
|
||||
}
|
||||
|
||||
func queryWithMessagesOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.Query(query)
|
||||
if q, ok := inst.(agentQueryMessageRunner); ok {
|
||||
return q.QueryWithMessages(query)
|
||||
}
|
||||
data, fields, err := inst.Query(query)
|
||||
return data, fields, nil, err
|
||||
}
|
||||
if q, ok := inst.(interface {
|
||||
QueryContext(context.Context, string) ([]map[string]interface{}, []string, error)
|
||||
}); ok {
|
||||
if q, ok := inst.(agentQueryMessageContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return q.QueryContext(ctx, query)
|
||||
return q.QueryContextWithMessages(ctx, query)
|
||||
}
|
||||
return inst.Query(query)
|
||||
if q, ok := inst.(agentQueryContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, fields, err := q.QueryContext(ctx, query)
|
||||
return data, fields, nil, err
|
||||
}
|
||||
if q, ok := inst.(agentQueryMessageRunner); ok {
|
||||
return q.QueryWithMessages(query)
|
||||
}
|
||||
data, fields, err := inst.Query(query)
|
||||
return data, fields, nil, err
|
||||
}
|
||||
|
||||
func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (int64, error) {
|
||||
func queryWithOptionalTimeout(inst agentQueryRunner, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
data, fields, _, err := queryWithMessagesOptionalTimeout(inst, query, timeoutMs)
|
||||
return data, fields, err
|
||||
}
|
||||
|
||||
func queryStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, error) {
|
||||
queryRunner, ok := inst.(agentQueryRunner)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("当前事务会话不支持查询语句")
|
||||
}
|
||||
return queryWithOptionalTimeout(queryRunner, query, timeoutMs)
|
||||
}
|
||||
|
||||
func queryStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]map[string]interface{}, []string, []string, error) {
|
||||
queryRunner, ok := inst.(agentQueryRunner)
|
||||
if !ok {
|
||||
return nil, nil, nil, fmt.Errorf("当前事务会话不支持查询语句")
|
||||
}
|
||||
return queryWithMessagesOptionalTimeout(queryRunner, query, timeoutMs)
|
||||
}
|
||||
|
||||
func queryMultiWithMessagesOptionalTimeout(inst db.Database, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs > 0 {
|
||||
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, err := q.QueryMultiContext(ctx, query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultMessageRunner); ok {
|
||||
data, messages, err := q.QueryMultiWithMessages(query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultRunner); ok {
|
||||
data, err := q.QueryMulti(query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
func queryMultiStatementWithMessagesOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) ([]connection.ResultSetData, []string, bool, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs > 0 {
|
||||
if q, ok := inst.(agentMultiResultMessageContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, messages, err := q.QueryMultiContextWithMessages(ctx, query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
data, err := q.QueryMultiContext(ctx, query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultMessageRunner); ok {
|
||||
data, messages, err := q.QueryMultiWithMessages(query)
|
||||
return data, messages, true, err
|
||||
}
|
||||
if q, ok := inst.(agentMultiResultRunner); ok {
|
||||
data, err := q.QueryMulti(query)
|
||||
return data, nil, true, err
|
||||
}
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
|
||||
func streamWithOptionalTimeout(inst db.StreamQueryExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.StreamQuery(query, consumer)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return inst.StreamQueryContext(ctx, query, consumer)
|
||||
}
|
||||
|
||||
func streamBufferedQueryResult(fields []string, data []map[string]interface{}, consumer db.QueryStreamConsumer) error {
|
||||
if err := consumer.SetColumns(fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
|
||||
for _, row := range data {
|
||||
values := make([]interface{}, len(fields))
|
||||
for idx, field := range fields {
|
||||
values[idx] = row[field]
|
||||
}
|
||||
if err := valueConsumer.ConsumeRowValues(values); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, row := range data {
|
||||
if err := consumer.ConsumeRow(row); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func streamStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
|
||||
if streamer, ok := inst.(db.StreamQueryExecer); ok {
|
||||
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
|
||||
}
|
||||
data, fields, err := queryStatementWithOptionalTimeout(inst, query, timeoutMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return streamBufferedQueryResult(fields, data, consumer)
|
||||
}
|
||||
|
||||
func streamDatabaseWithOptionalTimeout(inst db.Database, query string, timeoutMs int64, consumer db.QueryStreamConsumer) error {
|
||||
if streamer, ok := inst.(db.StreamQueryExecer); ok {
|
||||
return streamWithOptionalTimeout(streamer, query, timeoutMs, consumer)
|
||||
}
|
||||
if provider, ok := inst.(db.SessionExecerProvider); ok {
|
||||
openCtx := context.Background()
|
||||
var cancel context.CancelFunc
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
}
|
||||
if effectiveTimeoutMs > 0 {
|
||||
openCtx, cancel = context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
session, err := provider.OpenSessionExecer(openCtx)
|
||||
if err == nil {
|
||||
defer session.Close()
|
||||
return streamStatementWithOptionalTimeout(session, query, timeoutMs, consumer)
|
||||
}
|
||||
}
|
||||
data, fields, err := queryWithOptionalTimeout(inst, query, timeoutMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return streamBufferedQueryResult(fields, data, consumer)
|
||||
}
|
||||
|
||||
func execWithOptionalTimeout(inst agentExecRunner, query string, timeoutMs int64) (int64, error) {
|
||||
effectiveTimeoutMs := timeoutMs
|
||||
if effectiveTimeoutMs <= 0 && strings.EqualFold(strings.TrimSpace(agentDriverType), "clickhouse") {
|
||||
effectiveTimeoutMs = int64(legacyClickHouseDefaultTimeout / time.Millisecond)
|
||||
@@ -319,12 +832,52 @@ func execWithOptionalTimeout(inst db.Database, query string, timeoutMs int64) (i
|
||||
if effectiveTimeoutMs <= 0 {
|
||||
return inst.Exec(query)
|
||||
}
|
||||
if e, ok := inst.(interface {
|
||||
ExecContext(context.Context, string) (int64, error)
|
||||
}); ok {
|
||||
if e, ok := inst.(agentExecContextRunner); ok {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(effectiveTimeoutMs)*time.Millisecond)
|
||||
defer cancel()
|
||||
return e.ExecContext(ctx, query)
|
||||
}
|
||||
return inst.Exec(query)
|
||||
}
|
||||
|
||||
func execStatementWithOptionalTimeout(inst db.StatementExecer, query string, timeoutMs int64) (int64, error) {
|
||||
return execWithOptionalTimeout(inst, query, timeoutMs)
|
||||
}
|
||||
|
||||
func countAgentResponseRows(data interface{}) int64 {
|
||||
rows, ok := data.([]map[string]interface{})
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int64(len(rows))
|
||||
}
|
||||
|
||||
func maybeReleaseAgentMemory(reason string, rows int64) {
|
||||
if rows < agentMemoryTrimRowsThreshold {
|
||||
return
|
||||
}
|
||||
if !agentMemoryTrimRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
|
||||
runAgentMemoryTrimAsync(func() {
|
||||
defer agentMemoryTrimRunning.Store(false)
|
||||
if delay := nextAgentMemoryTrimDelay(); delay > 0 {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
agentMemoryTrimFn()
|
||||
agentMemoryTrimLastAt.Store(time.Now().UnixNano())
|
||||
})
|
||||
}
|
||||
|
||||
func nextAgentMemoryTrimDelay() time.Duration {
|
||||
lastUnixNano := agentMemoryTrimLastAt.Load()
|
||||
if lastUnixNano <= 0 {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(time.Unix(0, lastUnixNano))
|
||||
if elapsed >= agentMemoryTrimMinInterval {
|
||||
return 0
|
||||
}
|
||||
return agentMemoryTrimMinInterval - elapsed
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
"GoNavi-Wails/internal/db"
|
||||
)
|
||||
|
||||
type duckMapLike map[any]any
|
||||
@@ -66,12 +68,42 @@ func TestNormalizeAgentResponseData_KeepByteSlice(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequestMetadataReportsAgentRevision(t *testing.T) {
|
||||
previousDriverType := agentDriverType
|
||||
previousFactory := agentDatabaseFactory
|
||||
t.Cleanup(func() {
|
||||
agentDriverType = previousDriverType
|
||||
agentDatabaseFactory = previousFactory
|
||||
})
|
||||
agentDriverType = "clickhouse"
|
||||
agentDatabaseFactory = func() db.Database { return nil }
|
||||
|
||||
runtimeState := &agentRuntime{sessions: make(map[string]db.StatementExecer)}
|
||||
resp := handleRequest(runtimeState, 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
|
||||
queryMessages []string
|
||||
multiResults []connection.ResultSetData
|
||||
multiMessages []string
|
||||
}
|
||||
|
||||
func (f *fakeAgentTimeoutDB) Connect(config connection.ConnectionConfig) error { return nil }
|
||||
@@ -88,6 +120,14 @@ func (f *fakeAgentTimeoutDB) QueryContext(ctx context.Context, query string) ([]
|
||||
}
|
||||
return []map[string]interface{}{{"ok": 1}}, []string{"ok"}, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(context.Background(), query)
|
||||
return data, fields, append([]string(nil), f.queryMessages...), err
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(ctx, query)
|
||||
return data, fields, append([]string(nil), f.queryMessages...), err
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) Exec(query string) (int64, error) {
|
||||
f.execCalled = true
|
||||
return 0, errors.New("exec should not be called")
|
||||
@@ -121,6 +161,121 @@ func (f *fakeAgentTimeoutDB) GetForeignKeys(dbName, tableName string) ([]connect
|
||||
func (f *fakeAgentTimeoutDB) GetTriggers(dbName, tableName string) ([]connection.TriggerDefinition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryMultiWithMessages(query string) ([]connection.ResultSetData, []string, error) {
|
||||
return append([]connection.ResultSetData(nil), f.multiResults...), append([]string(nil), f.multiMessages...), nil
|
||||
}
|
||||
func (f *fakeAgentTimeoutDB) QueryMultiContextWithMessages(ctx context.Context, query string) ([]connection.ResultSetData, []string, error) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
return f.QueryMultiWithMessages(query)
|
||||
}
|
||||
|
||||
type fakeAgentSessionDB struct {
|
||||
fakeAgentTimeoutDB
|
||||
session *fakeAgentStatementSession
|
||||
}
|
||||
|
||||
func (f *fakeAgentSessionDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) {
|
||||
f.session = &fakeAgentStatementSession{}
|
||||
return f.session, nil
|
||||
}
|
||||
|
||||
type fakeAgentStatementSession struct {
|
||||
queryCalls int
|
||||
execCalls int
|
||||
closed bool
|
||||
messages []string
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatementSession) Query(query string) ([]map[string]interface{}, []string, error) {
|
||||
return f.QueryContext(context.Background(), query)
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatementSession) QueryContext(ctx context.Context, query string) ([]map[string]interface{}, []string, error) {
|
||||
f.queryCalls++
|
||||
return []map[string]interface{}{{"session_ok": 1}}, []string{"session_ok"}, nil
|
||||
}
|
||||
func (f *fakeAgentStatementSession) QueryWithMessages(query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(context.Background(), query)
|
||||
return data, fields, append([]string(nil), f.messages...), err
|
||||
}
|
||||
func (f *fakeAgentStatementSession) QueryContextWithMessages(ctx context.Context, query string) ([]map[string]interface{}, []string, []string, error) {
|
||||
data, fields, err := f.QueryContext(ctx, query)
|
||||
return data, fields, append([]string(nil), f.messages...), err
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatementSession) Exec(query string) (int64, error) {
|
||||
return f.ExecContext(context.Background(), query)
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatementSession) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
f.execCalls++
|
||||
return 9, nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentStatementSession) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeAgentStreamSession struct {
|
||||
closed bool
|
||||
streamCalls int
|
||||
deadlineSet bool
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) Exec(query string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) ExecContext(ctx context.Context, query string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) Close() error {
|
||||
f.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) StreamQuery(query string, consumer db.QueryStreamConsumer) error {
|
||||
return f.StreamQueryContext(context.Background(), query, consumer)
|
||||
}
|
||||
|
||||
func (f *fakeAgentStreamSession) StreamQueryContext(ctx context.Context, query string, consumer db.QueryStreamConsumer) error {
|
||||
f.streamCalls++
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
f.deadlineSet = true
|
||||
}
|
||||
if err := consumer.SetColumns([]string{"id", "name"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if valueConsumer, ok := consumer.(db.QueryStreamValueConsumer); ok {
|
||||
if err := valueConsumer.ConsumeRowValues([]interface{}{1, "alice"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := valueConsumer.ConsumeRowValues([]interface{}{2, "bob"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := consumer.ConsumeRow(map[string]interface{}{"id": 1, "name": "alice"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return consumer.ConsumeRow(map[string]interface{}{"id": 2, "name": "bob"})
|
||||
}
|
||||
|
||||
type fakeAgentSessionStreamDB struct {
|
||||
fakeAgentTimeoutDB
|
||||
session *fakeAgentStreamSession
|
||||
openCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAgentSessionStreamDB) OpenSessionExecer(ctx context.Context) (db.StatementExecer, error) {
|
||||
f.openCalls++
|
||||
f.session = &fakeAgentStreamSession{}
|
||||
return f.session, nil
|
||||
}
|
||||
|
||||
func TestQueryWithOptionalTimeout_UsesQueryContext(t *testing.T) {
|
||||
fake := &fakeAgentTimeoutDB{}
|
||||
@@ -170,3 +325,277 @@ func TestQueryWithOptionalTimeout_ClickHouseLegacyModeUsesQueryContext(t *testin
|
||||
t.Fatalf("clickhouse legacy query 调用路径异常,QueryContext=%v Query=%v", fake.queryContextCalled, fake.queryCalled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequest_QueryIncludesServerMessages(t *testing.T) {
|
||||
old := agentDriverType
|
||||
defer func() { agentDriverType = old }()
|
||||
agentDriverType = "sqlserver"
|
||||
|
||||
fake := &fakeAgentTimeoutDB{
|
||||
queryMessages: []string{"PRINT sql line 1", "PRINT sql line 2"},
|
||||
}
|
||||
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
|
||||
|
||||
resp := handleRequest(runtimeState, agentRequest{
|
||||
ID: 11,
|
||||
Method: agentMethodQuery,
|
||||
Query: "exec dbo.p_get_select",
|
||||
TimeoutMs: int64((2 * time.Second).Milliseconds()),
|
||||
})
|
||||
if !resp.Success {
|
||||
t.Fatalf("query request failed: %s", resp.Error)
|
||||
}
|
||||
if len(resp.Messages) != 2 || resp.Messages[0] != "PRINT sql line 1" {
|
||||
t.Fatalf("expected query messages to be preserved, got %#v", resp.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequest_QueryMultiIncludesResultSetsAndMessages(t *testing.T) {
|
||||
old := agentDriverType
|
||||
defer func() { agentDriverType = old }()
|
||||
agentDriverType = "sqlserver"
|
||||
|
||||
fake := &fakeAgentTimeoutDB{
|
||||
multiResults: []connection.ResultSetData{
|
||||
{
|
||||
StatementIndex: 1,
|
||||
Rows: []map[string]interface{}{{"name": "master"}},
|
||||
Columns: []string{"name"},
|
||||
},
|
||||
{
|
||||
StatementIndex: 1,
|
||||
Rows: []map[string]interface{}{},
|
||||
Columns: []string{},
|
||||
Messages: []string{"PRINT generated sql"},
|
||||
},
|
||||
},
|
||||
multiMessages: []string{"batch top-level message"},
|
||||
}
|
||||
runtimeState := &agentRuntime{inst: fake, sessions: make(map[string]db.StatementExecer)}
|
||||
|
||||
resp := handleRequest(runtimeState, agentRequest{
|
||||
ID: 12,
|
||||
Method: agentMethodQueryMulti,
|
||||
Query: "exec dbo.p_get_select",
|
||||
TimeoutMs: int64((2 * time.Second).Milliseconds()),
|
||||
})
|
||||
if !resp.Success {
|
||||
t.Fatalf("queryMulti request failed: %s", resp.Error)
|
||||
}
|
||||
if len(resp.Messages) != 1 || resp.Messages[0] != "batch top-level message" {
|
||||
t.Fatalf("expected top-level messages to be preserved, got %#v", resp.Messages)
|
||||
}
|
||||
resultSets, ok := resp.Data.([]connection.ResultSetData)
|
||||
if !ok {
|
||||
t.Fatalf("expected []connection.ResultSetData, got %T", resp.Data)
|
||||
}
|
||||
if len(resultSets) != 2 {
|
||||
t.Fatalf("expected 2 result sets, got %#v", resultSets)
|
||||
}
|
||||
if len(resultSets[1].Messages) != 1 || resultSets[1].Messages[0] != "PRINT generated sql" {
|
||||
t.Fatalf("expected message-only result set to be preserved, got %#v", resultSets[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRequest_UsesPinnedSessionForSessionScopedQueryAndExec(t *testing.T) {
|
||||
old := agentDriverType
|
||||
defer func() { agentDriverType = old }()
|
||||
agentDriverType = "sqlserver"
|
||||
|
||||
fake := &fakeAgentSessionDB{}
|
||||
runtimeState := &agentRuntime{
|
||||
inst: fake,
|
||||
sessions: make(map[string]db.StatementExecer),
|
||||
}
|
||||
|
||||
openResp := handleRequest(runtimeState, agentRequest{ID: 1, Method: agentMethodOpenSession})
|
||||
if !openResp.Success {
|
||||
t.Fatalf("openSession failed: %s", openResp.Error)
|
||||
}
|
||||
sessionID, ok := openResp.Data.(string)
|
||||
if !ok || strings.TrimSpace(sessionID) == "" {
|
||||
t.Fatalf("unexpected session id payload: %#v", openResp.Data)
|
||||
}
|
||||
if fake.session == nil {
|
||||
t.Fatal("expected OpenSessionExecer to create a pinned session")
|
||||
}
|
||||
|
||||
queryResp := handleRequest(runtimeState, agentRequest{
|
||||
ID: 2,
|
||||
Method: agentMethodQuery,
|
||||
SessionID: sessionID,
|
||||
Query: "SELECT 1",
|
||||
})
|
||||
if !queryResp.Success {
|
||||
t.Fatalf("session query failed: %s", queryResp.Error)
|
||||
}
|
||||
if len(queryResp.Messages) != 0 {
|
||||
t.Fatalf("expected empty default session messages, got %#v", queryResp.Messages)
|
||||
}
|
||||
if fake.queryCalled || fake.queryContextCalled {
|
||||
t.Fatalf("expected session query to bypass database-level query path, got Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
|
||||
}
|
||||
if fake.session.queryCalls != 1 {
|
||||
t.Fatalf("expected pinned session queryCalls=1, got %d", fake.session.queryCalls)
|
||||
}
|
||||
|
||||
execResp := handleRequest(runtimeState, agentRequest{
|
||||
ID: 3,
|
||||
Method: agentMethodExec,
|
||||
SessionID: sessionID,
|
||||
Query: "UPDATE t SET v = 1",
|
||||
})
|
||||
if !execResp.Success {
|
||||
t.Fatalf("session exec failed: %s", execResp.Error)
|
||||
}
|
||||
if fake.execCalled || fake.execContextCalled {
|
||||
t.Fatalf("expected session exec to bypass database-level exec path, got Exec=%v ExecContext=%v", fake.execCalled, fake.execContextCalled)
|
||||
}
|
||||
if fake.session.execCalls != 1 {
|
||||
t.Fatalf("expected pinned session execCalls=1, got %d", fake.session.execCalls)
|
||||
}
|
||||
|
||||
closeResp := handleRequest(runtimeState, agentRequest{
|
||||
ID: 4,
|
||||
Method: agentMethodCloseSession,
|
||||
SessionID: sessionID,
|
||||
})
|
||||
if !closeResp.Success {
|
||||
t.Fatalf("closeSession failed: %s", closeResp.Error)
|
||||
}
|
||||
if !fake.session.closed {
|
||||
t.Fatal("expected pinned session to close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStreamRequest_UsesSessionStreamerAndWritesChunks(t *testing.T) {
|
||||
old := agentDriverType
|
||||
originalAsync := runAgentMemoryTrimAsync
|
||||
originalTrim := agentMemoryTrimFn
|
||||
originalLastAt := agentMemoryTrimLastAt.Load()
|
||||
defer func() { agentDriverType = old }()
|
||||
defer func() {
|
||||
runAgentMemoryTrimAsync = originalAsync
|
||||
agentMemoryTrimFn = originalTrim
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(originalLastAt)
|
||||
}()
|
||||
agentDriverType = "oceanbase"
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(0)
|
||||
|
||||
fake := &fakeAgentSessionStreamDB{}
|
||||
runtimeState := &agentRuntime{
|
||||
inst: fake,
|
||||
sessions: make(map[string]db.StatementExecer),
|
||||
}
|
||||
|
||||
trimmed := 0
|
||||
runAgentMemoryTrimAsync = func(fn func()) {
|
||||
fn()
|
||||
}
|
||||
agentMemoryTrimFn = func() {
|
||||
trimmed++
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
writer := bufio.NewWriter(&out)
|
||||
if err := handleStreamRequest(runtimeState, agentRequest{
|
||||
ID: 9,
|
||||
Method: agentMethodStreamQuery,
|
||||
Query: "SELECT * FROM person_info",
|
||||
TimeoutMs: int64((2 * time.Second).Milliseconds()),
|
||||
}, writer); err != nil {
|
||||
t.Fatalf("handleStreamRequest 返回错误: %v", err)
|
||||
}
|
||||
|
||||
if fake.openCalls != 1 {
|
||||
t.Fatalf("expected OpenSessionExecer called once, got %d", fake.openCalls)
|
||||
}
|
||||
if fake.session == nil || fake.session.streamCalls != 1 {
|
||||
t.Fatalf("expected session streamer used once, session=%#v", fake.session)
|
||||
}
|
||||
if !fake.session.deadlineSet {
|
||||
t.Fatal("expected stream query context deadline to be set")
|
||||
}
|
||||
if !fake.session.closed {
|
||||
t.Fatal("expected session to close after streaming")
|
||||
}
|
||||
if fake.queryCalled || fake.queryContextCalled {
|
||||
t.Fatalf("unexpected fallback query path, Query=%v QueryContext=%v", fake.queryCalled, fake.queryContextCalled)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 stream responses, got %d: %q", len(lines), out.String())
|
||||
}
|
||||
|
||||
var columnsResp struct {
|
||||
Success bool `json:"success"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
Fields []string `json:"fields"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &columnsResp); err != nil {
|
||||
t.Fatalf("decode columns response failed: %v", err)
|
||||
}
|
||||
if !columnsResp.Success || columnsResp.ChunkType != agentChunkColumns || len(columnsResp.Fields) != 2 {
|
||||
t.Fatalf("unexpected columns response: %#v", columnsResp)
|
||||
}
|
||||
|
||||
var rowsResp struct {
|
||||
Success bool `json:"success"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
Data [][]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[1]), &rowsResp); err != nil {
|
||||
t.Fatalf("decode rows response failed: %v", err)
|
||||
}
|
||||
if !rowsResp.Success || rowsResp.ChunkType != agentChunkRows || len(rowsResp.Data) != 2 {
|
||||
t.Fatalf("unexpected rows response: %#v", rowsResp)
|
||||
}
|
||||
if got := rowsResp.Data[1][1]; got != "bob" {
|
||||
t.Fatalf("unexpected streamed row payload: %v", rowsResp.Data)
|
||||
}
|
||||
|
||||
var doneResp struct {
|
||||
Success bool `json:"success"`
|
||||
ChunkType string `json:"chunkType"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[2]), &doneResp); err != nil {
|
||||
t.Fatalf("decode done response failed: %v", err)
|
||||
}
|
||||
if !doneResp.Success || doneResp.ChunkType != agentChunkDone {
|
||||
t.Fatalf("unexpected done response: %#v", doneResp)
|
||||
}
|
||||
if trimmed != 0 {
|
||||
t.Fatalf("小流式任务不应触发内存回收,got=%d", trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeReleaseAgentMemory_TriggersTrimForLargeJobs(t *testing.T) {
|
||||
originalAsync := runAgentMemoryTrimAsync
|
||||
originalTrim := agentMemoryTrimFn
|
||||
originalLastAt := agentMemoryTrimLastAt.Load()
|
||||
t.Cleanup(func() {
|
||||
runAgentMemoryTrimAsync = originalAsync
|
||||
agentMemoryTrimFn = originalTrim
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(originalLastAt)
|
||||
})
|
||||
|
||||
agentMemoryTrimRunning.Store(false)
|
||||
agentMemoryTrimLastAt.Store(0)
|
||||
triggered := 0
|
||||
runAgentMemoryTrimAsync = func(fn func()) {
|
||||
fn()
|
||||
}
|
||||
agentMemoryTrimFn = func() {
|
||||
triggered++
|
||||
}
|
||||
|
||||
maybeReleaseAgentMemory("test-large-query", agentMemoryTrimRowsThreshold)
|
||||
|
||||
if triggered != 1 {
|
||||
t.Fatalf("大查询完成后应触发一次内存回收,got=%d", triggered)
|
||||
}
|
||||
}
|
||||
|
||||
12
cmd/optional-driver-agent/provider_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_gaussdb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_gaussdb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "gaussdb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.GaussDB{}
|
||||
}
|
||||
}
|
||||
12
cmd/optional-driver-agent/provider_iotdb.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_iotdb_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "iotdb"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.IoTDBDB{}
|
||||
}
|
||||
}
|
||||
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_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_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_trino.go
Normal file
@@ -0,0 +1,12 @@
|
||||
//go:build gonavi_trino_driver
|
||||
|
||||
package main
|
||||
|
||||
import "GoNavi-Wails/internal/db"
|
||||
|
||||
func init() {
|
||||
agentDriverType = "trino"
|
||||
agentDatabaseFactory = func() db.Database {
|
||||
return &db.TrinoDB{}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,12 @@
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/mariadb"
|
||||
},
|
||||
"oceanbase": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/oceanbase"
|
||||
},
|
||||
"doris": {
|
||||
"engine": "go",
|
||||
"version": "1.9.3",
|
||||
@@ -61,6 +67,12 @@
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/vastbase"
|
||||
},
|
||||
"opengauss": {
|
||||
"engine": "go",
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/opengauss"
|
||||
},
|
||||
"mongodb": {
|
||||
"engine": "go",
|
||||
"version": "2.5.0",
|
||||
@@ -84,6 +96,12 @@
|
||||
"version": "1.11.1",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/postgres"
|
||||
},
|
||||
"elasticsearch": {
|
||||
"engine": "go",
|
||||
"version": "8.19.0",
|
||||
"checksumPolicy": "off",
|
||||
"downloadUrl": "builtin://activate/elasticsearch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
docs/需求追踪/需求进度追踪-Oracle对象跳转与存储过程修改执行-20260625.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 需求进度追踪 - Oracle对象跳转与存储过程修改执行
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:Oracle对象跳转与存储过程修改执行
|
||||
- 提出日期:2026-06-25
|
||||
- 负责人:Codex
|
||||
- 目标:SQL编辑器支持 Oracle 序列、存储包 Ctrl/Cmd 点击跳转;对象编辑执行 Oracle 存储过程时不再生成非法第二条语句。
|
||||
- 非目标:不调整 Oracle 连接驱动、不改数据库 schema、不重构侧栏对象树。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:QueryEditor 对象元数据、hover/跳转逻辑、DefinitionViewer 对象编辑 SQL 生成、相关 i18n 和回归测试。
|
||||
- 验收标准:序列/存储包在 SQL 编辑器可出现链接提示并打开定义页;带 SQLPlus `/` 的 Oracle PL/SQL 编辑 SQL 不生成 `/;`,执行时保留完整定义交给后端拆分。
|
||||
- 依赖与约束:复用现有侧栏 sequence/package tab 类型;不混入 `frontend/wailsjs/go/models.ts` 既有未提交改动。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认序列/存储包侧栏已可打开,但 SQL 编辑器跳转缺失;存储过程修改仍报 ORA-00900。
|
||||
- [x] 阶段 2(影响分析):影响前端 QueryEditor、DefinitionViewer、i18n、测试;后端拆分已有基础保护。
|
||||
- [x] 阶段 3(方案设计):补齐元数据与跳转类型;对象编辑 SQL 保留 SQLPlus `/` 语义。
|
||||
- [x] 阶段 4(实施计划):先补导航闭环,再修 PL/SQL 生成/执行,最后跑定向测试。
|
||||
- [x] 阶段 5(实现与自检):已完成定向测试、QueryEditor 全文件测试、前端 build、后端 Oracle 拆分测试。
|
||||
- [ ] 阶段 6(评审与交付):待提交推送。
|
||||
- [ ] 阶段 7(发布与观察):发布后验证 Oracle 对象编辑与 SQL 编辑器跳转。
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:补齐 QueryEditor sequence/package 元数据、hover、点击打开定义页;修复对象编辑 SQLPlus `/;`;执行路径兼容旧编辑页 `/;`。
|
||||
- 进行中:提交并推送。
|
||||
- 待处理:发布后验证 Oracle 实库。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:三段名称 `schema.package.proc` 与 `db.schema.table` 存在歧义。
|
||||
- 阻塞:无。
|
||||
- 缓解措施:仅当第一段不是可见数据库时,将三段名称按 Oracle schema-qualified sequence/package 解析。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:序列和存储包复用侧栏现有 `sequence-def` / `package-def` tab 结构。
|
||||
- 决策 2:Oracle PL/SQL 定义执行时保留 SQLPlus `/` 分隔符,由后端拆分器去掉 delimiter,避免前端用分号重组造成 ORA-00900。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:QueryEditor 导航回归测试、DefinitionViewer 对象编辑测试、SQL 语句拆分测试、i18n catalog 测试、前端 build、后端 Oracle 拆分测试。
|
||||
- 结果:通过。
|
||||
- 证据(日志/截图/链接):`npm test -- src/components/QueryEditor.external-sql-save.test.tsx`;`npm test -- src/components/DefinitionViewer.object-edit.test.tsx src/i18n/catalog.test.ts src/utils/sqlStatementSelection.test.ts`;`go test ./internal/app -run 'TestDBQueryMultiKeepsOracle|TestDBQueryMultiSkipsOracle'`;`npm run build`。
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:提交并推送到 dev。
|
||||
- 负责人:Codex
|
||||
74
docs/需求追踪/需求进度追踪-生产连接只读保护-20260623.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# 需求进度追踪 - 生产连接只读保护
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:生产连接只读保护
|
||||
- 提出日期:2026-06-23
|
||||
- 负责人:Codex
|
||||
- 目标:为 SQL 类数据库与 MongoDB 连接增加连接级只读保护,启用后仅允许查询,阻止写入、DDL、导入和同步目标操作
|
||||
- 非目标:不为所有侧栏写操作都新增前端隐藏逻辑;不引入新的环境分级体系;不改造 JVM 只读能力
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:
|
||||
- 连接配置模型、保存/回填与 RPC 序列化链路
|
||||
- 连接弹窗只读开关、查询编辑器本地拦截、DataGrid 导入入口收口
|
||||
- 后端 SQL/Mongo 查询判定与写操作统一守卫
|
||||
- 验收标准:
|
||||
- 支持的数据源出现“生产连接/只允许查询”开关
|
||||
- 启用后普通查询仍可执行,非查询 SQL / Mongo 写命令被前后端阻止
|
||||
- 导入、结构变更、清表、同步目标等关键写入口被后端拒绝
|
||||
- 依赖与约束:
|
||||
- 保持现有数据源能力判定与 QueryEditor 执行链路
|
||||
- MongoDB 前端判定以保守拦截为主,最终正确性由后端守卫兜底
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认采用连接级 `readOnly` 布尔字段,不新建环境系统
|
||||
- [x] 阶段 2(影响分析):梳理前端能力面板、查询执行、导入与后端写入口
|
||||
- [x] 阶段 3(方案设计):确定“前端预拦截 + 后端最终守卫”双层保护
|
||||
- [x] 阶段 4(实施计划):接入配置链路、能力判定、查询判定与写入口守卫
|
||||
- [x] 阶段 5(实现与自检):补文案、测试与定向验证
|
||||
- [x] 阶段 6(评审与交付):确认范围、风险、回滚点和验证命令
|
||||
- [ ] 阶段 7(发布与观察):待体验验证
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:
|
||||
- 新增连接级 `readOnly` 配置字段及前后端序列化支持
|
||||
- 连接弹窗为 SQL 类数据库与 MongoDB 增加生产连接保护开关
|
||||
- QueryEditor 增加本地非查询拦截,DataGrid 导入入口在只读连接下禁用
|
||||
- 后端为查询、DDL、导入、清表、同步等写入口增加统一只读守卫
|
||||
- MongoDB 查询判定改为命令级白名单,不再把所有 JSON 命令都视为只读
|
||||
- 补充前端/后端定向测试与需求追踪文档
|
||||
- 进行中:
|
||||
- 等待体验包验证连接弹窗、查询拦截和写操作拒绝文案
|
||||
- 待处理:
|
||||
- 如需进一步优化体验,再补侧栏对象级写菜单的前端隐藏/禁用
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:
|
||||
- 前端 SQL/Mongo 只读判定是保守策略,边界命令可能仍需后端兜底
|
||||
- 现有部分侧栏写菜单仍可能显示,但执行时会被后端拒绝
|
||||
- 阻塞:
|
||||
- 暂无
|
||||
- 缓解措施:
|
||||
- 关键写入口统一走后端守卫;前端只负责提前反馈与减少误操作
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:只对 SQL 类数据库和 MongoDB 支持连接级生产保护,其他数据源忽略 `readOnly`
|
||||
- 决策 2:采用顶层 `readOnly` 布尔字段,避免新增环境枚举和迁移成本
|
||||
- 决策 3:MongoDB 只读判定按命令白名单处理,防止把写命令误放行
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:
|
||||
- 前端数据源能力判定、RPC 配置、连接配置测试
|
||||
- 后端只读连接守卫与 SQL/Mongo 查询判定测试
|
||||
- 结果:
|
||||
- 通过
|
||||
- 证据(日志/截图/链接):
|
||||
- `go test ./internal/app -run 'Test(EnsureReadOnlyConnectionAllows|SupportsConnectionReadOnlyMode|IsReadOnlySQLQuery)'`
|
||||
- `npm --prefix frontend test -- src/utils/dataSourceCapabilities.test.ts src/utils/connectionRpcConfig.test.ts src/components/connectionModal/connectionModalConfig.keepalive.test.ts`
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:
|
||||
- 用体验包回归验证生产连接下的查询、导入、建库删库、同步目标和 Mongo 命令拦截
|
||||
- 如需更完整 UX,再补侧栏写菜单的只读态收口
|
||||
- 负责人:
|
||||
- Codex
|
||||
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>
|
||||
3746
frontend/package-lock.json
generated
@@ -1,12 +1,14 @@
|
||||
{
|
||||
"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",
|
||||
"i18n:scan": "go run ../tools/i18n-scan --root .."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
@@ -15,11 +17,21 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"antd": "^5.12.0",
|
||||
"clsx": "^2.1.0",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.3",
|
||||
"mermaid": "^11.13.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.8.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zustand": "^4.4.7"
|
||||
@@ -28,9 +40,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 @@
|
||||
5b8157374dae5f9340e31b2d0bd2c00e
|
||||
84ec3a6d42105c92f224232f0d83a33b
|
||||
1
frontend/public/db-icons/chroma.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="163" height="40" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="prefix__a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="2" width="53" height="36"><path d="M52.503 2.501H.003v35h52.5v-35z" fill="#fff"/></mask><g mask="url(#prefix__a)"><path d="M17.503 2.501c-9.665 0-17.5 7.835-17.5 17.5s7.835 17.5 17.5 17.5 17.5-7.835 17.5-17.5-7.835-17.5-17.5-17.5z" fill="#327EFF"/><path d="M35.003 2.501c-9.665 0-17.5 7.835-17.5 17.5s7.835 17.5 17.5 17.5 17.5-7.834 17.5-17.5c0-9.665-7.835-17.5-17.5-17.5z" fill="#FFDE2D"/><path d="M17.503 20.002c0-9.665 7.835-17.5 17.5-17.5v17.5h-17.5z" fill="#FF6446"/><path d="M35.003 20.001c0 9.665-7.835 17.5-17.5 17.5v-17.5h17.5z" fill="#FF6446"/></g><path d="M68.543 30.45c-6.03 0-10.32-4.65-10.32-11.1 0-6.36 3.96-11.22 10.29-11.22 5.28 0 8.4 3.15 8.85 7.23h-4.32c-.39-2.16-2.01-3.57-4.53-3.57-3.96 0-5.85 3.3-5.85 7.56 0 4.38 2.28 7.53 5.88 7.53 2.55 0 4.35-1.53 4.62-3.78h4.26a7.596 7.596 0 01-2.37 5.07c-1.47 1.38-3.54 2.28-6.51 2.28zM83.41 8.55v8.07h.09c1.11-1.62 2.37-2.43 4.47-2.43 3.18 0 5.31 2.4 5.31 5.76V30H89.2v-9.45c0-1.65-.96-2.82-2.67-2.82-1.8 0-3.12 1.44-3.12 3.54V30h-4.08V8.55h4.08zm16.315 6.06v2.46h.09c.93-1.74 1.98-2.64 3.78-2.64.45 0 .72.03.96.12v3.57h-.09c-2.67-.27-4.59 1.14-4.59 4.38V30h-4.08V14.61h3.93zm13.438 15.84c-4.77 0-8.04-3.54-8.04-8.13 0-4.59 3.27-8.13 8.04-8.13s8.04 3.54 8.04 8.13c0 4.59-3.27 8.13-8.04 8.13zm0-3.12c2.49 0 3.9-2.01 3.9-5.01s-1.41-5.04-3.9-5.04c-2.52 0-3.9 2.04-3.9 5.04s1.38 5.01 3.9 5.01zM123.1 30V14.61h3.93v2.07h.09c.84-1.41 2.34-2.49 4.47-2.49 1.95 0 3.51 1.08 4.26 2.7h.06c1.05-1.68 2.67-2.7 4.62-2.7 3.24 0 5.07 2.1 5.07 5.46V30h-4.08v-9.66c0-1.74-.87-2.64-2.37-2.64-1.71 0-2.76 1.32-2.76 3.36V30h-4.08v-9.66c0-1.74-.87-2.64-2.37-2.64-1.65 0-2.76 1.32-2.76 3.36V30h-4.08zm34.71 0c-.24-.3-.39-1.02-.48-1.71h-.06c-.78 1.17-1.89 2.07-4.53 2.07-3.15 0-5.37-1.65-5.37-4.71 0-3.39 2.76-4.47 6.18-4.95 2.55-.36 3.72-.57 3.72-1.74 0-1.11-.87-1.83-2.58-1.83-1.92 0-2.85.69-2.97 2.16h-3.63c.12-2.7 2.13-5.07 6.629-5.07 4.621 0 6.481 2.07 6.481 5.67v7.83c0 1.17.18 1.86.54 2.13V30h-3.93zm-4.08-2.49c2.34 0 3.63-1.44 3.63-2.94v-2.31c-.72.42-1.83.66-2.851.9-2.129.48-3.179.96-3.179 2.4s.96 1.95 2.4 1.95z" fill="#000"/></svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
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 |
BIN
frontend/public/db-icons/dameng.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
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 |
BIN
frontend/public/db-icons/gaussdb.ico
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
frontend/public/db-icons/goldendb.ico
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/public/db-icons/highgo.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
57
frontend/public/db-icons/iotdb.svg
Normal file
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1"
|
||||
id="Õ_xBA__x2264__x201E__1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 107.7 107.7"
|
||||
style="enable-background:new 0 0 107.7 107.7;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#9E2878;}
|
||||
</style>
|
||||
<g>
|
||||
<g id="g1133" transform="translate(-244.51235,-228.78793)">
|
||||
<path id="path1119" class="st0" d="M340.8,253.8c2.6-1,5.5,0.4,6.2,3c0.7,2.6-1.1,5.7-3.7,6.4c-3.2,0.6-6.2-1.4-9.3,0.2
|
||||
c-3.1,1.7-4,5.2-4.7,8.3c-1.4,5-8.5,7.3-12.1,4.2c-3.3-2.4-3.4-7.8-0.2-11c2.2-2.5,5.9-3.3,8.8-2c2.7,1.2,6,1.7,8.6-0.4
|
||||
C337.5,260.1,336.7,255.1,340.8,253.8L340.8,253.8z"/>
|
||||
<path id="path1121" class="st0" d="M280.5,244.7c4.2-2.2,9.5,1.5,8.2,6.1c-1.4,5.4-0.7,11.5,2.9,15.5c3.4,4,9.8,4.8,14.6,1.9
|
||||
c3.7-2.1,6-5.8,7.4-9.6c1-3.1,0.6-6.2,1.1-9.3c1-3.8,5.8-6,9.1-4.2c3.2,1.4,4,5.9,1.7,8.8c-1.4,2.2-4.2,2.7-6.3,3.8
|
||||
c-3.6,1.9-6.5,4.9-8.4,8.6c-1.2,2.1-1.1,4.5-1.7,6.7c-0.9,1.9-3,2.7-4.8,2.9c-4.8,0.6-9.5,3-13,6.7c-1.7,1.7-3.1,4.2-5.6,4.2
|
||||
c-2.7-0.2-4.7-2.6-7.4-2.7c-4.9-0.7-10.2,0.7-14.4,4c-3.7,3.1-9.4,0.8-9.6-3.7c-0.9-5.1,6.2-9.5,10.1-6.2c4.3,4,10.8,5.6,16.9,3.7
|
||||
c5.6-1.9,9.7-8.3,8.6-14c-0.9-4.9-4.2-9.3-8.6-11.4c-1.7-0.8-3.6-1.6-4.2-3.5C275.6,250.2,277.3,246.2,280.5,244.7L280.5,244.7z"
|
||||
/>
|
||||
<path id="path1123" class="st0" d="M277.8,235.9c2.2-0.8,4.2,1.3,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1.1
|
||||
C275,238.9,276,236.4,277.8,235.9z"/>
|
||||
<path id="path1125" class="st0" d="M246.9,278.8c2.2-0.8,4.2,1.2,3.3,3.4c-0.6,2.1-3.7,2.7-4.8,1C244,281.9,245,279.3,246.9,278.8
|
||||
L246.9,278.8z"/>
|
||||
<path id="path1127" class="st0" d="M328.2,236.2c2.2-0.7,4.2,1.3,3.3,3.5c-0.6,2-3.7,2.7-4.8,1
|
||||
C325.4,239.2,326.3,236.8,328.2,236.2z"/>
|
||||
<path id="path1129" class="st0" d="M253.6,257.7c0.4-3.7,5.5-5.9,8.1-3.6c1.9,1.1,1.6,3.6,2.2,5.4c0.4,2.4,2.7,4.3,5.2,4.3
|
||||
c3.2,0.3,6.4-2.3,9.5-1.2c4.8,1.2,6.5,7.5,3.2,11.5c-2.9,4.1-9.3,4.5-12,0.7c-2.4-2.8-0.5-7.1-2.7-10.1c-1.7-2.9-5.4-2.7-8.3-1.9
|
||||
C255.8,263.8,253,260.7,253.6,257.7L253.6,257.7z"/>
|
||||
<path id="path1131" class="st0" d="M300.8,230c3.3-1.9,7.5,0.9,6.7,4.6c0,2.3-2.2,3.6-3.5,5.2c-1.9,1.9-2.3,4.9-1.2,7
|
||||
c1.3,2.9,4.9,4,5.5,7.2c1.2,4.8-3.3,10.1-8.2,9.8c-4.8,0.1-8.3-5-6.3-9.5c1.2-3.8,5.8-4.9,7.2-8.5c1.7-3.2-0.4-6.1-2.4-8.1
|
||||
C296.7,235.6,297.9,231.4,300.8,230z"/>
|
||||
</g>
|
||||
<g id="g1149" transform="translate(-244.51235,-228.78793)">
|
||||
<path id="path1135" class="st0" d="M256,311.5c-2.6,1-5.5-0.4-6.2-3c-0.7-2.6,1.1-5.7,3.7-6.4c3.2-0.6,6.2,1.4,9.3-0.2
|
||||
c3.1-1.6,4-5.1,4.7-8.2c1.4-5,8.5-7.3,12.1-4.2c3.3,2.4,3.4,7.8,0.2,10.9c-2.2,2.5-5.9,3.3-8.8,2c-2.7-1.2-6-1.7-8.6,0.4
|
||||
C259.1,305,259.9,310.2,256,311.5L256,311.5z"/>
|
||||
<path id="path1137" class="st0" d="M316.1,320.5c-4.2,2.2-9.5-1.5-8.2-6c1.4-5.4,0.7-11.6-2.9-15.6c-3.4-4-9.8-4.7-14.6-1.9
|
||||
c-3.7,2-6,5.7-7.4,9.5c-1,3.1-0.6,6.2-1.1,9.3c-1,3.8-5.8,6-9.1,4.3c-3.2-1.4-4-5.9-1.7-8.9c1.4-2.2,4.2-2.7,6.3-3.8
|
||||
c3.6-1.9,6.5-4.9,8.4-8.6c1.2-2,1.1-4.5,1.7-6.6c0.9-1.9,3-2.7,4.8-2.9c4.8-0.6,9.5-3,13-6.7c1.7-1.6,3.1-4.2,5.6-4.1
|
||||
c2.7,0.1,4.7,2.5,7.4,2.7c4.9,0.6,10.2-0.8,14.4-4c3.7-3.2,9.4-0.9,9.6,3.6c0.9,5.1-6.2,9.5-10.1,6.2c-4.3-4-10.8-5.6-16.9-3.7
|
||||
c-5.6,1.9-9.7,8.3-8.6,14c0.9,4.8,4.2,9.3,8.6,11.3c1.7,0.8,3.6,1.6,4.2,3.5C321.2,314.9,319.4,319.1,316.1,320.5L316.1,320.5z"/>
|
||||
<path id="path1139" class="st0" d="M318.9,329.4c-2.2,0.8-4.2-1.3-3.3-3.4c0.6-2.1,3.7-2.7,4.8-1.1
|
||||
C321.7,326.3,320.7,328.8,318.9,329.4z"/>
|
||||
<path id="path1141" class="st0" d="M349.9,286.5c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2.1,3.7-2.7,4.8-1
|
||||
C352.8,283.5,351.8,285.9,349.9,286.5z"/>
|
||||
<path id="path1143" class="st0" d="M268.5,329c-2.2,0.7-4.2-1.3-3.3-3.5c0.6-2,3.7-2.7,4.8-1C271.3,325.9,270.3,328.5,268.5,329z"
|
||||
/>
|
||||
<path id="path1145" class="st0" d="M343.1,307.4c-0.4,3.7-5.5,5.9-8.1,3.6c-1.9-1.1-1.6-3.6-2.2-5.4c-0.4-2.4-2.7-4.3-5.2-4.3
|
||||
c-3.2-0.3-6.4,2.3-9.5,1.2c-4.8-1.2-6.5-7.5-3.2-11.5c2.9-4.1,9.3-4.5,12-0.7c2.4,2.8,0.5,7,2.7,10c1.7,2.9,5.4,2.7,8.3,1.9
|
||||
C341,301.4,343.8,304.4,343.1,307.4L343.1,307.4z"/>
|
||||
<path id="path1147" class="st0" d="M295.8,335.2c-3.3,2-7.5-0.9-6.7-4.6c0-2.3,2.2-3.6,3.5-5.2c1.9-1.9,2.3-4.9,1.2-7
|
||||
c-1.3-2.9-4.9-4-5.5-7.2c-1.2-4.8,3.3-10.1,8.2-9.8c4.8-0.1,8.3,5,6.3,9.6c-1.2,3.7-5.8,4.8-7.2,8.4c-1.7,3.2,0.4,6.1,2.4,8.2
|
||||
C300,329.6,298.8,333.8,295.8,335.2L295.8,335.2z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
BIN
frontend/public/db-icons/iris.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
frontend/public/db-icons/jvm.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/db-icons/kafka.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/public/db-icons/kingbase.ico
Normal file
|
After Width: | Height: | Size: 9.4 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 |
35
frontend/public/db-icons/mqtt.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.2" baseProfile="tiny" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
|
||||
y="0px" viewBox="0 0 1100 300" overflow="visible" xml:space="preserve">
|
||||
<g id="black_bg" display="none">
|
||||
<rect x="-2384.9" y="-306.8" display="inline" width="4531.8" height="1152"/>
|
||||
</g>
|
||||
<g id="logos">
|
||||
<g>
|
||||
<path fill="#FFFFFF" d="M388,150.1c1.7,3.7,3.3,7.5,4.8,11.4c1.5-4,3.1-7.8,4.9-11.6c1.7-3.7,3.5-7.4,5.3-10.9l50.5-99.5
|
||||
c0.9-1.7,1.8-3,2.8-4c0.9-0.9,2-1.6,3.2-2.1c1.2-0.5,2.5-0.7,4-0.7c1.5,0,3.2,0,5.2,0h34.8v196.6h-40.2V116.3
|
||||
c0-5.5,0.3-11.4,0.8-17.8l-52.1,101.1c-1.6,3.2-3.8,5.5-6.5,7.2c-2.7,1.6-5.9,2.4-9.4,2.4h-6.2c-3.5,0-6.6-0.8-9.4-2.4
|
||||
c-2.7-1.6-4.9-4-6.5-7.2L321.4,98.3c0.4,3.2,0.6,6.3,0.8,9.4c0.2,3.1,0.3,6,0.3,8.6v113.1h-40.2V32.8h34.8c2,0,3.7,0,5.2,0
|
||||
c1.5,0,2.8,0.2,4,0.7c1.2,0.5,2.2,1.1,3.2,2.1c0.9,0.9,1.9,2.3,2.8,4l50.6,99.9C384.6,142.9,386.3,146.4,388,150.1z"/>
|
||||
<path fill="#FFFFFF" d="M719.1,131c0,8.5-0.9,16.6-2.6,24.4c-1.8,7.8-4.3,15.1-7.6,21.9c-3.3,6.8-7.3,13.2-12.2,19
|
||||
c-4.8,5.9-10.3,11-16.4,15.5l49.8,54.4h-37.5c-5.4,0-10.3-0.7-14.7-2c-4.4-1.3-8.3-3.9-11.7-7.9L641.7,229c-4,0.8-8,1.4-12.1,1.9
|
||||
c-4.1,0.4-8.3,0.7-12.6,0.7c-15.3,0-29.2-2.6-41.8-7.7c-12.6-5.1-23.4-12.2-32.3-21.3c-9-9-15.9-19.7-20.8-31.9
|
||||
c-4.9-12.2-7.4-25.5-7.4-39.7c0-14.2,2.5-27.4,7.4-39.7c4.9-12.2,11.8-22.9,20.8-31.9c9-9,19.7-16.1,32.3-21.2
|
||||
c12.6-5.1,26.5-7.7,41.8-7.7c15.3,0,29.2,2.6,41.8,7.8c12.6,5.2,23.3,12.3,32.2,21.3c8.9,9,15.8,19.6,20.7,31.9
|
||||
C716.6,103.7,719.1,116.8,719.1,131z M672.4,131c0-9.7-1.3-18.5-3.8-26.3c-2.5-7.8-6.1-14.4-10.9-19.8
|
||||
c-4.7-5.4-10.5-9.5-17.3-12.4c-6.8-2.9-14.6-4.3-23.4-4.3c-8.8,0-16.7,1.4-23.6,4.3c-6.9,2.9-12.7,7-17.4,12.4
|
||||
c-4.7,5.4-8.3,12-10.9,19.8c-2.5,7.8-3.8,16.5-3.8,26.3c0,9.8,1.3,18.6,3.8,26.4c2.5,7.8,6.1,14.4,10.9,19.8
|
||||
c4.7,5.4,10.5,9.5,17.4,12.4c6.9,2.8,14.7,4.3,23.6,4.3c8.7,0,16.5-1.4,23.4-4.3c6.8-2.8,12.6-7,17.3-12.4
|
||||
c4.7-5.4,8.3-12,10.9-19.8C671.1,149.6,672.4,140.8,672.4,131z"/>
|
||||
<path fill="#FFFFFF" d="M865.6,32.7v36.2h-53.3v160.5h-45.6V68.9h-53.3V32.7H865.6z"/>
|
||||
<path fill="#FFFFFF" d="M1040.1,32.7v36.2h-55.3v160.5h-45.6V68.9h-53.3V32.7H1040.1z"/>
|
||||
</g>
|
||||
<path fill="#FFFFFF" d="M34.9,144c-0.2,0-0.4,0-0.6,0v77.6c0,5.6,4.6,10.2,10.2,10.2h79.9C123.7,183.3,83.8,144,34.9,144z"/>
|
||||
<path fill="#FFFFFF" d="M34.9,80c-0.2,0-0.4,0-0.6,0v33c65.9,0.3,119.5,53.3,120.2,118.8h34.2C188.1,148,119.3,80,34.9,80z"/>
|
||||
<path fill="#FFFFFF" d="M237.2,221.7v-70.1C214,94.8,167.3,50,109.1,29H44.5c-5.6,0-10.2,4.6-10.2,10.2V49
|
||||
c101.4,0.3,183.9,82,184.5,182.8h8.2C232.6,231.8,237.2,227.3,237.2,221.7z"/>
|
||||
<path fill="#FFFFFF" d="M210.5,57.3c9.4,9.4,19,21.3,26.7,31.8v-50c0-5.6-4.5-10.1-10.1-10.1h-51.5
|
||||
C187.5,37.3,199.9,46.8,210.5,57.3z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
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 |
BIN
frontend/public/db-icons/oceanbase.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/public/db-icons/opengauss.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
frontend/public/db-icons/oracle.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
frontend/public/db-icons/postgres.svg
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
35
frontend/public/db-icons/qdrant.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Capa_2" data-name="Capa 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 346.42 400">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #9e0d38;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #dc244c;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #ff516b;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Vectors">
|
||||
<g>
|
||||
<g>
|
||||
<polygon class="cls-2" points="173.21 0 0 100 0 300 173.21 400 238.16 362.5 238.16 287.5 173.21 325 64.96 262.5 64.96 137.5 173.21 75 281.46 137.5 281.46 387.5 346.42 350 346.42 100 173.21 0"/>
|
||||
<polygon class="cls-2" points="108.26 162.5 108.26 237.5 173.21 275 238.16 237.5 238.16 162.5 173.21 125 108.26 162.5"/>
|
||||
</g>
|
||||
<g>
|
||||
<polygon class="cls-1" points="238.16 287.5 238.16 362.5 173.21 400 173.21 325 238.16 287.5"/>
|
||||
<polygon class="cls-1" points="346.42 100 346.42 350 281.46 387.5 281.46 137.5 346.42 100"/>
|
||||
<polygon class="cls-3" points="346.42 100 281.46 137.5 173.21 75 64.96 137.5 0 100 173.21 0 346.42 100"/>
|
||||
<polygon class="cls-2" points="173.21 325 173.21 400 0 300 0 100 64.96 137.5 64.96 262.5 173.21 325"/>
|
||||
<polygon class="cls-3" points="238.16 162.5 173.21 200 108.26 162.5 173.21 125 238.16 162.5"/>
|
||||
<polygon class="cls-2" points="173.21 200 173.21 275 108.26 237.5 108.26 162.5 173.21 200"/>
|
||||
<polygon class="cls-1" points="238.16 162.5 238.16 237.5 173.21 275 173.21 200 238.16 162.5"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
7
frontend/public/db-icons/rabbitmq.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg role="img" viewBox="0 0 132.29167 132.29166" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>RabbitMQ</title>
|
||||
<path
|
||||
d="M 127.32189 54.270409 H 84.999977 a 5.2690424 5.2690424 0 0 1 -5.302308 -5.302309 V 6.6461888 A 5.2690424 5.2690424 0 0 0 74.39536 1.3438804 H 58.555783 A 5.2690424 5.2690424 0 0 0 53.253474 6.6461888 V 48.9681 a 5.2690424 5.2690424 0 0 1 -5.302309 5.302309 H 32.111589 A 5.2690424 5.2690424 0 0 1 26.80928 48.9681 V 6.6461888 A 5.2690424 5.2690424 0 0 0 21.506972 1.3106149 H 5.6341299 A 5.2690424 5.2690424 0 0 0 0.3650864 6.6461888 V 128.31903 a 5.2690424 5.2690424 0 0 0 5.3023089 5.30231 H 127.32189 a 5.2690424 5.2690424 0 0 0 5.30231 -5.30231 V 59.572717 a 5.2690424 5.2690424 0 0 0 -5.30231 -5.302308 z m -21.17517 44.908545 a 7.9542581 7.9542581 0 0 1 -7.95425 7.987516 H 87.573659 a 7.9542581 7.9542581 0 0 1 -7.954257 -7.987516 V 88.593818 a 7.9542581 7.9542581 0 0 1 7.954257 -7.987517 h 10.618811 a 7.9542581 7.9542581 0 0 1 7.95425 7.987517 z"
|
||||
fill="#FF6600"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1016 B |
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 |
BIN
frontend/public/db-icons/rocketmq.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
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 |
BIN
frontend/public/db-icons/starrocks.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/public/db-icons/tdengine.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
72
frontend/public/db-icons/vastbase.svg
Normal file
@@ -0,0 +1,72 @@
|
||||
<svg width="159" height="48" viewBox="0 0 159 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1_798)">
|
||||
<path d="M93.932 39.4721C93.6905 39.3017 93.4059 39.1653 93.0955 39.0461C92.7764 38.9268 92.4573 38.8246 92.1382 38.7223C91.8277 38.6371 90.0598 38.2623 89.7062 38.1004C88.7404 37.6403 89.1888 37.1717 89.3527 37.061C89.4993 36.9673 89.6545 36.9332 89.8615 36.8906C90.0598 36.848 90.422 36.8054 91.0602 36.8054C91.4052 36.8054 91.8019 36.848 92.0865 36.8991C92.371 36.9502 92.6298 37.0184 92.8454 37.078C93.0092 37.1206 93.0868 37.1547 93.3456 37.1717C93.587 37.1973 93.7509 37.078 93.8716 36.9843C93.9751 36.9076 94.0441 36.7372 94.0441 36.6605C94.0441 36.5583 94.0441 36.2601 94.0441 36.2175C94.0441 35.996 93.8199 35.8171 93.5353 35.7319C93.5094 35.7234 93.4749 35.7149 93.4404 35.6978C93.2421 35.6382 93.0265 35.5871 92.7764 35.5445C92.5263 35.5019 92.2503 35.4593 91.9657 35.4337C91.6725 35.4082 91.3879 35.3911 91.0947 35.3911C90.5428 35.3911 90.0081 35.4337 89.5079 35.5274C89.0077 35.6126 88.5765 35.7489 88.2143 35.9364C87.8435 36.1153 87.5502 36.3453 87.3346 36.6265C87.1191 36.8991 87.0156 37.2228 87.0156 37.5807C87.0156 37.8959 87.0932 38.1685 87.2398 38.3986C87.3864 38.6286 87.5934 38.8246 87.8262 38.9864C88.0677 39.1568 88.3437 39.2931 88.6455 39.4124C89.3354 39.685 91.2758 40.0173 92.052 40.2729C92.8281 40.5285 92.7591 41.3634 92.2589 41.5935C91.7501 41.8235 91.1033 41.832 90.3703 41.832C89.9132 41.832 89.3958 41.7809 89.0508 41.7127C88.7059 41.6446 88.4989 41.5594 88.2574 41.4827C88.0073 41.406 87.7227 41.2186 87.4468 41.2186C87.1104 41.2186 86.8086 41.3634 86.8086 41.6616C86.8086 41.7894 86.8086 41.7979 86.8086 41.832C86.8086 41.8661 86.8086 41.9172 86.8086 41.9683V42.275C86.8086 42.488 86.9379 42.6073 87.0587 42.6755C87.1708 42.7436 87.2484 42.7777 87.4295 42.8459C87.6106 42.9055 87.8435 42.9736 88.1194 43.0333C88.3954 43.0929 88.7145 43.1526 89.0767 43.1952C89.4389 43.2378 89.7149 43.2633 90.1288 43.2633C90.7497 43.2633 91.4569 43.2122 92.0002 43.11C92.5435 43.0077 93.0178 42.8629 93.4318 42.6499C93.8371 42.4539 94.1648 42.1984 94.3977 41.9002C94.6391 41.602 94.7599 41.2527 94.7599 40.8522C94.7599 40.5455 94.6823 40.2814 94.527 40.0599C94.3718 39.8384 94.1735 39.6424 93.932 39.4721Z" fill="#A0A0A0"/>
|
||||
<path d="M119.071 36.0554C118.235 35.3994 117.528 35.3057 115.777 35.3057H113.078C113.078 35.3057 113.034 35.3057 113 35.3227H112.965C112.534 35.3227 112.181 35.672 112.181 36.098V42.4707C112.181 42.8967 112.534 43.246 112.965 43.246H113.103C113.103 43.246 113.147 43.246 113.164 43.246H115.587C117.321 43.246 118.088 43.1779 118.968 42.5304C120.003 41.7636 120.218 41.0479 120.218 39.1651C120.218 37.4697 120.054 36.8392 119.063 36.0639L119.071 36.0554ZM118.028 41.2524L117.881 41.3717C117.579 41.6699 116.475 41.7977 115.579 41.7977H114.294C114.104 41.7892 113.905 41.6784 113.905 41.4824V37.2056C113.905 37.0096 114.121 36.7285 114.311 36.7285L115.544 36.7114C116.57 36.7114 117.433 36.737 118.054 37.3419C118.416 37.6997 118.485 38.2024 118.485 39.2247C118.485 40.3664 118.459 40.8264 118.019 41.2609L118.028 41.2524Z" fill="#A0A0A0"/>
|
||||
<path d="M107.101 35.3911H99.9258C99.7016 35.3911 99.5205 35.57 99.5205 35.7915V36.6605C99.5205 36.8906 99.7102 37.0695 99.9345 37.0695H102.582V42.4284C102.582 42.8544 102.642 43.2037 103.074 43.2037H103.832C104.264 43.2037 104.281 42.8544 104.281 42.4284V37.0695H107.101C107.299 37.0695 107.455 36.9076 107.455 36.7117V35.7489C107.455 35.553 107.291 35.3911 107.092 35.3911H107.101Z" fill="#A0A0A0"/>
|
||||
<path d="M145.236 35.3911H138.061C137.836 35.3911 137.655 35.57 137.655 35.7915V36.6605C137.655 36.8906 137.845 37.0695 138.069 37.0695H140.717V42.4284C140.717 42.8544 140.777 43.2037 141.208 43.2037H141.967C142.398 43.2037 142.416 42.8544 142.416 42.4284V37.0695H145.236C145.434 37.0695 145.598 36.9076 145.598 36.7117V35.7489C145.598 35.553 145.434 35.3911 145.236 35.3911Z" fill="#A0A0A0"/>
|
||||
<path d="M69.535 35.3994H68.9572C68.5174 35.3738 68.4139 35.4164 68.2846 35.655L68.1811 35.8424L68.1121 35.9702L67.9482 36.2684C67.9482 36.2684 67.9482 36.2684 67.9482 36.2769L65.3697 40.9542L62.5755 35.9276C62.5755 35.9276 62.5755 35.9191 62.5669 35.9105L62.4203 35.6464C62.2909 35.4164 62.1012 35.3908 61.7821 35.3823H61.1439C60.661 35.3908 60.6179 35.7742 60.7472 36.0128L61.256 36.9499C61.256 36.9499 61.2733 36.984 61.2819 37.0011L64.4555 42.6922C64.6366 42.9989 64.9643 43.2119 65.3352 43.2119C65.706 43.2119 66.0337 42.9989 66.2148 42.6922L69.4143 37.0181L69.949 35.9872C70.0783 35.7572 70.0611 35.3823 69.535 35.3994Z" fill="#A0A0A0"/>
|
||||
<path d="M82.0395 41.585L78.8831 35.9279C78.702 35.6127 78.3829 35.4082 78.0121 35.4082C77.6413 35.4082 77.3222 35.6127 77.1411 35.9279L73.9933 41.6191C73.9933 41.6191 73.9761 41.6531 73.9675 41.6702L73.4587 42.6073C73.3293 42.8374 73.4587 43.1015 73.6829 43.2378H74.4677C74.873 43.2378 74.9851 43.2037 75.1145 42.9737L75.2611 42.7096C75.2611 42.7096 75.2611 42.7011 75.2697 42.6925L75.8389 41.6531H78.6934C78.8486 41.6531 79.116 41.4316 78.9349 41.0738L78.5295 40.3496C78.4951 40.2815 78.4261 40.2389 78.3484 40.2389H76.6064L78.0207 37.6659L80.5734 42.3432C80.5734 42.3432 80.5734 42.3432 80.5734 42.3518L80.7373 42.65L80.8063 42.7777L80.9098 42.9652C81.0391 43.1952 81.272 43.2293 81.6169 43.2293L82.3758 43.2463C82.5483 43.2378 82.807 42.9141 82.6259 42.6159L82.0136 41.585H82.0395Z" fill="#A0A0A0"/>
|
||||
<path d="M132.895 41.585L129.739 35.9279C129.558 35.6127 129.238 35.4082 128.868 35.4082C128.497 35.4082 128.178 35.6127 127.997 35.9279L124.849 41.6191C124.849 41.6191 124.832 41.6531 124.823 41.6702L124.314 42.6073C124.185 42.8374 124.314 43.1015 124.538 43.2378H125.323C125.728 43.2378 125.841 43.2037 125.97 42.9737L126.117 42.7096C126.117 42.7096 126.117 42.7011 126.125 42.6925L126.694 41.6531H129.549C129.704 41.6531 129.971 41.4316 129.79 41.0738L129.385 40.3496C129.351 40.2815 129.282 40.2389 129.204 40.2389H127.462L128.876 37.6659L131.429 42.3432C131.429 42.3432 131.429 42.3432 131.429 42.3518L131.593 42.6585L131.662 42.7863L131.765 42.9737C131.895 43.2037 132.127 43.2378 132.481 43.2378L133.24 43.2548C133.412 43.2463 133.671 42.9226 133.49 42.6244L132.878 41.585H132.895Z" fill="#A0A0A0"/>
|
||||
<path d="M158.94 42.6156L158.328 41.5762L155.171 35.9191C154.99 35.6039 154.671 35.3994 154.3 35.3994C153.929 35.3994 153.61 35.6039 153.429 35.9191L150.281 41.6103C150.281 41.6103 150.264 41.6444 150.256 41.6614L149.747 42.5986C149.617 42.8286 149.747 43.0927 149.971 43.229H150.756C151.161 43.229 151.273 43.1949 151.403 42.9649L151.549 42.7008C151.549 42.7008 151.549 42.6923 151.558 42.6838L152.127 41.6444H154.981C155.137 41.6444 155.404 41.4228 155.223 41.065L154.818 40.3408C154.783 40.2727 154.714 40.2301 154.637 40.2301H152.894L154.309 37.6571L156.862 42.3345C156.862 42.3345 156.862 42.3345 156.862 42.343L157.025 42.6497L157.094 42.7775L157.198 42.9649C157.327 43.1949 157.56 43.229 157.905 43.229L158.664 43.2461C158.836 43.2375 159.095 42.9138 158.914 42.6156H158.94Z" fill="#A0A0A0"/>
|
||||
<path d="M141.476 29.751V22.2366C141.89 22.0833 142.304 21.9214 142.718 21.751C143.192 21.5551 143.244 21.4869 143.235 21.112C143.235 20.8053 143.244 20.0556 143.244 19.5615C143.244 19.1951 143.02 19.0758 142.692 19.161C142.295 19.2633 141.881 19.3655 141.467 19.4677V14.9523H142.977C143.14 14.9523 143.27 14.8245 143.27 14.6626V12.7372C143.27 12.5753 143.14 12.4475 142.977 12.4475H141.467V10.1813C141.467 10.0194 141.338 9.8916 141.174 9.8916H138.828C138.665 9.8916 138.535 10.0194 138.535 9.90864V12.4475H137.095C136.931 12.4475 136.802 12.5753 136.802 12.7372V14.6626C136.802 14.8245 136.931 14.9523 137.095 14.9523H138.535V20.0812C138.061 20.1578 137.595 20.2175 137.129 20.2516C137.026 20.2601 136.922 20.2686 136.871 20.3538C136.793 20.4816 136.81 20.6861 136.81 20.6861V22.6712C136.81 22.6712 136.767 23.1653 136.897 23.2931C136.966 23.3612 137.164 23.4038 137.336 23.3698C137.742 23.2931 138.138 23.1994 138.535 23.1057V28.4901C138.509 28.6009 138.458 28.6861 138.328 28.7968C138.251 28.8565 138.138 28.8991 137.992 28.9161L137.224 28.9417C137.06 28.9417 136.931 29.0695 136.931 29.2313V31.1568C136.931 31.3186 137.06 31.4464 137.224 31.4464H139.863L139.898 31.4294C140.769 31.3357 141.441 30.4837 141.467 29.8107V29.7936C141.467 29.7936 141.467 29.7766 141.467 29.7681C141.467 29.7595 141.467 29.7425 141.467 29.734L141.476 29.751Z" fill="#19140F"/>
|
||||
<path d="M158.361 19.1268H153.98V17.3121H157.792C158.033 17.3121 158.223 17.1162 158.223 16.8861V10.2663C158.223 10.0278 158.025 9.84033 157.792 9.84033L146.426 9.92553C146.426 9.92553 146.365 9.90849 146.331 9.90849H143.985C143.821 9.90849 143.692 10.0363 143.692 10.1982V23.0714C143.562 25.3632 143.131 26.8797 142.234 28.8904V31.4548H142.303C142.898 31.4037 146.279 27.6209 146.615 22.0576L146.633 21.6486H151.057V23.4719H147.141C146.969 23.4719 146.822 23.6167 146.822 23.7871V31.2077C146.822 31.3781 146.969 31.523 147.141 31.523H157.913C158.085 31.523 158.232 31.3781 158.232 31.2077V23.7871C158.232 23.6167 158.085 23.4719 157.913 23.4719H153.997V21.6486H158.378C158.542 21.6486 158.672 21.5208 158.672 21.359V19.4335C158.672 19.2716 158.542 19.1438 158.378 19.1438L158.361 19.1268ZM155.222 26.2408V28.7626C155.222 28.8819 155.127 28.9756 155.006 28.9756H149.979C149.858 28.9756 149.763 28.8819 149.763 28.7626V26.2408C149.763 26.1215 149.858 26.0278 149.979 26.0278H155.006C155.127 26.0278 155.222 26.1215 155.222 26.2408ZM147.124 12.4303H154.696C154.972 12.4303 155.205 12.6604 155.205 12.933V14.4154C155.205 14.688 154.972 14.9181 154.696 14.9181H147.124C146.883 14.9181 146.684 14.7477 146.633 14.5262V12.8307C146.676 12.6007 146.883 12.4303 147.124 12.4303ZM146.641 19.1268V17.3121H151.048V19.1268H146.641Z" fill="#19140F"/>
|
||||
<path d="M114.397 10.4023H112.051C111.889 10.4023 111.758 10.532 111.758 10.692V13.0094C111.758 13.1694 111.889 13.299 112.051 13.299H114.397C114.559 13.299 114.69 13.1694 114.69 13.0094V10.692C114.69 10.532 114.559 10.4023 114.397 10.4023Z" fill="#19140F"/>
|
||||
<path d="M122.58 10.4023H120.235C120.073 10.4023 119.941 10.532 119.941 10.692V13.0094C119.941 13.1694 120.073 13.299 120.235 13.299H122.58C122.742 13.299 122.874 13.1694 122.874 13.0094V10.692C122.874 10.532 122.742 10.4023 122.58 10.4023Z" fill="#19140F"/>
|
||||
<path d="M122.132 29.1031C122.046 29.1031 121.951 29.1031 121.865 29.1031C121.123 29.1031 120.382 28.9839 119.692 28.7794C121.003 27.6633 121.908 26.232 122.072 24.5536H123.176C123.34 24.5536 123.469 24.4258 123.469 24.264V22.3385C123.469 22.1766 123.34 22.0488 123.176 22.0488H115.182V21.3502C115.182 21.1883 115.052 21.0605 114.888 21.0605H112.543C112.379 21.0605 112.249 21.1883 112.249 21.3502V22.0488H111.646C111.482 22.0488 111.353 22.1766 111.353 22.3385V24.264C111.353 24.4258 111.482 24.5536 111.646 24.5536H112.482C112.698 26.249 113.431 27.6378 114.509 28.7112C113.707 28.9583 112.87 29.1031 112.068 29.1031C111.991 29.1031 111.913 29.1031 111.835 29.1031C111.585 29.1031 111.378 29.2991 111.378 29.5462V30.969C111.378 31.2075 111.577 31.412 111.818 31.412C111.896 31.412 111.982 31.412 112.06 31.412C113.716 31.412 115.44 31.0542 116.984 30.4067C118.441 31.0712 120.132 31.412 121.857 31.412C121.934 31.412 122.02 31.412 122.098 31.412C122.348 31.412 122.546 31.2075 122.546 30.9604V29.4865C122.546 29.265 122.357 29.0861 122.132 29.0946V29.1031ZM115.354 24.5621H119.2C119.14 25.5845 118.321 26.6324 117.122 27.4588C116.113 26.6495 115.44 25.6186 115.354 24.5621Z" fill="#19140F"/>
|
||||
<path d="M132.714 29.0521C131.636 28.9755 130.644 28.5239 129.782 27.8338C131.187 26.164 132.041 24.017 132.093 21.6144C132.11 21.4526 132.11 21.3333 132.11 21.2907V14.9435H132.86C133.024 14.9435 133.154 14.8072 133.154 14.6538V12.7284C133.154 12.5665 133.024 12.4387 132.86 12.4387H128.1C128.16 12.2257 128.221 11.9957 128.281 11.7657C128.402 11.263 128.462 10.8455 128.454 10.2918C128.454 10.0617 128.264 9.88281 128.031 9.88281H125.332C124.987 9.88281 124.719 10.1725 124.745 10.5048C124.754 10.6496 124.762 10.8029 124.762 10.9563C124.762 12.4302 124.193 13.7763 123.262 14.7902V14.1597C123.262 13.9978 123.133 13.87 122.969 13.87H118.777V10.198C118.777 10.0362 118.648 9.90837 118.484 9.90837H116.139C115.975 9.90837 115.845 10.0362 115.845 10.198V13.87H111.628C111.464 13.87 111.335 13.9978 111.335 14.1597V16.4771C111.335 16.6389 111.464 16.7667 111.628 16.7667H113.293C113.293 16.7667 113.293 16.7923 113.293 16.8093C113.293 17.6187 112.378 18.1725 111.723 18.5559C111.585 18.6325 111.464 18.6922 111.378 18.8029C111.361 18.8285 111.344 18.8881 111.335 18.9648V20.3791C111.344 20.4472 111.369 20.5069 111.395 20.541C111.464 20.6262 111.585 20.6602 111.697 20.6688C111.818 20.6858 111.87 20.6858 111.99 20.6858C113.715 20.6858 115.595 19.3312 115.845 17.6613V20.4728C115.845 20.6347 115.975 20.7625 116.139 20.7625H118.484C118.648 20.7625 118.777 20.6347 118.777 20.4728V17.6443C119.019 19.3141 120.908 20.6858 122.632 20.6858C122.753 20.6858 122.813 20.6858 122.926 20.6688C123.046 20.6517 123.158 20.6262 123.227 20.541C123.253 20.5069 123.279 20.4558 123.288 20.3961V18.9478C123.279 18.8796 123.271 18.8285 123.245 18.8029C123.158 18.6922 123.029 18.6325 122.9 18.5559C122.244 18.1725 121.33 17.6187 121.33 16.8093C121.33 16.7923 121.33 16.7838 121.33 16.7667H122.831V16.903C122.831 16.903 122.788 17.1331 122.969 17.3205C123.15 17.5079 123.374 17.4227 123.633 17.3546C123.745 17.3205 123.857 17.2864 123.96 17.2524V21.7678C123.96 21.8189 123.96 21.8615 123.96 21.8956C123.96 21.9808 123.96 22.066 123.96 22.1512V22.2449C123.96 22.2449 123.96 22.2449 123.96 22.2534C124.038 24.3407 124.788 26.2321 126.004 27.7401C125.185 28.3535 124.236 28.7965 123.227 28.984C123.003 29.0266 122.839 29.2311 122.839 29.4611V30.9265C122.839 31.1821 123.064 31.3695 123.314 31.3354C125.03 31.0969 126.599 30.4749 127.91 29.5463C129.281 30.526 130.92 31.1735 132.705 31.378C132.947 31.4036 133.154 31.2247 133.154 30.9861C133.154 30.6624 133.154 29.8786 133.154 29.5889C133.154 29.0351 132.757 29.0777 132.679 29.0777L132.714 29.0521ZM127.039 14.9435H128.824C128.824 14.9435 129.178 14.9776 129.178 15.3695V22.066C129.074 23.3525 128.626 24.5878 127.936 25.6613C127.289 24.5282 126.918 23.2417 126.918 21.9978C126.918 21.9297 126.918 21.87 126.901 21.8274V17.0308C126.901 16.869 126.772 16.7412 126.608 16.7412H125.142C125.944 16.2896 126.556 15.6847 127.031 14.952L127.039 14.9435Z" fill="#19140F"/>
|
||||
<path d="M107.308 29.8531H98.5372V29.0097H105.937C106.161 29.0097 106.351 28.8223 106.351 28.6007V27.7828C106.351 27.5613 106.161 27.3739 105.937 27.3739H98.5372V26.5134H105.117C105.264 26.5134 105.376 26.3941 105.376 26.2493V20.1748C105.376 20.0299 105.255 19.9106 105.117 19.9106H88.4472C88.3006 19.9106 88.1885 20.0299 88.1885 20.1748V26.2493C88.1885 26.3941 88.3092 26.5134 88.4472 26.5134H95.0273V27.3739H87.6279C87.4037 27.3739 87.214 27.5613 87.214 27.7828V28.6007C87.214 28.8223 87.4037 29.0097 87.6279 29.0097H95.0273V29.8531H86.2567C86.0325 29.8531 85.8428 30.0406 85.8428 30.2621V31.08C85.8428 31.3015 86.0325 31.4889 86.2567 31.4889H107.299C107.523 31.4889 107.713 31.3015 107.713 31.08V30.2621C107.713 30.0406 107.523 29.8531 107.299 29.8531H107.308ZM102.125 24.5368C102.125 24.7158 101.978 24.8606 101.797 24.8606H98.5372V24.0001H101.797C101.978 24.0001 102.125 24.1449 102.125 24.3238V24.5368ZM101.797 21.5464C101.978 21.5464 102.125 21.6913 102.125 21.8702V22.0832C102.125 22.2621 101.978 22.4069 101.797 22.4069H98.5372V21.5464H101.797ZM91.5001 21.8702C91.5001 21.6913 91.6467 21.5464 91.8278 21.5464H95.0445V22.4069H91.8278C91.6467 22.4069 91.5001 22.2621 91.5001 22.0832V21.8702ZM91.8278 24.8606C91.6467 24.8606 91.5001 24.7158 91.5001 24.5368V24.3238C91.5001 24.1449 91.6467 24.0001 91.8278 24.0001H95.0445V24.8606H91.8278Z" fill="#19140F"/>
|
||||
<path d="M107.317 17.3975H86.2655C86.0369 17.3975 85.8516 17.5806 85.8516 17.8064V18.6158C85.8516 18.8416 86.0369 19.0247 86.2655 19.0247H107.317C107.545 19.0247 107.731 18.8416 107.731 18.6158V17.8064C107.731 17.5806 107.545 17.3975 107.317 17.3975Z" fill="#19140F"/>
|
||||
<path d="M88.2919 16.4944H105.298C105.445 16.4944 105.557 16.3751 105.557 16.2303V10.1557C105.557 10.0109 105.436 9.8916 105.298 9.8916H88.2919C88.1453 9.8916 88.0332 10.0109 88.0332 10.1557V16.2303C88.0332 16.3751 88.1539 16.4944 88.2919 16.4944ZM91.3189 11.8341C91.3189 11.6552 91.4655 11.5103 91.6466 11.5103H101.944C102.125 11.5103 102.271 11.6552 102.271 11.8341V12.0471C102.271 12.226 102.125 12.3708 101.944 12.3708H91.6466C91.4655 12.3708 91.3189 12.226 91.3189 12.0471V11.8341ZM91.3189 14.3304C91.3189 14.1515 91.4655 14.0066 91.6466 14.0066H101.944C102.125 14.0066 102.271 14.1515 102.271 14.3304V14.5434C102.271 14.7223 102.125 14.8671 101.944 14.8671H91.6466C91.4655 14.8671 91.3189 14.7223 91.3189 14.5434V14.3304Z" fill="#19140F"/>
|
||||
<path d="M63.7749 16.043H61.1274C60.9721 16.043 60.8428 16.1708 60.8428 16.3241V20.2176C60.8428 20.371 60.9721 20.4988 61.1274 20.4988H63.7059C63.8612 20.4988 64.0509 20.3284 64.0509 20.175V16.3241C64.0509 16.1708 63.9388 16.043 63.7835 16.043H63.7749Z" fill="#19140F"/>
|
||||
<path d="M63.749 21.606H61.1274C60.9721 21.606 60.8428 21.7338 60.8428 21.8871V31.1651C60.8428 31.3184 60.9721 31.4462 61.1274 31.4462H63.7404C63.8957 31.4462 64.0164 31.3184 64.0164 31.1651V21.8871C64.0164 21.7338 63.9043 21.606 63.749 21.606Z" fill="#19140F"/>
|
||||
<path d="M81.5318 20.4731H80.0916V15.4635C80.0916 15.1568 79.8415 14.9097 79.5311 14.9097H68.3975C68.7425 14.6797 69.2082 14.2963 69.6911 13.7936H81.0489C81.2041 13.7936 81.3248 13.6743 81.3248 13.5125V11.5615C81.3248 11.4081 81.1955 11.2888 81.0489 11.2888H70.8726C70.8898 11.1696 70.933 10.1728 70.933 10.1728C70.933 10.0194 70.8036 9.8916 70.6484 9.8916H68.1474C67.9922 9.8916 67.8628 10.0194 67.8628 10.1728V10.8714C67.8628 12.03 66.5692 13.1546 65.4568 13.3591C65.4568 13.3591 65.0342 13.3762 65.0342 13.7169V15.1057C65.0342 15.259 65.1635 15.3868 65.3101 15.3868L66.5951 15.1823V20.4646H65.7413C65.5861 20.4646 65.4568 20.5838 65.4568 20.7457V22.6882C65.4568 22.8415 65.5861 22.9693 65.7413 22.9693H66.5951V27.9448C66.5951 28.2856 66.828 28.3879 67.1384 28.3879H77.1163V28.7627C77.1163 28.9757 77.1163 29.2228 76.78 29.2228H74.2101C74.0548 29.2228 73.822 29.325 73.8737 29.5636C73.9255 29.8021 74.6758 31.0119 74.6758 31.0119C74.762 31.1653 74.8482 31.4209 75.1328 31.4209H79.6087C80.0399 31.4209 80.0485 31.2079 80.0485 30.9779L80.0744 28.3879L80.954 28.4049C81.1092 28.4049 81.2903 28.2686 81.2903 28.1152V26.1983C81.2817 26.045 81.1179 25.8831 80.9626 25.8831H80.0744V22.9608H81.5059C81.6612 22.9608 81.7905 22.833 81.7905 22.6797V20.7372C81.7905 20.5838 81.6784 20.456 81.5232 20.456L81.5318 20.4731ZM77.1508 25.619C77.1508 25.7723 77.0215 25.9001 76.8663 25.9001H75.0294V24.1365C75.0294 23.8554 74.9172 23.7446 74.6326 23.7446H72.4163C72.2611 23.7446 72.08 23.8639 72.08 24.0684V25.9001H69.9067C69.8205 25.9001 69.5531 25.8234 69.5531 25.6275V23.3101C69.5531 23.1568 69.6739 23.0205 69.8377 23.0205H76.8663C77.0215 23.0205 77.1508 23.2249 77.1508 23.3783V25.619ZM77.1336 20.4731H75.038V18.6925C75.038 18.4965 74.9172 18.3261 74.762 18.3261H72.3645C72.2093 18.3261 72.08 18.4965 72.08 18.6499V20.4731H69.5359V17.7979C69.5359 17.5082 69.7774 17.3719 69.9326 17.3719L76.8318 17.3975C76.987 17.3975 77.1508 17.5338 77.1508 17.7382L77.1336 20.4731Z" fill="#19140F"/>
|
||||
<path d="M61.1184 14.9353H63.7401C63.8953 14.9353 64.0161 14.816 64.0161 14.6541V10.7606C64.0161 10.6073 63.8695 10.4795 63.7142 10.4795H61.1098C60.9546 10.4795 60.8252 10.6073 60.8252 10.7606V14.6541C60.8252 14.8075 60.9546 14.9353 61.1098 14.9353H61.1184Z" fill="#19140F"/>
|
||||
<path d="M34.323 44.7455L34.2626 44.6518C34.2109 44.5411 34.1591 44.4388 34.1074 44.3451L26.915 32.1364C26.5097 31.4718 25.768 31.0288 24.9315 31.0288C24.095 31.0288 23.3447 31.4804 22.9394 32.1534L15.7211 44.2599C15.7211 44.2599 15.7039 44.2855 15.6952 44.3025L15.6435 44.3877C15.6004 44.4644 15.5573 44.5496 15.5141 44.6263L15.4365 44.7455C14.9105 45.606 15.1864 46.7391 16.0574 47.2588L16.8508 47.7359C17.7219 48.2556 18.8689 47.983 19.3949 47.1225L19.8779 46.3387C19.9124 46.2876 19.9382 46.2365 19.9641 46.1768L24.897 37.9383L29.7782 46.1002C29.8127 46.1768 29.8472 46.262 29.8989 46.3387L30.2007 46.8329C30.2439 46.9095 30.2956 46.9862 30.3387 47.0544L30.3818 47.1225C30.9079 47.983 32.0549 48.2556 32.9259 47.7359L33.7193 47.2588C34.5903 46.7391 34.8663 45.606 34.3402 44.7455H34.323Z" fill="url(#paint0_radial_1_798)"/>
|
||||
<path d="M47.7674 26.2578H47.6553C47.5346 26.2492 47.4138 26.2407 47.3017 26.2407L33.0032 26.2918C32.2184 26.3089 31.4595 26.7178 31.0369 27.442C30.6144 28.1662 30.6402 29.0267 31.0197 29.7082L38.031 41.9425C38.031 41.9596 38.0482 41.9681 38.0568 41.9851L38.1086 42.0788C38.1517 42.1555 38.2034 42.2322 38.2552 42.3089L38.3242 42.4367C38.8157 43.3227 39.9455 43.6465 40.8424 43.1609L41.653 42.7178C42.5499 42.2322 42.8776 41.1161 42.3861 40.2301L41.9376 39.4292C41.9117 39.3781 41.8773 39.327 41.8428 39.2759L37.091 30.9436L46.6808 30.8499C46.767 30.8584 46.8533 30.8669 46.9481 30.8669L47.526 30.8499C47.6122 30.8499 47.7071 30.8499 47.7933 30.8499H47.8709C48.8885 30.8243 49.7078 29.9809 49.6819 28.9755L49.6647 28.0639C49.6388 27.0586 48.785 26.2492 47.7674 26.2748V26.2578Z" fill="url(#paint1_radial_1_798)"/>
|
||||
<path d="M38.2819 5.51233L38.2302 5.61457C38.1612 5.70829 38.0922 5.81052 38.0405 5.90424L30.9343 18.1641C30.5549 18.8457 30.5376 19.6976 30.9602 20.4218C31.3828 21.146 32.1503 21.5549 32.9437 21.572L47.1732 21.6912C47.1905 21.6912 47.2077 21.6912 47.2164 21.6912H47.3198C47.4061 21.6912 47.5009 21.6912 47.5958 21.6827H47.7424C48.76 21.7083 49.6138 20.9074 49.6397 19.8936L49.6569 18.982C49.6828 17.9767 48.8635 17.1332 47.8459 17.1076L46.9231 17.0906C46.8628 17.0906 46.8024 17.0906 46.742 17.0906L37.0573 16.9884L41.766 8.73278C41.8178 8.66462 41.8695 8.58795 41.9126 8.51127L42.1886 8.00861C42.2317 7.93193 42.2748 7.85525 42.3179 7.77858L42.3524 7.71042C42.844 6.82437 42.5077 5.70829 41.6194 5.22266L40.8088 4.77964C39.9119 4.29402 38.7821 4.62629 38.2906 5.50381L38.2819 5.51233Z" fill="url(#paint2_radial_1_798)"/>
|
||||
<path d="M15.359 3.25477L15.4194 3.34848C15.4711 3.45924 15.5229 3.56147 15.5746 3.65519L22.767 15.8639C23.1723 16.5285 23.914 16.9715 24.7505 16.9715C25.5871 16.9715 26.3373 16.5199 26.7427 15.8469L33.9609 3.74039C33.9609 3.74039 33.9782 3.71483 33.9868 3.69779L34.0385 3.61259C34.0817 3.53591 34.1248 3.45072 34.1679 3.37404L34.2455 3.25477C34.7716 2.39428 34.4956 1.26115 33.6246 0.741453L32.8312 0.26435C31.9602 -0.255352 30.8132 0.0172783 30.2871 0.877768L29.8042 1.66158C29.7697 1.7127 29.7438 1.76382 29.7179 1.82346L24.785 10.062L19.9039 1.90013C19.8694 1.82346 19.8349 1.73826 19.7831 1.66158L19.4813 1.16744C19.4382 1.09076 19.3864 1.01408 19.3433 0.945926L19.3002 0.877768C18.7741 0.0172783 17.6271 -0.255352 16.7561 0.26435L15.9627 0.741453C15.0917 1.26115 14.8157 2.39428 15.3418 3.25477H15.359Z" fill="url(#paint3_radial_1_798)"/>
|
||||
<path d="M1.91414 21.7426H2.02625C2.14699 21.7511 2.26772 21.7596 2.37983 21.7596L16.6784 21.7085C17.4631 21.6914 18.2221 21.2825 18.6446 20.5583C19.0672 19.8342 19.0413 18.9737 18.6619 18.2921L11.6506 6.05779C11.6506 6.04075 11.6333 6.03223 11.6247 6.0152L11.573 5.92148C11.5299 5.8448 11.4781 5.76812 11.4264 5.69145L11.3574 5.56365C10.8658 4.6776 9.73607 4.35385 8.83918 4.83948L8.02853 5.2825C7.13164 5.76812 6.80393 6.8842 7.29549 7.77025L7.74394 8.57111C7.76981 8.62222 7.80431 8.67334 7.8388 8.72446L12.5906 17.0567L3.00076 17.1504C2.91452 17.1419 2.82828 17.1334 2.73342 17.1334L2.15561 17.1504C2.06937 17.1504 1.97451 17.1504 1.88827 17.1504H1.81065C0.793024 17.1845 -0.0262521 18.028 -0.000380225 19.0418L0.0168677 19.9534C0.0427396 20.9588 0.896512 21.7681 1.91414 21.7426Z" fill="url(#paint4_radial_1_798)"/>
|
||||
<path d="M11.4002 42.488L11.4519 42.3858C11.5209 42.292 11.5899 42.1898 11.6417 42.0961L18.7478 29.8362C19.1273 29.1547 19.1445 28.3027 18.7219 27.5785C18.2994 26.8543 17.5318 26.4454 16.7384 26.4284L2.5175 26.3091C2.50025 26.3091 2.483 26.3091 2.47438 26.3091H2.37089C2.28465 26.3091 2.18979 26.3091 2.09493 26.3176H1.94832C0.930692 26.292 0.0769193 27.0929 0.0510474 28.1067L0.0337995 29.0183C0.00792757 30.0237 0.827204 30.8671 1.84483 30.8927L2.7676 30.9097C2.82796 30.9097 2.88833 30.9097 2.9487 30.9097L12.6334 31.012L7.92473 39.2675C7.87298 39.3357 7.82124 39.4124 7.77812 39.4891L7.50215 39.9917C7.45903 40.0684 7.41591 40.1451 7.37279 40.2218L7.3383 40.2899C6.84673 41.176 7.18306 42.292 8.07133 42.7777L8.88199 43.2207C9.77888 43.7063 10.9086 43.374 11.4002 42.4965V42.488Z" fill="url(#paint5_radial_1_798)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<radialGradient id="paint0_radial_1_798" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.727 23.6565) scale(24.7299 24.4309)">
|
||||
<stop stop-color="#FFC80F"/>
|
||||
<stop offset="0.33" stop-color="#FFA512"/>
|
||||
<stop offset="0.77" stop-color="#FF7D17"/>
|
||||
<stop offset="1" stop-color="#FF6E19"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint1_radial_1_798" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.7267 23.6564) scale(24.7299 24.4309)">
|
||||
<stop stop-color="#FFC80F"/>
|
||||
<stop offset="0.33" stop-color="#FFA512"/>
|
||||
<stop offset="0.77" stop-color="#FF7D17"/>
|
||||
<stop offset="1" stop-color="#FF6E19"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint2_radial_1_798" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.7276 23.6565) scale(24.7299 24.4309)">
|
||||
<stop stop-color="#FFC80F"/>
|
||||
<stop offset="0.33" stop-color="#FFA512"/>
|
||||
<stop offset="0.77" stop-color="#FF7D17"/>
|
||||
<stop offset="1" stop-color="#FF6E19"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint3_radial_1_798" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.7272 23.6566) scale(24.7299 24.4309)">
|
||||
<stop stop-color="#FFC80F"/>
|
||||
<stop offset="0.33" stop-color="#FFA512"/>
|
||||
<stop offset="0.77" stop-color="#FF7D17"/>
|
||||
<stop offset="1" stop-color="#FF6E19"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint4_radial_1_798" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.727 23.6567) scale(24.7299 24.4309)">
|
||||
<stop stop-color="#FFC80F"/>
|
||||
<stop offset="0.33" stop-color="#FFA512"/>
|
||||
<stop offset="0.77" stop-color="#FF7D17"/>
|
||||
<stop offset="1" stop-color="#FF6E19"/>
|
||||
</radialGradient>
|
||||
<radialGradient id="paint5_radial_1_798" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24.7267 23.6566) scale(24.7299 24.4309)">
|
||||
<stop stop-color="#FFC80F"/>
|
||||
<stop offset="0.33" stop-color="#FFA512"/>
|
||||
<stop offset="0.77" stop-color="#FF7D17"/>
|
||||
<stop offset="1" stop-color="#FF6E19"/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_1_798">
|
||||
<rect width="159" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 26 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,3 +1,8 @@
|
||||
: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;
|
||||
@@ -7,7 +12,22 @@ html, body, #root {
|
||||
}
|
||||
|
||||
body, #root {
|
||||
border-radius: 14px; /* Slightly rounded app window corners */
|
||||
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 样式优化 */
|
||||
@@ -37,6 +57,60 @@ body, #root {
|
||||
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: max-content !important;
|
||||
min-width: 100%;
|
||||
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: max-content;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.redis-viewer-workbench .ant-tree {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -291,35 +365,217 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* 驱动管理:统一关闭 antd sticky 横向条,仅保留自定义独立横向条 */
|
||||
.driver-manager-table .ant-table-sticky-scroll {
|
||||
display: none !important;
|
||||
.driver-manager-modal .ant-modal-body {
|
||||
background: var(--ant-color-bg-layout, #f5f5f5);
|
||||
}
|
||||
|
||||
/* 仅在独立横向条激活时隐藏表格自身横向滚动条,避免出现双横向条 */
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content,
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body {
|
||||
overflow-x: auto !important;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-content::-webkit-scrollbar:horizontal,
|
||||
.driver-manager-table-wrap.driver-manager-table-wrap-external-active .driver-manager-table .ant-table-body::-webkit-scrollbar:horizontal {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.driver-manager-table-wrap {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.driver-manager-footer {
|
||||
width: 100%;
|
||||
.driver-manager-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.driver-manager-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.driver-manager-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.driver-manager-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(64px, 1fr));
|
||||
gap: 8px;
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.driver-manager-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
justify-content: center;
|
||||
min-height: 58px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: rgba(5, 5, 5, 0.02);
|
||||
}
|
||||
|
||||
.driver-manager-stat span:first-child {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.driver-manager-stat-warning span:first-child {
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.driver-manager-directory-panel {
|
||||
border: 1px solid rgba(5, 5, 5, 0.08);
|
||||
border-radius: 8px;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.driver-manager-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.driver-manager-search {
|
||||
min-width: 280px;
|
||||
flex: 1 1 360px;
|
||||
}
|
||||
|
||||
.driver-manager-toolbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-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 {
|
||||
@@ -328,15 +584,110 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.driver-manager-hscroll {
|
||||
width: 100%;
|
||||
height: 12px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-gutter: stable;
|
||||
background: transparent;
|
||||
@media (max-width: 900px) {
|
||||
.driver-manager-header,
|
||||
.driver-manager-card-main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.driver-manager-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;
|
||||
}
|
||||
}
|
||||
|
||||
.driver-manager-hscroll-inner {
|
||||
height: 1px;
|
||||
.security-update-action-btn.ant-btn,
|
||||
.security-update-action-btn.ant-btn-default,
|
||||
.security-update-action-btn.ant-btn-primary,
|
||||
.security-update-action-btn.ant-btn-text {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.security-update-action-btn.ant-btn:focus,
|
||||
.security-update-action-btn.ant-btn:focus-visible,
|
||||
.security-update-action-btn.ant-btn-default:focus,
|
||||
.security-update-action-btn.ant-btn-default:focus-visible,
|
||||
.security-update-action-btn.ant-btn-primary:focus,
|
||||
.security-update-action-btn.ant-btn-primary:focus-visible,
|
||||
.security-update-action-btn.ant-btn-text:focus,
|
||||
.security-update-action-btn.ant-btn-text:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.security-update-banner {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.security-update-result-card {
|
||||
transition: background 0.22s ease, box-shadow 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
|
||||
.security-update-result-card-active {
|
||||
animation: security-update-result-pulse 1.8s ease;
|
||||
}
|
||||
|
||||
@keyframes security-update-result-pulse {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
75
frontend/src/App.tab-display.i18n.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const tabDisplaySource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./utils/tabDisplay.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('App tab display i18n guards', () => {
|
||||
it('localizes the tab display settings copy and preview labels', () => {
|
||||
[
|
||||
'app.theme.tab_display.title',
|
||||
'app.theme.tab_display.description',
|
||||
'app.theme.tab_display.layout.single',
|
||||
'app.theme.tab_display.layout.double',
|
||||
'app.theme.tab_display.badge.current',
|
||||
'app.theme.tab_display.row.primary',
|
||||
'app.theme.tab_display.row.secondary',
|
||||
'app.theme.tab_display.action.move_up',
|
||||
'app.theme.tab_display.action.move_down',
|
||||
'app.theme.tab_display.preview.prefix',
|
||||
'app.theme.tab_display.preview.default_label',
|
||||
'app.theme.tab_display.preview.secondary',
|
||||
'app.theme.tab_display.preview.focused',
|
||||
].forEach((key) => {
|
||||
expect(appSource).toContain(`t('${key}'`);
|
||||
});
|
||||
|
||||
[
|
||||
'Tab 标签展示',
|
||||
'自定义连接名、对象类型、对象名、数据库、Schema 和 Host/IP 的展示顺序',
|
||||
"'单行'",
|
||||
"'双行'",
|
||||
'当前预览:',
|
||||
'默认标签',
|
||||
',副行',
|
||||
';当前选中',
|
||||
'上移',
|
||||
'下移',
|
||||
].forEach((legacyText) => {
|
||||
expect(appSource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps tab display element metadata as i18n keys', () => {
|
||||
[
|
||||
'connection',
|
||||
'kind',
|
||||
'object',
|
||||
'database',
|
||||
'schema',
|
||||
'host',
|
||||
].forEach((elementKey) => {
|
||||
expect(tabDisplaySource).toContain(`labelKey: 'app.theme.tab_display.element.${elementKey}.label'`);
|
||||
expect(tabDisplaySource).toContain(`descriptionKey: 'app.theme.tab_display.element.${elementKey}.description'`);
|
||||
});
|
||||
|
||||
[
|
||||
'连接名',
|
||||
'连接简称或环境名',
|
||||
'对象类型',
|
||||
'对象名',
|
||||
'当前 DB / catalog 名称',
|
||||
'连接目标地址摘要',
|
||||
].forEach((legacyText) => {
|
||||
expect(tabDisplaySource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
458
frontend/src/App.tool-center.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
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 appUtilityStylesSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./hooks/useAppUtilityStyles.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
const appSidebarResizeSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./hooks/useAppSidebarResize.ts', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const 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: t('app.tools.entry.snippets.title')");
|
||||
expect(appSource).toContain("description: t('app.tools.entry.snippets.description')");
|
||||
expect(appSource).toContain("handleOpenToolCenterPane('workspace', 'snippet-settings')");
|
||||
expect(appSource).toContain('gonavi:open-snippet-settings');
|
||||
expect(appSource).toContain("setIsSnippetModalOpen(false);");
|
||||
expect(appSource).not.toContain('setIsSnippetModalOpen(true)');
|
||||
|
||||
const snippetIndex = appSource.indexOf("key: 'snippet-settings'");
|
||||
const shortcutIndex = appSource.indexOf("key: 'shortcut-settings'", snippetIndex);
|
||||
expect(snippetIndex).toBeGreaterThan(-1);
|
||||
expect(shortcutIndex).toBeGreaterThan(snippetIndex);
|
||||
});
|
||||
|
||||
it('uses scalable side navigation for the tool center instead of horizontal segmented switching', () => {
|
||||
expect(appSource).toContain("type ToolCenterGroupKey = 'config' | 'workflow' | 'workspace';");
|
||||
expect(appSource).toContain("const [activeToolCenterGroupKey, setActiveToolCenterGroupKey] = useState<ToolCenterGroupKey>('config');");
|
||||
expect(appSource).toContain("const [toolCenterBackGroupKey, setToolCenterBackGroupKey] = useState<ToolCenterGroupKey | null>(null);");
|
||||
expect(appSource).toContain("title: t('app.tools.group.config.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workflow.title')");
|
||||
expect(appSource).toContain("title: t('app.tools.group.workspace.title')");
|
||||
expect(appSource).toContain("toolCenterGroups.find((group) => group.key === activeToolCenterGroupKey)");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("gridTemplateColumns: '232px minmax(0, 1fr)'");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterNavPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterNavScrollStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterContentPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterDetailPanelStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterDetailBodyStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain('role="tablist" aria-orientation="vertical"');
|
||||
expect(appSource).toContain('role="tab"');
|
||||
expect(appSource).toContain('aria-selected={active}');
|
||||
expect(appSource).toContain('title={`${group.title} - ${group.description}`}');
|
||||
expect(appUtilityStylesSource).toContain("borderRight: `1px solid ${overlayTheme.divider}`");
|
||||
expect(appSource).toContain('setActiveToolCenterPane(null);');
|
||||
expect(appSource).toContain('group.items.length');
|
||||
expect(appSource).toContain("const handleOpenToolCenterPane = useCallback((group: ToolCenterGroupKey, key: ToolCenterPaneKey) => {");
|
||||
expect(appSource).toContain("const [activeToolCenterPane, setActiveToolCenterPane] = useState<ToolCenterPaneState | null>(null);");
|
||||
expect(appSource).toContain("const handleReturnToToolCenter = useCallback((closeChild?: () => void) => {");
|
||||
expect(appSource).toContain("t('common.back_to_previous')");
|
||||
expect(appSource).toContain("width={1080}");
|
||||
expect(appSource).toContain('centered');
|
||||
});
|
||||
|
||||
it('keeps the tool center modal height fixed across group switches and scrolls the list area internally', () => {
|
||||
expect(appUtilityStylesSource).toContain('const toolCenterModalContentStyle = useMemo<React.CSSProperties>(() => ({');
|
||||
expect(appUtilityStylesSource).toContain("height: 'min(820px, calc(100vh - 64px))'");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalWorkspaceStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterModalSplitStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appUtilityStylesSource).toContain("const toolCenterScrollableListStyle = useMemo<React.CSSProperties>(() => ({");
|
||||
expect(appSource).toContain("body: { paddingTop: 8, paddingBottom: 8, overflow: 'hidden', flex: 1, minHeight: 0 }");
|
||||
expect(appSource).toContain('style={toolCenterModalWorkspaceStyle}');
|
||||
expect(appSource).toContain('style={toolCenterModalSplitStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterNavScrollStyle}');
|
||||
expect(appSource).toContain('style={toolCenterContentPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailPanelStyle}');
|
||||
expect(appSource).toContain('style={toolCenterDetailBodyStyle}');
|
||||
expect(appSource).toContain('style={toolCenterScrollableListStyle}');
|
||||
expect(appUtilityStylesSource).toContain("overflowY: 'auto'");
|
||||
expect(appSource).toContain("borderTop: index === 0 ? `1px solid ${overlayTheme.divider}` : 'none'");
|
||||
expect(appSource).toContain("borderBottom: `1px solid ${overlayTheme.divider}`");
|
||||
});
|
||||
|
||||
it('lets the tool center detail header own embedded tool titles', () => {
|
||||
const renderPaneStart = appSource.indexOf('const renderToolCenterPane = () => {');
|
||||
const renderPaneSource = appSource.slice(
|
||||
renderPaneStart,
|
||||
appSource.indexOf('};\n\n return (', renderPaneStart),
|
||||
);
|
||||
const connectionPackageSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'connection-package')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'data-root')"),
|
||||
);
|
||||
const dataRootSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'data-root')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'security-update')"),
|
||||
);
|
||||
const securityUpdateSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'security-update')"),
|
||||
renderPaneSource.indexOf("activeToolCenterPane.key === 'schema-compare'"),
|
||||
);
|
||||
const dataSyncSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("activeToolCenterPane.key === 'schema-compare'"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'drivers')"),
|
||||
);
|
||||
const driverSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'drivers')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'snippet-settings')"),
|
||||
);
|
||||
const snippetSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'snippet-settings')"),
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')"),
|
||||
);
|
||||
const shortcutSource = renderPaneSource.slice(
|
||||
renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')"),
|
||||
renderPaneSource.indexOf('return null;', renderPaneSource.indexOf("if (activeToolCenterPane.key === 'shortcut-settings')")),
|
||||
);
|
||||
|
||||
expect(appSource).toContain('activeToolCenterPaneItem?.title ?? activeToolCenterGroup.title');
|
||||
expect(connectionPackageSource).toContain('<ConnectionPackagePasswordModal');
|
||||
expect(connectionPackageSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(dataRootSource).toContain('title={null}');
|
||||
expect(dataRootSource).toContain('closable={false}');
|
||||
expect(dataRootSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(securityUpdateSource).toContain('<SecurityUpdateSettingsModal');
|
||||
expect(securityUpdateSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(dataSyncSource).toContain('<DataSyncModal');
|
||||
expect(dataSyncSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(driverSource).toContain('<DriverManagerModal');
|
||||
expect(driverSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(snippetSource).toContain('<SnippetSettingsModal');
|
||||
expect(snippetSource).not.toContain('renderUtilityModalTitle');
|
||||
expect(shortcutSource).toContain('title={null}');
|
||||
expect(shortcutSource).toContain('closable={false}');
|
||||
expect(shortcutSource).not.toContain('renderUtilityModalTitle');
|
||||
});
|
||||
|
||||
it('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('new CustomEvent');
|
||||
expect(appSource).toContain("'gonavi:show-sql-execution-log'");
|
||||
expect(appSource).toContain("detail: { mode: 'open' }");
|
||||
expect(appSource).toContain('toggleAppLogPanel();');
|
||||
expect(appSource).toContain('const handleFocusSidebarSearch = useCallback(');
|
||||
expect(appSource).toContain('const antdTheme = useMemo(() => ({');
|
||||
expect(appSource).toContain('theme={antdTheme}');
|
||||
expect(appSource).toContain('onOpenTools={handleOpenToolsModal}');
|
||||
expect(appSource).toContain('onOpenSettings={handleOpenSettingsModal}');
|
||||
expect(appSource).toContain('onToggleLogPanel={handleToggleLogPanel}');
|
||||
expect(appSource).toContain('onFocusCommandSearch={handleFocusSidebarSearch}');
|
||||
expect(appSource).not.toContain('onOpenTools={() => setIsToolsModalOpen(true)}');
|
||||
expect(appSource).not.toContain('onOpenSettings={() => setIsSettingsModalOpen(true)}');
|
||||
expect(appSource).not.toContain('onToggleLogPanel={() => setIsLogPanelOpen((prev) => !prev)}');
|
||||
expect(appSource).not.toContain('sqlLogCount={sqlLogCount}');
|
||||
expect(appSource).not.toContain('theme={{');
|
||||
expect(appSource).not.toContain('const sqlLogs = useStore(state => state.sqlLogs);');
|
||||
});
|
||||
|
||||
it('renders the shared SQL log panel only for legacy layouts', () => {
|
||||
const logPanelIndex = appSource.indexOf('<LogPanel', appSource.indexOf('<Content'));
|
||||
const logPanelGuardIndex = appSource.lastIndexOf('{isLogPanelOpen && (', logPanelIndex);
|
||||
const legacyOnlyGuardIndex = appSource.lastIndexOf('{!isV2Ui && isLogPanelOpen && (', logPanelIndex);
|
||||
|
||||
expect(logPanelIndex).toBeGreaterThan(-1);
|
||||
expect(logPanelGuardIndex).toBe(-1);
|
||||
expect(legacyOnlyGuardIndex).toBeGreaterThan(-1);
|
||||
expect(appSource).toContain('onClose={handleCloseLogPanel}');
|
||||
expect(appSource).toContain('onResizeStart={handleLogResizeStart}');
|
||||
});
|
||||
|
||||
it('lets the v2 Sidebar own the entire left layout instead of stacking legacy controls above it', () => {
|
||||
const siderIndex = appSource.indexOf("className={isV2Ui ? 'gn-v2-app-sider' : undefined}");
|
||||
const legacyGuardIndex = appSource.indexOf('{!isV2Ui && (', siderIndex);
|
||||
const legacyCreateIndex = appSource.indexOf('<Button icon={<PlusOutlined />} onClick={handleCreateConnection}', legacyGuardIndex);
|
||||
const legacyCreateTitleIndex = appSource.indexOf("title={t('connection.new')}", legacyCreateIndex);
|
||||
const legacyQueryIndex = appSource.indexOf('<Button icon={<ConsoleSqlOutlined />} onClick={handleNewQuery}', legacyGuardIndex);
|
||||
const legacyQueryTitleIndex = appSource.indexOf("title={t('query.new')}", legacyQueryIndex);
|
||||
const sidebarIndex = appSource.indexOf('<Sidebar', legacyGuardIndex);
|
||||
const floatingLogIndex = appSource.indexOf('Floating SQL Log Toggle', sidebarIndex);
|
||||
const floatingLogGuardIndex = appSource.indexOf('{!isV2Ui && (', floatingLogIndex);
|
||||
|
||||
expect(siderIndex).toBeGreaterThan(-1);
|
||||
expect(legacyGuardIndex).toBeGreaterThan(siderIndex);
|
||||
expect(legacyCreateIndex).toBeGreaterThan(legacyGuardIndex);
|
||||
expect(legacyCreateIndex).toBeLessThan(sidebarIndex);
|
||||
expect(legacyCreateTitleIndex).toBeGreaterThan(legacyCreateIndex);
|
||||
expect(legacyQueryIndex).toBeGreaterThan(legacyCreateIndex);
|
||||
expect(legacyQueryIndex).toBeLessThan(sidebarIndex);
|
||||
expect(legacyQueryTitleIndex).toBeGreaterThan(legacyQueryIndex);
|
||||
expect(appSource).toContain('paddingBottom: isV2Ui ? 0 : 58');
|
||||
expect(floatingLogIndex).toBeGreaterThan(sidebarIndex);
|
||||
expect(floatingLogGuardIndex).toBeGreaterThan(floatingLogIndex);
|
||||
});
|
||||
|
||||
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(appSidebarResizeSource).toContain('if (e.button !== 0)');
|
||||
expect(appSource).toContain('onContextMenu={(event) => {');
|
||||
expect(appSource).toContain('event.preventDefault();');
|
||||
expect(appSource).toContain('event.stopPropagation();');
|
||||
|
||||
const guardIndex = appSidebarResizeSource.indexOf('if (e.button !== 0)');
|
||||
const ghostDisplayIndex = appSidebarResizeSource.indexOf("ghostRef.current.style.display = 'block'", guardIndex);
|
||||
const dragStartIndex = appSidebarResizeSource.indexOf('sidebarDragRef.current = {', guardIndex);
|
||||
|
||||
expect(guardIndex).toBeGreaterThan(-1);
|
||||
expect(ghostDisplayIndex).toBeGreaterThan(guardIndex);
|
||||
expect(dragStartIndex).toBeGreaterThan(guardIndex);
|
||||
});
|
||||
|
||||
it('positions sidebar resize guide from the rendered sider edge', () => {
|
||||
expect(appSidebarResizeSource).toContain('const siderRef = useRef<HTMLDivElement | null>(null);');
|
||||
expect(appSource).toContain('ref={siderRef}');
|
||||
expect(appSidebarResizeSource).toContain('const siderRect = siderRef.current?.getBoundingClientRect();');
|
||||
expect(appSidebarResizeSource).toContain('const startGuideLeft = siderRect?.right ?? sidebarWidth;');
|
||||
expect(appSidebarResizeSource).toContain('const startWidth = siderRect?.width ?? sidebarWidth;');
|
||||
expect(appSidebarResizeSource).toContain('resolveSidebarResizeBounds(siderRef.current)');
|
||||
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft}px`;');
|
||||
expect(appSidebarResizeSource).toContain('ghostRef.current.style.left = `${startGuideLeft + (newWidth - startWidth)}px`;');
|
||||
});
|
||||
|
||||
it('keeps legacy sidebar resize bounds aligned with the v2 sider CSS limits', () => {
|
||||
expect(appCss).toMatch(/body\[data-ui-version="legacy"\]\s+\.ant-layout-sider\s*\{[^}]*min-width:\s*232px\s*!important;[^}]*max-width:\s*420px\s*!important;/s);
|
||||
});
|
||||
|
||||
it('keeps 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('const errorMessage = error?.message;');
|
||||
expect(appSource).toContain("typeof errorMessage === 'string'");
|
||||
expect(appSource).toContain("t('app.connection.message.editable_load_failed_with_detail', { detail })");
|
||||
expect(appSource).toContain("t('app.connection.message.editable_load_failed')");
|
||||
expect(appSource).toContain('setEditingConnection(nextConnection);');
|
||||
expect(appSource).toContain('setIsModalOpen(true);');
|
||||
});
|
||||
|
||||
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('const scheduleWindowBoundsRepair = (delayMs = 80) => {');
|
||||
expect(appSource).toContain('if (hydrated) {');
|
||||
expect(appSource).toContain('scheduleWindowBoundsRepair(360);');
|
||||
expect(appSource).toContain('scheduleWindowStateSave(320);');
|
||||
expect(appSource).toContain('const unsubscribeHydration = useStore.persist.onFinishHydration(() => {');
|
||||
expect(appSource).toContain('scheduleWindowBoundsRepair();');
|
||||
expect(appSource).toContain('scheduleWindowStateSave(260);');
|
||||
expect(appSource).toContain("window.addEventListener('resize', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('focus', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('pageshow', handleWindowRuntimeChange);");
|
||||
expect(appSource).toContain("window.addEventListener('pagehide', handleWindowLifecycleFlush, { capture: true });");
|
||||
expect(appSource).toContain("window.addEventListener('beforeunload', handleWindowLifecycleFlush, { capture: true });");
|
||||
});
|
||||
|
||||
it('clamps normal runtime window bounds back into the visible screen after display changes', () => {
|
||||
expect(appSource).toContain('const readCurrentVisibleViewport = () => ({');
|
||||
expect(appSource).toContain('const repairRuntimeWindowBounds = async () => {');
|
||||
expect(appSource).toContain('const nextBounds = resolveVisibleStartupWindowBounds(currentBounds, readCurrentVisibleViewport());');
|
||||
expect(appSource).toContain("void emitWindowDiagnostic('adjust:runtime-window-bounds'");
|
||||
expect(appSource).toContain('WindowSetSize(nextBounds.width, nextBounds.height);');
|
||||
expect(appSource).toContain('WindowSetPosition(nextBounds.x, nextBounds.y);');
|
||||
});
|
||||
|
||||
it('keeps titlebar double-click on maximise while shortcuts may enter macOS fullscreen', () => {
|
||||
expect(appSource).toContain('const handleTitleBarWindowToggle = async (options?: { allowMacNativeFullscreen?: boolean }) => {');
|
||||
expect(appSource).toContain('const allowMacNativeFullscreen = options?.allowMacNativeFullscreen === true;');
|
||||
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("t('app.theme.data_table.font_size')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.sidebar_tree_font_size')");
|
||||
expect(appSource).toContain("const tableDoubleClickAction = appearance.tableDoubleClickAction === 'open-design' ? 'open-design' : 'open-data';");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_data')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action.open_design')");
|
||||
expect(appSource).toContain("t('app.theme.data_table.table_double_click_action_hint')");
|
||||
expect(appSource).toContain("setAppearance({ tableDoubleClickAction: value as 'open-data' | 'open-design' })");
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies, t)');
|
||||
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies, t)');
|
||||
expect(appSource).toContain('ListInstalledFontFamilies()');
|
||||
expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);');
|
||||
expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';");
|
||||
expect(appSource).toContain('<LinuxCJKFontBanner');
|
||||
expect(linuxCJKFontBannerSource).toContain('data-gonavi-linux-cjk-font-banner="true"');
|
||||
expect(linuxCJKFontBannerSource).toContain('useI18n');
|
||||
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.title')");
|
||||
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.description')");
|
||||
expect(linuxCJKFontBannerSource).toContain("t('app.linux_cjk_font_banner.action.open_font_settings')");
|
||||
expect(linuxCJKFontBannerSource).toContain("t('common.close')");
|
||||
expect(linuxCJKFontBannerSource).not.toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
|
||||
expect(linuxCJKFontBannerSource).not.toContain('Chinese text may render as');
|
||||
expect(linuxCJKFontBannerSource).not.toContain('Font Settings');
|
||||
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_prefix')");
|
||||
expect(appSource).toContain("t('app.theme.font_family.linux_cjk_install_suffix')");
|
||||
expect(appSource).not.toContain('Ubuntu/Linux 未检测到中文 CJK 字体');
|
||||
expect(appSource).not.toContain(',然后重启 GoNavi。');
|
||||
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');
|
||||
});
|
||||
});
|
||||
22
frontend/src/App.tools-entry.i18n.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appSource = readFileSync(
|
||||
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
describe('App tools entry i18n guards', () => {
|
||||
it('localizes compare tool entry titles and descriptions', () => {
|
||||
expect(appSource).toContain("t('app.tools.entry.schema_compare.title')");
|
||||
expect(appSource).toContain("t('app.tools.entry.schema_compare.description')");
|
||||
expect(appSource).toContain("t('app.tools.entry.data_compare.title')");
|
||||
expect(appSource).toContain("t('app.tools.entry.data_compare.description')");
|
||||
|
||||
expect(appSource).not.toContain("title: '表结构比对'");
|
||||
expect(appSource).not.toContain("description: '对比源表与目标表结构差异,只预览不执行。'");
|
||||
expect(appSource).not.toContain("title: '数据比对'");
|
||||
expect(appSource).not.toContain("description: '按主键分析新增、更新、删除和相同行。'");
|
||||
});
|
||||
});
|
||||
5166
frontend/src/App.tsx
62
frontend/src/App.ui-version.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
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("t('app.theme.ui_version.title')", themeBranchIndex);
|
||||
const lightThemeIndex = appSource.indexOf("t('app.theme.mode.light.label')", themeBranchIndex);
|
||||
const appearanceBranchIndex = appSource.indexOf(') : (', themeBranchIndex);
|
||||
const macWindowIndex = appSource.indexOf("t('app.theme.mac_window.title')");
|
||||
|
||||
expect(themeBranchIndex).toBeGreaterThan(-1);
|
||||
expect(uiVersionIndex).toBeGreaterThan(themeBranchIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(lightThemeIndex);
|
||||
expect(uiVersionIndex).toBeLessThan(appearanceBranchIndex);
|
||||
expect(macWindowIndex).toBeGreaterThan(uiVersionIndex);
|
||||
expect(appSource).toContain("badge: t('app.theme.ui_version.legacy.badge')");
|
||||
expect(appSource).toContain("badge: t('app.theme.ui_version.v2.badge')");
|
||||
expect(appSource).toContain("onClick={() => setAppearance({ uiVersion: item.key as 'legacy' | 'v2' })}");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.beta_warning')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.platform_hint')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(appSource).toContain("value={appearance.v2SidebarSearchMode ?? 'command'}");
|
||||
expect(appSource).toContain("setAppearance({ v2SidebarSearchMode: value as 'command' | 'filter' })");
|
||||
});
|
||||
|
||||
it('uses the card-style v2 switch from the redesign instead of the segmented pill', () => {
|
||||
const uiVersionIndex = appSource.indexOf("t('app.theme.ui_version.title')");
|
||||
const themeModeIndex = appSource.indexOf("t('app.theme.mode_title')", uiVersionIndex);
|
||||
const uiVersionBlock = appSource.slice(uiVersionIndex, themeModeIndex);
|
||||
|
||||
expect(uiVersionBlock).toContain("t('app.theme.ui_version.badge.new')");
|
||||
expect(uiVersionBlock).toContain("gridTemplateColumns: 'repeat(2, minmax(0, 1fr))'");
|
||||
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.legacy.label')");
|
||||
expect(uiVersionBlock).toContain("label: t('app.theme.ui_version.v2.label')");
|
||||
expect(uiVersionBlock).toContain('CheckOutlined');
|
||||
expect(uiVersionBlock).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(uiVersionBlock).toContain('<Segmented');
|
||||
});
|
||||
|
||||
it('localizes the v2 sidebar search mode copy', () => {
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.title')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.command')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.filter')");
|
||||
expect(appSource).toContain("t('app.theme.ui_version.sidebar_search.hint')");
|
||||
expect(appSource).not.toContain('新版左侧搜索模式');
|
||||
expect(appSource).not.toContain('新版命令搜索');
|
||||
expect(appSource).not.toContain('旧版侧栏筛选');
|
||||
expect(appSource).not.toContain('新版命令搜索适合跳转连接、表和动作');
|
||||
});
|
||||
});
|
||||
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;
|
||||
}
|
||||
100
frontend/src/components/AIChatPanel.message-boundary.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { t as catalogTranslate } from '../i18n/catalog';
|
||||
|
||||
const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8');
|
||||
const testSource = readFileSync(new URL('./AIChatPanel.message-boundary.test.tsx', import.meta.url), 'utf8');
|
||||
const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8');
|
||||
const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8');
|
||||
const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8');
|
||||
|
||||
const REQUIRED_RENDER_BOUNDARY_KEYS = [
|
||||
'ai_chat.message.render_error.title',
|
||||
'ai_chat.message.render_error.body',
|
||||
'ai_chat.message.render_error.unknown',
|
||||
'ai_chat.message.render_error.retry',
|
||||
'ai_chat.message.render_error.delete',
|
||||
] as const;
|
||||
|
||||
describe('AIChatPanel merge resolution', () => {
|
||||
it('clears conflict markers from the merged files', () => {
|
||||
expect(source).not.toMatch(/^<{7}|^={7}|^>{7}/m);
|
||||
expect(testSource).not.toMatch(/^<{7}|^={7}|^>{7}/m);
|
||||
});
|
||||
|
||||
it('keeps dev split architecture while retaining render-boundary isolation', () => {
|
||||
expect(source).toContain("import AIChatPanelConversationView from './ai/AIChatPanelConversationView';");
|
||||
expect(source).toContain("import { useAIChatRuntimeResources } from './ai/useAIChatRuntimeResources';");
|
||||
expect(source).toContain("import { useAIChatStreamSubscription } from './ai/useAIChatStreamSubscription';");
|
||||
expect(source).toContain("import { useAIChatLocalTools } from './ai/useAIChatLocalTools';");
|
||||
|
||||
expect(boundarySource).toContain('class AIMessageRenderBoundary extends React.Component');
|
||||
expect(conversationViewSource).toContain("import AIMessageRenderBoundary from './AIMessageRenderBoundary';");
|
||||
expect(conversationViewSource).toContain('<AIMessageRenderBoundary');
|
||||
expect(source).toContain('onMessageRenderError={handleMessageRenderError}');
|
||||
expect(source).toContain('__gonaviLastAIMessageRenderError');
|
||||
expect(source).toContain('[AI Message Render Error]');
|
||||
});
|
||||
|
||||
it('keeps render-boundary recovery chrome translated through catalog keys', () => {
|
||||
expect(boundarySource).toContain('useOptionalI18n()');
|
||||
expect(boundarySource).toContain("catalogTranslate('en-US'");
|
||||
for (const key of REQUIRED_RENDER_BOUNDARY_KEYS) {
|
||||
expect(catalogTranslate('en-US', key)).not.toBe(key);
|
||||
expect(catalogTranslate('zh-CN', key)).not.toBe(key);
|
||||
expect(boundarySource).toContain(key);
|
||||
}
|
||||
|
||||
for (const oldCopy of [
|
||||
'这条 AI 消息渲染失败,已自动隔离',
|
||||
'其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。',
|
||||
'未知渲染错误',
|
||||
'重试渲染',
|
||||
'删除这条消息',
|
||||
]) {
|
||||
expect(boundarySource).not.toContain(oldCopy);
|
||||
}
|
||||
});
|
||||
|
||||
it('restores panel-level i18n orchestration for composer notices and send lifecycle text', () => {
|
||||
expect(source).toContain("import { useI18n } from '../i18n/provider';");
|
||||
expect(source).toContain("import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';");
|
||||
expect(source).toContain("import { buildAIComposerNotice } from '../utils/aiComposerNotice';");
|
||||
expect(source).toContain("const { t } = useI18n();");
|
||||
expect(source).toContain("const [composerNoticeState, setComposerNoticeState] = useState<AIComposerNoticeDescriptor | null>(null);");
|
||||
expect(source).toContain("buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice");
|
||||
expect(source).toContain("setComposerNoticeState({ kind: 'missing_provider' });");
|
||||
expect(source).toContain("setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });");
|
||||
expect(source).toContain("setComposerNoticeState({ kind: 'missing_model' });");
|
||||
expect(source).toContain('const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t));');
|
||||
expect(source).toContain('toAIRequestMessage(userMsg, t)');
|
||||
|
||||
for (const key of [
|
||||
'ai_chat.panel.status.model_connecting',
|
||||
'ai_chat.panel.status.waking_engine',
|
||||
'ai_chat.panel.status.waiting_response',
|
||||
'ai_chat.panel.status.memory_summary',
|
||||
'ai_chat.panel.message.service_not_ready',
|
||||
]) {
|
||||
expect(source).toContain(`t('${key}'`);
|
||||
}
|
||||
|
||||
expect(source).not.toContain('buildMissingProviderNotice');
|
||||
expect(source).not.toContain('buildIncompleteProviderNotice');
|
||||
expect(source).not.toContain('buildMissingModelNotice');
|
||||
});
|
||||
|
||||
it('keeps translated session and insight chrome in the panel layer instead of falling back to hardcoded copy', () => {
|
||||
expect(source).toContain("() => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title')");
|
||||
expect(source).toContain("title: session.title || t('ai_chat.panel.session.default_title')");
|
||||
expect(source).toContain('buildAIChatInsights({');
|
||||
expect(source).toContain('translate: t,');
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_title', { count: contextCount })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_body', { tables: tablePreview })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length })");
|
||||
expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.write.detected_title', { count: writeCount })");
|
||||
expect(source).not.toContain("|| '新对话'");
|
||||
});
|
||||
});
|
||||
724
frontend/src/components/AIChatPanel.tsx
Normal file
@@ -0,0 +1,724 @@
|
||||
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 type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';
|
||||
import { buildAIComposerNotice } from '../utils/aiComposerNotice';
|
||||
import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut';
|
||||
import { toAIRequestMessage } from '../utils/aiMessagePayload';
|
||||
import { compressContextIfNeeded, getDynamicMaxContextChars } from '../utils/aiChatRuntime';
|
||||
import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts';
|
||||
import { isMacLikePlatform } from '../utils/appearance';
|
||||
import { buildAvailableAIChatTools } from '../utils/aiToolRegistry';
|
||||
import {
|
||||
buildAIChatInsights,
|
||||
buildAIChatInlineHistorySessions,
|
||||
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';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
|
||||
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 { t } = useI18n();
|
||||
const [input, setInput] = useState('');
|
||||
const [draftAttachments, setDraftAttachments] = useState<AIChatAttachment[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat');
|
||||
const [composerNoticeState, setComposerNoticeState] = useState<AIComposerNoticeDescriptor | null>(null);
|
||||
const {
|
||||
activeProvider,
|
||||
composerNotice: runtimeComposerNotice,
|
||||
dynamicModels,
|
||||
fetchDynamicModels,
|
||||
handleComposerAction,
|
||||
handleModelChange,
|
||||
handleOpenSettingsFromPanel,
|
||||
loadingModels,
|
||||
mcpTools,
|
||||
skills,
|
||||
userPromptSettings,
|
||||
} = useAIChatRuntimeResources({ onOpenSettings });
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const nudgeCountRef = useRef(0);
|
||||
const {
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
} = useAIChatPlanContexts();
|
||||
|
||||
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, t),
|
||||
[mcpTools, t],
|
||||
);
|
||||
const aiChatSendShortcutBinding = useStore(state => resolveShortcutBinding(
|
||||
state.shortcutOptions,
|
||||
'sendAIChatMessage',
|
||||
activeShortcutPlatform,
|
||||
));
|
||||
const { sid, messages, orderedAISessions } = useAIChatSessionState({
|
||||
aiActiveSessionId,
|
||||
aiPanelVisible,
|
||||
createNewAISession,
|
||||
});
|
||||
|
||||
useAIChatAutoContext({
|
||||
aiPanelVisible,
|
||||
activeTabId,
|
||||
tabs,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (runtimeComposerNotice) {
|
||||
setComposerNoticeState(null);
|
||||
}
|
||||
}, [runtimeComposerNotice]);
|
||||
|
||||
const getConnectionName = useCallback(() => {
|
||||
let connectionId = activeContext?.connectionId;
|
||||
if (!connectionId) {
|
||||
const activeTab = tabs.find(tab => tab.id === activeTabId);
|
||||
connectionId = activeTab?.connectionId;
|
||||
}
|
||||
if (!connectionId) return '';
|
||||
const connection = connections.find(item => item.id === connectionId);
|
||||
return connection ? connection.name : '';
|
||||
}, [activeContext, activeTabId, connections, tabs]);
|
||||
|
||||
const activeConnName = getConnectionName();
|
||||
const composerNotice = useMemo(
|
||||
() => buildAIComposerNotice(t, composerNoticeState) ?? runtimeComposerNotice,
|
||||
[composerNoticeState, runtimeComposerNotice, t],
|
||||
);
|
||||
|
||||
const textColor = overlayTheme.titleText;
|
||||
const mutedColor = overlayTheme.mutedText;
|
||||
const borderColor = overlayTheme.divider;
|
||||
const 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 = (event: Event) => {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (detail?.prompt) {
|
||||
setInput(detail.prompt);
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
window.addEventListener('gonavi:ai:inject-prompt', handler);
|
||||
return () => window.removeEventListener('gonavi:ai:inject-prompt', handler);
|
||||
}, []);
|
||||
|
||||
const generateTitleForSession = useAIChatSessionTitleGenerator({ updateAISessionTitle });
|
||||
|
||||
const handleScrollMessages = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.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,
|
||||
translate: t,
|
||||
});
|
||||
}, [availableTools, skills, t, userPromptSettings]);
|
||||
|
||||
const {
|
||||
executeLocalTools,
|
||||
resetToolCallState,
|
||||
toolContextMapRef,
|
||||
} = useAIChatLocalTools({
|
||||
sid,
|
||||
activeProviderModel: activeProvider?.model,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
mcpTools,
|
||||
nextMessageId: genId,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
setSending,
|
||||
skills,
|
||||
translate: t,
|
||||
updateAIChatMessage,
|
||||
userPromptSettings,
|
||||
});
|
||||
|
||||
const handleRetryMessage = useCallback(async (msg: AIChatMessage) => {
|
||||
const historyLocal = useStore.getState().aiChatHistory[sid] || [];
|
||||
const aiIndex = historyLocal.findIndex(message => message.id === msg.id);
|
||||
if (aiIndex <= 0) return;
|
||||
|
||||
let lastUserMsgIndex = -1;
|
||||
for (let i = aiIndex - 1; i >= 0; i--) {
|
||||
if (historyLocal[i].role === 'user') {
|
||||
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);
|
||||
|
||||
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((message) => toAIRequestMessage(message, t));
|
||||
|
||||
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,
|
||||
translate: t,
|
||||
});
|
||||
} catch {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
sid,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
truncateAIChatMessages,
|
||||
addAIChatMessage,
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
resetToolCallState,
|
||||
t,
|
||||
updateAIChatMessage,
|
||||
]);
|
||||
|
||||
useAIChatStreamSubscription({
|
||||
sid,
|
||||
sending,
|
||||
setSending,
|
||||
availableTools,
|
||||
addAIChatMessage,
|
||||
updateAIChatMessage,
|
||||
buildSystemContextMessages,
|
||||
executeLocalTools,
|
||||
generateTitleForSession,
|
||||
nextMessageId: genId,
|
||||
nudgeCountRef,
|
||||
pendingJVMPlanContextRef,
|
||||
pendingJVMDiagnosticPlanContextRef,
|
||||
translate: t,
|
||||
});
|
||||
|
||||
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') {
|
||||
setComposerNoticeState({ kind: 'missing_provider' });
|
||||
return;
|
||||
}
|
||||
if (readiness.status === 'provider_incomplete') {
|
||||
setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });
|
||||
return;
|
||||
}
|
||||
if (readiness.status === 'missing_model' || readiness.status === 'loading_models') {
|
||||
setComposerNoticeState({ kind: 'missing_model' });
|
||||
return;
|
||||
}
|
||||
setComposerNoticeState(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);
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.model_connecting') });
|
||||
|
||||
const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t));
|
||||
|
||||
let finalMessagesPayload = chatMessages;
|
||||
const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model);
|
||||
const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit, t);
|
||||
if (summary) {
|
||||
const compressedMsg: AIChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: t('ai_chat.panel.status.memory_summary', { summary }),
|
||||
timestamp: Date.now() - 1000,
|
||||
};
|
||||
useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]);
|
||||
finalMessagesPayload = [
|
||||
{ role: 'assistant', content: compressedMsg.content },
|
||||
toAIRequestMessage(userMsg, t),
|
||||
];
|
||||
}
|
||||
|
||||
const allMessages = [...systemMessages, ...finalMessagesPayload];
|
||||
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waking_engine') });
|
||||
updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waiting_response') });
|
||||
|
||||
await dispatchAIChatPayload({
|
||||
sid,
|
||||
messages: allMessages,
|
||||
tools: availableTools,
|
||||
addAIChatMessage,
|
||||
updateAIChatMessage,
|
||||
setSending,
|
||||
nextMessageId: genId,
|
||||
pendingAssistantMessageId: connectingMsg.id,
|
||||
jvmPlanContext: currentJVMPlanContext,
|
||||
jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext,
|
||||
unavailableContent: t('ai_chat.panel.message.service_not_ready'),
|
||||
translate: t,
|
||||
onNonStreamSuccess: messages.length === 0
|
||||
? () => generateTitleForSession(sid)
|
||||
: undefined,
|
||||
});
|
||||
}, [
|
||||
input,
|
||||
draftAttachments,
|
||||
sending,
|
||||
messages,
|
||||
addAIChatMessage,
|
||||
sid,
|
||||
activeContext,
|
||||
activeProvider,
|
||||
aiContexts,
|
||||
availableTools,
|
||||
buildSystemContextMessages,
|
||||
dynamicModels,
|
||||
generateTitleForSession,
|
||||
getCurrentJVMPlanContext,
|
||||
getCurrentJVMDiagnosticPlanContext,
|
||||
loadingModels,
|
||||
resetToolCallState,
|
||||
t,
|
||||
updateAIChatMessage,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
|
||||
consumeAIChatSendShortcutOnKeyDown(aiChatSendShortcutBinding, event, handleSend);
|
||||
}, [aiChatSendShortcutBinding, handleSend]);
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
try {
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (Service?.AIChatCancel) {
|
||||
await Service.AIChatCancel(sid);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to stop chat stream', error);
|
||||
}
|
||||
setSending(false);
|
||||
}, [sid]);
|
||||
|
||||
const { inferredConnectionId, inferredDbName } = useMemo(
|
||||
() => inferAIChatConnectionContext({
|
||||
activeConnectionId: activeContext?.connectionId,
|
||||
activeDbName: activeContext?.dbName,
|
||||
messages,
|
||||
toolContextEntries: toolContextMapRef.current.values(),
|
||||
}),
|
||||
[activeContext?.connectionId, activeContext?.dbName, messages],
|
||||
);
|
||||
|
||||
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 || t('ai_chat.panel.session.default_title'),
|
||||
[orderedAISessions, sid, t],
|
||||
);
|
||||
const activeConnectionConfig = useMemo(() => {
|
||||
if (!inferredConnectionId) return undefined;
|
||||
const connection = connections.find(item => item.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(() => {
|
||||
return buildAIChatInsights({
|
||||
contextTableNames,
|
||||
sqlLogs,
|
||||
translate: t,
|
||||
});
|
||||
}, [contextTableNames, sqlLogs, t]);
|
||||
const panelHistorySessions = useMemo(
|
||||
() => buildAIChatInlineHistorySessions(
|
||||
orderedAISessions.map((session) => ({
|
||||
...session,
|
||||
title: session.title || t('ai_chat.panel.session.default_title'),
|
||||
})),
|
||||
),
|
||||
[orderedAISessions, t],
|
||||
);
|
||||
const effectivePanelMode = useMemo(
|
||||
() => resolveAIChatPanelMode(isV2Ui, activePanelMode),
|
||||
[activePanelMode, isV2Ui],
|
||||
);
|
||||
|
||||
const handleComposerActionWithNoticeReset = useCallback((actionKey: 'open-settings' | 'reload-models') => {
|
||||
setComposerNoticeState(null);
|
||||
handleComposerAction(actionKey);
|
||||
}, [handleComposerAction]);
|
||||
|
||||
const handleModelChangeWithNoticeReset = useCallback((model: string) => {
|
||||
setComposerNoticeState(null);
|
||||
void handleModelChange(model);
|
||||
}, [handleModelChange]);
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className={`ai-chat-panel${isV2Ui ? ' gn-v2-ai-panel' : ''}`} style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
|
||||
{isResizing && panelRect.current && createPortal(
|
||||
<div
|
||||
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={handleComposerActionWithNoticeReset}
|
||||
onModelChange={handleModelChangeWithNoticeReset}
|
||||
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;
|
||||
167
frontend/src/components/AISettingsModal.edit-password.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
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('localizes user prompt settings toast fallbacks', () => {
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.prompts.message.saved'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed'))");
|
||||
expect(source).not.toContain("'自定义提示词已保存'");
|
||||
expect(source).not.toContain("'保存自定义提示词失败'");
|
||||
});
|
||||
|
||||
it('localizes MCP server toast fallbacks', () => {
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.saved'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed'))");
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.mcp_server.message.deleted'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed'))");
|
||||
expect(source).toContain("messageApi.success(res?.message || t('ai_settings.mcp_server.message.test_success'))");
|
||||
expect(source).toContain("messageApi.error(res?.message || t('ai_settings.mcp_server.message.test_failed'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed'))");
|
||||
expect(source).not.toContain("'MCP 服务已保存'");
|
||||
expect(source).not.toContain("'保存 MCP 服务失败'");
|
||||
expect(source).not.toContain("'MCP 服务已删除'");
|
||||
expect(source).not.toContain("'删除 MCP 服务失败'");
|
||||
expect(source).not.toContain("'MCP 服务连接成功'");
|
||||
expect(source).not.toContain("'MCP 服务测试失败'");
|
||||
expect(source).not.toContain("'测试 MCP 服务失败'");
|
||||
});
|
||||
|
||||
it('localizes Skill toast fallbacks', () => {
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.skill.message.saved'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.save_failed'))");
|
||||
expect(source).toContain("messageApi.success(t('ai_settings.skill.message.deleted'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed'))");
|
||||
expect(source).not.toContain("'Skill 已保存'");
|
||||
expect(source).not.toContain("'保存 Skill 失败'");
|
||||
expect(source).not.toContain("'Skill 已删除'");
|
||||
expect(source).not.toContain("'删除 Skill 失败'");
|
||||
});
|
||||
|
||||
it('localizes MCP HTTP control and copy fallbacks', () => {
|
||||
expect(source).toContain("throw new Error(t('ai_settings.clipboard.error.unsupported'))");
|
||||
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime'))");
|
||||
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version'))");
|
||||
expect(source).toContain("throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'))");
|
||||
expect(source).toContain("messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped'))");
|
||||
expect(source).toContain("messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed'))");
|
||||
expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.url_unavailable'))");
|
||||
expect(source).toContain("copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied'))");
|
||||
expect(source).toContain("messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required'))");
|
||||
expect(source).toContain("copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied'))");
|
||||
expect(source).not.toContain("'当前环境不支持复制到剪贴板'");
|
||||
expect(source).not.toContain("'当前运行时暂不支持 MCP HTTP 服务控制'");
|
||||
expect(source).not.toContain("'当前版本暂不支持启动 MCP HTTP 服务'");
|
||||
expect(source).not.toContain("'当前版本暂不支持停止 MCP HTTP 服务'");
|
||||
expect(source).not.toContain("'GoNavi MCP HTTP 服务已启动'");
|
||||
expect(source).not.toContain("'GoNavi MCP HTTP 服务已停止'");
|
||||
expect(source).not.toContain("'切换 GoNavi MCP HTTP 服务失败'");
|
||||
expect(source).not.toContain("'当前没有可复制的 MCP HTTP URL'");
|
||||
expect(source).not.toContain("'MCP HTTP URL 已复制'");
|
||||
expect(source).not.toContain("'请先启动 MCP HTTP 服务生成 Authorization Header'");
|
||||
expect(source).not.toContain("'Authorization Header 已复制'");
|
||||
});
|
||||
|
||||
it('localizes MCP HTTP default status fallback', () => {
|
||||
expect(source).toContain("const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({");
|
||||
expect(source).toContain("message: t('ai_settings.mcp_http.status.not_running')");
|
||||
expect(source).toContain("useState<AIMCPHTTPServerStatus>(() => defaultMCPHTTPServerStatus)");
|
||||
expect(source).not.toContain("'GoNavi MCP HTTP 服务未启动'");
|
||||
});
|
||||
|
||||
it('localizes Skill required built-in tool option labels', () => {
|
||||
expect(source).toContain("label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`");
|
||||
expect(source).toContain("]), [mcpTools, t]);");
|
||||
expect(source).not.toContain("label: `${tool.name} · 内置工具`");
|
||||
expect(source).not.toContain("· 内置工具");
|
||||
});
|
||||
|
||||
it('loads MCP servers and skills through the AI service', () => {
|
||||
expect(source).toContain('Service.AIGetMCPClientInstallStatuses?.()');
|
||||
expect(source).toContain('Service.AIGetMCPServers?.()');
|
||||
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 || t('ai_settings.message.test_failed'))");
|
||||
expect(source).not.toContain("`测试失败: ${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;');
|
||||
});
|
||||
});
|
||||
872
frontend/src/components/AISettingsModal.tsx
Normal file
@@ -0,0 +1,872 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { Form, message as antdMessage } from 'antd';
|
||||
import { RobotOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel, AIUserPromptSettings, AIMCPServerConfig, AIMCPToolDescriptor, AIMCPClientInstallStatus, AIMCPHTTPServerStatus, AISkillConfig } from '../types';
|
||||
import {
|
||||
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 { useI18n } from '../i18n/provider';
|
||||
import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
|
||||
import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus';
|
||||
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
|
||||
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,
|
||||
localizeProviderPreset,
|
||||
localizeProviderPresets,
|
||||
matchProviderPreset,
|
||||
waitForAIService,
|
||||
} from './ai/aiSettingsModalConfig';
|
||||
interface AISettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
focusProviderId?: string;
|
||||
onBeforeExternalMCPUse?: () => Promise<void>;
|
||||
}
|
||||
|
||||
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: '',
|
||||
};
|
||||
|
||||
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, onBeforeExternalMCPUse }) => {
|
||||
const { t } = useI18n();
|
||||
const defaultMCPHTTPServerStatus = useMemo<AIMCPHTTPServerStatus>(() => ({
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
message: t('ai_settings.mcp_http.status.not_running'),
|
||||
}), [t]);
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
const [safetyLevel, setSafetyLevel] = useState<AISafetyLevel>('readonly');
|
||||
const [contextLevel, setContextLevel] = useState<AIContextLevel>('schema_only');
|
||||
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
|
||||
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
|
||||
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(() => defaultMCPHTTPServerStatus);
|
||||
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 localizedProviderPresets = useMemo(
|
||||
() => localizeProviderPresets(PROVIDER_PRESETS, t),
|
||||
[t],
|
||||
);
|
||||
const findLocalizedPreset = useCallback(
|
||||
(key: string) => localizeProviderPreset(findPreset(key), t),
|
||||
[t],
|
||||
);
|
||||
const matchLocalizedProviderPreset = useCallback(
|
||||
(provider: Pick<AIProviderConfig, 'type' | 'baseUrl' | 'apiFormat'>) =>
|
||||
localizeProviderPreset(matchProviderPreset(provider), t),
|
||||
[t],
|
||||
);
|
||||
const skillRequiredToolOptions = useMemo(() => ([
|
||||
...BUILTIN_AI_TOOL_INFO.map((tool) => ({
|
||||
label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`,
|
||||
value: tool.name,
|
||||
})),
|
||||
...mcpTools.map((tool) => ({
|
||||
label: `${tool.alias} · ${tool.serverName}`,
|
||||
value: tool.alias,
|
||||
})),
|
||||
]), [mcpTools, t]);
|
||||
|
||||
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(t('ai_settings.clipboard.error.unsupported'));
|
||||
}
|
||||
await navigator.clipboard.writeText(text);
|
||||
void messageApi.success(successMessage);
|
||||
}, [messageApi, t]);
|
||||
|
||||
const {
|
||||
handleCopySelectedMCPConfigPath,
|
||||
handleCopySelectedMCPLaunchCommand,
|
||||
handleInstallSelectedMCPClient,
|
||||
handleSelectMCPClient,
|
||||
loadMCPClientStatuses,
|
||||
mcpClientStatusLoading,
|
||||
mcpClientStatuses,
|
||||
resetMCPClientSelectionTouched,
|
||||
selectedMCPClient,
|
||||
selectedMCPClientCommandText,
|
||||
selectedMCPClientStatus,
|
||||
syncMCPClientStatuses,
|
||||
} = useAIMCPClientInstaller({
|
||||
resolveAIService,
|
||||
messageApi,
|
||||
copyTextToClipboard,
|
||||
onBeforeInstall: async () => {
|
||||
setLoading(true);
|
||||
await onBeforeExternalMCPUse?.();
|
||||
},
|
||||
onAfterInstall: () => setLoading(false),
|
||||
onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')),
|
||||
translate: t,
|
||||
});
|
||||
|
||||
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?.(), defaultMCPHTTPServerStatus),
|
||||
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 = {
|
||||
...defaultMCPHTTPServerStatus,
|
||||
...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); }
|
||||
}, [defaultMCPHTTPServerStatus, 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 || t('ai_settings.message.load_provider_failed'));
|
||||
}
|
||||
};
|
||||
|
||||
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 || t('ai_settings.provider.next_provider');
|
||||
void messageApi.success(t('ai_settings.message.deleted_and_switched', { name: newActiveName }));
|
||||
} else {
|
||||
void messageApi.success(t('ai_settings.message.deleted'));
|
||||
}
|
||||
} else {
|
||||
void messageApi.success(t('ai_settings.message.deleted'));
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.delete_failed')); }
|
||||
};
|
||||
|
||||
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 localizedPreset = localizeProviderPreset(preset, t);
|
||||
const isCustomLike = ['custom', 'ollama', 'codebuddy', 'cursor'].includes(values.presetKey);
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
valuesModel: values.model,
|
||||
customModels: values.models,
|
||||
});
|
||||
// 内置供应商自动使用 preset label 作为名称
|
||||
const finalName = isCustomLike ? (values.name || localizedPreset.label) : localizedPreset.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(t('ai_settings.message.saved')); resetProviderEditorSession(); void loadConfig();
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) { /* antd form validation error, ignore */ }
|
||||
else void messageApi.error(e?.message || t('ai_settings.message.save_failed'));
|
||||
} 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(t('ai_settings.message.switched'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed'));
|
||||
} catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.switch_failed')); }
|
||||
};
|
||||
|
||||
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(t('ai_settings.prompts.message.saved'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || t('ai_settings.prompts.message.save_failed'));
|
||||
} 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(t('ai_settings.mcp_server.message.saved'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.save_failed'));
|
||||
} 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(t('ai_settings.mcp_server.message.deleted'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.delete_failed'));
|
||||
} 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 || t('ai_settings.mcp_server.message.test_success'));
|
||||
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 || t('ai_settings.mcp_server.message.test_failed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_server.message.test_request_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMCPHTTPServer = async (checked: boolean) => {
|
||||
try {
|
||||
setMCPHTTPServerLoading(true);
|
||||
const Service = await resolveAIService();
|
||||
if (!Service) {
|
||||
throw new Error(t('ai_settings.mcp_http.error.control_unsupported_runtime'));
|
||||
}
|
||||
if (checked && typeof Service.AIStartMCPHTTPServer !== 'function') {
|
||||
throw new Error(t('ai_settings.mcp_http.error.start_unsupported_version'));
|
||||
}
|
||||
if (!checked && typeof Service.AIStopMCPHTTPServer !== 'function') {
|
||||
throw new Error(t('ai_settings.mcp_http.error.stop_unsupported_version'));
|
||||
}
|
||||
if (checked) {
|
||||
await onBeforeExternalMCPUse?.();
|
||||
}
|
||||
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 = {
|
||||
...defaultMCPHTTPServerStatus,
|
||||
...nextStatus,
|
||||
};
|
||||
setMCPHTTPServerStatus(normalizedStatus);
|
||||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev));
|
||||
}
|
||||
void messageApi.success(checked ? t('ai_settings.mcp_http.message.started') : t('ai_settings.mcp_http.message.stopped'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || t('ai_settings.mcp_http.message.toggle_failed'));
|
||||
} 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(t('ai_settings.mcp_http.message.url_unavailable'));
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(url, t('ai_settings.mcp_http.message.url_copied'));
|
||||
};
|
||||
|
||||
const handleCopyMCPHTTPServerAuthorization = async () => {
|
||||
const authorizationHeader = String(mcpHTTPServerStatus.authorizationHeader || '').trim();
|
||||
if (!authorizationHeader) {
|
||||
void messageApi.error(t('ai_settings.mcp_http.message.authorization_header_required'));
|
||||
return;
|
||||
}
|
||||
await copyTextToClipboard(`Authorization: ${authorizationHeader}`, t('ai_settings.mcp_http.message.authorization_header_copied'));
|
||||
};
|
||||
|
||||
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(t('ai_settings.skill.message.saved'));
|
||||
window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || t('ai_settings.skill.message.save_failed'));
|
||||
} 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(t('ai_settings.skill.message.deleted'));
|
||||
} catch (e: any) {
|
||||
void messageApi.error(e?.message || t('ai_settings.skill.message.delete_failed'));
|
||||
} 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 allowEmptySecret = values.presetKey === 'codebuddy';
|
||||
const secretDraft = resolveProviderSecretDraft({
|
||||
apiKeyInput: values.apiKey,
|
||||
});
|
||||
if (secretDraft.mode === 'clear' && !allowEmptySecret) {
|
||||
throw new Error(t('ai_settings.message.test_requires_new_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(t('ai_settings.message.test_success')); }
|
||||
else { setTestStatus('error'); void messageApi.error(res?.message || t('ai_settings.message.test_failed')); }
|
||||
} catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || t('ai_settings.message.test_failed')); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
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 }}>{t('ai_settings.title')}</div>
|
||||
<div style={{ marginTop: 3, color: overlayTheme.mutedText, fontSize: 12 }}>
|
||||
{t('ai_settings.subtitle')}
|
||||
</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={localizedProviderPresets}
|
||||
watchedPresetKey={watchedPresetKey}
|
||||
watchedApiFormat={watchedApiFormat}
|
||||
loading={loading}
|
||||
testStatus={testStatus}
|
||||
primaryPasswordVisible={primaryPasswordVisible}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
inputBg={inputBg}
|
||||
onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible}
|
||||
resolveProviderPreset={matchLocalizedProviderPreset}
|
||||
resolvePresetByKey={findLocalizedPreset}
|
||||
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;
|
||||
397
frontend/src/components/ConnectionModal.edit-password.test.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8');
|
||||
const connectionModalConfigSource = readFileSync(new URL('./connectionModal/connectionModalConfig.ts', import.meta.url), 'utf8');
|
||||
const connectionModalStep2Source = readFileSync(new URL('./connectionModal/ConnectionModalStep2.tsx', import.meta.url), 'utf8');
|
||||
const connectionModalNetworkSecuritySource = readFileSync(new URL('./connectionModal/ConnectionModalNetworkSecuritySection.tsx', import.meta.url), 'utf8');
|
||||
const connectionModalUriSource = readFileSync(new URL('./connectionModal/connectionModalUri.ts', import.meta.url), 'utf8');
|
||||
const redisSectionsSource = readFileSync(new URL('./ConnectionModalRedisSections.tsx', import.meta.url), 'utf8');
|
||||
const mongoSectionsSource = readFileSync(new URL('./ConnectionModalMongoSections.tsx', import.meta.url), 'utf8');
|
||||
const connectionTypeCatalogSource = readFileSync(new URL('../utils/connectionTypeCatalog.ts', import.meta.url), 'utf8');
|
||||
const connectionTypeCapabilitiesSource = readFileSync(new URL('../utils/connectionTypeCapabilities.ts', import.meta.url), 'utf8');
|
||||
const source = `${connectionModalSource}\n${connectionModalConfigSource}\n${connectionModalStep2Source}\n${connectionModalNetworkSecuritySource}\n${connectionModalUriSource}\n${redisSectionsSource}\n${mongoSectionsSource}\n${connectionTypeCatalogSource}\n${connectionTypeCapabilitiesSource}`;
|
||||
|
||||
describe('ConnectionModal edit password behavior', () => {
|
||||
it('keeps the prefilled primary password masked by default', () => {
|
||||
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 || "") === ""');
|
||||
});
|
||||
|
||||
it('reuses the shared backend-cancel helper for file and certificate pickers', () => {
|
||||
expect(source).not.toContain('res?.message !== "已取消"');
|
||||
expect(source.match(/isBackendCancelledResult\(res\)/g) ?? []).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('uses localized SSL mode labels instead of hardcoded English strings', () => {
|
||||
expect(source).not.toContain('label: "Preferred"');
|
||||
expect(source).not.toContain('label: "Required"');
|
||||
expect(source).not.toContain('label: "Skip Verify"');
|
||||
expect(source).toMatch(
|
||||
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.preferred",\s*\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.required",\s*\)/,
|
||||
);
|
||||
expect(source).toMatch(
|
||||
/label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.skip_verify",\s*\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal data source registry', () => {
|
||||
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("'connection_modal.step1.hint.elasticsearch'");
|
||||
expect(source).toContain(
|
||||
"'Index browsing, Mapping inspection, JSON DSL, and query_string queries'",
|
||||
);
|
||||
expect(source).toContain('const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([');
|
||||
expect(source).toContain('"mqtt",');
|
||||
expect(source).toContain(
|
||||
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant" || type === "rocketmq" || type === "mqtt" || type === "kafka" || type === "rabbitmq") ? "" : "root";',
|
||||
);
|
||||
expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)');
|
||||
expect(source).toContain('connection.modal.field.displayDatabases.label');
|
||||
});
|
||||
|
||||
it('keeps MQTT username optional during test-connection validation', () => {
|
||||
expect(source).toContain('"mqtt",');
|
||||
expect(source).toContain('PRIMARY_USERNAME_OPTIONAL_TYPES.has(dbType)');
|
||||
expect(source).toContain('connection.modal.field.username.required');
|
||||
expect(source).toContain('connection.modal.field.username.optional_placeholder');
|
||||
});
|
||||
|
||||
it('exposes Chroma in the create-connection picker with vector defaults', () => {
|
||||
expect(source).toContain("case 'chroma':");
|
||||
expect(source).toContain('return 8000;');
|
||||
expect(source).toContain('chroma: ["http", "https", "chroma"]');
|
||||
expect(source).toContain("key: 'chroma'");
|
||||
expect(source).toContain("name: 'Chroma'");
|
||||
expect(source).toContain('type === "chroma"');
|
||||
expect(source).toContain("'connection_modal.step1.hint.chroma'");
|
||||
expect(source).toContain(
|
||||
"'Collection browsing, vector retrieval, and metadata filtering'",
|
||||
);
|
||||
expect(source).toContain('return "http://127.0.0.1:8000/default_database?tenant=default_tenant";');
|
||||
expect(source).toContain('return "tenant=default_tenant&apiKey=...";');
|
||||
});
|
||||
|
||||
it('exposes Qdrant in the create-connection picker with vector defaults', () => {
|
||||
expect(source).toContain("case 'qdrant':");
|
||||
expect(source).toContain('return 6333;');
|
||||
expect(source).toContain('qdrant: ["http", "https", "qdrant"]');
|
||||
expect(source).toContain("key: 'qdrant'");
|
||||
expect(source).toContain("name: 'Qdrant'");
|
||||
expect(source).toContain('type === "qdrant"');
|
||||
expect(source).toContain("'connection_modal.step1.hint.qdrant'");
|
||||
expect(source).toContain(
|
||||
"'Collection browsing, vector search, and Payload filtering'",
|
||||
);
|
||||
expect(source).toContain('return "http://127.0.0.1:6333";');
|
||||
expect(source).toContain('return "apiKey=...";');
|
||||
});
|
||||
|
||||
it('exposes Apache IoTDB in the create-connection picker with timeseries defaults', () => {
|
||||
expect(source).toContain("case 'iotdb':");
|
||||
expect(source).toContain('return 6667;');
|
||||
expect(source).toContain('iotdb: ["iotdb"]');
|
||||
expect(source).toContain("key: 'iotdb'");
|
||||
expect(source).toContain("name: 'Apache IoTDB'");
|
||||
expect(source).toContain('dbType === "iotdb"');
|
||||
expect(source).toContain("return 'Storage Group / Device / Timeseries';");
|
||||
expect(source).toContain('return "iotdb://root:root@127.0.0.1:6667/root.sg";');
|
||||
expect(source).toContain('return "fetchSize=1024&timeZone=Asia%2FShanghai";');
|
||||
});
|
||||
|
||||
it('exposes RocketMQ in the create-connection picker with nameserver and topic defaults', () => {
|
||||
expect(source).toContain("case 'rocketmq':");
|
||||
expect(source).toContain('return 9876;');
|
||||
expect(source).toContain('rocketmq: ["rocketmq", "rmq"]');
|
||||
expect(source).toContain("key: 'rocketmq'");
|
||||
expect(source).toContain("name: 'RocketMQ'");
|
||||
expect(source).toContain('dbType === "rocketmq"');
|
||||
expect(source).toContain("return 'NameServer / Topic / Consumer Group';");
|
||||
expect(source).toContain('return "rocketmq://accessKey:secretKey@127.0.0.1:9876,127.0.0.2:9876/orders.events?topology=cluster&groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
|
||||
expect(source).toContain('return "groupId=gonavi&namespace=prod&tag=TagA&pullBatchSize=32&startOffset=latest";');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.rocketmq.defaultTopic.label")');
|
||||
expect(source).toContain('connection.modal.field.username.label');
|
||||
expect(source).toContain('connection.modal.field.password.label');
|
||||
expect(source).toContain('connection.modal.field.username.optional_placeholder');
|
||||
expect(source).toContain('connection.modal.field.password.retained');
|
||||
});
|
||||
|
||||
it('exposes MQTT in the create-connection picker with broker and topic-filter defaults', () => {
|
||||
expect(source).toContain("case 'mqtt':");
|
||||
expect(source).toContain('return 1883;');
|
||||
expect(source).toContain('mqtt: ["mqtt", "mqtts", "tcp", "ssl", "tls"]');
|
||||
expect(source).toContain("key: 'mqtt'");
|
||||
expect(source).toContain("name: 'MQTT'");
|
||||
expect(source).toContain('dbType === "mqtt"');
|
||||
expect(source).toContain("return 'Broker / Topic Filter / QoS';");
|
||||
expect(source).toContain('return "mqtt://user:pass@127.0.0.1:1883/devices%2F%2B%2Ftelemetry?topology=cluster&clientId=gonavi-desktop&qos=1";');
|
||||
expect(source).toContain('return "topics=devices%2F%2B%2Ftelemetry,%24SYS%2F%23&clientId=gonavi-desktop&qos=1&cleanSession=true&fetchWaitMs=4000";');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.mqtt.defaultTopicFilter.label")');
|
||||
});
|
||||
|
||||
it('exposes Kafka in the create-connection picker with broker and topic defaults', () => {
|
||||
expect(source).toContain("case 'kafka':");
|
||||
expect(source).toContain('return 9092;');
|
||||
expect(source).toContain("key: 'kafka'");
|
||||
expect(source).toContain("name: 'Kafka'");
|
||||
expect(source).toContain('dbType === "kafka"');
|
||||
expect(source).toContain("return 'Broker / Topic / Consumer Group';");
|
||||
expect(source).toContain('return "kafka://user:pass@127.0.0.1:9092,127.0.0.2:9092/orders.events?topology=cluster&groupId=analytics&mechanism=scram-sha-256";');
|
||||
expect(source).toContain('return "groupId=gonavi&mechanism=scram-sha-256&clientId=gonavi-desktop&startOffset=latest";');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.kafka.defaultTopic.label")');
|
||||
});
|
||||
|
||||
it('exposes RabbitMQ in the create-connection picker with management-api and vhost defaults', () => {
|
||||
expect(source).toContain("case 'rabbitmq':");
|
||||
expect(source).toContain('return 15672;');
|
||||
expect(source).toContain('rabbitmq: ["rabbitmq", "http", "https"]');
|
||||
expect(source).toContain("key: 'rabbitmq'");
|
||||
expect(source).toContain("name: 'RabbitMQ'");
|
||||
expect(source).toContain('dbType === "rabbitmq"');
|
||||
expect(source).toContain("return 'Management API / Virtual Host / Queue';");
|
||||
expect(source).toContain('return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30";');
|
||||
expect(source).toContain('return "defaultQueue=orders.queue&exchange=events.topic&managementPathPrefix=/rabbitmq";');
|
||||
expect(source).toContain('t("connection.modal.messageQueue.rabbitmq.defaultVirtualHost.label")');
|
||||
});
|
||||
|
||||
it('exposes GaussDB in the create-connection picker with PostgreSQL-family defaults', () => {
|
||||
expect(source).toContain("case 'gaussdb':");
|
||||
expect(source).toContain('return 5432;');
|
||||
expect(source).toContain('gaussdb: ["gaussdb", "postgresql", "postgres"]');
|
||||
expect(source).toContain("key: 'gaussdb'");
|
||||
expect(source).toContain("name: 'GaussDB'");
|
||||
expect(source).toContain('type === "gaussdb"');
|
||||
expect(source).toContain('return "gaussdb://user:pass@127.0.0.1:5432/db_name";');
|
||||
expect(source).toContain('return "application_name=GoNavi&statement_timeout=30000";');
|
||||
expect(source).toContain('? "gaussdb"');
|
||||
expect(source).toContain('dbType === "gaussdb"');
|
||||
});
|
||||
|
||||
it('exposes GoldenDB in the create-connection picker with MySQL-compatible defaults', () => {
|
||||
expect(source).toContain("case 'goldendb':");
|
||||
expect(source).toContain('return 1523;');
|
||||
expect(source).toContain("key: 'goldendb'");
|
||||
expect(source).toContain("name: 'GoldenDB'");
|
||||
expect(source).toContain('type === "goldendb"');
|
||||
expect(source).toContain("'connection_modal.step1.hint.goldendb'");
|
||||
expect(source).toContain("'MySQL compatible / distributed transactions'");
|
||||
expect(source).toContain('dbType === "goldendb" ? "goldendb" : "mysql"');
|
||||
expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"');
|
||||
expect(source).toContain('? "goldendb"');
|
||||
});
|
||||
|
||||
it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => {
|
||||
expect(source).toContain('connection.modal.field.oceanBaseServiceName.label');
|
||||
expect(source).toMatch(
|
||||
/isOceanBaseOracle\s*\?\s*\[\]\s*:\s*\[\s*createUriAwareRequiredRule\(\s*t\("connection\.modal\.field\.serviceName\.required"/,
|
||||
);
|
||||
expect(source).toContain('connection.modal.field.oceanBaseServiceName.help');
|
||||
expect(source).toContain('connection.modal.field.serviceName.help');
|
||||
expect(source).toContain('connection.modal.field.serviceName.required');
|
||||
expect(source).not.toContain('请输入 OceanBase Oracle 服务名');
|
||||
expect(source).not.toContain('Oracle 租户必须填写监听器注册的 SERVICE_NAME');
|
||||
});
|
||||
|
||||
it('uses localized message queue service, topology, and extra host copy', () => {
|
||||
[
|
||||
'label="默认 Topic(可选)"',
|
||||
'label="默认 Topic / Filter(可选)"',
|
||||
'label="默认 Virtual Host(可选)"',
|
||||
'label: "单 Broker"',
|
||||
'label: "单 NameServer"',
|
||||
'label="额外 Broker 地址"',
|
||||
'label="额外 NameServer 地址"',
|
||||
'help="可输入多个 broker 地址,格式:host:port(回车确认)"',
|
||||
'help="可输入多个 NameServer 地址,格式:host:port(回车确认)"',
|
||||
].forEach((snippet) => {
|
||||
expect(source).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.messageQueue.kafka.defaultTopic.help',
|
||||
'connection.modal.messageQueue.rocketmq.defaultTopic.help',
|
||||
'connection.modal.messageQueue.mqtt.defaultTopicFilter.help',
|
||||
'connection.modal.messageQueue.rabbitmq.defaultVirtualHost.help',
|
||||
'connection.modal.messageQueue.kafka.topology.single.label',
|
||||
'connection.modal.messageQueue.rocketmq.topology.single.label',
|
||||
'connection.modal.messageQueue.mqtt.topology.cluster.description',
|
||||
'connection.modal.messageQueue.kafka.extraBrokers.placeholder',
|
||||
'connection.modal.messageQueue.rocketmq.extraNameServers.placeholder',
|
||||
'connection.modal.messageQueue.mqtt.extraBrokers.placeholder',
|
||||
].forEach((key) => {
|
||||
expect(source).toContain(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal Redis Sentinel configuration', () => {
|
||||
it('exposes Sentinel topology fields and safe defaults', () => {
|
||||
expect(source).toContain('connection.modal.redis.topology.sentinel.label');
|
||||
expect(source).toContain('name="redisSentinelMaster"');
|
||||
expect(source).toContain('connection.modal.redis.sentinel.master.label');
|
||||
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('uses localized Redis topology, sentinel, credential, and database-scope copy', () => {
|
||||
[
|
||||
'label: "单机模式"',
|
||||
'description: "只连接一个 Redis 节点。"',
|
||||
'label: "集群模式"',
|
||||
'description: "Redis Cluster,配置多个种子节点。"',
|
||||
'label: "哨兵模式"',
|
||||
'description: "通过 Sentinel 发现主节点,适合主从高可用。"',
|
||||
'? "Sentinel 附加节点地址"',
|
||||
': "集群附加节点地址"',
|
||||
'? "上方主机地址作为第一个 Sentinel;这里填写其他 Sentinel 节点,格式:host:port"',
|
||||
': "主节点使用上方主机地址;这里填写其他种子节点,格式:host:port"',
|
||||
'label="Sentinel master 名称"',
|
||||
'help="填写 Sentinel 配置中的 monitor 名称,例如 mymaster。"',
|
||||
'label="密码 (可选)"',
|
||||
'emptyPlaceholder: "Redis 密码(如果设置了 requirepass)"',
|
||||
'retainedLabel: "已保存 Redis 密码"',
|
||||
'label="Sentinel 用户名(可选)"',
|
||||
'placeholder="留空表示 Sentinel 不使用 ACL 用户名"',
|
||||
'label="Sentinel 密码(可选)"',
|
||||
'emptyPlaceholder: "Sentinel 自身认证密码,留空则不发送"',
|
||||
'retainedLabel: "已保存 Sentinel 密码"',
|
||||
'clearLabel: "清除已保存 Sentinel 密码"',
|
||||
'label="显示数据库 (留空显示全部)"',
|
||||
'help="连接测试成功后可选择"',
|
||||
'placeholder="选择显示的数据库"',
|
||||
].forEach((snippet) => {
|
||||
expect(redisSectionsSource).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.redis.topology.single.label',
|
||||
'connection.modal.redis.topology.cluster.description',
|
||||
'connection.modal.redis.topology.sentinel.label',
|
||||
'connection.modal.redis.hosts.sentinel.label',
|
||||
'connection.modal.redis.hosts.cluster.help',
|
||||
'connection.modal.redis.sentinel.master.required',
|
||||
'connection.modal.redis.credentials.primary.placeholder.empty',
|
||||
'connection.modal.redis.credentials.sentinelPassword.clear',
|
||||
'connection.modal.redis.databaseScope.placeholder',
|
||||
].forEach((key) => {
|
||||
expect(redisSectionsSource).toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
it('uses localized Redis test feedback and optional-auth placeholders', () => {
|
||||
[
|
||||
'测试连接前请填写新的 Sentinel 密码,或取消清除已保存 Sentinel 密码',
|
||||
'连接成功但拉取 Redis 数据库列表超时',
|
||||
'连接成功,但获取 Redis 数据库列表失败',
|
||||
'未知错误',
|
||||
'? "未开启认证可留空"',
|
||||
].forEach((snippet) => {
|
||||
expect(connectionModalSource).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.secret.blocking.redis_sentinel',
|
||||
'connection.modal.test.redis_database_list_timeout',
|
||||
'connection.modal.test.redis_database_list_failure',
|
||||
'connection.modal.error.unknown',
|
||||
'connection.modal.field.username.optional_placeholder',
|
||||
].forEach((key) => {
|
||||
expect(source).toContain(key);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the saved host as the primary Redis node when editing multi-node configs', () => {
|
||||
expect(source).toContain('const savedPrimaryAddress = isFileDbConfigType');
|
||||
expect(source).toContain('savedPrimaryAddress,');
|
||||
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('connection.modal.mongodb.discovery.srv_ssh_warning');
|
||||
expect(source).toContain('name="mongoReplicaPassword"');
|
||||
expect(source).toContain('clearKey: "mongoReplicaPassword"');
|
||||
expect(source).toContain('connection.modal.action.discover_members');
|
||||
expect(source).toContain('fieldName: "mongoReadPreference"');
|
||||
});
|
||||
|
||||
it('uses localized MongoDB topology, discovery, replica, and policy copy', () => {
|
||||
[
|
||||
'label: "单机模式"',
|
||||
'description: "只连接一个 MongoDB 节点。"',
|
||||
'label: "副本集 / 多节点"',
|
||||
'description: "配置副本集名称和多个候选节点。"',
|
||||
'label: "标准地址"',
|
||||
'description: "使用 host:port 直连或副本集节点列表。"',
|
||||
'label: "SRV 地址"',
|
||||
'description: "使用 mongodb+srv,由 DNS 发现目标节点。"',
|
||||
'<Tag color="blue">当前</Tag>',
|
||||
'message="SRV 与 SSH 隧道同时启用时,可能依赖本地 DNS 解析能力"',
|
||||
'label={mongoSrv ? "附加 SRV 主机(可选)" : "附加节点地址"}',
|
||||
'? "可输入多个候选主机名,格式:host;若留空则仅使用上方主机。"',
|
||||
': "可输入多个节点地址,格式:host:port(回车确认)"',
|
||||
'label="副本集名称(可选)"',
|
||||
'label="副本集用户名(可选)"',
|
||||
'placeholder="留空沿用主用户名"',
|
||||
'label="副本集密码(可选)"',
|
||||
'emptyPlaceholder: "留空沿用主密码"',
|
||||
'retainedLabel: "已保存副本集密码"',
|
||||
'clearLabel: "清除已保存副本集密码"',
|
||||
'当前已保存副本集密码。留空表示继续沿用,输入新值表示替换。',
|
||||
'自动发现成员',
|
||||
'title: "角色"',
|
||||
'title: "健康"',
|
||||
'? "正常" : "异常"',
|
||||
'label="认证库 (authSource)"',
|
||||
'placeholder="默认使用 database 或 admin"',
|
||||
'<Text strong>读偏好 (readPreference)</Text>',
|
||||
'description: "只读主节点。"',
|
||||
'description: "主节点优先。"',
|
||||
'description: "只读从节点。"',
|
||||
'description: "从节点优先。"',
|
||||
'description: "选择最近节点。"',
|
||||
].forEach((snippet) => {
|
||||
expect(mongoSectionsSource).not.toContain(snippet);
|
||||
});
|
||||
|
||||
[
|
||||
'connection.modal.mongodb.topology.single.label',
|
||||
'connection.modal.mongodb.discovery.standard.label',
|
||||
'connection.modal.mongodb.discovery.srv_ssh_warning',
|
||||
'connection.modal.mongodb.replica.hosts.srv.label',
|
||||
'connection.modal.mongodb.replica.password.description',
|
||||
'connection.modal.action.discover_members',
|
||||
'connection.modal.mongodb.members.role',
|
||||
'connection.modal.mongodb.policy.auth_source.label',
|
||||
'connection.modal.mongodb.read_preference.primary',
|
||||
].forEach((key) => {
|
||||
expect(mongoSectionsSource).toContain(key);
|
||||
});
|
||||
});
|
||||
});
|
||||
1213
frontend/src/components/ConnectionModal.i18n.test.tsx
Normal file
397
frontend/src/components/ConnectionModalMongoSections.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
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 { useI18n } from "../i18n/provider";
|
||||
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,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: renderChoiceCards({
|
||||
fieldName: "mongoTopology",
|
||||
value: String(mongoTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: t("connection.modal.mongodb.topology.single.label"),
|
||||
description: t("connection.modal.topology.mongodb_single_description"),
|
||||
},
|
||||
{
|
||||
value: "replica",
|
||||
label: t("connection.modal.mongodb.topology.replica.label"),
|
||||
description: t("connection.modal.topology.mongodb_replica_description"),
|
||||
},
|
||||
],
|
||||
}),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoDiscovery",
|
||||
icon: <ApiOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="mongoSrv" hidden valuePropName="checked">
|
||||
<Checkbox />
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: false,
|
||||
label: t("connection.modal.mongodb.discovery.standard.label"),
|
||||
description: t("connection.modal.mongodb.discovery.standard.description"),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: t("connection.modal.mongodb.discovery.srv.label"),
|
||||
description: t("connection.modal.mongodb.discovery.srv.description"),
|
||||
},
|
||||
].map((option) => {
|
||||
const active = mongoSrv === option.value;
|
||||
return (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => setChoiceFieldValue("mongoSrv", option.value)}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 14,
|
||||
border: active
|
||||
? darkMode
|
||||
? "1px solid rgba(255,214,102,0.42)"
|
||||
: "1px solid rgba(22,119,255,0.36)"
|
||||
: darkMode
|
||||
? "1px solid rgba(255,255,255,0.08)"
|
||||
: "1px solid rgba(16,24,40,0.08)",
|
||||
background: active
|
||||
? darkMode
|
||||
? "rgba(255,214,102,0.10)"
|
||||
: "rgba(22,119,255,0.07)"
|
||||
: darkMode
|
||||
? "rgba(255,255,255,0.03)"
|
||||
: "rgba(16,24,40,0.03)",
|
||||
color: darkMode ? "#f5f7ff" : "#162033",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Space size={8} wrap>
|
||||
<Text strong>{option.label}</Text>
|
||||
{active ? (
|
||||
<Tag color="blue">
|
||||
{t("connection.modal.mongodb.discovery.current")}
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
<div style={{ ...modalMutedTextStyle, marginTop: 6 }}>
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{mongoSrv && useSSH && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={t("connection.modal.mongodb.discovery.srv_ssh_warning")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{mongoTopology === "replica" &&
|
||||
renderConfigSectionCard({
|
||||
sectionKey: "replica",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="mongoHosts"
|
||||
label={t(
|
||||
mongoSrv
|
||||
? "connection.modal.mongodb.replica.hosts.srv.label"
|
||||
: "connection.modal.mongodb.replica.hosts.standard.label",
|
||||
)}
|
||||
help={t(
|
||||
mongoSrv
|
||||
? "connection.modal.mongodb.replica.hosts.srv.help"
|
||||
: "connection.modal.mongodb.replica.hosts.standard.help",
|
||||
)}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={t(
|
||||
mongoSrv
|
||||
? "connection.modal.mongodb.replica.hosts.srv.placeholder"
|
||||
: "connection.modal.mongodb.replica.hosts.standard.placeholder",
|
||||
)}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="mongoReplicaSet"
|
||||
label={t("connection.modal.mongodb.replica.set.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.mongodb.replica.set.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="mongoReplicaUser"
|
||||
label={t("connection.modal.mongodb.replica.user.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.mongodb.replica.user.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="mongoReplicaPassword"
|
||||
label={t("connection.modal.mongodb.replica.password.label")}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
emptyPlaceholder: t(
|
||||
"connection.modal.mongodb.replica.password.placeholder.empty",
|
||||
),
|
||||
retainedLabel: t(
|
||||
"connection.modal.mongodb.replica.password.placeholder.retained",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "mongoReplicaPassword",
|
||||
clearKey: "mongoReplicaPassword",
|
||||
hasStoredSecret: initialValues?.hasMongoReplicaPassword,
|
||||
clearLabel: t("connection.modal.mongodb.replica.password.clear"),
|
||||
description: t("connection.modal.mongodb.replica.password.description"),
|
||||
})}
|
||||
<Space size={8} style={{ marginTop: 12, marginBottom: 12 }}>
|
||||
<Button
|
||||
onClick={handleDiscoverMongoMembers}
|
||||
loading={discoveringMembers}
|
||||
>
|
||||
{t("connection.modal.action.discover_members")}
|
||||
</Button>
|
||||
</Space>
|
||||
{mongoMembers.length > 0 && (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(record) => record.host}
|
||||
pagination={false}
|
||||
dataSource={mongoMembers}
|
||||
style={{ marginBottom: 12 }}
|
||||
columns={[
|
||||
{ title: "Host", dataIndex: "host", width: "48%" },
|
||||
{
|
||||
title: t("connection.modal.mongodb.members.role"),
|
||||
dataIndex: "role",
|
||||
width: "32%",
|
||||
render: (value: string, record: MongoMemberInfo) => (
|
||||
<Tag color={record.isSelf ? "blue" : "default"}>
|
||||
{value || "UNKNOWN"}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t("connection.modal.mongodb.members.health"),
|
||||
dataIndex: "healthy",
|
||||
width: "20%",
|
||||
render: (value: boolean) => (
|
||||
<Tag color={value ? "success" : "error"}>
|
||||
{t(
|
||||
value
|
||||
? "connection.modal.mongodb.members.health.ok"
|
||||
: "connection.modal.mongodb.members.health.error",
|
||||
)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "mongoPolicy",
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="mongoAuthSource"
|
||||
label={t("connection.modal.mongodb.policy.auth_source.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.mongodb.policy.auth_source.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<Text strong>{t("connection.modal.mongodb.read_preference")}</Text>
|
||||
{renderChoiceCards({
|
||||
fieldName: "mongoReadPreference",
|
||||
value: String(mongoReadPreference),
|
||||
minWidth: 130,
|
||||
options: [
|
||||
{
|
||||
value: "primary",
|
||||
label: "primary",
|
||||
description: t("connection.modal.mongodb.read_preference.primary"),
|
||||
},
|
||||
{
|
||||
value: "primaryPreferred",
|
||||
label: "primaryPreferred",
|
||||
description: t(
|
||||
"connection.modal.mongodb.read_preference.primary_preferred",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "secondary",
|
||||
label: "secondary",
|
||||
description: t("connection.modal.mongodb.read_preference.secondary"),
|
||||
},
|
||||
{
|
||||
value: "secondaryPreferred",
|
||||
label: "secondaryPreferred",
|
||||
description: t(
|
||||
"connection.modal.mongodb.read_preference.secondary_preferred",
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "nearest",
|
||||
label: "nearest",
|
||||
description: t("connection.modal.mongodb.read_preference.nearest"),
|
||||
},
|
||||
],
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionModalMongoSections;
|
||||
252
frontend/src/components/ConnectionModalRedisSections.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
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 { useI18n } from "../i18n/provider";
|
||||
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,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "connectionMode",
|
||||
icon: <ClusterOutlined />,
|
||||
children: (
|
||||
<>
|
||||
{renderChoiceCards({
|
||||
fieldName: "redisTopology",
|
||||
value: String(redisTopology),
|
||||
options: [
|
||||
{
|
||||
value: "single",
|
||||
label: t("connection.modal.redis.topology.single.label"),
|
||||
description: t("connection.modal.redis.topology.single.description"),
|
||||
},
|
||||
{
|
||||
value: "cluster",
|
||||
label: t("connection.modal.redis.topology.cluster.label"),
|
||||
description: t("connection.modal.redis.topology.cluster.description"),
|
||||
},
|
||||
{
|
||||
value: "sentinel",
|
||||
label: t("connection.modal.redis.topology.sentinel.label"),
|
||||
description: t("connection.modal.redis.topology.sentinel.description"),
|
||||
},
|
||||
],
|
||||
})}
|
||||
{(redisTopology === "cluster" || redisTopology === "sentinel") && (
|
||||
<>
|
||||
<Form.Item
|
||||
name="redisHosts"
|
||||
label={t(
|
||||
redisTopology === "sentinel"
|
||||
? "connection.modal.redis.hosts.sentinel.label"
|
||||
: "connection.modal.redis.hosts.cluster.label",
|
||||
)}
|
||||
help={t(
|
||||
redisTopology === "sentinel"
|
||||
? "connection.modal.redis.hosts.sentinel.help"
|
||||
: "connection.modal.redis.hosts.cluster.help",
|
||||
)}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder={t(
|
||||
redisTopology === "sentinel"
|
||||
? "connection.modal.redis.hosts.sentinel.placeholder"
|
||||
: "connection.modal.redis.hosts.cluster.placeholder",
|
||||
)}
|
||||
tokenSeparators={[",", ";", " "]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === "sentinel" && (
|
||||
<Form.Item
|
||||
name="redisSentinelMaster"
|
||||
label={t("connection.modal.redis.sentinel.master.label")}
|
||||
help={t("connection.modal.redis.sentinel.master.help")}
|
||||
rules={[
|
||||
createUriAwareRequiredRule(
|
||||
t("connection.modal.redis.sentinel.master.required"),
|
||||
),
|
||||
]}
|
||||
style={{ marginTop: 16, marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.redis.sentinel.master.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "credentials",
|
||||
icon: <SafetyCertificateOutlined />,
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="password" label={t("connection.modal.redis.credentials.primary.label")}>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
visibilityToggle={{
|
||||
visible: primaryPasswordVisible,
|
||||
onVisibleChange: setPrimaryPasswordVisible,
|
||||
}}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasPrimaryPassword,
|
||||
emptyPlaceholder: t("connection.modal.redis.credentials.primary.placeholder.empty"),
|
||||
retainedLabel: t("connection.modal.redis.credentials.primary.placeholder.retained"),
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
{redisTopology === "sentinel" && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="redisSentinelUser"
|
||||
label={t("connection.modal.redis.credentials.sentinelUser.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={t("connection.modal.redis.credentials.sentinelUser.placeholder")}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="redisSentinelPassword"
|
||||
label={t("connection.modal.redis.credentials.sentinelPassword.label")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password
|
||||
{...noAutoCapInputProps}
|
||||
placeholder={getStoredSecretPlaceholder({
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
emptyPlaceholder: t(
|
||||
"connection.modal.redis.credentials.sentinelPassword.placeholder.empty",
|
||||
),
|
||||
retainedLabel: t(
|
||||
"connection.modal.redis.credentials.sentinelPassword.placeholder.retained",
|
||||
),
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{renderStoredSecretControls({
|
||||
fieldName: "redisSentinelPassword",
|
||||
clearKey: "redisSentinelPassword",
|
||||
hasStoredSecret: initialValues?.hasRedisSentinelPassword,
|
||||
clearLabel: t("connection.modal.redis.credentials.sentinelPassword.clear"),
|
||||
description: t(
|
||||
"connection.modal.redis.credentials.sentinelPassword.description",
|
||||
),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
})}
|
||||
|
||||
{renderConfigSectionCard({
|
||||
sectionKey: "databaseScope",
|
||||
icon: <DatabaseOutlined />,
|
||||
children: (
|
||||
<Form.Item
|
||||
name="includeRedisDatabases"
|
||||
label={t("connection.modal.redis.databaseScope.label")}
|
||||
help={t("connection.modal.redis.databaseScope.help")}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder={t("connection.modal.redis.databaseScope.placeholder")}
|
||||
allowClear
|
||||
>
|
||||
{redisDbList.map((db) => (
|
||||
<Select.Option key={db} value={db}>
|
||||
db{db}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
),
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionModalRedisSections;
|
||||
126
frontend/src/components/ConnectionPackagePasswordModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React from 'react';
|
||||
import { Button, Checkbox, Input, Typography } from 'antd';
|
||||
import { useI18n } from '../i18n/provider';
|
||||
|
||||
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;
|
||||
onBack?: () => void;
|
||||
embedded?: boolean;
|
||||
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,
|
||||
onBack,
|
||||
embedded = false,
|
||||
onIncludeSecretsChange,
|
||||
onUseFilePasswordChange,
|
||||
onPasswordChange,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConnectionPackagePasswordModalProps) {
|
||||
const { t } = useI18n();
|
||||
const isExportMode = mode === 'export';
|
||||
const showFilePasswordInput = isExportMode ? useFilePassword : true;
|
||||
const resolvedConfirmText = confirmText ?? t('common.confirm');
|
||||
const resolvedCancelText = cancelText ?? t('common.cancel');
|
||||
const placeholder = isExportMode
|
||||
? t('app.connection_package.dialog.file_password_placeholder')
|
||||
: t('app.connection_package.dialog.restore_password_placeholder');
|
||||
const helperText = !includeSecrets
|
||||
? t('app.connection_package.dialog.help.exclude_passwords')
|
||||
: (useFilePassword
|
||||
? t('app.connection_package.dialog.help.share_file_password_separately')
|
||||
: t('app.connection_package.dialog.help.encrypted_passwords_recommend_file_password'));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
embedded={embedded}
|
||||
title={embedded ? null : (
|
||||
<span style={{ minWidth: 0 }}>{title}</span>
|
||||
)}
|
||||
closable={embedded ? false : undefined}
|
||||
onCancel={onCancel}
|
||||
destroyOnHidden={false}
|
||||
maskClosable={false}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel}>
|
||||
{resolvedCancelText}
|
||||
</Button>,
|
||||
<Button key="confirm" type="primary" loading={confirmLoading} onClick={onConfirm}>
|
||||
{resolvedConfirmText}
|
||||
</Button>,
|
||||
onBack ? (
|
||||
<Button key="back" onClick={onBack}>
|
||||
{t('common.back_to_previous')}
|
||||
</Button>
|
||||
) : null,
|
||||
]}
|
||||
>
|
||||
{isExportMode ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<Checkbox
|
||||
checked={includeSecrets}
|
||||
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
|
||||
>
|
||||
{t('app.connection_package.dialog.option.include_passwords')}
|
||||
</Checkbox>
|
||||
<Checkbox
|
||||
checked={useFilePassword}
|
||||
disabled={!includeSecrets}
|
||||
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
|
||||
>
|
||||
{t('app.connection_package.dialog.option.use_file_password')}
|
||||
</Checkbox>
|
||||
</div>
|
||||
) : null}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
215
frontend/src/components/DataExportDialog.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import Modal from './common/ResizableDraggableModal';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Form, InputNumber, Select, message } from 'antd';
|
||||
import { ExportOutlined } from '@ant-design/icons';
|
||||
import { t } from '../i18n';
|
||||
|
||||
export type DataExportFormat = 'csv' | 'xlsx' | 'json' | 'md' | 'html';
|
||||
export type DataExportScope = 'selected' | 'page' | 'all' | 'filteredAll';
|
||||
|
||||
export type DataExportFileOptions = {
|
||||
format: DataExportFormat;
|
||||
xlsxMaxRowsPerSheet?: number;
|
||||
};
|
||||
|
||||
export type DataExportDialogValues = DataExportFileOptions & {
|
||||
scope: DataExportScope | string;
|
||||
};
|
||||
|
||||
export type DataExportScopeOption = {
|
||||
value: DataExportScope | string;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export type ShowDataExportDialogOptions = {
|
||||
title: string;
|
||||
scopeOptions: DataExportScopeOption[];
|
||||
initialValues?: Partial<DataExportDialogValues>;
|
||||
okText?: string;
|
||||
};
|
||||
|
||||
export const MAX_XLSX_ROWS_PER_SHEET = 1048575;
|
||||
export const DEFAULT_XLSX_ROWS_PER_SHEET = MAX_XLSX_ROWS_PER_SHEET;
|
||||
export const DEFAULT_DATA_EXPORT_FORMAT: DataExportFormat = 'xlsx';
|
||||
|
||||
export const DATA_EXPORT_FORMAT_OPTIONS: Array<{ value: DataExportFormat; label: string }> = [
|
||||
{ value: 'xlsx', label: 'Excel (XLSX)' },
|
||||
{ value: 'csv', label: 'CSV' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'md', label: 'Markdown' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
];
|
||||
|
||||
const resolveDefaultScope = (scopeOptions: DataExportScopeOption[], initialScope?: string): string => {
|
||||
const matchedInitial = scopeOptions.find((item) => item.value === initialScope && !item.disabled);
|
||||
if (matchedInitial) return String(matchedInitial.value);
|
||||
const firstEnabled = scopeOptions.find((item) => !item.disabled);
|
||||
return String(firstEnabled?.value || scopeOptions[0]?.value || 'all');
|
||||
};
|
||||
|
||||
const normalizeDialogValues = (
|
||||
scopeOptions: DataExportScopeOption[],
|
||||
initialValues?: Partial<DataExportDialogValues>,
|
||||
): DataExportDialogValues => {
|
||||
const format = (initialValues?.format || DEFAULT_DATA_EXPORT_FORMAT) as DataExportFormat;
|
||||
const scope = resolveDefaultScope(scopeOptions, initialValues?.scope ? String(initialValues.scope) : undefined);
|
||||
const xlsxMaxRowsPerSheet = Number(initialValues?.xlsxMaxRowsPerSheet) > 0
|
||||
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(initialValues?.xlsxMaxRowsPerSheet)))
|
||||
: DEFAULT_XLSX_ROWS_PER_SHEET;
|
||||
return {
|
||||
format,
|
||||
scope,
|
||||
xlsxMaxRowsPerSheet,
|
||||
};
|
||||
};
|
||||
|
||||
const validateDialogValues = (
|
||||
values: DataExportDialogValues,
|
||||
scopeOptions: DataExportScopeOption[],
|
||||
): string | null => {
|
||||
if (!DATA_EXPORT_FORMAT_OPTIONS.some((item) => item.value === values.format)) {
|
||||
return t('data_export.dialog.validation.format_required');
|
||||
}
|
||||
if (scopeOptions.length > 0) {
|
||||
const matchedScope = scopeOptions.find((item) => String(item.value) === String(values.scope));
|
||||
if (!matchedScope || matchedScope.disabled) {
|
||||
return t('data_export.dialog.validation.scope_required');
|
||||
}
|
||||
}
|
||||
if (values.format === 'xlsx') {
|
||||
const rows = Math.trunc(Number(values.xlsxMaxRowsPerSheet) || 0);
|
||||
if (!Number.isFinite(rows) || rows <= 0) {
|
||||
return t('data_export.dialog.validation.xlsx_max_rows_required');
|
||||
}
|
||||
if (rows > MAX_XLSX_ROWS_PER_SHEET) {
|
||||
return t('data_export.dialog.validation.xlsx_max_rows_limit', {
|
||||
maxRows: MAX_XLSX_ROWS_PER_SHEET.toLocaleString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const DataExportDialogContent: React.FC<{
|
||||
scopeOptions: DataExportScopeOption[];
|
||||
initialValues?: Partial<DataExportDialogValues>;
|
||||
onChange: (values: DataExportDialogValues) => void;
|
||||
}> = ({ scopeOptions, initialValues, onChange }) => {
|
||||
const [values, setValues] = useState<DataExportDialogValues>(() => normalizeDialogValues(scopeOptions, initialValues));
|
||||
|
||||
useEffect(() => {
|
||||
onChange(values);
|
||||
}, [onChange, values]);
|
||||
|
||||
const selectedScope = useMemo(
|
||||
() => scopeOptions.find((item) => String(item.value) === String(values.scope)),
|
||||
[scopeOptions, values.scope],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-export-config-modal="true">
|
||||
<Form layout="vertical" colon={false}>
|
||||
<Form.Item label={t('data_export.dialog.field.format')} style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
value={values.format}
|
||||
options={DATA_EXPORT_FORMAT_OPTIONS}
|
||||
onChange={(format) => setValues((prev) => ({ ...prev, format: format as DataExportFormat }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('data_export.dialog.field.scope')} style={{ marginBottom: 8 }}>
|
||||
<Select
|
||||
value={values.scope}
|
||||
disabled={scopeOptions.length <= 1}
|
||||
options={scopeOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.label,
|
||||
disabled: item.disabled,
|
||||
}))}
|
||||
onChange={(scope) => setValues((prev) => ({ ...prev, scope }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{selectedScope?.description && (
|
||||
<div style={{ marginBottom: 16, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
|
||||
{selectedScope.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{values.format === 'xlsx' && (
|
||||
<Form.Item
|
||||
label={t('data_export.dialog.field.xlsx_max_rows')}
|
||||
extra={t('data_export.dialog.field.xlsx_max_rows_help', {
|
||||
maxRows: MAX_XLSX_ROWS_PER_SHEET.toLocaleString(),
|
||||
})}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={MAX_XLSX_ROWS_PER_SHEET}
|
||||
step={100000}
|
||||
style={{ width: '100%' }}
|
||||
value={values.xlsxMaxRowsPerSheet}
|
||||
onChange={(nextValue) => setValues((prev) => ({
|
||||
...prev,
|
||||
xlsxMaxRowsPerSheet: Number(nextValue) > 0
|
||||
? Math.min(MAX_XLSX_ROWS_PER_SHEET, Math.trunc(Number(nextValue)))
|
||||
: 0,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export async function showDataExportDialog(
|
||||
modal: ReturnType<typeof Modal.useModal>[0],
|
||||
options: ShowDataExportDialogOptions,
|
||||
): Promise<DataExportDialogValues | null> {
|
||||
const initialValues = normalizeDialogValues(options.scopeOptions, options.initialValues);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let latestValues = initialValues;
|
||||
|
||||
const finish = (nextValue: DataExportDialogValues | null) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
resolve(nextValue);
|
||||
};
|
||||
|
||||
modal.confirm({
|
||||
title: options.title,
|
||||
icon: <ExportOutlined />,
|
||||
width: 520,
|
||||
centered: true,
|
||||
maskClosable: true,
|
||||
okText: options.okText || t('data_export.dialog.action.start'),
|
||||
cancelText: t('common.cancel'),
|
||||
content: (
|
||||
<DataExportDialogContent
|
||||
scopeOptions={options.scopeOptions}
|
||||
initialValues={initialValues}
|
||||
onChange={(values) => {
|
||||
latestValues = values;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
onOk: async () => {
|
||||
const errorMessage = validateDialogValues(latestValues, options.scopeOptions);
|
||||
if (errorMessage) {
|
||||
void message.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
finish(latestValues);
|
||||
},
|
||||
onCancel: () => {
|
||||
finish(null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
65
frontend/src/components/DataExportFlow.i18n.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const componentFiles = [
|
||||
'./DataExportDialog.tsx',
|
||||
'./ExportProgressModal.tsx',
|
||||
'./TableExportWorkbench.tsx',
|
||||
'./useExportProgressRunner.ts',
|
||||
'../utils/tableExportTab.ts',
|
||||
] as const;
|
||||
|
||||
const localeFiles = [
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
'en-US',
|
||||
'ja-JP',
|
||||
'de-DE',
|
||||
'ru-RU',
|
||||
] as const;
|
||||
|
||||
const sources = componentFiles.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8'));
|
||||
const combinedSource = sources.join('\n');
|
||||
const catalogs = Object.fromEntries(localeFiles.map((locale) => [
|
||||
locale,
|
||||
JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>,
|
||||
])) as Record<typeof localeFiles[number], Record<string, string>>;
|
||||
|
||||
const extractKeys = (source: string): string[] => (
|
||||
Array.from(new Set(source.match(/data_export(?:\.[a-z0-9_]+)+/g) || [])).sort()
|
||||
);
|
||||
|
||||
const placeholdersOf = (value: string): string[] => (
|
||||
Array.from(value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g), (match) => match[1]).sort()
|
||||
);
|
||||
|
||||
describe('data export i18n', () => {
|
||||
it('routes dialog, progress modal, and workbench copy through translation keys instead of inline Han literals', () => {
|
||||
expect(sources[0]).toContain("t('data_export.dialog.field.format')");
|
||||
expect(sources[1]).toContain("t('data_export.progress.title.error')");
|
||||
expect(sources[2]).toContain("t('data_export.workbench.title')");
|
||||
expect(sources[3]).toContain("t('data_export.progress.title.done')");
|
||||
expect(sources[3]).toContain("t('data_export.progress.title.error')");
|
||||
expect(sources[4]).toContain("t('data_export.workbench.scope.all.label')");
|
||||
expect(sources[4]).toContain("t('data_export.workbench.scope.all.description')");
|
||||
expect(sources[4]).toContain("t('data_export.progress.value.target_fallback')");
|
||||
expect(sources[4]).toContain("t('data_export.workbench.task.export_target'");
|
||||
expect(combinedSource).not.toMatch(/\p{Script=Han}/u);
|
||||
});
|
||||
|
||||
it('keeps all extracted data_export keys present in every supported locale with matching placeholders', () => {
|
||||
const keys = extractKeys(combinedSource);
|
||||
const baseline = catalogs['zh-CN'];
|
||||
|
||||
expect(keys.length).toBeGreaterThan(0);
|
||||
|
||||
keys.forEach((key) => {
|
||||
expect(baseline, `zh-CN:${key}`).toHaveProperty(key);
|
||||
const expectedPlaceholders = placeholdersOf(baseline[key]);
|
||||
localeFiles.forEach((locale) => {
|
||||
expect(catalogs[locale], `${locale}:${key}`).toHaveProperty(key);
|
||||
expect(placeholdersOf(catalogs[locale][key]), `${locale}:${key}`).toEqual(expectedPlaceholders);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid auto commit delay i18n guards', () => {
|
||||
it('localizes auto commit delay option labels', () => {
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.toolbar.commit_delay.seconds', { seconds: item.seconds })");
|
||||
|
||||
[
|
||||
"label: '3 秒'",
|
||||
"label: '5 秒'",
|
||||
"label: '10 秒'",
|
||||
"label: '30 秒'",
|
||||
].forEach((legacyText) => {
|
||||
expect(dataGridSource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
frontend/src/components/DataGrid.auto-commit.i18n.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid auto commit i18n guards', () => {
|
||||
it('localizes auto commit toast wrappers while preserving raw details', () => {
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_success')");
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.message.auto_commit_failed', { detail: res.message })");
|
||||
|
||||
expect(dataGridSource).not.toContain("'自动提交成功'");
|
||||
expect(dataGridSource).not.toContain('`自动提交失败: ${res.message}`');
|
||||
});
|
||||
});
|
||||
17
frontend/src/components/DataGrid.cell-undo-menu.i18n.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const legacyMenuSource = readFileSync(new URL('./DataGridLegacyCellContextMenu.tsx', import.meta.url), 'utf8');
|
||||
const v2MenuSource = readFileSync(new URL('./V2TableContextMenu.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid cell undo menu i18n guards', () => {
|
||||
it('localizes cell undo action labels in legacy and v2 menus', () => {
|
||||
[
|
||||
legacyMenuSource,
|
||||
v2MenuSource,
|
||||
].forEach((source) => {
|
||||
expect(source).toContain("data_grid.context_menu.undo_cell_change");
|
||||
expect(source).not.toContain('撤销此单元格修改');
|
||||
});
|
||||
});
|
||||
});
|
||||
24
frontend/src/components/DataGrid.cell-undo.i18n.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid cell undo i18n guards', () => {
|
||||
it('localizes cell undo toast wrappers', () => {
|
||||
[
|
||||
"translateDataGrid('data_grid.message.undo_added_row_hint')",
|
||||
"translateDataGrid('data_grid.message.undo_cell_original_missing')",
|
||||
"translateDataGrid('data_grid.message.undo_cell_success')",
|
||||
].forEach((expected) => {
|
||||
expect(dataGridSource).toContain(expected);
|
||||
});
|
||||
|
||||
[
|
||||
'新增行请使用删除选中或整表回滚撤销',
|
||||
'未找到该单元格的原始数据,无法撤销',
|
||||
'已撤销单元格修改',
|
||||
].forEach((legacyText) => {
|
||||
expect(dataGridSource).not.toContain(legacyText);
|
||||
});
|
||||
});
|
||||
});
|
||||
2578
frontend/src/components/DataGrid.ddl.test.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const dataGridSource = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid embedded designer title i18n guards', () => {
|
||||
it('localizes the embedded table designer tab title while preserving the raw table name parameter', () => {
|
||||
expect(dataGridSource).toContain("translateDataGrid('data_grid.embedded_designer.title'");
|
||||
expect(dataGridSource).toContain('tableName: tableName ||');
|
||||
expect(dataGridSource).not.toContain('title: `设计表 (${tableName || \'\'}');
|
||||
});
|
||||
|
||||
it('keeps the embedded designer title key in every locale catalog with the tableName placeholder', () => {
|
||||
(['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const).forEach((locale) => {
|
||||
const catalog = JSON.parse(
|
||||
readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'),
|
||||
) as Record<string, string>;
|
||||
|
||||
expect(catalog['data_grid.embedded_designer.title']).toEqual(expect.any(String));
|
||||
expect(catalog['data_grid.embedded_designer.title']).toContain('{{tableName}}');
|
||||
});
|
||||
});
|
||||
});
|
||||
2597
frontend/src/components/DataGrid.layout.test.tsx
Normal file
20
frontend/src/components/DataGrid.row-number.i18n.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const source = readFileSync(new URL('./DataGrid.tsx', import.meta.url), 'utf8');
|
||||
const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
|
||||
const rowNumberAriaKey = 'data_grid.aria.row_number';
|
||||
|
||||
describe('DataGrid row number i18n', () => {
|
||||
it('localizes the row number column aria label', () => {
|
||||
expect(source).toContain(`aria-label={translateDataGrid('${rowNumberAriaKey}')}`);
|
||||
expect(source).not.toContain('aria-label="行号"');
|
||||
});
|
||||
|
||||
it('keeps the row number aria label available in every locale', () => {
|
||||
locales.forEach((locale) => {
|
||||
const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record<string, string>;
|
||||
expect(catalog[rowNumberAriaKey], `${locale}:${rowNumberAriaKey}`).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const secondaryActionsSource = readFileSync(new URL('./DataGridSecondaryActions.tsx', import.meta.url), 'utf8');
|
||||
|
||||
describe('DataGrid secondary actions i18n guards', () => {
|
||||
it('localizes the object design action label', () => {
|
||||
expect(secondaryActionsSource).toContain("translate('data_grid.secondary.object_design')");
|
||||
expect(secondaryActionsSource).not.toContain("'对象设计'");
|
||||
expect(secondaryActionsSource).not.toContain('>对象设计<');
|
||||
});
|
||||
});
|
||||