Compare commits
851
Commits
699387dbe9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e7ccc1ba4 | ||
|
|
ec5dfcd82f | ||
|
|
7a59c65252 | ||
|
|
5d3358d844 | ||
|
|
eceddb5f7d | ||
|
|
376827ea97 | ||
|
|
024ecddfed | ||
|
|
51d5b8a2a1 | ||
|
|
374de05059 | ||
|
|
ee65fc7fd2 | ||
|
|
a254cbd3e9 | ||
|
|
f3dd1ec3b1 | ||
|
|
7fe26d3fff | ||
|
|
30ceab4615 | ||
|
|
d391b0fd00 | ||
|
|
304c59c8f3 | ||
|
|
b497eb4a59 | ||
|
|
561378312f | ||
|
|
a061ec86ce | ||
|
|
49f1212e33 | ||
|
|
a5386f72c6 | ||
|
|
ec582ac9f9 | ||
|
|
4f9347b6aa | ||
|
|
305896796c | ||
|
|
5afa7ab9df | ||
|
|
c2733017fd | ||
|
|
44a03fd5f4 | ||
|
|
beb898b1ab | ||
|
|
330df53195 | ||
|
|
8bb4b98fc9 | ||
|
|
4cd296c066 | ||
|
|
f97fd72bd0 | ||
|
|
ac84e991d1 | ||
|
|
0e4e4670dd | ||
|
|
c5bdbc0457 | ||
|
|
3e48fdf61e | ||
|
|
1970db0b56 | ||
|
|
5f31c58df2 | ||
|
|
4a119358ca | ||
|
|
6928127bfa | ||
|
|
7885f42cff | ||
|
|
7c33482be7 | ||
|
|
7bdf7cf018 | ||
|
|
384d2b7aa0 | ||
|
|
4868de2cda | ||
|
|
9e8913ea0a | ||
|
|
f5e9420266 | ||
|
|
79909f449e | ||
|
|
d07cffe99e | ||
|
|
2c7262770b | ||
|
|
fac4a714e0 | ||
|
|
7a8279f8e7 | ||
|
|
fbb53c6890 | ||
|
|
7faa2d53d8 | ||
|
|
b124795d84 | ||
|
|
f53cdd4dc8 | ||
|
|
4c562428b4 | ||
|
|
ef7b5939ba | ||
|
|
6bcfbfb252 | ||
|
|
1dfcc38a83 | ||
|
|
8e74b60570 | ||
|
|
3ce5bfc20c | ||
|
|
b56b9bbe37 | ||
|
|
ed38dec314 | ||
|
|
3595935475 | ||
|
|
e6d9ff49a2 | ||
|
|
55423403a6 | ||
|
|
8c4aabb9a4 | ||
|
|
dcd306b428 | ||
|
|
bcba0f598a | ||
|
|
b1c3d86e44 | ||
|
|
6db34646ed | ||
|
|
6f29e3d865 | ||
|
|
7d78a33a9d | ||
|
|
6d01e7489a | ||
|
|
09b1e535e4 | ||
|
|
00ae62f62b | ||
|
|
41366e7fc4 | ||
|
|
d40906b4a3 | ||
|
|
8ad5ef9aff | ||
|
|
ba32e0ef9c | ||
|
|
02246c7ec8 | ||
|
|
6c3c0f7c6d | ||
|
|
451b04160b | ||
|
|
0007e78bfe | ||
|
|
f6fde18857 | ||
|
|
f14e6bd88d | ||
|
|
93e83e1835 | ||
|
|
3c07ef7a8d | ||
|
|
f80ca1e27d | ||
|
|
1549d5b25d | ||
|
|
030baa511e | ||
|
|
e30de6562c | ||
|
|
39aed0d430 | ||
|
|
427968a721 | ||
|
|
42cc64106d | ||
|
|
35bb96e82f | ||
|
|
5b16bc6e15 | ||
|
|
4e62b325d2 | ||
|
|
5c6b422866 | ||
|
|
1c95f94bca | ||
|
|
324bcfdadc | ||
|
|
df12f51199 | ||
|
|
4740078135 | ||
|
|
9c70d7929a | ||
|
|
869d437485 | ||
|
|
9805a371d8 | ||
|
|
d98f8d7975 | ||
|
|
1d6bc8f7cc | ||
|
|
b25ebc5fff | ||
|
|
1273a0138e | ||
|
|
4f6e17ba5e | ||
|
|
92a3c2b8c7 | ||
|
|
dc6dbc05d8 | ||
|
|
4083613dbe | ||
|
|
ed526709c8 | ||
|
|
b9000e1b93 | ||
|
|
54cc5301c3 | ||
|
|
d86a85e6a6 | ||
|
|
48e54dc115 | ||
|
|
b32b56f8bc | ||
|
|
8c553c7221 | ||
|
|
7b65b3c6d8 | ||
|
|
a9709377bb | ||
|
|
d7616b193e | ||
|
|
09f3a94cea | ||
|
|
7f1a0dbeb4 | ||
|
|
264015b7e9 | ||
|
|
b45fd161f0 | ||
|
|
b2811cc248 | ||
|
|
80aecab586 | ||
|
|
59ddf677b6 | ||
|
|
32ac2112b0 | ||
|
|
f9b7997ec2 | ||
|
|
bc26d62602 | ||
|
|
5732be847e | ||
|
|
a6776ffe0a | ||
|
|
1cd19434d1 | ||
|
|
f208424a26 | ||
|
|
479b324f95 | ||
|
|
1902ba1e87 | ||
|
|
fe3ef82718 | ||
|
|
6195508481 | ||
|
|
8f1bbf2411 | ||
|
|
117da19f16 | ||
|
|
1be52d75a0 | ||
|
|
18951b01d6 | ||
|
|
aa692f3c9f | ||
|
|
05c7ade3f5 | ||
|
|
7650c3537a | ||
|
|
a579a5b00a | ||
|
|
ea80cd96cc | ||
|
|
fc04bf65e6 | ||
|
|
4ea00f1322 | ||
|
|
4cdde50695 | ||
|
|
542daca457 | ||
|
|
4c13ef478c | ||
|
|
985f18c750 | ||
|
|
fd034ff055 | ||
|
|
17515cf9c5 | ||
|
|
8aa116ec43 | ||
|
|
b520d29368 | ||
|
|
f019760297 | ||
|
|
9b27508a5b | ||
|
|
1c501ff713 | ||
|
|
8e9d3a7e49 | ||
|
|
1df2822f01 | ||
|
|
b340b77d5a | ||
|
|
c3328e7279 | ||
|
|
d9d1122e9c | ||
|
|
7e86f2a821 | ||
|
|
b7a910b758 | ||
|
|
54ea83b262 | ||
|
|
794d981260 | ||
|
|
52749bf232 | ||
|
|
72e55d95fd | ||
|
|
293e1121c5 | ||
|
|
5d83a81b31 | ||
|
|
cfe806728d | ||
|
|
c152e00501 | ||
|
|
6ded62df92 | ||
|
|
9fbfab54c2 | ||
|
|
5dd7d14334 | ||
|
|
fcec5b996f | ||
|
|
ed1487d505 | ||
|
|
57ff405691 | ||
|
|
74021d26bb | ||
|
|
6da08d9d4f | ||
|
|
32172582ff | ||
|
|
7d61de70e3 | ||
|
|
fa89f43ac9 | ||
|
|
3179754670 | ||
|
|
116b6b2223 | ||
|
|
8843ae4261 | ||
|
|
135fb4eaa4 | ||
|
|
8d871c0828 | ||
|
|
174fbb54c5 | ||
|
|
63ab8b8fc9 | ||
|
|
6681b0c306 | ||
|
|
bbb41547e2 | ||
|
|
d71528f500 | ||
|
|
5febdb35c8 | ||
|
|
ac3ed9c956 | ||
|
|
a308f96837 | ||
|
|
13bde11dda | ||
|
|
b74381cfa2 | ||
|
|
b927b9ec7c | ||
|
|
3bd15feccb | ||
|
|
af4fb87e6d | ||
|
|
1a88498d57 | ||
|
|
d53d86332a | ||
|
|
44fa34fdff | ||
|
|
e7ca0ee5df | ||
|
|
d32bdc0581 | ||
|
|
ffd8d7d1b7 | ||
|
|
dbb7de41e9 | ||
|
|
761c84d70f | ||
|
|
81b60eb427 | ||
|
|
ef15416ef7 | ||
|
|
0cfd4c898b | ||
|
|
9a22c7eb8b | ||
|
|
de0e5cd481 | ||
|
|
a120f61e13 | ||
|
|
0d51beeaad | ||
|
|
573040381d | ||
|
|
7a0445fc4a | ||
|
|
0bc72616e5 | ||
|
|
064f4e0955 | ||
|
|
4b5098a753 | ||
|
|
05f16d5fd7 | ||
|
|
af8dccdc45 | ||
|
|
dcb80cff6e | ||
|
|
2e2558e5ce | ||
|
|
817ac9c647 | ||
|
|
e94ecdd7b1 | ||
|
|
659b77719c | ||
|
|
7f556892ad | ||
|
|
cb76b95b01 | ||
|
|
319ec2f47f | ||
|
|
4b18f7ef2c | ||
|
|
1461c9fe27 | ||
|
|
5641c40958 | ||
|
|
0bee2c605e | ||
|
|
c472045ce4 | ||
|
|
6dc9265260 | ||
|
|
008e63524d | ||
|
|
cff3083a91 | ||
|
|
4d69aaee50 | ||
|
|
ee39024549 | ||
|
|
5810693e01 | ||
|
|
0722c4f00e | ||
|
|
4f2edebab6 | ||
|
|
ce6fbb4e39 | ||
|
|
cf39e72126 | ||
|
|
740bd48f42 | ||
|
|
18e579a8c6 | ||
|
|
cdab7a5e5f | ||
|
|
bd01199930 | ||
|
|
7ed739615e | ||
|
|
60e4d67772 | ||
|
|
7287cfeaec | ||
|
|
f03d72c880 | ||
|
|
6e90a96a15 | ||
|
|
b368678662 | ||
|
|
40b9a3f36b | ||
|
|
6fd80be243 | ||
|
|
855e85e7db | ||
|
|
7e7bf6db23 | ||
|
|
6f815678ec | ||
|
|
8ff74f56ee | ||
|
|
a3b708f9d4 | ||
|
|
9cb5e7bea3 | ||
|
|
16b57dcf1c | ||
|
|
1875470ac7 | ||
|
|
c955fd35f2 | ||
|
|
36b3ba6f45 | ||
|
|
039fa6d863 | ||
|
|
26ce9a5904 | ||
|
|
31e6ba00af | ||
|
|
89ae462610 | ||
|
|
afd08885fc | ||
|
|
536ac67f3c | ||
|
|
7b83893cf6 | ||
|
|
83c164a427 | ||
|
|
5fd8c6fc31 | ||
|
|
b733e41993 | ||
|
|
88669f6989 | ||
|
|
80373746f8 | ||
|
|
d2034f5031 | ||
|
|
ad2290cbb7 | ||
|
|
c870f08395 | ||
|
|
80fb4068d1 | ||
|
|
01cd734989 | ||
|
|
f44180d06c | ||
|
|
c7d18d426c | ||
|
|
0ec405f566 | ||
|
|
dd320c99c1 | ||
|
|
fc75d7b810 | ||
|
|
b3012b3133 | ||
|
|
ab6c2339a0 | ||
|
|
3eb9cc739c | ||
|
|
e9a1a75259 | ||
|
|
d69e1d33c3 | ||
|
|
8023e2e472 | ||
|
|
918ca371be | ||
|
|
9e3267fef4 | ||
|
|
6081e8dcc9 | ||
|
|
c801ff5047 | ||
|
|
8460132feb | ||
|
|
732f615a09 | ||
|
|
738abe5102 | ||
|
|
915311dcb6 | ||
|
|
c200427358 | ||
|
|
7b5dcf16a1 | ||
|
|
2db2414270 | ||
|
|
365b2eafe5 | ||
|
|
ea88499996 | ||
|
|
f4ff3e16d4 | ||
|
|
c67d5ae4bc | ||
|
|
00a087b1a4 | ||
|
|
a0f59826fe | ||
|
|
e7e281f10c | ||
|
|
93579cbc9f | ||
|
|
4ea02f25ac | ||
|
|
1a9a2762f3 | ||
|
|
b447c41f13 | ||
|
|
8e1fd0a30f | ||
|
|
cf6dc24193 | ||
|
|
c8fdd81d6a | ||
|
|
27868b6131 | ||
|
|
ef718042d2 | ||
|
|
194280af72 | ||
|
|
50fac36169 | ||
|
|
2595bb02db | ||
|
|
fe57022550 | ||
|
|
962ab5c290 | ||
|
|
6dbe1fe4c6 | ||
|
|
12f60897d9 | ||
|
|
b75bf197a3 | ||
|
|
613b8a57a3 | ||
|
|
af72364078 | ||
|
|
224f86ec08 | ||
|
|
fc9bf27560 | ||
|
|
d8d0b5f290 | ||
|
|
ce429e1caf | ||
|
|
248912f610 | ||
|
|
f646fa8573 | ||
|
|
0cf4639d95 | ||
|
|
0a52bf36f9 | ||
|
|
346146b4fa | ||
|
|
f00c3faeaa | ||
|
|
59e9bbf8a6 | ||
|
|
1af51ab172 | ||
|
|
7b55cfb190 | ||
|
|
476e2f961d | ||
|
|
979639342a | ||
|
|
356f7fe5bd | ||
|
|
6037ca184b | ||
|
|
8ef2f7e49f | ||
|
|
5091da6a60 | ||
|
|
d0a3972ca7 | ||
|
|
2ae2b2fd07 | ||
|
|
90c1f7a62b | ||
|
|
8fcb136d55 | ||
|
|
d6e3b377b4 | ||
|
|
2b3e325d63 | ||
|
|
4caf81c9e2 | ||
|
|
88661e1574 | ||
|
|
67ec3c8aeb | ||
|
|
59901b9e10 | ||
|
|
5a17b399a0 | ||
|
|
4c6b02a6b9 | ||
|
|
56bbb63afd | ||
|
|
00817766a6 | ||
|
|
16d67c8a87 | ||
|
|
45dc909e4c | ||
|
|
20fde1d652 | ||
|
|
38bbd917a9 | ||
|
|
f61142ee9a | ||
|
|
cf4554e7c8 | ||
|
|
271e7f0518 | ||
|
|
b5f37d6d46 | ||
|
|
e506309434 | ||
|
|
89a9cf24e9 | ||
|
|
7fc1ac98de | ||
|
|
fa53a0f4f9 | ||
|
|
0374de3fec | ||
|
|
401d130f03 | ||
|
|
61667aa03c | ||
|
|
cded20ae02 | ||
|
|
f9ec4964f7 | ||
|
|
1355a9244f | ||
|
|
ea16f9c2fe | ||
|
|
ae793414be | ||
|
|
7761435a81 | ||
|
|
8d16ce6e02 | ||
|
|
1ce8e263c3 | ||
|
|
a366898f65 | ||
|
|
bd0abf891c | ||
|
|
cf6c11852e | ||
|
|
26ac8167f3 | ||
|
|
47bf9c8f0b | ||
|
|
f97614433c | ||
|
|
d6bc1067f9 | ||
|
|
eb14c4afd3 | ||
|
|
6f2adfaef1 | ||
|
|
7f60071460 | ||
|
|
63b5edd901 | ||
|
|
bd3d50c6c6 | ||
|
|
592bfb4fa2 | ||
|
|
b94c820361 | ||
|
|
ab495854d5 | ||
|
|
bc97c4ddc3 | ||
|
|
d49fd9f6a4 | ||
|
|
800baca0ca | ||
|
|
1c1d3f17b7 | ||
|
|
66f8bb933d | ||
|
|
a8060fa71d | ||
|
|
4d6b2c6045 | ||
|
|
c331b2aeef | ||
|
|
264f2c51b2 | ||
|
|
ed71314986 | ||
|
|
bd7586a3b7 | ||
|
|
95cdad5c5d | ||
|
|
7172fd942d | ||
|
|
d4bb43a275 | ||
|
|
505d9156fe | ||
|
|
2801a89c1f | ||
|
|
b798c7a45d | ||
|
|
edb60d08b4 | ||
|
|
456d57b2de | ||
|
|
9a40ad18c7 | ||
|
|
bbd5e97bac | ||
|
|
10c63e02d5 | ||
|
|
c315297d7f | ||
|
|
5201f2ab6f | ||
|
|
7a3f5d7c34 | ||
|
|
a4af024f32 | ||
|
|
11eda33e58 | ||
|
|
f9bc0f69c4 | ||
|
|
7f520803ff | ||
|
|
8d9cca2fe5 | ||
|
|
a5f0484466 | ||
|
|
3dd3f71c97 | ||
|
|
20387fdb06 | ||
|
|
2618530afb | ||
|
|
0afead968a | ||
|
|
7597972eea | ||
|
|
a1eb545bef | ||
|
|
0df6e760e3 | ||
|
|
c38616b290 | ||
|
|
970b377b9a | ||
|
|
03c2f1f84a | ||
|
|
fe6b4da455 | ||
|
|
c4531b9757 | ||
|
|
0a958644b8 | ||
|
|
09cff17d72 | ||
|
|
c7384b10dd | ||
|
|
c7a552dfd5 | ||
|
|
044830b58a | ||
|
|
9cc9ff6d52 | ||
|
|
3d4179a756 | ||
|
|
67547fdf23 | ||
|
|
2882b83c21 | ||
|
|
7a80297a56 | ||
|
|
913958820c | ||
|
|
7c730b83d6 | ||
|
|
381256ed59 | ||
|
|
c0e0912eee | ||
|
|
902e71ee62 | ||
|
|
3764d48755 | ||
|
|
835e0f983d | ||
|
|
4dafe96c4b | ||
|
|
3d5bd6fb8b | ||
|
|
ad714bb59c | ||
|
|
6fcd63b923 | ||
|
|
cd7082454a | ||
|
|
2f571c2a98 | ||
|
|
cd2b5f6c48 | ||
|
|
f3fd38dfd2 | ||
|
|
f7e0dbc1ce | ||
|
|
359a607010 | ||
|
|
4653128a18 | ||
|
|
c289016324 | ||
|
|
c4662acdf0 | ||
|
|
bb8161e1c4 | ||
|
|
effdac91fd | ||
|
|
dc6bf34262 | ||
|
|
ae626ba1c8 | ||
|
|
b375fcc55c | ||
|
|
32e76b440f | ||
|
|
0d617d88c9 | ||
|
|
ce21cc2b9b | ||
|
|
633ab7abe3 | ||
|
|
fa476af70e | ||
|
|
c1a0eade38 | ||
|
|
ca9dc7794f | ||
|
|
c06aadb9a9 | ||
|
|
3bec2a5777 | ||
|
|
a6819ec7a5 | ||
|
|
833f94bf3a | ||
|
|
aad83072a8 | ||
|
|
8b999444b2 | ||
|
|
95ff0761b7 | ||
|
|
d2447f8aa6 | ||
|
|
530a5b998f | ||
|
|
1abf9fe340 | ||
|
|
03871f0342 | ||
|
|
89801236eb | ||
|
|
be07db3409 | ||
|
|
e9dd928859 | ||
|
|
6075028520 | ||
|
|
ec1107c65a | ||
|
|
db0d63b99f | ||
|
|
b3b21df409 | ||
|
|
721fa29b8d | ||
|
|
e72d0710e6 | ||
|
|
b88eab0bfe | ||
|
|
74e43aaa76 | ||
|
|
a6b7d09706 | ||
|
|
95289829ee | ||
|
|
daae86e1af | ||
|
|
50065ccc3e | ||
|
|
1889c9f048 | ||
|
|
64ed47165d | ||
|
|
3481620ed0 | ||
|
|
ba8d4b1b90 | ||
|
|
c1c21faf7e | ||
|
|
481f40378a | ||
|
|
52d790c654 | ||
|
|
0e7b888277 | ||
|
|
1e5286d36b | ||
|
|
d787f3ff97 | ||
|
|
45c7a4a59c | ||
|
|
670e271ff6 | ||
|
|
5243bbf0d4 | ||
|
|
1cbc0c6ed8 | ||
|
|
537fb97f48 | ||
|
|
9fffe7b590 | ||
|
|
282a12a90b | ||
|
|
cb84c88792 | ||
|
|
ef9409cee6 | ||
|
|
678143e7b9 | ||
|
|
ba1c8c2822 | ||
|
|
2ef02e2865 | ||
|
|
ff11f3b156 | ||
|
|
14f9534651 | ||
|
|
2c139e5c20 | ||
|
|
a48007a44d | ||
|
|
fb5cdf247d | ||
|
|
098df7f921 | ||
|
|
175d32f01e | ||
|
|
56d1b2a417 | ||
|
|
e7ffb44b45 | ||
|
|
bcba58f227 | ||
|
|
a26ef3fadd | ||
|
|
0871c8ea81 | ||
|
|
3cedcd1bc2 | ||
|
|
d4e33706cf | ||
|
|
2c4bd3eb2a | ||
|
|
6bbb79213b | ||
|
|
27f54c8e35 | ||
|
|
7efbbeed3f | ||
|
|
f00045502f | ||
|
|
0a4448dd70 | ||
|
|
9e9918b7f3 | ||
|
|
486fc34a4b | ||
|
|
1873018a2a | ||
|
|
632a7d15ba | ||
|
|
6f1860f5d0 | ||
|
|
129eb25078 | ||
|
|
3f02048bc5 | ||
|
|
abf3a4ae31 | ||
|
|
5c226237c0 | ||
|
|
af04fcfd3c | ||
|
|
5acd1e0e25 | ||
|
|
26afa6ea3a | ||
|
|
39bafb65c4 | ||
|
|
27d8a9ea71 | ||
|
|
2e25894eb5 | ||
|
|
70cba11fb7 | ||
|
|
d89752d543 | ||
|
|
7ab5f29f87 | ||
|
|
5e2cc7b56a | ||
|
|
2e9650d15f | ||
|
|
7cd39e278a | ||
|
|
e4745d501e | ||
|
|
d599bbccd9 | ||
|
|
5b5db0f8f5 | ||
|
|
009f9874c0 | ||
|
|
f629a4f373 | ||
|
|
9496db5171 | ||
|
|
d3eef19b95 | ||
|
|
7c0d5945ca | ||
|
|
6ffc5ba3d6 | ||
|
|
b7f5523874 | ||
|
|
d0c6c3e431 | ||
|
|
1a0b023658 | ||
|
|
daee4cd67f | ||
|
|
51a591e2b9 | ||
|
|
d84d485ec7 | ||
|
|
5fbcfa2b49 | ||
|
|
5a61e68a8c | ||
|
|
51dd38163d | ||
|
|
49ffdc6ab2 | ||
|
|
4a12de38cb | ||
|
|
d64c4cf1b4 | ||
|
|
d86147f406 | ||
|
|
9fa7190232 | ||
|
|
4f2d17f6ee | ||
|
|
05a6b6fc54 | ||
|
|
5b89fc200b | ||
|
|
620a625a72 | ||
|
|
1839150ecb | ||
|
|
2bf9b00022 | ||
|
|
4eb2ddef66 | ||
|
|
73e58517f6 | ||
|
|
bb37df19fc | ||
|
|
65a69b1dec | ||
|
|
cf264d5e81 | ||
|
|
d7950730dd | ||
|
|
274b888fd0 | ||
|
|
f0f908d9e1 | ||
|
|
ff7b1c547a | ||
|
|
0c4a0d18f4 | ||
|
|
5b7b904bde | ||
|
|
f5a7c7a833 | ||
|
|
6cbcca689d | ||
|
|
5ea70f0b03 | ||
|
|
b639d57c5b | ||
|
|
9dc278bd83 | ||
|
|
f3fcfe45da | ||
|
|
44e165d34e | ||
|
|
129432ae45 | ||
|
|
5916fca54d | ||
|
|
8af1b9ac4b | ||
|
|
7cea99d0ae | ||
|
|
05f40d7728 | ||
|
|
0d7d4fea61 | ||
|
|
dda343e398 | ||
|
|
13fda31e37 | ||
|
|
4833053a5c | ||
|
|
4f8ab94fac | ||
|
|
5b48be68da | ||
|
|
7308f055c7 | ||
|
|
a0adddce59 | ||
|
|
9d458dd99a | ||
|
|
03dd4ea707 | ||
|
|
5fd7c212ed | ||
|
|
1cd81fb61a | ||
|
|
9e6ee55de9 | ||
|
|
1f4a39d8f8 | ||
|
|
5984d4e42d | ||
|
|
94de0e7ddf | ||
|
|
ddf7c37556 | ||
|
|
0985d0cb0a | ||
|
|
ff4ec1eb43 | ||
|
|
d2e95acd75 | ||
|
|
bd9d415e89 | ||
|
|
2085a702b9 | ||
|
|
2a3e8bf392 | ||
|
|
183e725eb4 | ||
|
|
5740c26a6d | ||
|
|
8eb62e712b | ||
|
|
71444b139a | ||
|
|
a73fa9a6c0 | ||
|
|
4c62fda62b | ||
|
|
95540ea32c | ||
|
|
b161d6e5c8 | ||
|
|
65fbf97099 | ||
|
|
0e1b0ce86e | ||
|
|
b8ef4d81a4 | ||
|
|
ed914d2eec | ||
|
|
1b4928d69b | ||
|
|
780e1eba21 | ||
|
|
1d904e6532 | ||
|
|
54db1131dc | ||
|
|
af30159a03 | ||
|
|
88eb29c645 | ||
|
|
41da33bb13 | ||
|
|
e54228d509 | ||
|
|
f9e411dda8 | ||
|
|
50c5769d01 | ||
|
|
ad128ef94e | ||
|
|
17dabca568 | ||
|
|
c6e0423e46 | ||
|
|
02d456b00b | ||
|
|
fff1590a43 | ||
|
|
af3539914d | ||
|
|
b77926388f | ||
|
|
1d073ea293 | ||
|
|
6eb189ca88 | ||
|
|
90646134d4 | ||
|
|
f143a111cd | ||
|
|
8111b17392 | ||
|
|
fbada8bd6a | ||
|
|
02cbff1ddf | ||
|
|
5c7fa38da8 | ||
|
|
192e102275 | ||
|
|
5222349c85 | ||
|
|
ad26feaaee | ||
|
|
ee4e599f2d | ||
|
|
7c0c21e984 | ||
|
|
fb2bbb64c5 | ||
|
|
1f84e6aa18 | ||
|
|
bfc16de962 | ||
|
|
5121b33ad5 | ||
|
|
b80ac46147 | ||
|
|
2cb78edabb | ||
|
|
992af2119d | ||
|
|
562beb15cf | ||
|
|
ae10b3d3fd | ||
|
|
d37c34f71c | ||
|
|
2e7cc56043 | ||
|
|
d4b7ec1deb | ||
|
|
ed1a47cf4e | ||
|
|
065a5bc99c | ||
|
|
dc14c29878 | ||
|
|
90b3ccb006 | ||
|
|
9ad6f41592 | ||
|
|
523af723ae | ||
|
|
c6b1acfe0e | ||
|
|
411905f919 | ||
|
|
bb9413cba6 | ||
|
|
e877ceb560 | ||
|
|
c484ee6ef3 | ||
|
|
f5746da0ae | ||
|
|
ea83f2211c | ||
|
|
e41350a81b | ||
|
|
9b6088986a | ||
|
|
ff521c9ad6 | ||
|
|
3dbd196e06 | ||
|
|
c8d199a694 | ||
|
|
0109a59922 | ||
|
|
dbb1f776f2 | ||
|
|
8eb0079bc4 | ||
|
|
59a393bf27 | ||
|
|
11f1c23fa7 | ||
|
|
4a4009133c | ||
|
|
e5df6fdaa7 | ||
|
|
9381d1ff53 | ||
|
|
4c81980804 | ||
|
|
eef73ee9e7 | ||
|
|
8ddbfdd7b7 | ||
|
|
75d1e6e53b | ||
|
|
5549f81901 | ||
|
|
07c4e969c3 | ||
|
|
90fd59b647 | ||
|
|
815b938363 | ||
|
|
f5c60cd425 | ||
|
|
10b3e6dd9f | ||
|
|
6674bded6a | ||
|
|
9d3bfb91cd | ||
|
|
85a124b886 | ||
|
|
406fb8d6a6 | ||
|
|
58f3f42bf8 | ||
|
|
48a82403b2 | ||
|
|
5cae5b5c93 | ||
|
|
ca12226b39 | ||
|
|
86692ff3f9 | ||
|
|
081eb0870d | ||
|
|
5cbdaa81c1 | ||
|
|
d7634d1ff6 | ||
|
|
af52f9e94d | ||
|
|
73fcd4ed72 | ||
|
|
616beb8c03 | ||
|
|
c4512f2bc2 | ||
|
|
8d2b78a322 | ||
|
|
42288bb0b1 | ||
|
|
2c1f08deb1 | ||
|
|
0ffd045576 | ||
|
|
176a7d3091 | ||
|
|
bb9a410ad6 | ||
|
|
c3f66e9a14 | ||
|
|
84321801a6 | ||
|
|
c6c2c0af6d | ||
|
|
1c5c6d86bb | ||
|
|
32cda220f7 | ||
|
|
57b12dea80 | ||
|
|
54ee4b3bdd | ||
|
|
c9c1789aba | ||
|
|
a06d36ac7b | ||
|
|
5d8b93ac5b | ||
|
|
abd43417a9 | ||
|
|
0d86e12d69 | ||
|
|
c8339feddb | ||
|
|
c6dcf9687a | ||
|
|
31e185664a | ||
|
|
25bb0213d4 | ||
|
|
b169574b25 | ||
|
|
1741b6861a | ||
|
|
9bd9e99057 | ||
|
|
51d65c694b | ||
|
|
9375b1c30c | ||
|
|
4f2391fb64 | ||
|
|
608a1148c9 | ||
|
|
d35f0b6730 | ||
|
|
8d4f07656a | ||
|
|
4d6fd641e5 | ||
|
|
e1f176429b | ||
|
|
251fe86527 | ||
|
|
b39bcbc53c | ||
|
|
3368261d52 | ||
|
|
79b1c8cb38 | ||
|
|
e971c2b1da | ||
|
|
0b8d7cdab5 | ||
|
|
28d57f2b7c | ||
|
|
e73b83ed83 | ||
|
|
315aee1b12 | ||
|
|
3e6af7863d | ||
|
|
d69334b37d | ||
|
|
d27c0757f2 | ||
|
|
5f01b3b316 | ||
|
|
c4e162839c | ||
|
|
41a1b4f465 | ||
|
|
4d4baa9cec | ||
|
|
13ed02c93a | ||
|
|
3312323f6e | ||
|
|
93841211d3 | ||
|
|
4204983db7 | ||
|
|
304d0bde74 | ||
|
|
21c9634178 | ||
|
|
f7cedc7163 | ||
|
|
e2b5018fc1 | ||
|
|
4ea8d5f8e8 | ||
|
|
aa58fb58ff | ||
|
|
0375a11adc | ||
|
|
d1f700c3d1 | ||
|
|
d98a36b060 | ||
|
|
9c827ff493 | ||
|
|
029df27b1d | ||
|
|
3028ab6f0c | ||
|
|
886ea8732c | ||
|
|
a5ed4bbb6f | ||
|
|
d4a9b7fd98 | ||
|
|
8a738c7998 | ||
|
|
2e442e20ec | ||
|
|
55571648a0 | ||
|
|
1436232a34 | ||
|
|
480e2bf07e | ||
|
|
09b1e8fd35 | ||
|
|
893af71a0b | ||
|
|
a0b84977a2 | ||
|
|
3c3ddac8be | ||
|
|
0f2ac6c34a | ||
|
|
2881c69e9c | ||
|
|
95553086dd | ||
|
|
e429b59077 | ||
|
|
b556384025 | ||
|
|
5e2c143449 | ||
|
|
ed6d523d05 |
No files matched your search
@@ -0,0 +1,81 @@
|
|||||||
|
# 换行规范
|
||||||
|
#
|
||||||
|
# 背景:本仓库历史索引(index)内全部为 LF(`git ls-files --eol` 显示 i/lf),
|
||||||
|
# 而 Windows 工作区长期依赖 core.autocrlf=true 做 CRLF↔LF 转换。这种隐式转换
|
||||||
|
# 会让「工具以 LF 写盘」的改动在提交时产生大量换行噪声与 "LF will be replaced
|
||||||
|
# by CRLF" 警告。此处把规则显式化,使任何机器、任何 core.autocrlf 设置下行为一致。
|
||||||
|
#
|
||||||
|
# 注意:不要为已跟踪文件添加 `-text`。索引为 LF 而工作区为 CRLF 的文件一旦
|
||||||
|
# 被标记为 `-text`,git 会认为工作区内容与索引不一致,从而产生虚假改动。
|
||||||
|
|
||||||
|
# 默认:按内容自动识别是否为文本,文本一律在仓库内规范化为 LF。
|
||||||
|
* text=auto
|
||||||
|
|
||||||
|
# 源码与配置(文本)
|
||||||
|
#
|
||||||
|
# 这里必须同时给出 `eol=crlf`。只写 `* text=auto` 时,工作区的换行形态仍由每个
|
||||||
|
# 开发者的 core.autocrlf 决定——工具(编辑器、脚本)以 LF 写盘时不会报错,只会
|
||||||
|
# 在提交时冒出 "LF will be replaced by CRLF" 警告,也就是本仓库此前的漂移来源。
|
||||||
|
# 声明 `eol=crlf` 后,git 会把工作区自动纠正为 CRLF,漂移自愈。
|
||||||
|
# 索引侧仍然是 LF,因此不会产生任何既有文件的改写。
|
||||||
|
*.php text eol=crlf
|
||||||
|
*.css text eol=crlf
|
||||||
|
*.scss text eol=crlf
|
||||||
|
*.js text eol=crlf
|
||||||
|
*.cjs text eol=crlf
|
||||||
|
*.mjs text eol=crlf
|
||||||
|
*.vue text eol=crlf
|
||||||
|
*.json text eol=crlf
|
||||||
|
*.html text eol=crlf
|
||||||
|
*.svg text eol=crlf
|
||||||
|
*.txt text eol=crlf
|
||||||
|
*.md text eol=crlf
|
||||||
|
*.yml text eol=crlf
|
||||||
|
*.yaml text eol=crlf
|
||||||
|
*.xml text eol=crlf
|
||||||
|
*.dist text eol=crlf
|
||||||
|
*.htaccess text eol=crlf
|
||||||
|
|
||||||
|
# gettext 目录按 LF 维护,避免与 xgettext/msgmerge 的默认输出冲突。
|
||||||
|
*.pot text
|
||||||
|
*.po text
|
||||||
|
|
||||||
|
# 构建产物必须声明为 LF。
|
||||||
|
#
|
||||||
|
# esbuild 恒定输出 LF,而上面的 `*.css eol=crlf` 会让工作区期待 CRLF,
|
||||||
|
# 于是每次执行 tools/build-public-assets.mjs 都会重新引入换行漂移。
|
||||||
|
# 这些路径必须放在上面各条之后:.gitattributes 以最后匹配的规则为准。
|
||||||
|
/assets/css/build/** text eol=lf
|
||||||
|
/assets/css/main.min.css text eol=lf
|
||||||
|
/assets/js/runtime/*.min.js text eol=lf
|
||||||
|
/assets/js/build/** text eol=lf
|
||||||
|
|
||||||
|
# 二进制资源(不做换行转换,也不做 diff)
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.webp binary
|
||||||
|
*.avif binary
|
||||||
|
*.ico binary
|
||||||
|
*.bmp binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.otf binary
|
||||||
|
*.eot binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
|
*.tgz binary
|
||||||
|
*.tar binary
|
||||||
|
*.pdf binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.mp4 binary
|
||||||
|
*.webm binary
|
||||||
|
*.psd binary
|
||||||
|
*.ai binary
|
||||||
|
*.sketch binary
|
||||||
|
|
||||||
|
# 这些文件本身必须是 LF,否则 git 解析 `.gitattributes` 时可能把 CR 读进模式串。
|
||||||
|
.gitattributes text eol=lf
|
||||||
|
.gitignore text eol=lf
|
||||||
+20
-20
@@ -2,25 +2,19 @@
|
|||||||
/.git/
|
/.git/
|
||||||
/.claude/
|
/.claude/
|
||||||
/.compare/
|
/.compare/
|
||||||
|
/.zcode/
|
||||||
|
/.mimosa/
|
||||||
|
/.workbuddy/
|
||||||
|
|
||||||
# Project planning, internal docs, and local-only development files
|
# Project planning, internal docs, and local-only development files
|
||||||
|
/.jio-chrome-profile/
|
||||||
/.specs/
|
/.specs/
|
||||||
/docs/
|
/docs/
|
||||||
/tools/*
|
|
||||||
/tests/*
|
|
||||||
!/tools/wp-cli-visual-fixtures.php
|
|
||||||
!/tests/fixtures/
|
|
||||||
!/tests/visual/
|
|
||||||
/tests/fixtures/*
|
|
||||||
!/tests/fixtures/page-visual-matrix-v1.json
|
|
||||||
!/tests/widget-system/
|
|
||||||
/tests/widget-system/*
|
|
||||||
!/tests/widget-system/post-visual-contract.test.cjs
|
|
||||||
!/tests/widget-system/lazyload-contract.test.cjs
|
|
||||||
!/tests/widget-system/toast-contract.test.cjs
|
|
||||||
!/tests/visual/docker-compose.zibll.yml
|
|
||||||
!/tests/visual/zibll-Caddyfile
|
|
||||||
!/tests/visual/README.md
|
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/AGENTS.md
|
/AGENTS.md
|
||||||
ask-*.md
|
ask-*.md
|
||||||
@@ -28,19 +22,25 @@ ask-*.md
|
|||||||
assets/css/routes/docs/*
|
assets/css/routes/docs/*
|
||||||
!assets/css/routes/docs/toolbar-search-enhancements.css
|
!assets/css/routes/docs/toolbar-search-enhancements.css
|
||||||
|
|
||||||
# Frontend build output and installed dependencies
|
# Frontend build output and installed dependencies.
|
||||||
/assets/admin/build/*
|
# assets/admin/build and assets/creator-studio/build are deploy-only:
|
||||||
!/assets/admin/build/.gitkeep
|
# build them on the target with
|
||||||
/assets/admin/node_modules/*
|
# cd assets/admin && npm ci && npm run build
|
||||||
!/assets/admin/node_modules/.gitkeep
|
# cd assets/creator-studio && npm ci && npm run build
|
||||||
|
/assets/admin/node_modules/
|
||||||
|
/assets/admin/build/
|
||||||
assets/admin/.vite-temp/
|
assets/admin/.vite-temp/
|
||||||
|
|
||||||
|
/assets/creator-studio/node_modules/
|
||||||
|
/assets/creator-studio/build/
|
||||||
|
|
||||||
# PHP installed dependencies
|
# PHP installed dependencies
|
||||||
/vendor/*
|
/vendor/*
|
||||||
!/vendor/.gitkeep
|
!/vendor/.gitkeep
|
||||||
|
|
||||||
# Temporary / recovery helpers
|
# Temporary / recovery helpers
|
||||||
restore-helper-monoliths.php
|
restore-helper-monoliths.php
|
||||||
|
/.phpunit.cache/
|
||||||
|
|
||||||
# OS / editor noise
|
# OS / editor noise
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -2,22 +2,15 @@
|
|||||||
/**
|
/**
|
||||||
* 404 template.
|
* 404 template.
|
||||||
*
|
*
|
||||||
* @package AKSRT
|
* @package Jio
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
get_header();
|
get_header();
|
||||||
|
|
||||||
$other_page_settings = class_exists('\AKSRT\Settings\Settings')
|
$search_placeholder = function_exists('jio_get_search_placeholder') ? jio_get_search_placeholder() : __('开启精彩搜索', 'jio');
|
||||||
? \AKSRT\Settings\Settings::get_other_page_settings()
|
$hot_keywords = function_exists('jio_get_search_hot_keywords') ? jio_get_search_hot_keywords() : [];
|
||||||
: [
|
|
||||||
'page_404_message' => __('页面未找到', 'aksrt'),
|
|
||||||
'page_404_search' => true,
|
|
||||||
'page_404_popular' => true,
|
|
||||||
];
|
|
||||||
$search_placeholder = function_exists('aksrt_get_search_placeholder') ? aksrt_get_search_placeholder() : __('开启精彩搜索', 'aksrt');
|
|
||||||
$hot_keywords = function_exists('aksrt_get_search_hot_keywords') ? aksrt_get_search_hot_keywords() : [];
|
|
||||||
|
|
||||||
$hot_query = new WP_Query([
|
$hot_query = new WP_Query([
|
||||||
'post_type' => 'post',
|
'post_type' => 'post',
|
||||||
@@ -43,38 +36,36 @@ $top_cats = get_terms([
|
|||||||
<div class="term-page-header__body term-page-header__body--error">
|
<div class="term-page-header__body term-page-header__body--error">
|
||||||
<div>
|
<div>
|
||||||
<p class="error-hero__icon" aria-hidden="true">
|
<p class="error-hero__icon" aria-hidden="true">
|
||||||
<i class="far fa-compass"></i>
|
<i class="fa fas fa-compass"></i>
|
||||||
</p>
|
</p>
|
||||||
<p class="error-hero__code" id="error-title">404</p>
|
<p class="error-hero__code" id="error-title">404</p>
|
||||||
<h1 class="error-hero__title"><?php echo esc_html($other_page_settings['page_404_message']); ?></h1>
|
<h1 class="error-hero__title"><?php esc_html_e('页面未找到', 'jio'); ?></h1>
|
||||||
<p class="error-hero__desc">
|
<p class="error-hero__desc">
|
||||||
<?php esc_html_e('你访问的页面不存在或已被删除。试试搜索,或者从下方常用入口继续浏览。', 'aksrt'); ?>
|
<?php esc_html_e('你访问的页面不存在或已被删除。试试搜索,或者从下方常用入口继续浏览。', 'jio'); ?>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<?php if (function_exists('aksrt_render_admin_quick_link')) { aksrt_render_admin_quick_link('page-display/other', __('404 页设置', 'aksrt')); } ?>
|
<?php if (function_exists('jio_render_admin_quick_link')) { jio_render_admin_quick_link('page-display/other', __('404 页设置', 'jio')); } ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!empty($other_page_settings['page_404_search'])) : ?>
|
|
||||||
<form role="search" method="get" class="error-hero__search" action="<?php echo esc_url(home_url('/')); ?>">
|
<form role="search" method="get" class="error-hero__search" action="<?php echo esc_url(home_url('/')); ?>">
|
||||||
<span class="error-hero__search-icon" aria-hidden="true">
|
<span class="error-hero__search-icon" aria-hidden="true">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||||
</span>
|
</span>
|
||||||
<label class="screen-reader-text" for="error-search-input"><?php esc_html_e('搜索', 'aksrt'); ?></label>
|
<label class="screen-reader-text" for="error-search-input"><?php esc_html_e('搜索', 'jio'); ?></label>
|
||||||
<input type="search" id="error-search-input" name="s" placeholder="<?php echo esc_attr($search_placeholder); ?>" autocomplete="off">
|
<input type="search" id="error-search-input" name="s" placeholder="<?php echo esc_attr($search_placeholder); ?>" autocomplete="off">
|
||||||
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('搜索', 'aksrt'); ?></button>
|
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('搜索', 'jio'); ?></button>
|
||||||
</form>
|
</form>
|
||||||
<?php aksrt_render_search_hot_keywords($hot_keywords, 'aksrt-search-hot-keywords'); ?>
|
<?php jio_render_search_hot_keywords($hot_keywords, 'jio-search-hot-keywords'); ?>
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="error-hero__actions">
|
<div class="error-hero__actions">
|
||||||
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-primary"><?php esc_html_e('返回首页', 'aksrt'); ?></a>
|
<a href="<?php echo esc_url(home_url('/')); ?>" class="btn-primary"><?php esc_html_e('返回首页', 'jio'); ?></a>
|
||||||
<a href="javascript:history.back()" class="btn-outline"><?php esc_html_e('返回上一页', 'aksrt'); ?></a>
|
<a href="javascript:history.back()" class="btn-outline"><?php esc_html_e('返回上一页', 'jio'); ?></a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<?php if (!empty($top_cats) && !is_wp_error($top_cats)) : ?>
|
<?php if (!empty($top_cats) && !is_wp_error($top_cats)) : ?>
|
||||||
<section class="error-section" aria-label="<?php esc_attr_e('热门分类', 'aksrt'); ?>">
|
<section class="error-section" aria-label="<?php esc_attr_e('热门分类', 'jio'); ?>">
|
||||||
<h2 class="error-section__title"><?php esc_html_e('热门分类', 'aksrt'); ?></h2>
|
<h2 class="error-section__title"><?php esc_html_e('热门分类', 'jio'); ?></h2>
|
||||||
<div class="error-section__chips">
|
<div class="error-section__chips">
|
||||||
<?php foreach ($top_cats as $cat) : ?>
|
<?php foreach ($top_cats as $cat) : ?>
|
||||||
<a class="error-section__chip" href="<?php echo esc_url(get_category_link($cat->term_id)); ?>">
|
<a class="error-section__chip" href="<?php echo esc_url(get_category_link($cat->term_id)); ?>">
|
||||||
@@ -86,9 +77,9 @@ $top_cats = get_terms([
|
|||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!empty($other_page_settings['page_404_popular']) && $hot_query->have_posts()) : ?>
|
<?php if ($hot_query->have_posts()) : ?>
|
||||||
<section class="error-section" aria-label="<?php esc_attr_e('热门内容', 'aksrt'); ?>">
|
<section class="error-section" aria-label="<?php esc_attr_e('热门内容', 'jio'); ?>">
|
||||||
<h2 class="error-section__title"><?php esc_html_e('热门内容', 'aksrt'); ?></h2>
|
<h2 class="error-section__title"><?php esc_html_e('热门内容', 'jio'); ?></h2>
|
||||||
<ul class="error-section__list">
|
<ul class="error-section__list">
|
||||||
<?php while ($hot_query->have_posts()) : $hot_query->the_post(); ?>
|
<?php while ($hot_query->have_posts()) : $hot_query->the_post(); ?>
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# AKSRT WordPress Theme
|
# Jio WordPress Theme
|
||||||
|
|
||||||
> **版本**: 3.4.0-dev | **架构**: 原生 PHP + WordPress (非 headless)
|
> **版本**: 3.4.0-dev | **架构**: 原生 PHP + WordPress (非 headless)
|
||||||
> **要求**: WordPress >= 6.4 | PHP >= 8.1
|
> **要求**: WordPress >= 6.4 | PHP >= 8.1
|
||||||
@@ -7,17 +7,14 @@
|
|||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
AKSRT 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题架构**开发(PHP + HTML + CSS + JavaScript),不依赖 REST API 进行前端渲染。
|
Jio 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题架构**开发(PHP + HTML + CSS + JavaScript),不依赖 REST API 进行前端渲染。
|
||||||
|
|
||||||
### 核心特性
|
### 核心特性
|
||||||
|
|
||||||
- **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台
|
- **自定义管理面板 (SPA)** — Vue3 + Vite + Naive UI 的 12 个一级业务域后台
|
||||||
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出、按域重置、迁移状态与健康检查
|
- **统一设置契约** — Schema 驱动表单、域控制器、导入导出
|
||||||
- **支付/下载主线** — 下载/付费内容中心、充值/VIP/分成/返佣/网关结构、10 分钟下载 token
|
- **用户与增长** — 统一登录注册、OAuth 登录、签到奖励、邀请奖励、等级/徽章/认证/举报结构
|
||||||
- **用户与增长** — OAuth 登录、邮箱验证门禁、签到奖励、邀请奖励、等级/徽章/认证/举报结构
|
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知
|
||||||
- **内容与交互** — 首页模块化、归档/搜索/404、单篇资源页、评论审核与通知中心
|
|
||||||
- **运维工具** — 配置导出导入、dry-run 校验、邮件回滚、数据库与环境诊断
|
|
||||||
- **自定义页面模板**:About / Friends / Login / Profile / Register
|
|
||||||
- **响应式设计**
|
- **响应式设计**
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -25,14 +22,14 @@ AKSRT 是一款现代化的 WordPress 主题,采用**原生 WordPress 主题
|
|||||||
## 文件结构
|
## 文件结构
|
||||||
|
|
||||||
```
|
```
|
||||||
AKSRT-theme/
|
Jio/
|
||||||
├── functions.php # 入口:常量 + Composer autoload + bootstrap + 单例 init
|
├── functions.php # 入口:常量 + Composer autoload + bootstrap + 单例 init
|
||||||
├── style.css # WordPress 主题声明头
|
├── style.css # WordPress 主题声明头
|
||||||
├── composer.json # 第三方依赖 + 主题 PSR-4(AKSRT\ → src/)
|
├── composer.json # 第三方依赖 + 主题 PSR-4(Jio\ → src/)
|
||||||
│
|
│
|
||||||
├── *.php # WP 核心模板保留根目录
|
├── *.php # WP 核心模板保留根目录
|
||||||
│
|
│
|
||||||
├── src/ # Composer PSR-4 类型根(namespace AKSRT\)
|
├── src/ # Composer PSR-4 类型根(namespace Jio\)
|
||||||
│ ├── Admin/ # SPA 编排 + 域控制器 + Schema + Native ops
|
│ ├── Admin/ # SPA 编排 + 域控制器 + Schema + Native ops
|
||||||
│ ├── Auth/ # 登录 / OAuth / 审核 / 邮箱
|
│ ├── Auth/ # 登录 / OAuth / 审核 / 邮箱
|
||||||
│ ├── Content/ # 下载 token / 付费内容 / VIP / 可见性
|
│ ├── Content/ # 下载 token / 付费内容 / VIP / 可见性
|
||||||
@@ -40,8 +37,8 @@ AKSRT-theme/
|
|||||||
│ ├── Credits/ # 积分 / 钱包 / 签到 / 邀请
|
│ ├── Credits/ # 积分 / 钱包 / 签到 / 邀请
|
||||||
│ ├── Extensions/ # MeiliSearch / 推送 / IP / 内容审核
|
│ ├── Extensions/ # MeiliSearch / 推送 / IP / 内容审核
|
||||||
│ ├── Frontend/ # 前端渲染辅助
|
│ ├── Frontend/ # 前端渲染辅助
|
||||||
│ ├── Messages/ # 站内信 / 私信 / 公告 / 举报
|
│ ├── Messages/ # 站内信 / 公告 / 举报
|
||||||
│ ├── Payment/ # 订单 / 网关 / 售后 / 物流 / 分成
|
│ ├── Payment/ # 订单 / 网关 / 物流 / 分成
|
||||||
│ │ └── Gateways/ # 支付宝 / 微信 / PayPal / Stripe
|
│ │ └── Gateways/ # 支付宝 / 微信 / PayPal / Stripe
|
||||||
│ ├── Settings/ # 设置门面 + 运行时 delegates
|
│ ├── Settings/ # 设置门面 + 运行时 delegates
|
||||||
│ ├── Shop/ # 商城服务 + meta box
|
│ ├── Shop/ # 商城服务 + meta box
|
||||||
@@ -60,9 +57,8 @@ AKSRT-theme/
|
|||||||
│ ├── pages/ # 自定义页面模板
|
│ ├── pages/ # 自定义页面模板
|
||||||
│ │ ├── page-about.php # 关于页面
|
│ │ ├── page-about.php # 关于页面
|
||||||
│ │ ├── page-friends.php # 友链墙
|
│ │ ├── page-friends.php # 友链墙
|
||||||
│ │ ├── page-login.php # 登录页面
|
│ │ ├── page-sign.php # 唯一登录注册页面
|
||||||
│ │ ├── page-profile.php # 用户中心
|
│ │ ├── page-profile.php # 用户中心
|
||||||
│ │ └── page-register.php # 注册页面
|
|
||||||
│ │
|
│ │
|
||||||
│ └── parts/ # 可复用模板片段
|
│ └── parts/ # 可复用模板片段
|
||||||
│ ├── content-post-card.php # 文章卡片组件
|
│ ├── content-post-card.php # 文章卡片组件
|
||||||
@@ -93,7 +89,7 @@ AKSRT-theme/
|
|||||||
|
|
||||||
## 安装方式
|
## 安装方式
|
||||||
|
|
||||||
主题类型(`AKSRT\…`)通过 Composer PSR-4 从 `src/` 自动加载;`vendor/` 不进 Git。**部署或更新主题后必须在主题根目录生成 autoload**,否则会出现 `Class "AKSRT\Core\DomainModel" not found` 等致命错误。
|
主题类型(`Jio\…`)通过 Composer PSR-4 从 `src/` 自动加载;`vendor/` 不进 Git。**部署或更新主题后必须在主题根目录生成 autoload**,否则会出现 `Class "Jio\Core\DomainModel" not found` 等致命错误。
|
||||||
|
|
||||||
### 前置:Composer(必做)
|
### 前置:Composer(必做)
|
||||||
|
|
||||||
@@ -110,16 +106,16 @@ composer dump-autoload -o
|
|||||||
确认:
|
确认:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php -r "require 'vendor/autoload.php'; echo class_exists('AKSRT\\\\Core\\\\DomainModel') ? \"ok\\n\" : \"missing\\n\";"
|
php -r "require 'vendor/autoload.php'; echo class_exists('Jio\\\\Core\\\\DomainModel') ? \"ok\\n\" : \"missing\\n\";"
|
||||||
```
|
```
|
||||||
|
|
||||||
应输出 `ok`。若 PHP 开启了 OPcache,重载 php-fpm / 重启 PHP 后再访问站点。
|
应输出 `ok`。若 PHP 开启了 OPcache,重载 php-fpm / 重启 PHP 后再访问站点。
|
||||||
|
|
||||||
### 方式一:直接上传(推荐)
|
### 方式一:直接上传(推荐)
|
||||||
|
|
||||||
1. 将整个 `AKSRT-theme` 文件夹上传到 `wp-content/themes/`
|
1. 将整个 `Jio` 文件夹上传到 `wp-content/themes/`
|
||||||
2. 在服务器主题目录执行上一节的 `composer install`(若打包时已带上完整 `vendor/` 且已 `dump-autoload`,可跳过)
|
2. 在服务器主题目录执行上一节的 `composer install`(若打包时已带上完整 `vendor/` 且已 `dump-autoload`,可跳过)
|
||||||
3. WordPress 后台 → **外观** → **主题** → 找到 **AKSRT** → **激活**
|
3. WordPress 后台 → **外观** → **主题** → 找到 **Jio** → **激活**
|
||||||
|
|
||||||
### 方式二:ZIP 打包上传
|
### 方式二:ZIP 打包上传
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@ php -r "require 'vendor/autoload.php'; echo class_exists('AKSRT\\\\Core\\\\Domai
|
|||||||
SSH 进入主题目录后:
|
SSH 进入主题目录后:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/wp-content/themes/AKsrt-theme # 以实际路径为准
|
cd /path/to/wp-content/themes/Jio # 以实际路径为准
|
||||||
composer install --no-dev --optimize-autoloader
|
composer install --no-dev --optimize-autoloader
|
||||||
# 或仅刷新映射:composer dump-autoload -o
|
# 或仅刷新映射:composer dump-autoload -o
|
||||||
# 可选:reload php-fpm / 清 OPcache
|
# 可选:reload php-fpm / 清 OPcache
|
||||||
@@ -154,9 +150,9 @@ composer install --no-dev --optimize-autoloader
|
|||||||
|
|
||||||
### 2. 后台配置路径
|
### 2. 后台配置路径
|
||||||
|
|
||||||
WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
|
WordPress 后台左侧 **Admin Bar** → ⚡ **Jio 主题设置**
|
||||||
|
|
||||||
或直接访问:`/wp-admin/admin.php?page=aksrt-settings`
|
或直接访问:`/wp-admin/admin.php?page=jio-settings`
|
||||||
|
|
||||||
### 3. 当前后台域
|
### 3. 当前后台域
|
||||||
|
|
||||||
@@ -166,7 +162,18 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
|
|||||||
- `功能&权限`:审核、验证码、评论/附件/下载权限
|
- `功能&权限`:审核、验证码、评论/附件/下载权限
|
||||||
- `用户&互动`:登录注册、OAuth、通知、签到、认证、等级、徽章、举报、积分
|
- `用户&互动`:登录注册、OAuth、通知、签到、认证、等级、徽章、举报、积分
|
||||||
- `支付&付费`:下载/付费内容中心、余额充值、VIP、创作分成、推广返佣、收款接口
|
- `支付&付费`:下载/付费内容中心、余额充值、VIP、创作分成、推广返佣、收款接口
|
||||||
- `备份&导入`:导入导出、健康检查、迁移状态、邮件回滚
|
- `备份&导入`:导入导出、健康检查、迁移状态、备份与更新
|
||||||
|
|
||||||
|
### 4. 推广、邀请与佣金(用户中心 → 收益)
|
||||||
|
|
||||||
|
- **推广链接**:用户中心「收益」页展示带 `?ref=` 的推广链接、二维码(可下载 PNG)与推广海报(按「推广返佣」设置中的海报文案生成)。归因模式(首触/末触)在「支付&付费 → 推广返佣 → 推广归因模式」配置。
|
||||||
|
- **一次性邀请码**:后台「用户 → 邀请码管理」三视图运营页——列表(状态筛选/搜索/批量删除/排序/分页/点击复制)、添加(系统自动生成:数量/标识/四类奖励[经验值、积分、余额、VIP]/高级选项自定义位数;或行格式文本导入:一行一码 `邀请码 奖励 标识`,奖励参数串 `points=20&balance=30&vip=1&vip_time=5&level_integral=100`,分隔符可自定义)、导出(状态筛选 + 文本文档[可回流导入]/Excel表格 双格式)。注册门控模式在「用户&互动 → 登录注册 → 邀请码注册」。消费为原子操作,并发注册不会超发;开启注册验证时奖励延迟到邮箱验证后发放。
|
||||||
|
- **佣金**:订单支付成功后按推广者等级分档比例记录到佣金账本,经「结算观察期」后自动结算入钱包,支持提现与退款联动回滚。「推广码禁用后停止返佣」开关默认关闭。
|
||||||
|
|
||||||
|
### 5. 部署注意事项
|
||||||
|
|
||||||
|
- **佣金结算时效**:结算依赖小时级 wp-cron,并有收益页访问触发的懒结算兜底。高流量站点建议禁用 `WP_CRON_LOCK` 场景下的默认 WP-Cron,改用系统 crontab 定时请求 `wp-cron.php`,保证结算与过期任务准时执行。
|
||||||
|
- **反代 / CDN 与 IP 风控**:主题默认按 Cloudflare → 通用代理头顺序识别真实 IP。若需收紧伪造风险,添加 `add_filter('jio_trusted_proxies', fn() => ['10.0.0.0/8', '173.245.48.0/20'])`(填入你的代理/CDN 网段);名单非空时仅当 `REMOTE_ADDR` 命中名单才采纳转发头,否则直接使用 `REMOTE_ADDR`(推广奖励的 IP 日上限、验证码等均依赖该函数)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -174,20 +181,20 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
|
|||||||
|
|
||||||
### Settings Facade 分层
|
### Settings Facade 分层
|
||||||
|
|
||||||
- `AKSRT_Settings` 仍是主题设置的**唯一静态 facade**,外部调用方式保持 `AKSRT_Settings::*`
|
- `JIO_Settings` 仍是主题设置的**唯一静态 facade**,外部调用方式保持 `JIO_Settings::*`
|
||||||
- 具体运行时 getter 已按领域拆分到 `inc/classes/settings/`,例如:
|
- 具体运行时 getter 已按领域拆分到 `inc/classes/settings/`,例如:
|
||||||
- `class-aksrt-settings-theme-search.php`
|
- `class-jio-settings-theme-search.php`
|
||||||
- `class-aksrt-settings-post-display.php`
|
- `class-jio-settings-post-display.php`
|
||||||
- `class-aksrt-settings-home-cms.php`
|
- `class-jio-settings-home-cms.php`
|
||||||
- `class-aksrt-settings-user-runtime.php`
|
- `class-jio-settings-user-runtime.php`
|
||||||
- `class-aksrt-settings-integration.php`
|
- `class-jio-settings-integration.php`
|
||||||
- `class-aksrt-settings-display-runtime.php`
|
- `class-jio-settings-display-runtime.php`
|
||||||
- `class-aksrt-settings-extension-runtime.php`
|
- `class-jio-settings-extension-runtime.php`
|
||||||
- `class-aksrt-settings-credits-runtime.php`
|
- `class-jio-settings-credits-runtime.php`
|
||||||
- `class-aksrt-settings-category-runtime.php`
|
- `class-jio-settings-category-runtime.php`
|
||||||
- `class-aksrt-settings-capabilities.php`
|
- `class-jio-settings-capabilities.php`
|
||||||
- `inc/bootstrap.php` 会先加载 `settings/class-aksrt-settings.php`,再按领域加载这些 delegate 类
|
- `inc/bootstrap.php` 会先加载 `settings/class-jio-settings.php`,再按领域加载这些 delegate 类
|
||||||
- 新增设置运行时逻辑时,优先放入对应 settings 子类;仅把聚合输出、统一入口和跨域装配保留在 `AKSRT_Settings`
|
- 新增设置运行时逻辑时,优先放入对应 settings 子类;仅把聚合输出、统一入口和跨域装配保留在 `JIO_Settings`
|
||||||
|
|
||||||
### 添加新辅助函数
|
### 添加新辅助函数
|
||||||
|
|
||||||
@@ -195,14 +202,14 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
|
|||||||
|
|
||||||
### 添加新页面模板
|
### 添加新页面模板
|
||||||
|
|
||||||
将 `page-{name}.php` 放入 `templates/pages/`,并在 `class-aksrt-templates.php` 中注册。
|
将 `page-{name}.php` 放入 `templates/pages/`,并在 `class-jio-templates.php` 中注册。
|
||||||
|
|
||||||
### CSS 组织规范
|
### CSS 组织规范
|
||||||
|
|
||||||
| 目录 | 用途 | 加载场景 |
|
| 目录 | 用途 | 加载场景 |
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| `css/frontend/` | 前端公共样式 | 所有前台页面 |
|
| `css/frontend/` | 前端公共样式 | 所有前台页面 |
|
||||||
| `css/admin/` | 后台管理样式 | 仅 AKSRT 设置页面 |
|
| `css/admin/` | 后台管理样式 | 仅 Jio 设置页面 |
|
||||||
| `css/shared/` | 共用组件样式 | 前后端按需加载(如验证码) |
|
| `css/shared/` | 共用组件样式 | 前后端按需加载(如验证码) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -211,17 +218,29 @@ WordPress 后台左侧 **Admin Bar** → ⚡ **AKSRT 主题设置**
|
|||||||
|
|
||||||
### 支持的平台
|
### 支持的平台
|
||||||
|
|
||||||
GitHub | Gitee | QQ | Google
|
GitHub | Gitee | QQ | Google | 微博 | 微信开放平台 | 微信公众号
|
||||||
:---:|:---:|:---:|:---:
|
:---:|:---:|:---:|:---:|:---:|:---:|:---:
|
||||||
✅ | ✅ | ✅ | ✅
|
✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅
|
||||||
|
|
||||||
### 回调地址格式
|
### 回调地址格式
|
||||||
|
|
||||||
|
直连 OAuth:
|
||||||
|
|
||||||
```
|
```
|
||||||
https://你的域名.com/?aksrt_oauth_callback={provider}
|
https://你的域名.com/oauth/callback/
|
||||||
```
|
```
|
||||||
|
|
||||||
`{provider}`: `github` / `gitee` / `qq` / `google`
|
代理服务端按平台配置:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://代理服务端域名.com/oauth/proxy/{provider}/callback/
|
||||||
|
```
|
||||||
|
|
||||||
|
彩虹聚合登录:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://你的域名.com/oauth/clogin/callback/
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+33
-39
@@ -2,9 +2,9 @@
|
|||||||
/**
|
/**
|
||||||
* Resource Archive Template
|
* Resource Archive Template
|
||||||
*
|
*
|
||||||
* Virtual list at /resources/ — ordinary posts marked with _aksrt_is_resource.
|
* Virtual list at /resources/ — ordinary posts marked with _jio_is_resource.
|
||||||
*
|
*
|
||||||
* @package AKSRT
|
* @package Jio
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
@@ -25,18 +25,18 @@ if (!in_array($current_price, ['all', 'free', 'paid'], true)) {
|
|||||||
$price_min = isset($_GET['price_min']) && $_GET['price_min'] !== '' ? (float) $_GET['price_min'] : '';
|
$price_min = isset($_GET['price_min']) && $_GET['price_min'] !== '' ? (float) $_GET['price_min'] : '';
|
||||||
$price_max = isset($_GET['price_max']) && $_GET['price_max'] !== '' ? (float) $_GET['price_max'] : '';
|
$price_max = isset($_GET['price_max']) && $_GET['price_max'] !== '' ? (float) $_GET['price_max'] : '';
|
||||||
|
|
||||||
$archive_url = function_exists('aksrt_resource_archive_url') ? aksrt_resource_archive_url() : home_url('/resources/');
|
$archive_url = function_exists('jio_resource_archive_url') ? jio_resource_archive_url() : home_url('/resources/');
|
||||||
|
|
||||||
$sort_options = [
|
$sort_options = [
|
||||||
'date' => __('最新', 'aksrt'),
|
'date' => __('最新', 'jio'),
|
||||||
'downloads' => __('热门下载', 'aksrt'),
|
'downloads' => __('热门下载', 'jio'),
|
||||||
'views' => __('阅读最多', 'aksrt'),
|
'views' => __('阅读最多', 'jio'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$price_options = [
|
$price_options = [
|
||||||
'all' => __('全部', 'aksrt'),
|
'all' => __('全部', 'jio'),
|
||||||
'free' => __('免费', 'aksrt'),
|
'free' => __('免费', 'jio'),
|
||||||
'paid' => __('付费', 'aksrt'),
|
'paid' => __('付费', 'jio'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$preserve = [];
|
$preserve = [];
|
||||||
@@ -59,18 +59,18 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
|
|||||||
<main id="main-content" class="site-main container" tabindex="-1">
|
<main id="main-content" class="site-main container" tabindex="-1">
|
||||||
<header class="resource-archive-header page-cover">
|
<header class="resource-archive-header page-cover">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<?php if (function_exists('aksrt_breadcrumbs')) : ?>
|
<?php if (function_exists('jio_breadcrumbs')) : ?>
|
||||||
<nav class="resource-archive-header__breadcrumbs" aria-label="<?php esc_attr_e('面包屑', 'aksrt'); ?>">
|
<nav class="resource-archive-header__breadcrumbs" aria-label="<?php esc_attr_e('面包屑', 'jio'); ?>">
|
||||||
<?php aksrt_breadcrumbs(); ?>
|
<?php jio_breadcrumbs(); ?>
|
||||||
</nav>
|
</nav>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<h1 class="resource-archive-header__title"><?php esc_html_e('资源', 'aksrt'); ?></h1>
|
<h1 class="resource-archive-header__title"><?php esc_html_e('资源', 'jio'); ?></h1>
|
||||||
<p class="resource-archive-header__sub">
|
<p class="resource-archive-header__sub">
|
||||||
<?php
|
<?php
|
||||||
global $wp_query;
|
global $wp_query;
|
||||||
/* translators: %s: resource count */
|
/* translators: %s: resource count */
|
||||||
printf(esc_html__('共 %s 个资源', 'aksrt'), esc_html(number_format_i18n((int) $wp_query->found_posts)));
|
printf(esc_html__('共 %s 个资源', 'jio'), esc_html(number_format_i18n((int) $wp_query->found_posts)));
|
||||||
?>
|
?>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,9 +79,9 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
|
|||||||
<div class="content-wrap">
|
<div class="content-wrap">
|
||||||
<div class="content-layout content-layout--no-sidebar">
|
<div class="content-layout content-layout--no-sidebar">
|
||||||
<div class="content-layout__primary resource-archive">
|
<div class="content-layout__primary resource-archive">
|
||||||
<nav class="resource-archive__toolbar" aria-label="<?php esc_attr_e('筛选', 'aksrt'); ?>">
|
<nav class="resource-archive__toolbar" aria-label="<?php esc_attr_e('筛选', 'jio'); ?>">
|
||||||
<div class="resource-archive__filter-group">
|
<div class="resource-archive__filter-group">
|
||||||
<span class="resource-archive__filter-label"><?php esc_html_e('排序', 'aksrt'); ?></span>
|
<span class="resource-archive__filter-label"><?php esc_html_e('排序', 'jio'); ?></span>
|
||||||
<ul class="resource-archive__sort">
|
<ul class="resource-archive__sort">
|
||||||
<?php foreach ($sort_options as $key => $label) :
|
<?php foreach ($sort_options as $key => $label) :
|
||||||
$args = $preserve;
|
$args = $preserve;
|
||||||
@@ -105,7 +105,7 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="resource-archive__filter-group">
|
<div class="resource-archive__filter-group">
|
||||||
<span class="resource-archive__filter-label"><?php esc_html_e('价格', 'aksrt'); ?></span>
|
<span class="resource-archive__filter-label"><?php esc_html_e('价格', 'jio'); ?></span>
|
||||||
<ul class="resource-archive__sort">
|
<ul class="resource-archive__sort">
|
||||||
<?php foreach ($price_options as $key => $label) :
|
<?php foreach ($price_options as $key => $label) :
|
||||||
$args = $preserve;
|
$args = $preserve;
|
||||||
@@ -139,49 +139,43 @@ $has_filter = $current_orderby !== 'date' || $current_price !== 'all' || $price_
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<label class="resource-archive__price-input">
|
<label class="resource-archive__price-input">
|
||||||
<span><?php esc_html_e('最低', 'aksrt'); ?></span>
|
<span><?php esc_html_e('最低', 'jio'); ?></span>
|
||||||
<input type="number" name="price_min" min="0" step="1" value="<?php echo esc_attr((string) $price_min); ?>">
|
<input type="number" name="price_min" min="0" step="1" value="<?php echo esc_attr((string) $price_min); ?>">
|
||||||
</label>
|
</label>
|
||||||
<span class="resource-archive__price-sep">—</span>
|
<span class="resource-archive__price-sep">—</span>
|
||||||
<label class="resource-archive__price-input">
|
<label class="resource-archive__price-input">
|
||||||
<span><?php esc_html_e('最高', 'aksrt'); ?></span>
|
<span><?php esc_html_e('最高', 'jio'); ?></span>
|
||||||
<input type="number" name="price_max" min="0" step="1" value="<?php echo esc_attr((string) $price_max); ?>">
|
<input type="number" name="price_max" min="0" step="1" value="<?php echo esc_attr((string) $price_max); ?>">
|
||||||
</label>
|
</label>
|
||||||
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('应用', 'aksrt'); ?></button>
|
<button type="submit" class="btn-primary btn-sm"><?php esc_html_e('应用', 'jio'); ?></button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($has_filter) : ?>
|
<?php if ($has_filter) : ?>
|
||||||
<a class="resource-archive__reset" href="<?php echo esc_url($archive_url); ?>">
|
<a class="resource-archive__reset" href="<?php echo esc_url($archive_url); ?>">
|
||||||
<i class="fas fa-times-circle" aria-hidden="true"></i>
|
<i class="fa fas fa-times-circle" aria-hidden="true"></i>
|
||||||
<?php esc_html_e('清除筛选', 'aksrt'); ?>
|
<?php esc_html_e('清除筛选', 'jio'); ?>
|
||||||
</a>
|
</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<?php if (have_posts()) : ?>
|
<?php if (have_posts()) : ?>
|
||||||
<?php
|
<?php
|
||||||
$render_config = function_exists('aksrt_resolve_post_list_render_config')
|
$render_config = jio_resolve_post_list_render_config([
|
||||||
? aksrt_resolve_post_list_render_config([
|
'context' => 'archive',
|
||||||
'context' => 'archive',
|
'overrides' => ['type' => 'card'],
|
||||||
'overrides' => ['mode' => 'card'],
|
]);
|
||||||
])
|
|
||||||
: ['context' => 'archive', 'mode' => 'card'];
|
|
||||||
|
|
||||||
if (function_exists('aksrt_render_post_list')) {
|
jio_render_post_list(array_merge($render_config, ['wrapper_class' => 'content-stream__list']));
|
||||||
aksrt_render_post_list(null, array_merge($render_config, ['wrapper_class' => 'content-stream__list']));
|
jio_render_pagination(null, [
|
||||||
}
|
'query_context' => 'archive',
|
||||||
if (function_exists('aksrt_render_pagination')) {
|
'render_config' => $render_config,
|
||||||
aksrt_render_pagination(null, [
|
]);
|
||||||
'query_context' => 'archive',
|
|
||||||
'render_config' => $render_config,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
?>
|
?>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<div class="no-results">
|
<div class="no-results">
|
||||||
<p><?php esc_html_e('暂无资源。', 'aksrt'); ?></p>
|
<p><?php esc_html_e('暂无资源。', 'jio'); ?></p>
|
||||||
<?php if ($has_filter) : ?>
|
<?php if ($has_filter) : ?>
|
||||||
<p><a class="btn-outline btn-sm" href="<?php echo esc_url($archive_url); ?>"><?php esc_html_e('清除筛选条件', 'aksrt'); ?></a></p>
|
<p><a class="btn-outline btn-sm" href="<?php echo esc_url($archive_url); ?>"><?php esc_html_e('清除筛选条件', 'jio'); ?></a></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
+19
-19
@@ -2,7 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* Generic archive template.
|
* Generic archive template.
|
||||||
*
|
*
|
||||||
* @package AKSRT
|
* @package Jio
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
@@ -11,31 +11,31 @@ get_header();
|
|||||||
|
|
||||||
global $wp_query;
|
global $wp_query;
|
||||||
|
|
||||||
$render_config = function_exists('aksrt_archive_get_render_config')
|
$render_config = function_exists('jio_archive_get_render_config')
|
||||||
? aksrt_archive_get_render_config(['context' => 'archive'])
|
? jio_archive_get_render_config(['context' => 'archive'])
|
||||||
: ['context' => 'archive', 'mode' => 'list'];
|
: ['context' => 'archive', 'mode' => 'list'];
|
||||||
$has_sidebar = function_exists('aksrt_archive_has_sidebar')
|
$has_sidebar = function_exists('jio_archive_has_sidebar')
|
||||||
? aksrt_archive_has_sidebar()
|
? jio_archive_has_sidebar()
|
||||||
: (function_exists('aksrt_is_show_sidebar') ? aksrt_is_show_sidebar() : false);
|
: (function_exists('jio_is_show_sidebar') ? jio_is_show_sidebar() : false);
|
||||||
|
|
||||||
$archive_description = trim(wp_strip_all_tags((string) get_the_archive_description()));
|
$archive_description = trim(wp_strip_all_tags((string) get_the_archive_description()));
|
||||||
$archive_summary = '';
|
$archive_summary = '';
|
||||||
if (is_author()) {
|
if (is_author()) {
|
||||||
$author = get_queried_object();
|
$author = get_queried_object();
|
||||||
if ($author instanceof WP_User) {
|
if ($author instanceof WP_User) {
|
||||||
$archive_summary = sprintf(__('作者:%s', 'aksrt'), $author->display_name);
|
$archive_summary = sprintf(__('作者:%s', 'jio'), $author->display_name);
|
||||||
}
|
}
|
||||||
} elseif ($archive_description !== '') {
|
} elseif ($archive_description !== '') {
|
||||||
$archive_summary = $archive_description;
|
$archive_summary = $archive_description;
|
||||||
} elseif (isset($wp_query->found_posts)) {
|
} elseif (isset($wp_query->found_posts)) {
|
||||||
$archive_summary = sprintf(
|
$archive_summary = sprintf(
|
||||||
esc_html(_n('共 %d 篇内容', '共 %d 篇内容', (int) $wp_query->found_posts, 'aksrt')),
|
esc_html(_n('共 %d 篇内容', '共 %d 篇内容', (int) $wp_query->found_posts, 'jio')),
|
||||||
(int) $wp_query->found_posts
|
(int) $wp_query->found_posts
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (function_exists('aksrt_archive_open_shell')) {
|
if (function_exists('jio_archive_open_shell')) {
|
||||||
aksrt_archive_open_shell([
|
jio_archive_open_shell([
|
||||||
'main_class' => 'site-main container',
|
'main_class' => 'site-main container',
|
||||||
'has_sidebar' => $has_sidebar,
|
'has_sidebar' => $has_sidebar,
|
||||||
'page_kind' => 'archive',
|
'page_kind' => 'archive',
|
||||||
@@ -45,16 +45,16 @@ if (function_exists('aksrt_archive_open_shell')) {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
<div class="content-main content-main--archive">
|
<div class="content-main content-main--archive">
|
||||||
<?php if (function_exists('aksrt_breadcrumbs')) { aksrt_breadcrumbs(); } ?>
|
<?php if (function_exists('jio_breadcrumbs')) { jio_breadcrumbs(); } ?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
ob_start();
|
ob_start();
|
||||||
if (function_exists('aksrt_render_admin_quick_link')) {
|
if (function_exists('jio_render_admin_quick_link')) {
|
||||||
aksrt_render_admin_quick_link('post-list/list', __('列表样式设置', 'aksrt'));
|
jio_render_admin_quick_link('post-list/list', __('列表样式设置', 'jio'));
|
||||||
}
|
}
|
||||||
$admin_link = trim((string) ob_get_clean());
|
$admin_link = trim((string) ob_get_clean());
|
||||||
get_template_part('templates/parts/page-header', null, [
|
get_template_part('templates/parts/page-header', null, [
|
||||||
'eyebrow' => __('内容归档', 'aksrt'),
|
'eyebrow' => __('内容归档', 'jio'),
|
||||||
'title' => get_the_archive_title(),
|
'title' => get_the_archive_title(),
|
||||||
'desc' => $archive_summary,
|
'desc' => $archive_summary,
|
||||||
'extra' => $admin_link,
|
'extra' => $admin_link,
|
||||||
@@ -62,19 +62,19 @@ if (function_exists('aksrt_archive_open_shell')) {
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
if (function_exists('aksrt_archive_render_stream')) {
|
if (function_exists('jio_archive_render_stream')) {
|
||||||
aksrt_archive_render_stream(null, $render_config, [
|
jio_archive_render_stream(null, $render_config, [
|
||||||
'page_kind' => 'archive',
|
'page_kind' => 'archive',
|
||||||
'query_context' => 'archive',
|
'query_context' => 'archive',
|
||||||
'stream_class' => 'content-stream--archive',
|
'stream_class' => 'content-stream--archive',
|
||||||
'empty_text' => __('暂无文章', 'aksrt'),
|
'empty_text' => __('暂无文章', 'jio'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</div>
|
</div>
|
||||||
<?php
|
<?php
|
||||||
if (function_exists('aksrt_archive_close_shell')) {
|
if (function_exists('jio_archive_close_shell')) {
|
||||||
aksrt_archive_close_shell(['has_sidebar' => $has_sidebar]);
|
jio_archive_close_shell(['has_sidebar' => $has_sidebar]);
|
||||||
} else {
|
} else {
|
||||||
echo '</div></div>';
|
echo '</div></div>';
|
||||||
if ($has_sidebar) { get_sidebar(); }
|
if ($has_sidebar) { get_sidebar(); }
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ const COMPONENT_STYLES = [
|
|||||||
// 内部基础组件
|
// 内部基础组件
|
||||||
['_internal/scrollbar/src/styles/index.cssr.mjs'],
|
['_internal/scrollbar/src/styles/index.cssr.mjs'],
|
||||||
// 布局
|
// 布局
|
||||||
['layout/src/styles/index.cssr.mjs'],
|
['layout/src/styles/layout.cssr.mjs'],
|
||||||
['layout-sider/src/styles/index.cssr.mjs'],
|
|
||||||
// 导航
|
// 导航
|
||||||
['menu/src/styles/index.cssr.mjs'],
|
['menu/src/styles/index.cssr.mjs'],
|
||||||
['breadcrumb/src/styles/index.cssr.mjs'],
|
['breadcrumb/src/styles/index.cssr.mjs'],
|
||||||
@@ -36,17 +35,19 @@ const COMPONENT_STYLES = [
|
|||||||
['steps/src/styles/index.cssr.mjs'],
|
['steps/src/styles/index.cssr.mjs'],
|
||||||
['pagination/src/styles/index.cssr.mjs'],
|
['pagination/src/styles/index.cssr.mjs'],
|
||||||
// 表单
|
// 表单
|
||||||
['form/src/styles/index.cssr.mjs'],
|
['form/src/styles/form.cssr.mjs'],
|
||||||
['form-item/src/styles/index.cssr.mjs'],
|
['form/src/styles/form-item.cssr.mjs'],
|
||||||
['input/src/styles/index.cssr.mjs'],
|
['input/src/styles/input.cssr.mjs'],
|
||||||
['input-number/src/styles/index.cssr.mjs'],
|
['input/src/styles/input-group.cssr.mjs'],
|
||||||
|
['input-number/src/styles/input-number.cssr.mjs'],
|
||||||
['switch/src/styles/index.cssr.mjs'],
|
['switch/src/styles/index.cssr.mjs'],
|
||||||
['select/src/styles/index.cssr.mjs'],
|
['select/src/styles/index.cssr.mjs'],
|
||||||
['radio/src/styles/index.cssr.mjs'],
|
['radio/src/styles/radio.cssr.mjs'],
|
||||||
|
['radio/src/styles/radio-group.cssr.mjs'],
|
||||||
['checkbox/src/styles/index.cssr.mjs'],
|
['checkbox/src/styles/index.cssr.mjs'],
|
||||||
['slider/src/styles/index.cssr.mjs'],
|
['slider/src/styles/index.cssr.mjs'],
|
||||||
['rate/src/styles/index.cssr.mjs'],
|
['rate/src/styles/index.cssr.mjs'],
|
||||||
['date-picker/src/styles/indexcode/index.cssr.mjs'],
|
['date-picker/src/styles/index.cssr.mjs'],
|
||||||
['time-picker/src/styles/index.cssr.mjs'],
|
['time-picker/src/styles/index.cssr.mjs'],
|
||||||
// 展示
|
// 展示
|
||||||
['card/src/styles/index.cssr.mjs'],
|
['card/src/styles/index.cssr.mjs'],
|
||||||
@@ -68,12 +69,12 @@ const COMPONENT_STYLES = [
|
|||||||
['notification/src/styles/index.cssr.mjs'],
|
['notification/src/styles/index.cssr.mjs'],
|
||||||
['popconfirm/src/styles/index.cssr.mjs'],
|
['popconfirm/src/styles/index.cssr.mjs'],
|
||||||
['popover/src/styles/index.cssr.mjs'],
|
['popover/src/styles/index.cssr.mjs'],
|
||||||
['tooltip/src/styles/index.cssr.mjs'],
|
// tooltip 复用 popover 的共享样式(自身没有独立 cssr 文件)。
|
||||||
['dropdown/src/styles/index.cssr.mjs'],
|
['dropdown/src/styles/index.cssr.mjs'],
|
||||||
// 其他
|
// 其他
|
||||||
['icon/src/styles/index.cssr.mjs'],
|
['icon/src/styles/index.cssr.mjs'],
|
||||||
['icon-wrapper/src/styles/index.cssr.mjs'],
|
['icon-wrapper/src/styles/index.cssr.mjs'],
|
||||||
['space/src/styles/index.cssr.mjs'],
|
// space 全部用内联 style 布局,没有 cssr 文件,故不在此列。
|
||||||
['divider/src/styles/index.cssr.mjs'],
|
['divider/src/styles/index.cssr.mjs'],
|
||||||
['gradient-text/src/styles/index.cssr.mjs'],
|
['gradient-text/src/styles/index.cssr.mjs'],
|
||||||
['ellipsis/src/styles/index.cssr.mjs'],
|
['ellipsis/src/styles/index.cssr.mjs'],
|
||||||
@@ -86,24 +87,24 @@ const COMPONENT_STYLES = [
|
|||||||
['tree-select/src/styles/index.cssr.mjs'],
|
['tree-select/src/styles/index.cssr.mjs'],
|
||||||
['tree/src/styles/index.cssr.mjs'],
|
['tree/src/styles/index.cssr.mjs'],
|
||||||
['thing/src/styles/index.cssr.mjs'],
|
['thing/src/styles/index.cssr.mjs'],
|
||||||
['color-picker/src/styles/indexcode/index.cssr.mjs'],
|
['color-picker/src/styles/index.cssr.mjs'],
|
||||||
['typography/src/styles/index.cssr.mjs'],
|
['typography/src/styles/p.cssr.mjs'],
|
||||||
|
['typography/src/styles/a.cssr.mjs'],
|
||||||
|
['typography/src/styles/blockquote.cssr.mjs'],
|
||||||
|
['typography/src/styles/list.cssr.mjs'],
|
||||||
|
['typography/src/styles/hr.cssr.mjs'],
|
||||||
|
['typography/src/styles/header.cssr.mjs'],
|
||||||
['auto-complete/src/styles/index.cssr.mjs'],
|
['auto-complete/src/styles/index.cssr.mjs'],
|
||||||
['cascader/src/styles/index.cssr.mjs'],
|
['cascader/src/styles/index.cssr.mjs'],
|
||||||
['dynamic-input/src/styles/index.cssr.mjs'],
|
['dynamic-input/src/styles/index.cssr.mjs'],
|
||||||
['dynamic-tags/src/styles/index.cssr.mjs'],
|
['dynamic-tags/src/styles/index.cssr.mjs'],
|
||||||
['input-otp/src/styles/indexcode/index.cssr.mjs'],
|
['input-otp/src/styles/index.cssr.mjs'],
|
||||||
// 过渡动画
|
|
||||||
['_styles/transitions/fade-in-width-expand.cssr.mjs'],
|
|
||||||
['_styles/transitions/fade-in-height-expand.cssr.mjs'],
|
|
||||||
['_styles/transitions/icon-switch.cssr.mjs'],
|
|
||||||
['_styles/transitions/scale-transition.cssr.mjs'],
|
|
||||||
// 全局
|
// 全局
|
||||||
['global-style/src/styles/index.cssr.mjs'],
|
['_styles/global/index.cssr.mjs'],
|
||||||
]
|
]
|
||||||
|
|
||||||
async function extractStyles() {
|
async function extractStyles() {
|
||||||
console.log('\n[AKSRT] 开始提取 NaiveUI 组件样式...\n')
|
console.log('\n[Jio] 开始提取 NaiveUI 组件样式...\n')
|
||||||
|
|
||||||
const allCss = []
|
const allCss = []
|
||||||
let successCount = 0
|
let successCount = 0
|
||||||
@@ -118,27 +119,28 @@ async function extractStyles() {
|
|||||||
|
|
||||||
const defaultExport = styleModule.default || styleModule
|
const defaultExport = styleModule.default || styleModule
|
||||||
|
|
||||||
if (typeof defaultExport === 'function') {
|
// cssr 模块有两种导出形态:函数本体,或带 render() 的 CNode 对象。
|
||||||
// 直接调用 render() 提取 CSS
|
// 两者都必须接收 props 对象(至少 {}),否则 render 内部读
|
||||||
const css = defaultExport({
|
// props.bPrefix 会抛错——card / data-table / dialog 等组件的样式
|
||||||
clsPrefix: 'n',
|
// 曾因漏传参数被整体跳过。
|
||||||
// 使用 light 主题变量
|
const css = typeof defaultExport === 'function'
|
||||||
})
|
? defaultExport({ clsPrefix: 'n' })
|
||||||
|
: defaultExport.render({ clsPrefix: 'n' })
|
||||||
|
|
||||||
if (typeof css === 'string' && css.trim()) {
|
if (typeof css === 'string' && css.trim()) {
|
||||||
allCss.push(`/* ${componentPath} */\n${css}`)
|
allCss.push(`/* ${componentPath} */\n${css}`)
|
||||||
successCount++
|
successCount++
|
||||||
}
|
|
||||||
} else if (defaultExport && typeof defaultExport.render === 'function') {
|
|
||||||
// 如果是 CNode 对象,调用 render()
|
|
||||||
const css = defaultExport.render()
|
|
||||||
if (typeof css === 'string' && css.trim()) {
|
|
||||||
allCss.push(`/* ${componentPath} */\n${css}`)
|
|
||||||
successCount++
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 某些路径可能不存在或结构不同,跳过但不中断
|
const code = err && err.code
|
||||||
|
// 路径写错时 import 会直接抛 MODULE_NOT_FOUND。这类错误必须显式报出来:
|
||||||
|
// 静默跳过的后果是组件样式整体缺失(取色器曾因此没有尺寸和边框,糊成一团),
|
||||||
|
// 而页面不报错,只能靠肉眼发现。
|
||||||
|
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND') {
|
||||||
|
console.error(`[Jio] 样式路径不存在,已跳过: ${componentPath}`)
|
||||||
|
} else {
|
||||||
|
console.error(`[Jio] 样式提取异常,已跳过: ${componentPath}: ${err && err.message}`)
|
||||||
|
}
|
||||||
failCount++
|
failCount++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,7 +148,7 @@ async function extractStyles() {
|
|||||||
const cssOutput = allCss.join('\n\n')
|
const cssOutput = allCss.join('\n\n')
|
||||||
|
|
||||||
if (!cssOutput.trim()) {
|
if (!cssOutput.trim()) {
|
||||||
console.warn('[AKSRT] 警告:未提取到任何 NaiveUI 样式!')
|
console.warn('[Jio] 警告:未提取到任何 NaiveUI 样式!')
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +165,7 @@ ${cssOutput}`
|
|||||||
|
|
||||||
await writeFile(cssFile, fullContent, 'utf-8')
|
await writeFile(cssFile, fullContent, 'utf-8')
|
||||||
|
|
||||||
console.log(`[AKSRT] NaiveUI 样式提取完成:`)
|
console.log(`[Jio] NaiveUI 样式提取完成:`)
|
||||||
console.log(` 成功: ${successCount} 个组件`)
|
console.log(` 成功: ${successCount} 个组件`)
|
||||||
console.log(` 跳过: ${failCount} 个组件`)
|
console.log(` 跳过: ${failCount} 个组件`)
|
||||||
console.log(` 输出: ${cssFile}`)
|
console.log(` 输出: ${cssFile}`)
|
||||||
@@ -174,6 +176,6 @@ ${cssOutput}`
|
|||||||
|
|
||||||
// 执行
|
// 执行
|
||||||
extractStyles().catch((err) => {
|
extractStyles().catch((err) => {
|
||||||
console.error('[AKSRT] 样式提取失败:', err)
|
console.error('[Jio] 样式提取失败:', err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AKSRT 主题设置</title>
|
<title>Jio 主题设置</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="aksrt-admin-app"></div>
|
<div id="jio-admin-app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Generated
+122
-94
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "aksrt-admin",
|
"name": "jio-admin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "aksrt-admin",
|
"name": "jio-admin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-css": "^6.3.1",
|
"@codemirror/lang-css": "^6.3.1",
|
||||||
@@ -21,34 +21,36 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.1.0",
|
"@vitejs/plugin-vue": "^5.1.0",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"esbuild": "^0.25.12",
|
||||||
"vite": "^6.0.0"
|
"vite": "^6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.11",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.29.3",
|
"version": "7.29.12",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
|
||||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
"integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.29.0"
|
"@babel/types": "^7.29.11"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -58,13 +60,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.11",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
|
||||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.11",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -675,9 +677,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@juggle/resize-observer": {
|
"node_modules/@juggle/resize-observer": {
|
||||||
@@ -1136,53 +1138,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz",
|
||||||
"integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==",
|
"integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.2",
|
"@babel/parser": "^7.29.9",
|
||||||
"@vue/shared": "3.5.33",
|
"@vue/shared": "3.5.44",
|
||||||
"entities": "^7.0.1",
|
"entities": "^7.0.1",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-dom": {
|
"node_modules/@vue/compiler-dom": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz",
|
||||||
"integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==",
|
"integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-core": "3.5.33",
|
"@vue/compiler-core": "3.5.44",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-sfc": {
|
"node_modules/@vue/compiler-sfc": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz",
|
||||||
"integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==",
|
"integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.29.2",
|
"@babel/parser": "^7.29.9",
|
||||||
"@vue/compiler-core": "3.5.33",
|
"@vue/compiler-core": "3.5.44",
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.44",
|
||||||
"@vue/compiler-ssr": "3.5.33",
|
"@vue/compiler-ssr": "3.5.44",
|
||||||
"@vue/shared": "3.5.33",
|
"@vue/shared": "3.5.44",
|
||||||
"estree-walker": "^2.0.2",
|
"estree-walker": "^2.0.2",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.29",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/compiler-ssr": {
|
"node_modules/@vue/compiler-ssr": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz",
|
||||||
"integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==",
|
"integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.44",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/devtools-api": {
|
"node_modules/@vue/devtools-api": {
|
||||||
@@ -1192,53 +1194,51 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@vue/reactivity": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz",
|
||||||
"integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==",
|
"integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-core": {
|
"node_modules/@vue/runtime-core": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz",
|
||||||
"integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==",
|
"integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.33",
|
"@vue/reactivity": "3.5.44",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.44"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/runtime-dom": {
|
"node_modules/@vue/runtime-dom": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz",
|
||||||
"integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==",
|
"integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/reactivity": "3.5.33",
|
"@vue/reactivity": "3.5.44",
|
||||||
"@vue/runtime-core": "3.5.33",
|
"@vue/runtime-core": "3.5.44",
|
||||||
"@vue/shared": "3.5.33",
|
"@vue/shared": "3.5.44",
|
||||||
"csstype": "^3.2.3"
|
"csstype": "^3.2.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/server-renderer": {
|
"node_modules/@vue/server-renderer": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz",
|
||||||
"integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==",
|
"integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-ssr": "3.5.33",
|
"@vue/compiler-ssr": "3.5.44",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/runtime-dom": "3.5.44",
|
||||||
},
|
"@vue/shared": "3.5.44"
|
||||||
"peerDependencies": {
|
|
||||||
"vue": "3.5.33"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vue/shared": {
|
"node_modules/@vue/shared": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz",
|
||||||
"integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==",
|
"integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/async-validator": {
|
"node_modules/async-validator": {
|
||||||
@@ -1309,6 +1309,17 @@
|
|||||||
"date-fns": "^3.0.0 || ^4.0.0"
|
"date-fns": "^3.0.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/echarts": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0",
|
||||||
|
"zrender": "6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -1471,9 +1482,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.20",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1530,9 +1541,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.13",
|
"version": "8.5.32",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||||
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
|
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -1549,9 +1560,9 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.20",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
@@ -1615,9 +1626,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -1652,6 +1663,13 @@
|
|||||||
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
|
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/vdirs": {
|
"node_modules/vdirs": {
|
||||||
"version": "0.1.8",
|
"version": "0.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
|
||||||
@@ -1665,9 +1683,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
|
||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1752,16 +1770,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.33",
|
"version": "3.5.44",
|
||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz",
|
||||||
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
|
"integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.33",
|
"@vue/compiler-dom": "3.5.44",
|
||||||
"@vue/compiler-sfc": "3.5.33",
|
"@vue/compiler-sfc": "3.5.44",
|
||||||
"@vue/runtime-dom": "3.5.33",
|
"@vue/runtime-dom": "3.5.44",
|
||||||
"@vue/server-renderer": "3.5.33",
|
"@vue/server-renderer": "3.5.44",
|
||||||
"@vue/shared": "3.5.33"
|
"@vue/shared": "3.5.44"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "*"
|
"typescript": "*"
|
||||||
@@ -1848,6 +1866,16 @@
|
|||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/zrender": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.3.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"name":"aksrt-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","vite":"^6.0.0"}}
|
{"name":"jio-admin","version":"1.0.0","private":true,"type":"module","scripts":{"dev":"vite","build":"vite build && node extract-naive-styles.mjs","build:public":"node ../../tools/build-public-assets.mjs","build:pay-charts":"node ../../tools/build-pay-charts.mjs","preview":"vite preview"},"dependencies":{"@codemirror/lang-css":"^6.3.1","@codemirror/lang-html":"^6.4.11","@codemirror/lang-javascript":"^6.2.5","@codemirror/theme-one-dark":"^6.1.3","codemirror":"^6.0.2","naive-ui":"^2.40.0","pinia":"^2.2.0","vue":"^3.5.0","vue-router":"^4.4.0","vuedraggable":"^4.1.0"},"devDependencies":{"@vitejs/plugin-vue":"^5.1.0","echarts":"^6.1.0","esbuild":"^0.25.12","vite":"^6.0.0"},"allowScripts":{"esbuild@0.25.12":true,"vue-demi@0.14.10":true}}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB |
+135
-45
@@ -1,26 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-config-provider :style-mount-target="styleMountTarget">
|
<n-config-provider
|
||||||
|
:style-mount-target="styleMountTarget"
|
||||||
|
:theme-overrides="adminThemeOverrides"
|
||||||
|
>
|
||||||
<n-message-provider>
|
<n-message-provider>
|
||||||
<n-dialog-provider>
|
<n-dialog-provider>
|
||||||
<n-notification-provider>
|
<n-notification-provider>
|
||||||
<n-layout v-if="!isMobile" class="aksrt-admin-layout" has-sider>
|
<n-layout v-if="!isMobile" class="jio-admin-layout" has-sider>
|
||||||
<n-layout-sider
|
<n-layout-sider
|
||||||
|
class="jio-admin-sider"
|
||||||
bordered
|
bordered
|
||||||
collapse-mode="width"
|
collapse-mode="width"
|
||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
:width="240"
|
:width="232"
|
||||||
show-collapse-trigger
|
show-collapse-trigger
|
||||||
:collapsed="collapsed"
|
:collapsed="collapsed"
|
||||||
:native-scrollbar="false"
|
:native-scrollbar="false"
|
||||||
@collapse="collapsed = true"
|
@collapse="collapsed = true"
|
||||||
@expand="collapsed = false"
|
@expand="collapsed = false"
|
||||||
>
|
>
|
||||||
<div class="aksrt-logo">
|
<div class="jio-logo">
|
||||||
<span class="logo-icon">AK</span>
|
<span class="logo-icon">AK</span>
|
||||||
<span v-if="!collapsed" class="logo-text">AKSRT</span>
|
<span v-if="!collapsed" class="jio-logo__copy">
|
||||||
|
<span class="logo-text">Jio</span>
|
||||||
|
<span class="jio-logo__version">v{{ themeVersion }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-menu
|
<n-menu
|
||||||
|
class="jio-admin-menu"
|
||||||
:value="activeMenuKey"
|
:value="activeMenuKey"
|
||||||
:collapsed="collapsed"
|
:collapsed="collapsed"
|
||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
@@ -29,35 +37,39 @@
|
|||||||
/>
|
/>
|
||||||
</n-layout-sider>
|
</n-layout-sider>
|
||||||
|
|
||||||
<n-layout :native-scrollbar="false" class="aksrt-admin-content">
|
<n-layout :native-scrollbar="false" class="jio-admin-content">
|
||||||
<div class="aksrt-admin-content__inner">
|
<div class="jio-admin-content__inner">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade-slide" mode="out-in">
|
<transition name="fade-slide" mode="out-in">
|
||||||
<div class="aksrt-page-wrapper" :key="route.fullPath">
|
<div class="jio-page-wrapper" :key="route.fullPath">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
|
||||||
<div v-if="pageError" class="aksrt-error-fallback">
|
<div v-if="pageError" class="jio-error-fallback">
|
||||||
<p class="aksrt-error-title">页面渲染异常</p>
|
<p class="jio-error-title">页面渲染异常</p>
|
||||||
<p class="aksrt-error-msg">{{ pageError }}</p>
|
<p class="jio-error-msg">{{ pageError }}</p>
|
||||||
<n-button size="small" @click="pageError = null">重试</n-button>
|
<n-button size="small" @click="pageError = null">重试</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
|
|
||||||
<div v-else class="aksrt-admin-mobile">
|
<div v-else class="jio-admin-mobile">
|
||||||
<MobileTopBar :title="currentPageTitle" @toggle="drawerActive = true" />
|
<MobileTopBar :title="currentPageTitle" @toggle="drawerActive = true" />
|
||||||
|
|
||||||
<n-drawer v-model:show="drawerActive" placement="left" :width="280">
|
<n-drawer v-model:show="drawerActive" placement="left" :width="280">
|
||||||
<n-drawer-content :native-scrollbar="false" body-content-style="padding:0;">
|
<n-drawer-content :native-scrollbar="false" body-content-style="padding:0;">
|
||||||
<div class="aksrt-logo aksrt-logo--drawer">
|
<div class="jio-logo jio-logo--drawer">
|
||||||
<span class="logo-icon">AK</span>
|
<span class="logo-icon">AK</span>
|
||||||
<span class="logo-text">AKSRT</span>
|
<span class="jio-logo__copy">
|
||||||
|
<span class="logo-text">Jio</span>
|
||||||
|
<span class="jio-logo__version">v{{ themeVersion }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<n-menu
|
<n-menu
|
||||||
|
class="jio-admin-menu"
|
||||||
:value="activeMenuKey"
|
:value="activeMenuKey"
|
||||||
:options="menuOptions"
|
:options="menuOptions"
|
||||||
@update:value="handleMenuClick"
|
@update:value="handleMenuClick"
|
||||||
@@ -65,18 +77,18 @@
|
|||||||
</n-drawer-content>
|
</n-drawer-content>
|
||||||
</n-drawer>
|
</n-drawer>
|
||||||
|
|
||||||
<div class="aksrt-admin-mobile__body">
|
<div class="jio-admin-mobile__body">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<transition name="fade-slide" mode="out-in">
|
<transition name="fade-slide" mode="out-in">
|
||||||
<div class="aksrt-page-wrapper" :key="route.fullPath">
|
<div class="jio-page-wrapper" :key="route.fullPath">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
|
|
||||||
<div v-if="pageError" class="aksrt-error-fallback">
|
<div v-if="pageError" class="jio-error-fallback">
|
||||||
<p class="aksrt-error-title">页面渲染异常</p>
|
<p class="jio-error-title">页面渲染异常</p>
|
||||||
<p class="aksrt-error-msg">{{ pageError }}</p>
|
<p class="jio-error-msg">{{ pageError }}</p>
|
||||||
<n-button size="small" @click="pageError = null">重试</n-button>
|
<n-button size="small" @click="pageError = null">重试</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,9 +113,63 @@ const collapsed = ref(false)
|
|||||||
const pageError = ref(null)
|
const pageError = ref(null)
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
const drawerActive = ref(false)
|
const drawerActive = ref(false)
|
||||||
|
const themeVersion = window.jioAdminData?.themeVersion || window.jioAdminData?.version || '9.0'
|
||||||
|
const MOBILE_BREAKPOINT = 960
|
||||||
|
|
||||||
|
const adminThemeOverrides = {
|
||||||
|
common: {
|
||||||
|
primaryColor: '#18A058',
|
||||||
|
primaryColorHover: '#36AD6A',
|
||||||
|
primaryColorPressed: '#0C7A43',
|
||||||
|
primaryColorSuppl: '#36AD6A',
|
||||||
|
bodyColor: '#F5F7F6',
|
||||||
|
cardColor: '#FFFFFF',
|
||||||
|
modalColor: '#FFFFFF',
|
||||||
|
popoverColor: '#FFFFFF',
|
||||||
|
textColorBase: '#1F2923',
|
||||||
|
textColor1: '#1F2923',
|
||||||
|
textColor2: '#465149',
|
||||||
|
textColor3: '#6F7A72',
|
||||||
|
borderColor: '#E2E8E3',
|
||||||
|
dividerColor: '#E2E8E3',
|
||||||
|
borderRadius: '8px',
|
||||||
|
borderRadiusSmall: '6px',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadiusMedium: '8px',
|
||||||
|
heightMedium: '36px',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: '12px',
|
||||||
|
borderColor: '#E2E8E3',
|
||||||
|
},
|
||||||
|
DataTable: {
|
||||||
|
thColor: '#F7F9F7',
|
||||||
|
thTextColor: '#465149',
|
||||||
|
tdColorHover: '#F7FBF8',
|
||||||
|
borderColor: '#E2E8E3',
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: '8px',
|
||||||
|
heightMedium: '36px',
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
itemColorActive: '#EAF7EF',
|
||||||
|
itemColorActiveHover: '#EAF7EF',
|
||||||
|
itemColorHover: '#F2F8F4',
|
||||||
|
itemTextColorActive: '#168A4B',
|
||||||
|
itemTextColorActiveHover: '#168A4B',
|
||||||
|
itemTextColorHover: '#168A4B',
|
||||||
|
},
|
||||||
|
Switch: {
|
||||||
|
railColorActive: '#18A058',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
function checkMobile() {
|
function checkMobile() {
|
||||||
isMobile.value = window.innerWidth < 768
|
isMobile.value = window.innerWidth < MOBILE_BREAKPOINT
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -118,7 +184,7 @@ onBeforeUnmount(() => {
|
|||||||
provide('collapsed', collapsed)
|
provide('collapsed', collapsed)
|
||||||
|
|
||||||
onErrorCaptured((err) => {
|
onErrorCaptured((err) => {
|
||||||
console.error('[AKSRT] 组件渲染错误:', err)
|
console.error('[Jio] 组件渲染错误:', err)
|
||||||
pageError.value = err?.message || String(err)
|
pageError.value = err?.message || String(err)
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -158,7 +224,7 @@ const menuOptions = ADMIN_SECTIONS.filter((section) => !section.hidden).map((sec
|
|||||||
children: section.children.map((child) => ({
|
children: section.children.map((child) => ({
|
||||||
label: child.secondaryEntry ? `${child.label} · 次级` : child.label,
|
label: child.secondaryEntry ? `${child.label} · 次级` : child.label,
|
||||||
key: child.key,
|
key: child.key,
|
||||||
props: child.secondaryEntry ? { class: 'aksrt-menu-item--secondary' } : undefined,
|
props: child.secondaryEntry ? { class: 'jio-menu-item--secondary' } : undefined,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -188,7 +254,7 @@ const activeMenuKey = computed(() => {
|
|||||||
return ADMIN_SECTIONS[0]?.children?.[0]?.key || 'global-common'
|
return ADMIN_SECTIONS[0]?.children?.[0]?.key || 'global-common'
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentPageTitle = computed(() => route.meta?.title || 'AKSRT')
|
const currentPageTitle = computed(() => route.meta?.title || 'Jio')
|
||||||
|
|
||||||
function handleMenuClick(key) {
|
function handleMenuClick(key) {
|
||||||
if (keyToPath[key]) {
|
if (keyToPath[key]) {
|
||||||
@@ -201,18 +267,18 @@ function handleMenuClick(key) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-logo {
|
.jio-logo {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
height: 60px;
|
height: 64px;
|
||||||
gap: 10px;
|
gap: 11px;
|
||||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
padding: 0 18px;
|
||||||
|
border-bottom: 1px solid var(--jio-color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-logo--drawer {
|
.jio-logo--drawer {
|
||||||
justify-content: flex-start;
|
height: 64px;
|
||||||
padding: 0 18px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo-icon {
|
.logo-icon {
|
||||||
@@ -221,52 +287,76 @@ function handleMenuClick(key) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 8px;
|
flex: 0 0 28px;
|
||||||
background: #18a058;
|
border-radius: 7px;
|
||||||
|
background: var(--jio-color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jio-logo__copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.logo-text {
|
.logo-text {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
letter-spacing: 1px;
|
letter-spacing: .08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-admin-content__inner {
|
.jio-logo__version {
|
||||||
padding: 24px 28px;
|
color: var(--jio-color-muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: .02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-admin-mobile {
|
.jio-admin-content__inner {
|
||||||
background: #f5f7fb;
|
width: 100%;
|
||||||
|
max-width: 1236px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 26px 28px 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-admin-mobile {
|
||||||
|
background: var(--jio-color-canvas);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-admin-mobile__body {
|
.jio-admin-mobile__body {
|
||||||
padding: 14px 12px 32px;
|
padding: 14px 12px 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-wrapper {
|
.jio-page-wrapper {
|
||||||
min-height: calc(100vh - 120px);
|
min-height: calc(100vh - 120px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-error-fallback {
|
.jio-error-fallback {
|
||||||
padding: 48px 20px;
|
padding: 48px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-error-title {
|
.jio-error-title {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #d03050;
|
color: #d03050;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-error-msg {
|
.jio-error-msg {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #777;
|
color: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.jio-admin-content__inner {
|
||||||
|
padding: 22px 20px 38px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,16 +1,31 @@
|
|||||||
/**
|
/**
|
||||||
* AKSRT Admin API Client
|
* Jio Admin API Client
|
||||||
*
|
*
|
||||||
* Communicates with the WordPress REST API at /wp-json/aksrt/v1
|
* Communicates with the WordPress REST API at /wp-json/jio/v1
|
||||||
* (or index.php?rest_route=/aksrt/v1 when pretty permalinks are off).
|
* (or index.php?rest_route=/jio/v1 when pretty permalinks are off).
|
||||||
* Methods are organized into domain modules while the client keeps the shared
|
* Methods are organized into domain modules while the client keeps the shared
|
||||||
* request pipeline and schema-domain normalization contract.
|
* request pipeline and schema-domain normalization contract.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiMethods } from './modules/index.js'
|
import { apiMethods } from './modules/index.js'
|
||||||
|
|
||||||
const API_ROOT = window.aksrtAdminData?.apiUrl || '/wp-json/aksrt/v1'
|
const API_ROOT = window.jioAdminData?.apiUrl || '/wp-json/jio/v1'
|
||||||
const NONCE = window.aksrtAdminData?.nonce || ''
|
const NONCE = window.jioAdminData?.nonce || ''
|
||||||
|
|
||||||
|
function createIdempotencyKey() {
|
||||||
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID()
|
||||||
|
}
|
||||||
|
if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') {
|
||||||
|
throw new Error('当前浏览器无法生成安全操作标识')
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||||
|
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDomainKey(domain) {
|
function normalizeDomainKey(domain) {
|
||||||
return String(domain || 'global').trim().replace(/-/g, '_')
|
return String(domain || 'global').trim().replace(/-/g, '_')
|
||||||
@@ -19,12 +34,11 @@ function normalizeDomainKey(domain) {
|
|||||||
/**
|
/**
|
||||||
* Join REST base + endpoint correctly for both pretty and plain permalinks.
|
* Join REST base + endpoint correctly for both pretty and plain permalinks.
|
||||||
*
|
*
|
||||||
* Pretty: https://site/wp-json/aksrt/v1 + /banners?kind=x
|
* Pretty: https://site/wp-json/jio/v1 + /settings/schema/global/common
|
||||||
* → https://site/wp-json/aksrt/v1/banners?kind=x
|
* → https://site/wp-json/jio/v1/settings/schema/global/common
|
||||||
*
|
*
|
||||||
* Plain: https://site/index.php?rest_route=/aksrt/v1 + /banners?kind=x
|
* Plain: https://site/index.php?rest_route=/jio/v1 + /settings/schema/global/common
|
||||||
* → https://site/index.php?rest_route=/aksrt/v1/banners&kind=x
|
* → https://site/index.php?rest_route=/jio/v1/settings/schema/global/common
|
||||||
* (must NOT produce rest_route=/aksrt/v1/banners?kind=x)
|
|
||||||
*
|
*
|
||||||
* @param {string} baseUrl
|
* @param {string} baseUrl
|
||||||
* @param {string} endpoint path starting with /, may include ?query
|
* @param {string} endpoint path starting with /, may include ?query
|
||||||
@@ -42,7 +56,7 @@ function joinRestUrl(baseUrl, endpoint) {
|
|||||||
path = '/' + path
|
path = '/' + path
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = String(baseUrl || '').trim() || '/wp-json/aksrt/v1'
|
const base = String(baseUrl || '').trim() || '/wp-json/jio/v1'
|
||||||
|
|
||||||
// Plain permalinks: rest_route query mode
|
// Plain permalinks: rest_route query mode
|
||||||
if (base.includes('rest_route=')) {
|
if (base.includes('rest_route=')) {
|
||||||
@@ -54,11 +68,11 @@ function joinRestUrl(baseUrl, endpoint) {
|
|||||||
return base.replace(/\/?$/, '') + path + (extraQuery ? (base.includes('?') ? '&' : '?') + extraQuery : '')
|
return base.replace(/\/?$/, '') + path + (extraQuery ? (base.includes('?') ? '&' : '?') + extraQuery : '')
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseRoute = (u.searchParams.get('rest_route') || '/aksrt/v1').replace(/\/$/, '') || '/aksrt/v1'
|
const baseRoute = (u.searchParams.get('rest_route') || '/jio/v1').replace(/\/$/, '') || '/jio/v1'
|
||||||
let fullRoute
|
let fullRoute
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
fullRoute = baseRoute
|
fullRoute = baseRoute
|
||||||
} else if (path.startsWith('/aksrt/')) {
|
} else if (path.startsWith('/jio/')) {
|
||||||
fullRoute = path
|
fullRoute = path
|
||||||
} else {
|
} else {
|
||||||
fullRoute = baseRoute + path
|
fullRoute = baseRoute + path
|
||||||
@@ -86,6 +100,7 @@ function joinRestUrl(baseUrl, endpoint) {
|
|||||||
class ApiClient {
|
class ApiClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.baseUrl = API_ROOT
|
this.baseUrl = API_ROOT
|
||||||
|
this.pendingIdempotencyKeys = new Map()
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeDomainKey(domain) {
|
normalizeDomainKey(domain) {
|
||||||
@@ -95,7 +110,7 @@ class ApiClient {
|
|||||||
async request(endpointOrConfig, options = {}) {
|
async request(endpointOrConfig, options = {}) {
|
||||||
// Dual-signature support:
|
// Dual-signature support:
|
||||||
// request('/users/1/restrictions')
|
// request('/users/1/restrictions')
|
||||||
// request({ url: '/aksrt/v1/users/1/restrictions', method: 'GET' })
|
// request({ url: '/jio/v1/users/1/restrictions', method: 'GET' })
|
||||||
let endpoint, method, headers, params, body, rest
|
let endpoint, method, headers, params, body, rest
|
||||||
|
|
||||||
if (typeof endpointOrConfig === 'object' && endpointOrConfig !== null) {
|
if (typeof endpointOrConfig === 'object' && endpointOrConfig !== null) {
|
||||||
@@ -119,10 +134,10 @@ class ApiClient {
|
|||||||
delete rest.data; delete rest.headers
|
delete rest.data; delete rest.headers
|
||||||
}
|
}
|
||||||
|
|
||||||
if (endpoint.startsWith('/wp-json/aksrt/v1')) {
|
if (endpoint.startsWith('/wp-json/jio/v1')) {
|
||||||
endpoint = endpoint.replace('/wp-json/aksrt/v1', '') || '/'
|
endpoint = endpoint.replace('/wp-json/jio/v1', '') || '/'
|
||||||
} else if (endpoint.startsWith('/aksrt/v1')) {
|
} else if (endpoint.startsWith('/jio/v1')) {
|
||||||
endpoint = endpoint.replace('/aksrt/v1', '') || '/'
|
endpoint = endpoint.replace('/jio/v1', '') || '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = joinRestUrl(this.baseUrl, endpoint)
|
let url = joinRestUrl(this.baseUrl, endpoint)
|
||||||
@@ -139,11 +154,22 @@ class ApiClient {
|
|||||||
finalBody = JSON.stringify(body)
|
finalBody = JSON.stringify(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isWrite = method !== 'GET' && method !== 'HEAD'
|
||||||
|
const suppliedKey = headers?.['Idempotency-Key'] || headers?.['idempotency-key'] || ''
|
||||||
|
const idempotencySignature = isWrite ? `${method}:${url}:${finalBody ?? ''}` : ''
|
||||||
|
const idempotencyKey = isWrite && !suppliedKey
|
||||||
|
? (this.pendingIdempotencyKeys.get(idempotencySignature) || createIdempotencyKey())
|
||||||
|
: suppliedKey
|
||||||
|
if (isWrite && !suppliedKey) {
|
||||||
|
this.pendingIdempotencyKeys.set(idempotencySignature, idempotencyKey)
|
||||||
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-WP-Nonce': NONCE,
|
'X-WP-Nonce': NONCE,
|
||||||
|
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||||
...headers,
|
...headers,
|
||||||
},
|
},
|
||||||
...rest,
|
...rest,
|
||||||
@@ -175,6 +201,9 @@ class ApiClient {
|
|||||||
throw new Error(msg)
|
throw new Error(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (idempotencySignature !== '') {
|
||||||
|
this.pendingIdempotencyKeys.delete(idempotencySignature)
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[API Error] ${endpoint}:`, error)
|
console.error(`[API Error] ${endpoint}:`, error)
|
||||||
|
|||||||
@@ -5,11 +5,4 @@ export const extensionsApiMethods = {
|
|||||||
|
|
||||||
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
|
testIpGeo(ip) { return this.request('/extensions/ip-test', { method: 'POST', data: { ip } }) },
|
||||||
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) },
|
testContentAudit(text) { return this.request('/extensions/audit-test', { method: 'POST', data: { text } }) },
|
||||||
|
|
||||||
/* AI 辅助 */
|
|
||||||
getAiStatus() { return this.request('/ai/status') },
|
|
||||||
updateAiSettings(data) { return this.request('/ai/settings', { method: 'POST', data }) },
|
|
||||||
generateSeo(data) { return this.request('/ai/seo-generate', { method: 'POST', data }) },
|
|
||||||
suggestSummary(data) { return this.request('/ai/summary-suggest', { method: 'POST', data }) },
|
|
||||||
suggestTags(data) { return this.request('/ai/tags-suggest', { method: 'POST', data }) },
|
|
||||||
}
|
}
|
||||||
@@ -3,25 +3,14 @@ export const globalApiMethods = {
|
|||||||
* 全局&功能 (Global & Functions)
|
* 全局&功能 (Global & Functions)
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
getMailSettings() { return this.request('/settings/mail') },
|
|
||||||
saveMailSettings(data) { return this.request('/settings/mail', { method: 'POST', body: JSON.stringify(data) }) },
|
|
||||||
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
|
testMail(to) { return this.request('/mail/test', { method: 'POST', body: JSON.stringify({ to }) }) },
|
||||||
|
|
||||||
getFooterSettings() { return this.request('/settings/footer') },
|
|
||||||
saveFooterSettings(data) { return this.request('/settings/footer', { method: 'POST', body: JSON.stringify(data) }) },
|
|
||||||
|
|
||||||
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
|
getFooterTabbarSettings() { return this.request('/settings/footer-tabbar') },
|
||||||
saveFooterTabbarSettings(data) { return this.request('/settings/footer-tabbar', { method: 'POST', body: JSON.stringify(data) }) },
|
saveFooterTabbarSettings(data) { return this.request('/settings/footer-tabbar', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
reorderFooterTabbar(ids) { return this.request('/settings/footer-tabbar/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
reorderFooterTabbar(ids) { return this.request('/settings/footer-tabbar/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
||||||
|
|
||||||
getFloatingButtons() { return this.request('/settings/floating-buttons') },
|
getFloatingButtons() { return this.request('/settings/floating-buttons') },
|
||||||
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
|
saveFloatingButtons(data) { return this.request('/settings/floating-buttons', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
reorderFloatingButtons(ids) { return this.request('/settings/floating-buttons/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
|
||||||
|
|
||||||
getFriendLinks() { return this.request('/friend-links') },
|
|
||||||
addFriendLink(data) { return this.request('/friend-links', { method: 'POST', body: JSON.stringify(data) }) },
|
|
||||||
deleteFriendLink(id) { return this.request(`/friend-links/${id}`, { method: 'DELETE' }) },
|
|
||||||
reorderFriendLinks(ids) { return this.request('/friend-links/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) },
|
|
||||||
|
|
||||||
getCmsFeatures() { return this.request('/settings/cms-features') },
|
getCmsFeatures() { return this.request('/settings/cms-features') },
|
||||||
saveCmsFeatures(cms_features) { return this.request('/settings/cms-features', { method: 'POST', body: JSON.stringify({ cms_features }) }) },
|
saveCmsFeatures(cms_features) { return this.request('/settings/cms-features', { method: 'POST', body: JSON.stringify({ cms_features }) }) },
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Icon catalog API — serves the universal icon picker (FA5 + sprite + custom).
|
||||||
|
*/
|
||||||
|
export const iconsApiMethods = {
|
||||||
|
getIconCatalog() {
|
||||||
|
return this.request('/icons/catalog')
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -2,18 +2,20 @@ import { globalApiMethods } from './global.js'
|
|||||||
import { pageDisplayApiMethods } from './page-display.js'
|
import { pageDisplayApiMethods } from './page-display.js'
|
||||||
import { permissionsApiMethods } from './permissions.js'
|
import { permissionsApiMethods } from './permissions.js'
|
||||||
import { usersApiMethods } from './users.js'
|
import { usersApiMethods } from './users.js'
|
||||||
import { paymentApiMethods } from './payment.js'
|
|
||||||
import { extensionsApiMethods } from './extensions.js'
|
import { extensionsApiMethods } from './extensions.js'
|
||||||
import { toolsApiMethods } from './tools.js'
|
import { toolsApiMethods } from './tools.js'
|
||||||
import { schemaApiMethods } from './schema.js'
|
import { schemaApiMethods } from './schema.js'
|
||||||
|
import { iconsApiMethods } from './icons.js'
|
||||||
|
import { ticketsApiMethods } from './tickets.js'
|
||||||
|
|
||||||
export const apiMethods = {
|
export const apiMethods = {
|
||||||
...globalApiMethods,
|
...globalApiMethods,
|
||||||
...pageDisplayApiMethods,
|
...pageDisplayApiMethods,
|
||||||
...permissionsApiMethods,
|
...permissionsApiMethods,
|
||||||
...usersApiMethods,
|
...usersApiMethods,
|
||||||
...paymentApiMethods,
|
|
||||||
...extensionsApiMethods,
|
...extensionsApiMethods,
|
||||||
...toolsApiMethods,
|
...toolsApiMethods,
|
||||||
...schemaApiMethods,
|
...schemaApiMethods,
|
||||||
|
...iconsApiMethods,
|
||||||
|
...ticketsApiMethods,
|
||||||
}
|
}
|
||||||
@@ -3,28 +3,18 @@ export const pageDisplayApiMethods = {
|
|||||||
* 页面&显示 (Page Display)
|
* 页面&显示 (Page Display)
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
getHomepageSettings() { return this.request('/settings/homepage') },
|
|
||||||
saveHomepageSettings(data) { return this.request('/settings/homepage', { method: 'POST', body: JSON.stringify(data) }) },
|
|
||||||
getHomepageCategories() { return this.request('/homepage/categories') },
|
|
||||||
|
|
||||||
getCategoryRecommendSettings() { return this.request('/settings/category-recommend') },
|
getCategoryRecommendSettings() { return this.request('/settings/category-recommend') },
|
||||||
saveCategoryRecommendSettings(data) { return this.request('/settings/category-recommend', { method: 'POST', body: JSON.stringify(data) }) },
|
saveCategoryRecommendSettings(data) { return this.request('/settings/category-recommend', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
getCategories() { return this.request('/categories') },
|
getCategories(taxonomy = 'category') {
|
||||||
|
const value = Array.isArray(taxonomy) ? taxonomy.join(',') : taxonomy
|
||||||
|
return this.request(`/categories?taxonomy=${encodeURIComponent(value || 'category')}`)
|
||||||
|
},
|
||||||
updateCategoryMeta(id, data) { return this.request(`/categories/${id}/meta`, { method: 'POST', body: JSON.stringify(data) }) },
|
updateCategoryMeta(id, data) { return this.request(`/categories/${id}/meta`, { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
searchUsers(search = '', number = 20) { return this.request(`/users/search?search=${encodeURIComponent(search)}&number=${number}`) },
|
searchUsers(search = '', number = 20) { return this.request(`/users/search?search=${encodeURIComponent(search)}&number=${number}`) },
|
||||||
|
|
||||||
getBanners(kind = 'hero') { return this.request(`/banners?kind=${encodeURIComponent(kind)}`) },
|
searchPageDisplayOptions(source, query = '', include = []) {
|
||||||
createBanner(data) { return this.request('/banners', { method: 'POST', body: JSON.stringify(data) }) },
|
const params = new URLSearchParams({ query })
|
||||||
updateBanner(id, data) { return this.request(`/banners/${id}`, { method: 'PUT', body: JSON.stringify(data) }) },
|
include.forEach(value => params.append('include[]', value))
|
||||||
deleteBanner(id) { return this.request(`/banners/${id}`, { method: 'DELETE' }) },
|
return this.request(`/settings/schema/page_display/search/${encodeURIComponent(source)}?${params}`)
|
||||||
|
},
|
||||||
getHeroes() { return this.getBanners('hero') },
|
|
||||||
createHero(data) { return this.createBanner({ ...data, kind: 'hero' }) },
|
|
||||||
updateHero(id, data) { return this.updateBanner(id, { ...data, kind: 'hero' }) },
|
|
||||||
deleteHero(id) { return this.deleteBanner(id) },
|
|
||||||
|
|
||||||
getHomepageBanners() { return this.getBanners('banner') },
|
|
||||||
createHomepageBanner(data) { return this.createBanner({ ...data, kind: 'banner' }) },
|
|
||||||
updateHomepageBanner(id, data) { return this.updateBanner(id, { ...data, kind: 'banner' }) },
|
|
||||||
deleteHomepageBanner(id) { return this.deleteBanner(id) },
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export const paymentApiMethods = {
|
|
||||||
/* ================================================================
|
|
||||||
* 支付&付费 (Payment)
|
|
||||||
* ================================================================ */
|
|
||||||
|
|
||||||
getPaymentOverview() { return this.request('/payment/overview') },
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,6 @@ export const permissionsApiMethods = {
|
|||||||
* 功能&权限 (Permissions)
|
* 功能&权限 (Permissions)
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
getPermissionsAttachmentSettings() { return this.getSchemaPage('permissions', 'attachment') },
|
|
||||||
savePermissionsAttachmentSettings(data) { return this.saveSchemaPage('permissions', 'attachment', data) },
|
|
||||||
|
|
||||||
getPermissionsLoginSettings() { return this.getSchemaPage('permissions', 'login') },
|
|
||||||
savePermissionsLoginSettings(data) { return this.saveSchemaPage('permissions', 'login', data) },
|
|
||||||
|
|
||||||
getPermissionsModerationSettings() { return this.getSchemaPage('permissions', 'moderation') },
|
|
||||||
|
|
||||||
getCapabilities() { return this.request('/permissions/capabilities') },
|
getCapabilities() { return this.request('/permissions/capabilities') },
|
||||||
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
|
saveCapabilities(user_caps) { return this.request('/permissions/capabilities', { method: 'POST', body: JSON.stringify({ user_caps }) }) },
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,12 @@ export const schemaApiMethods = {
|
|||||||
getSchemaPage(domain, section) {
|
getSchemaPage(domain, section) {
|
||||||
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`)
|
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`)
|
||||||
},
|
},
|
||||||
|
testCaptcha(provider, token) {
|
||||||
|
return this.request('/captcha/test', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ provider, token }),
|
||||||
|
})
|
||||||
|
},
|
||||||
saveSchemaPage(domain, section, data, version = '1.0.0') {
|
saveSchemaPage(domain, section, data, version = '1.0.0') {
|
||||||
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, {
|
return this.request(`/settings/schema/${this.normalizeDomainKey(domain)}/${encodeURIComponent(section)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 工单管理端 REST(命名空间 jio/v1/admin)。
|
||||||
|
*
|
||||||
|
* TicketRestAdmin 把三块字典 CRUD(工单状态 / 部门 / 预设回复)挂在
|
||||||
|
* jio/v1/admin 下,全部要求 manage_options。api 客户端的 baseUrl 只到
|
||||||
|
* jio/v1,因此这里必须用 /jio/ 全路径形式(joinRestUrl 的
|
||||||
|
* startsWith('/jio/') 分支会原样拼接,不再追加 base 前缀)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ADMIN = '/jio/v1/admin/tickets'
|
||||||
|
|
||||||
|
export const ticketsApiMethods = {
|
||||||
|
/* ---------------- 工单状态 ---------------- */
|
||||||
|
|
||||||
|
listTicketStatuses() { return this.request(`${ADMIN}/statuses`) },
|
||||||
|
createTicketStatus(data) {
|
||||||
|
return this.request(`${ADMIN}/statuses`, { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
},
|
||||||
|
updateTicketStatus(id, data) {
|
||||||
|
return this.request(`${ADMIN}/statuses/${id}`, { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
},
|
||||||
|
deleteTicketStatus(id) {
|
||||||
|
return this.request(`${ADMIN}/statuses/${id}/delete`, { method: 'POST', body: '{}' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------------- 工单部门 ---------------- */
|
||||||
|
|
||||||
|
listTicketTypes() { return this.request(`${ADMIN}/types`) },
|
||||||
|
createTicketType(data) {
|
||||||
|
return this.request(`${ADMIN}/types`, { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
},
|
||||||
|
updateTicketType(id, data) {
|
||||||
|
return this.request(`${ADMIN}/types/${id}`, { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
},
|
||||||
|
deleteTicketType(id) {
|
||||||
|
return this.request(`${ADMIN}/types/${id}/delete`, { method: 'POST', body: '{}' })
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ---------------- 预设回复 ---------------- */
|
||||||
|
|
||||||
|
listTicketPrereplies() { return this.request(`${ADMIN}/prereplies`) },
|
||||||
|
createTicketPrereply(data) {
|
||||||
|
return this.request(`${ADMIN}/prereplies`, { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
},
|
||||||
|
updateTicketPrereply(id, data) {
|
||||||
|
return this.request(`${ADMIN}/prereplies/${id}`, { method: 'POST', body: JSON.stringify(data) })
|
||||||
|
},
|
||||||
|
deleteTicketPrereply(id) {
|
||||||
|
return this.request(`${ADMIN}/prereplies/${id}/delete`, { method: 'POST', body: '{}' })
|
||||||
|
},
|
||||||
|
/** 排序入参必须是全量 ID,数量不符后端直接拒绝。 */
|
||||||
|
reorderTicketPrereplies(ids) {
|
||||||
|
return this.request(`${ADMIN}/prereplies/order`, { method: 'POST', body: JSON.stringify({ ids }) })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -3,14 +3,10 @@ export const toolsApiMethods = {
|
|||||||
* 扩展&增强 / 备份&导入 (Extensions & Tools)
|
* 扩展&增强 / 备份&导入 (Extensions & Tools)
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|
||||||
getHealthEmailVerification() { return this.request('/health/email-verification') },
|
|
||||||
getUpdateInfo() { return this.request('/tools/update-info') },
|
getUpdateInfo() { return this.request('/tools/update-info') },
|
||||||
detectUpdate() { return this.request('/tools/update-check', { method: 'POST', body: JSON.stringify({}) }) },
|
detectUpdate() { return this.request('/tools/update-check', { method: 'POST', body: JSON.stringify({}) }) },
|
||||||
dismissUpdate(version) { return this.request('/tools/update-dismiss', { method: 'POST', body: JSON.stringify({ version }) }) },
|
dismissUpdate(version) { return this.request('/tools/update-dismiss', { method: 'POST', body: JSON.stringify({ version }) }) },
|
||||||
|
|
||||||
getEmailRollbackUsers() { return this.request('/email-rollback/users') },
|
|
||||||
executeEmailRollback(id) { return this.request(`/email-rollback/users/${id}/rollback`, { method: 'POST' }) },
|
|
||||||
|
|
||||||
/* ================================================================
|
/* ================================================================
|
||||||
* Settings Management (Import / Export / Reset / Health / Schema)
|
* Settings Management (Import / Export / Reset / Health / Schema)
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ export const usersApiMethods = {
|
|||||||
|
|
||||||
getAuthSettings() { return this.request('/settings/auth') },
|
getAuthSettings() { return this.request('/settings/auth') },
|
||||||
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
|
saveAuthSettings(data) { return this.request('/settings/auth', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
|
testSms(phone) { return this.request('/sms/test', { method: 'POST', body: JSON.stringify({ phone }) }) },
|
||||||
getOAuthSettings() { return this.request('/settings/oauth') },
|
|
||||||
saveOAuthSettings(data) { return this.request('/settings/oauth', { method: 'POST', body: JSON.stringify(data) }) },
|
|
||||||
|
|
||||||
getNotificationSettings() { return this.request('/settings/notifications') },
|
getNotificationSettings() { return this.request('/settings/notifications') },
|
||||||
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
|
saveNotificationSettings(data) { return this.request('/settings/notifications', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
@@ -20,25 +18,13 @@ export const usersApiMethods = {
|
|||||||
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
|
deleteNotification(id) { return this.request(`/notifications/${id}`, { method: 'DELETE' }) },
|
||||||
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
|
runNotificationCleanup() { return this.request('/notifications/cleanup', { method: 'POST', body: JSON.stringify({}) }) },
|
||||||
|
|
||||||
getIdentitySettings() { return this.request('/identity/settings') },
|
getCreditSettings() { return this.request('/assets/settings') },
|
||||||
saveIdentitySettings(data) {
|
saveCreditSettings(data) { return this.request('/assets/settings', { method: 'POST', body: JSON.stringify(data) }) },
|
||||||
return this.request('/identity/settings', { method: 'POST', body: JSON.stringify(data) })
|
|
||||||
},
|
|
||||||
testIdentitySettings(data = {}) {
|
|
||||||
return this.request('/identity/settings/test', { method: 'POST', body: JSON.stringify(data) })
|
|
||||||
},
|
|
||||||
|
|
||||||
getCreditSettings() { return this.request('/credits/settings') },
|
|
||||||
saveCreditSettings(data) { return this.request('/credits/settings', { method: 'POST', body: JSON.stringify(data) }) },
|
|
||||||
listCreditUsers(params = {}) {
|
listCreditUsers(params = {}) {
|
||||||
const qs = new URLSearchParams(params).toString()
|
const qs = new URLSearchParams(params).toString()
|
||||||
return this.request('/credits/users' + (qs ? `?${qs}` : ''))
|
return this.request('/assets/accounts' + (qs ? `?${qs}` : ''))
|
||||||
},
|
},
|
||||||
adjustUserCredits(id, delta, reason) {
|
adjustUserCredits(id, asset, delta, remark) {
|
||||||
return this.request(`/credits/users/${id}/adjust`, { method: 'POST', body: JSON.stringify({ delta, reason }) })
|
return this.request(`/assets/accounts/${id}/adjust`, { method: 'POST', body: JSON.stringify({ asset, delta, remark }) })
|
||||||
},
|
|
||||||
getCreditLog(params = {}) {
|
|
||||||
const qs = new URLSearchParams(params).toString()
|
|
||||||
return this.request('/credits/log' + (qs ? `?${qs}` : ''))
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1,35 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-deferred-page">
|
<div class="jio-deferred-page">
|
||||||
<div class="aksrt-deferred-page__icon">
|
<div class="jio-deferred-page__icon">
|
||||||
<FaIcon :icon="resolvedIcon" :size="48" />
|
<FaIcon :icon="resolvedIcon" :size="48" />
|
||||||
</div>
|
</div>
|
||||||
<h2 class="aksrt-deferred-page__title">{{ resolvedTitle }}</h2>
|
<h2 class="jio-deferred-page__title">{{ resolvedTitle }}</h2>
|
||||||
<p class="aksrt-deferred-page__desc">{{ resolvedDesc }}</p>
|
<p class="jio-deferred-page__desc">{{ resolvedDesc }}</p>
|
||||||
<div class="aksrt-deferred-page__meta">
|
<div class="jio-deferred-page__meta">
|
||||||
<n-tag size="small" type="info">{{ resolvedStatus }}</n-tag>
|
<n-tag size="small" type="info">{{ resolvedStatus }}</n-tag>
|
||||||
<n-tag v-if="resolvedPhase" size="small" type="warning">Phase {{ resolvedPhase }}</n-tag>
|
<n-tag v-if="resolvedPhase" size="small" type="warning">Phase {{ resolvedPhase }}</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resolvedDependsOn.length" class="aksrt-deferred-page__depends">
|
<div v-if="resolvedDependsOn.length" class="jio-deferred-page__depends">
|
||||||
<span class="aksrt-deferred-page__label">依赖</span>
|
<span class="jio-deferred-page__label">依赖</span>
|
||||||
<div class="aksrt-deferred-page__depends-list">
|
<div class="jio-deferred-page__depends-list">
|
||||||
<n-tag v-for="item in resolvedDependsOn" :key="item" size="small" type="default">{{ item }}</n-tag>
|
<n-tag v-for="item in resolvedDependsOn" :key="item" size="small" type="default">{{ item }}</n-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resolvedReason" class="aksrt-deferred-page__reason">
|
<div v-if="resolvedReason" class="jio-deferred-page__reason">
|
||||||
<span class="aksrt-deferred-page__label">原因</span>
|
<span class="jio-deferred-page__label">原因</span>
|
||||||
<p>{{ resolvedReason }}</p>
|
<p>{{ resolvedReason }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="resolvedFeatures && resolvedFeatures.length" class="aksrt-deferred-page__features">
|
<div v-if="resolvedFeatures && resolvedFeatures.length" class="jio-deferred-page__features">
|
||||||
<div
|
<div
|
||||||
v-for="(feat, i) in resolvedFeatures"
|
v-for="(feat, i) in resolvedFeatures"
|
||||||
:key="i"
|
:key="i"
|
||||||
class="aksrt-deferred-page__feature"
|
class="jio-deferred-page__feature"
|
||||||
>
|
>
|
||||||
<FaIcon icon="fas fa-check-circle" :size="14" />
|
<FaIcon icon="fa fas fa-check-circle" :size="14" />
|
||||||
<span>{{ feat }}</span>
|
<span>{{ feat }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="aksrt-deferred-page__badge">
|
<div class="jio-deferred-page__badge">
|
||||||
<n-tag type="info" size="small">即将推出</n-tag>
|
<n-tag type="info" size="small">即将推出</n-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,7 +63,7 @@ const route = useRoute()
|
|||||||
/** Merge direct props with route meta deferredProps */
|
/** Merge direct props with route meta deferredProps */
|
||||||
const resolvedTitle = computed(() => props.title || route.meta?.deferredProps?.title || '功能开发中')
|
const resolvedTitle = computed(() => props.title || route.meta?.deferredProps?.title || '功能开发中')
|
||||||
const resolvedDesc = computed(() => props.description || route.meta?.deferredProps?.description || '此功能正在规划开发中,将在后续版本上线。')
|
const resolvedDesc = computed(() => props.description || route.meta?.deferredProps?.description || '此功能正在规划开发中,将在后续版本上线。')
|
||||||
const resolvedIcon = computed(() => props.icon || route.meta?.deferredProps?.icon || 'fas fa-rocket')
|
const resolvedIcon = computed(() => props.icon || route.meta?.deferredProps?.icon || 'fa fas fa-rocket')
|
||||||
const resolvedStatus = computed(() => props.status || route.meta?.deferredProps?.status || 'deferred')
|
const resolvedStatus = computed(() => props.status || route.meta?.deferredProps?.status || 'deferred')
|
||||||
const resolvedPhase = computed(() => props.phase || route.meta?.deferredProps?.phase || '')
|
const resolvedPhase = computed(() => props.phase || route.meta?.deferredProps?.phase || '')
|
||||||
const resolvedDependsOn = computed(() => {
|
const resolvedDependsOn = computed(() => {
|
||||||
@@ -78,7 +78,7 @@ const resolvedFeatures = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-deferred-page {
|
.jio-deferred-page {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -88,75 +88,75 @@ const resolvedFeatures = computed(() => {
|
|||||||
padding: 40px 20px;
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__icon {
|
.jio-deferred-page__icon {
|
||||||
color: #c0c4cc;
|
color: var(--jio-color-border-strong);
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__title {
|
.jio-deferred-page__title {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__desc {
|
.jio-deferred-page__desc {
|
||||||
margin: 0 0 20px;
|
margin: 0 0 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #8a909c;
|
color: var(--jio-color-muted);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__meta {
|
.jio-deferred-page__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__depends,
|
.jio-deferred-page__depends,
|
||||||
.aksrt-deferred-page__reason {
|
.jio-deferred-page__reason {
|
||||||
width: min(520px, 100%);
|
width: min(520px, 100%);
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__label {
|
.jio-deferred-page__label {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #4b5563;
|
color: var(--jio-color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__depends-list {
|
.jio-deferred-page__depends-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__reason p {
|
.jio-deferred-page__reason p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #666;
|
color: var(--jio-color-muted);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__features {
|
.jio-deferred-page__features {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__feature {
|
.jio-deferred-page__feature {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #666;
|
color: var(--jio-color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-deferred-page__feature :deep(i) {
|
.jio-deferred-page__feature :deep(i) {
|
||||||
color: #52c41a;
|
color: #52c41a;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-empty-state">
|
<div class="jio-empty-state">
|
||||||
<div class="aksrt-empty-state__icon">
|
<div class="jio-empty-state__icon">
|
||||||
<i :class="icon" style="font-size: 48px;"></i>
|
<i :class="icon" style="font-size: 48px;"></i>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="aksrt-empty-state__title">{{ title }}</h3>
|
<h3 class="jio-empty-state__title">{{ title }}</h3>
|
||||||
<p class="aksrt-empty-state__desc">{{ desc }}</p>
|
<p class="jio-empty-state__desc">{{ desc }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -12,12 +12,12 @@
|
|||||||
defineProps({
|
defineProps({
|
||||||
title: { type: String, default: '功能开发中' },
|
title: { type: String, default: '功能开发中' },
|
||||||
desc: { type: String, default: '此功能模块正在规划中,后续版本将逐步开放。' },
|
desc: { type: String, default: '此功能模块正在规划中,后续版本将逐步开放。' },
|
||||||
icon: { type: String, default: 'fas fa-hourglass-half' },
|
icon: { type: String, default: 'fa fas fa-hourglass-half' },
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-empty-state {
|
.jio-empty-state {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -26,22 +26,22 @@ defineProps({
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-empty-state__icon {
|
.jio-empty-state__icon {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
color: #c0c4cc;
|
color: var(--jio-color-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-empty-state__title {
|
.jio-empty-state__title {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-empty-state__desc {
|
.jio-empty-state__desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #7a7f8a;
|
color: var(--jio-color-muted);
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="aksrt-mobile-topbar">
|
<header class="jio-mobile-topbar">
|
||||||
<button
|
<button
|
||||||
class="aksrt-mobile-topbar__menu"
|
class="jio-mobile-topbar__menu"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="打开菜单"
|
aria-label="打开菜单"
|
||||||
@click="$emit('toggle')"
|
@click="$emit('toggle')"
|
||||||
@@ -13,35 +13,35 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="aksrt-mobile-topbar__title">{{ title }}</div>
|
<div class="jio-mobile-topbar__title">{{ title }}</div>
|
||||||
|
|
||||||
<div class="aksrt-mobile-topbar__brand">AK</div>
|
<div class="jio-mobile-topbar__brand">AK</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
defineProps({
|
||||||
title: { type: String, default: 'AKSRT' },
|
title: { type: String, default: 'Jio' },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['toggle'])
|
defineEmits(['toggle'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-mobile-topbar {
|
.jio-mobile-topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
background: #fff;
|
background: var(--jio-color-surface);
|
||||||
border-bottom: 1px solid #eceff5;
|
border-bottom: 1px solid var(--jio-color-border);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-mobile-topbar__menu {
|
.jio-mobile-topbar__menu {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -50,26 +50,26 @@ defineEmits(['toggle'])
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-left: -6px;
|
margin-left: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-mobile-topbar__menu:hover {
|
.jio-mobile-topbar__menu:hover {
|
||||||
background: #f3f5f9;
|
background: var(--jio-color-primary-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-mobile-topbar__title {
|
.jio-mobile-topbar__title {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-mobile-topbar__brand {
|
.jio-mobile-topbar__brand {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -77,7 +77,7 @@ defineEmits(['toggle'])
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: #18a058;
|
background: var(--jio-color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-card class="oauth-provider-card" title="">
|
|
||||||
<!-- 平台信息头部 -->
|
|
||||||
<div class="oauth-provider-header">
|
|
||||||
<div class="oauth-header-left">
|
|
||||||
<span class="oauth-platform-icon">
|
|
||||||
<img v-if="icon" :src="icon" :alt="name" class="oauth-platform-img" />
|
|
||||||
<span v-else class="oauth-platform-text">{{ name.charAt(0) }}</span>
|
|
||||||
</span>
|
|
||||||
<div class="oauth-platform-info">
|
|
||||||
<h4>{{ name }}</h4>
|
|
||||||
<a :href="docUrl" target="_blank" class="oauth-doc-link">
|
|
||||||
前往 {{ name }} 创建应用 ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-switch v-model:value="modelValue.enabled" class="oauth-enable-switch">
|
|
||||||
<template #checked>启用</template>
|
|
||||||
<template #unchecked>停用</template>
|
|
||||||
</n-switch>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 表单区域(启用后才展开) -->
|
|
||||||
<div v-if="modelValue.enabled" class="oauth-form-body">
|
|
||||||
<n-form label-placement="top" class="aksrt-form oauth-form-grid">
|
|
||||||
<n-form-item
|
|
||||||
v-for="field in fields"
|
|
||||||
:key="field.key"
|
|
||||||
:label="field.label"
|
|
||||||
>
|
|
||||||
<n-input
|
|
||||||
v-if="!field.readonly"
|
|
||||||
v-model:value="modelValue[field.key]"
|
|
||||||
:type="field.secret ? 'password' : 'text'"
|
|
||||||
:show-password-on-click="field.secret"
|
|
||||||
:placeholder="'输入 ' + field.label"
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
<n-input
|
|
||||||
v-else
|
|
||||||
:value="callbackUrl"
|
|
||||||
readonly
|
|
||||||
size="large"
|
|
||||||
style='font-family: "SF Mono", Consolas, monospace; font-size:13px;'
|
|
||||||
/>
|
|
||||||
<template v-if="field.hint" #feedback>
|
|
||||||
<span class="aksrt-hint-text">{{ field.hint }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-if="field.readonly && !field.hint" #feedback>
|
|
||||||
<span class="aksrt-hint-text">将此地址填写到对应平台的「授权回调域 / 回调 URL」中</span>
|
|
||||||
</template>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 未启用时的提示 -->
|
|
||||||
<div v-else class="oauth-disabled-hint">
|
|
||||||
<p>开启开关以配置此平台的 OAuth 凭证</p>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: { type: Object, required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
docUrl: { type: String, required: true },
|
|
||||||
fields: { type: Array, required: true },
|
|
||||||
icon: { type: String, default: '' },
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
const callbackUrl = computed(() => {
|
|
||||||
const siteUrl = window.aksrtAdminData?.siteUrl || window.location.origin
|
|
||||||
return `${siteUrl}/oauth/callback/`
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.oauth-provider-card {
|
|
||||||
border-radius: 12px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 平台头部 */
|
|
||||||
.oauth-provider-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-header-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-platform-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #f7f8fa;
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-platform-img {
|
|
||||||
width: 26px;
|
|
||||||
height: 26px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-platform-text {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-platform-info h4 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1a1a2e;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-doc-link {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #1565c0;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.oauth-doc-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 启用开关 */
|
|
||||||
.oauth-enable-switch {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 表单体 */
|
|
||||||
.oauth-form-body {
|
|
||||||
padding-top: 8px;
|
|
||||||
border-top: 1px solid #f0f0f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oauth-form-grid :deep(.n-form-item) {
|
|
||||||
max-width: 520px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 禁用提示 */
|
|
||||||
.oauth-disabled-hint {
|
|
||||||
text-align: center;
|
|
||||||
padding: 28px 0 10px;
|
|
||||||
}
|
|
||||||
.oauth-disabled-hint p {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #bbb;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="aksrt-page-header">
|
<header class="jio-page-header">
|
||||||
<div class="aksrt-page-header__main">
|
<div class="jio-page-header__main">
|
||||||
<h2 class="aksrt-page-header__title">{{ title }}</h2>
|
<h2 class="jio-page-header__title">{{ title }}</h2>
|
||||||
<p v-if="desc" class="aksrt-page-header__desc">
|
<p v-if="desc" class="jio-page-header__desc">
|
||||||
<slot name="desc">{{ desc }}</slot>
|
<slot name="desc">{{ desc }}</slot>
|
||||||
</p>
|
</p>
|
||||||
<div v-else-if="$slots.desc" class="aksrt-page-header__desc">
|
<div v-else-if="$slots.desc" class="jio-page-header__desc">
|
||||||
<slot name="desc" />
|
<slot name="desc" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.actions" class="aksrt-page-header__actions">
|
<div v-if="$slots.actions" class="jio-page-header__actions">
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -23,7 +23,7 @@ defineProps({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-page-header {
|
.jio-page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -32,28 +32,28 @@ defineProps({
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-header__main {
|
.jio-page-header__main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-header__title {
|
.jio-page-header__title {
|
||||||
margin: 0 0 6px;
|
margin: 0 0 6px;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-header__desc {
|
.jio-page-header__desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #7a7f8a;
|
color: var(--jio-color-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.65;
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-header__actions {
|
.jio-page-header__actions {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -61,20 +61,20 @@ defineProps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.aksrt-page-header {
|
.jio-page-header {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-header__title {
|
.jio-page-header__title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-header__desc {
|
.jio-page-header__desc {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-page-header__actions {
|
.jio-page-header__actions {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,11 +38,14 @@
|
|||||||
<n-form-item label="AccessKey Secret">
|
<n-form-item label="AccessKey Secret">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="model.access_key_secret"
|
v-model:value="model.access_key_secret"
|
||||||
type="password"
|
type="text"
|
||||||
show-password-on="click"
|
show-password-on="click"
|
||||||
autocomplete="current-password"
|
class="jio-secret-input"
|
||||||
|
autocomplete="new-password"
|
||||||
placeholder="阿里云 RAM 用户 AccessKey Secret"
|
placeholder="阿里云 RAM 用户 AccessKey Secret"
|
||||||
|
@update:value="onSecretInput('access_key_secret', $event)"
|
||||||
/>
|
/>
|
||||||
|
<n-button v-if="model.access_key_secret === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('access_key_secret')">清除</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-alert type="warning" style="margin-bottom: 8px;">
|
<n-alert type="warning" style="margin-bottom: 8px;">
|
||||||
请使用 RAM 子账号密钥并授权 <code>AliyunYundunAFSFullAccess</code> 权限,切勿使用主账号密钥。
|
请使用 RAM 子账号密钥并授权 <code>AliyunYundunAFSFullAccess</code> 权限,切勿使用主账号密钥。
|
||||||
@@ -58,6 +61,16 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const model = defineModel({ type: Object, default: () => ({}) })
|
const model = defineModel({ type: Object, default: () => ({}) })
|
||||||
|
const emit = defineEmits(['clear-secret', 'restore-secret'])
|
||||||
|
|
||||||
|
function clearSecret(field) {
|
||||||
|
emit('clear-secret', field)
|
||||||
|
model.value = { ...model.value, [field]: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSecretInput(field, value) {
|
||||||
|
if (value !== '') emit('restore-secret', field)
|
||||||
|
}
|
||||||
|
|
||||||
const isConfigured = computed(() => {
|
const isConfigured = computed(() => {
|
||||||
const c = model.value
|
const c = model.value
|
||||||
|
|||||||
@@ -5,10 +5,8 @@
|
|||||||
<n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" />
|
<n-input v-model:value="model.site_key" placeholder="Cloudflare Turnstile Site Key" autocomplete="off" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="Secret Key">
|
<n-form-item label="Secret Key">
|
||||||
<n-input v-model:value="model.secret_key" type="password" show-password-on="click" autocomplete="current-password" />
|
<n-input v-model:value="model.secret_key" type="text" show-password-on="click" class="jio-secret-input" autocomplete="new-password" @update:value="onSecretInput('secret_key', $event)" />
|
||||||
</n-form-item>
|
<n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
|
||||||
<n-form-item label="自定义域名">
|
|
||||||
<n-input v-model:value="model.domain" placeholder="留空使用默认域名" autocomplete="url" />
|
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="主题">
|
<n-form-item label="主题">
|
||||||
<n-select v-model:value="model.theme" :options="themeOptions" clearable />
|
<n-select v-model:value="model.theme" :options="themeOptions" clearable />
|
||||||
@@ -26,6 +24,16 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const model = defineModel({ type: Object, default: () => ({}) })
|
const model = defineModel({ type: Object, default: () => ({}) })
|
||||||
|
const emit = defineEmits(['clear-secret', 'restore-secret'])
|
||||||
|
|
||||||
|
function clearSecret(field) {
|
||||||
|
emit('clear-secret', field)
|
||||||
|
model.value = { ...model.value, [field]: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSecretInput(field, value) {
|
||||||
|
if (value !== '') emit('restore-secret', field)
|
||||||
|
}
|
||||||
|
|
||||||
const isConfigured = computed(() => {
|
const isConfigured = computed(() => {
|
||||||
const c = model.value
|
const c = model.value
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
<n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" />
|
<n-input v-model:value="model.captcha_id" placeholder="极验 GeeTest CaptchaID" autocomplete="off" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="Captcha Key">
|
<n-form-item label="Captcha Key">
|
||||||
<n-input v-model:value="model.captcha_key" type="password" show-password-on="click" autocomplete="current-password" />
|
<n-input v-model:value="model.captcha_key" type="text" show-password-on="click" class="jio-secret-input" autocomplete="new-password" @update:value="onSecretInput('captcha_key', $event)" />
|
||||||
|
<n-button v-if="model.captcha_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('captcha_key')">清除</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
|
<n-tag v-if="isConfigured" type="success" size="small">✓ 已完整配置</n-tag>
|
||||||
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
|
<n-tag v-else type="warning" size="small">未完成配置</n-tag>
|
||||||
@@ -17,6 +18,16 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const model = defineModel({ type: Object, default: () => ({ product: 'bind' }) })
|
const model = defineModel({ type: Object, default: () => ({ product: 'bind' }) })
|
||||||
|
const emit = defineEmits(['clear-secret', 'restore-secret'])
|
||||||
|
|
||||||
|
function clearSecret(field) {
|
||||||
|
emit('clear-secret', field)
|
||||||
|
model.value = { ...model.value, [field]: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSecretInput(field, value) {
|
||||||
|
if (value !== '') emit('restore-secret', field)
|
||||||
|
}
|
||||||
|
|
||||||
const isConfigured = computed(() => {
|
const isConfigured = computed(() => {
|
||||||
const c = model.value
|
const c = model.value
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
<n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" />
|
<n-input v-model:value="model.secret_id" placeholder="腾讯云 API SecretId" autocomplete="off" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="SecretKey">
|
<n-form-item label="SecretKey">
|
||||||
<n-input v-model:value="model.secret_key" type="text" placeholder="腾讯云 API SecretKey" autocomplete="off" show-password-on="click" />
|
<n-input v-model:value="model.secret_key" type="text" class="jio-secret-input" placeholder="腾讯云 API SecretKey" autocomplete="off" @update:value="onSecretInput('secret_key', $event)" />
|
||||||
|
<n-button v-if="model.secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('secret_key')">清除</n-button>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-divider title-placement="left">验证码密钥</n-divider>
|
<n-divider title-placement="left">验证码密钥</n-divider>
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
<n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" />
|
<n-input v-model:value="model.app_id" placeholder="从验证码控制台获取 CaptchaAppId" autocomplete="off" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="AppSecretKey">
|
<n-form-item label="AppSecretKey">
|
||||||
<n-input v-model:value="model.app_secret_key" type="text" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="off" show-password-on="click" />
|
<n-input v-model:value="model.app_secret_key" type="text" class="jio-secret-input" placeholder="从验证码控制台获取 AppSecretKey" autocomplete="off" @update:value="onSecretInput('app_secret_key', $event)" />
|
||||||
|
<n-button v-if="model.app_secret_key === '***'" size="tiny" type="error" ghost style="margin-left:8px" @click="clearSecret('app_secret_key')">清除</n-button>
|
||||||
<template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template>
|
<template #feedback>请从「验证管理 → 基础配置」完整复制,注意区分 I/l/1/O/0</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
@@ -34,6 +36,16 @@
|
|||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const model = defineModel({ type: Object, default: () => ({}) })
|
const model = defineModel({ type: Object, default: () => ({}) })
|
||||||
|
const emit = defineEmits(['clear-secret', 'restore-secret'])
|
||||||
|
|
||||||
|
function clearSecret(field) {
|
||||||
|
emit('clear-secret', field)
|
||||||
|
model.value = { ...model.value, [field]: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSecretInput(field, value) {
|
||||||
|
if (value !== '') emit('restore-secret', field)
|
||||||
|
}
|
||||||
|
|
||||||
const isConfigured = computed(() => {
|
const isConfigured = computed(() => {
|
||||||
const c = model.value
|
const c = model.value
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<n-form label-placement="left" label-width="110" class="jio-form float-common-fields">
|
||||||
|
<n-form-item label="PC端显示" :show-feedback="false">
|
||||||
|
<n-switch :value="!!modelValue.pc_s" @update:value="update('pc_s', $event)" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="移动端显示" :show-feedback="false">
|
||||||
|
<n-switch :value="!!modelValue.m_s" @update:value="update('m_s', $event)" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="颜色" :show-feedback="false">
|
||||||
|
<div class="float-common-fields__colors">
|
||||||
|
<div class="float-common-fields__color">
|
||||||
|
<span>自定义图标颜色</span>
|
||||||
|
<JioColorPicker
|
||||||
|
:model-value="modelValue.color?.color || ''"
|
||||||
|
alpha
|
||||||
|
clearable
|
||||||
|
@update:model-value="updateColor('color', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="float-common-fields__color">
|
||||||
|
<span>自定义背景颜色</span>
|
||||||
|
<JioColorPicker
|
||||||
|
:model-value="modelValue.color?.bg || ''"
|
||||||
|
alpha
|
||||||
|
clearable
|
||||||
|
@update:model-value="updateColor('bg', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
<div class="float-common-fields__desc">自定义颜色请注意日间、夜间模式的适配</div>
|
||||||
|
</n-form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import JioColorPicker from '@/components/form/JioColorPicker.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
function update(key, value) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, [key]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateColor(key, value) {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
color: { ...(props.modelValue.color || { color: '', bg: '' }), [key]: value || '' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.float-common-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-common-fields__colors {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-common-fields__color {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 180px;
|
||||||
|
flex: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-common-fields__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
padding-left: 110px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<div class="float-btn-panel">
|
||||||
|
<div class="jio-sortable__head" v-bind="headAttrs">
|
||||||
|
<JioSortHandle @click.stop />
|
||||||
|
<div class="jio-sortable__main">
|
||||||
|
<div class="jio-sortable__title-row">
|
||||||
|
<span class="jio-sortable__title">{{ meta.title }}</span>
|
||||||
|
<template v-if="modelValue.type !== 'more'">
|
||||||
|
<n-tag v-if="modelValue.pc_s" size="small" type="info" :bordered="false">PC</n-tag>
|
||||||
|
<n-tag v-if="modelValue.m_s" size="small" type="warning" :bordered="false">移动</n-tag>
|
||||||
|
<n-tag v-if="!modelValue.pc_s && !modelValue.m_s" size="small" :bordered="false">未显示</n-tag>
|
||||||
|
</template>
|
||||||
|
<n-tag v-else size="small" :bordered="false">{{ modelValue.buttons.length }} 个</n-tag>
|
||||||
|
</div>
|
||||||
|
<span v-if="meta.warn" class="jio-sortable__desc float-btn-panel__warn">
|
||||||
|
<i class="fa fas fa-info-circle" aria-hidden="true" /> {{ meta.warn }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<i
|
||||||
|
class="fa fas jio-sortable__toggle"
|
||||||
|
:class="expanded ? 'fa-chevron-down' : 'fa-chevron-right'"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="expanded" class="jio-sortable__body">
|
||||||
|
<div class="float-btn-panel__subtitle">{{ meta.panel }}</div>
|
||||||
|
|
||||||
|
<FloatMoreButtonsEditor
|
||||||
|
v-if="modelValue.type === 'more'"
|
||||||
|
:model-value="modelValue.buttons"
|
||||||
|
@update:model-value="patch({ buttons: $event })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<FloatBtnCommonFields :model-value="modelValue" @update:model-value="emit('update:modelValue', $event)" />
|
||||||
|
|
||||||
|
<n-form label-placement="left" label-width="110" class="jio-form float-btn-panel__extra">
|
||||||
|
<n-form-item v-if="modelValue.type === 'qrcode'" label="一句话简介">
|
||||||
|
<n-input :value="modelValue.desc" @update:value="patch({ desc: $event })" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item v-if="modelValue.type === 'add'" label="按钮图标">
|
||||||
|
<FaIconPicker v-model="addIcon" placeholder="选择图标" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item v-if="modelValue.type === 'service_qq'" label="QQ号码">
|
||||||
|
<n-input :value="modelValue.qq" placeholder="仅填写数字" @update:value="patch({ qq: $event })" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<n-form-item v-if="modelValue.type === 'service_wechat'" label="微信二维码">
|
||||||
|
<JioMediaField
|
||||||
|
:model-value="modelValue.wechat_img"
|
||||||
|
placeholder="选择二维码图片"
|
||||||
|
@update:model-value="patch({ wechat_img: $event })"
|
||||||
|
/>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/** 悬浮按钮排序列表中的单个卡片;外层 .jio-sortable__item 由 FloatingButtonsSettings 提供。 */
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||||
|
import JioMediaField from '@/components/form/JioMediaField.vue'
|
||||||
|
import JioSortHandle from '@/components/form/JioSortHandle.vue'
|
||||||
|
import { collapseHeadAttrs } from '@/components/form/useCollapsibleItems.js'
|
||||||
|
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
|
||||||
|
import FloatMoreButtonsEditor from './FloatMoreButtonsEditor.vue'
|
||||||
|
import { FLOAT_BTN_META } from './floating-defaults.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Object, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
const headAttrs = computed(() => collapseHeadAttrs(expanded.value, () => { expanded.value = !expanded.value }))
|
||||||
|
const meta = computed(() => FLOAT_BTN_META[props.modelValue.type] || { title: props.modelValue.type, panel: '' })
|
||||||
|
|
||||||
|
const addIcon = computed({
|
||||||
|
get: () => props.modelValue.icon || '',
|
||||||
|
set: (value) => patch({ icon: value || '' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
function patch(partial) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...partial })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.float-btn-panel__warn {
|
||||||
|
color: var(--jio-color-warning, #D97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__subtitle {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--jio-color-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-btn-panel__extra {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<div class="float-more-editor">
|
||||||
|
<n-collapse :default-expanded-names="[]" accordion>
|
||||||
|
<n-collapse-item
|
||||||
|
v-for="(btn, index) in modelValue"
|
||||||
|
:key="index"
|
||||||
|
:name="index"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<span class="float-more-editor__head">
|
||||||
|
<IconValue v-if="btn.icon" :value="btn.icon" />
|
||||||
|
<span>{{ btn.desc || `按钮 ${index + 1}` }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
quaternary
|
||||||
|
type="error"
|
||||||
|
:disabled="modelValue.length <= 1"
|
||||||
|
@click.stop="remove(index)"
|
||||||
|
>
|
||||||
|
<template #icon><i class="fa fas fa-trash-alt" /></template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-form label-placement="left" label-width="110" class="jio-form">
|
||||||
|
<n-form-item label="按钮简介">
|
||||||
|
<n-input :value="btn.desc" @update:value="patch(index, { desc: $event })" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="按钮图标">
|
||||||
|
<FaIconPicker :model-value="btn.icon" placeholder="选择图标" @update:model-value="patch(index, { icon: $event || '' })" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="!btn.hover" label="按钮链接">
|
||||||
|
<div class="float-more-editor__link">
|
||||||
|
<n-input
|
||||||
|
:value="btn.link.url"
|
||||||
|
placeholder="https://"
|
||||||
|
@update:value="patch(index, { link: { ...btn.link, url: $event } })"
|
||||||
|
/>
|
||||||
|
<n-checkbox
|
||||||
|
:checked="btn.link.target === '_blank'"
|
||||||
|
@update:checked="patch(index, { link: { ...btn.link, target: $event ? '_blank' : '' } })"
|
||||||
|
>
|
||||||
|
新窗口打开
|
||||||
|
</n-checkbox>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item label="悬浮内容">
|
||||||
|
<div class="float-more-editor__block">
|
||||||
|
<div class="float-more-editor__subtitle">鼠标移到此处时显示内容</div>
|
||||||
|
<n-input
|
||||||
|
type="textarea"
|
||||||
|
:value="btn.hover"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 8 }"
|
||||||
|
@update:value="patch(index, { hover: $event })"
|
||||||
|
/>
|
||||||
|
<div class="float-more-editor__desc">设置此处后,按钮链接将失效<br>支持HTML代码,请注意代码规范及标签闭合</div>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="btn.hover" label="悬浮内容宽度">
|
||||||
|
<div class="float-more-editor__block">
|
||||||
|
<n-input-number
|
||||||
|
:value="btn.hover_width"
|
||||||
|
:min="50"
|
||||||
|
:max="500"
|
||||||
|
:step="10"
|
||||||
|
style="width: 180px;"
|
||||||
|
@update:value="patch(index, { hover_width: $event ?? 200 })"
|
||||||
|
>
|
||||||
|
<template #suffix>PX</template>
|
||||||
|
</n-input-number>
|
||||||
|
<div class="float-more-editor__desc">移动端宽度最大240px</div>
|
||||||
|
</div>
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<FloatBtnCommonFields :model-value="btn" @update:model-value="replace(index, $event)" />
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
|
||||||
|
<div class="float-more-editor__footer">
|
||||||
|
<n-button size="small" type="primary" @click="add">
|
||||||
|
<template #icon><i class="fa fas fa-plus" /></template>
|
||||||
|
添加按钮
|
||||||
|
</n-button>
|
||||||
|
<span class="float-more-editor__desc">此处内容至少保留一个,如一个都无需显示请关闭PC端和移动端显示即可</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||||
|
import IconValue from '@/components/form/IconValue.vue'
|
||||||
|
import FloatBtnCommonFields from './FloatBtnCommonFields.vue'
|
||||||
|
import { defaultMoreButton } from './floating-defaults.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, required: true },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
function replace(index, value) {
|
||||||
|
const next = props.modelValue.slice()
|
||||||
|
next.splice(index, 1, value)
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch(index, partial) {
|
||||||
|
replace(index, { ...props.modelValue[index], ...partial })
|
||||||
|
}
|
||||||
|
|
||||||
|
function add() {
|
||||||
|
emit('update:modelValue', [...props.modelValue, defaultMoreButton()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index) {
|
||||||
|
if (props.modelValue.length <= 1) return
|
||||||
|
const next = props.modelValue.slice()
|
||||||
|
next.splice(index, 1)
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.float-more-editor__head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__subtitle,
|
||||||
|
.float-more-editor__desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--n-text-color-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-more-editor__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 右侧悬浮按钮:类型元数据与默认值(与 src/Settings/FloatButtons.php 保持一致)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const emptyColor = () => ({ color: '', bg: '' })
|
||||||
|
|
||||||
|
/** 类型元数据:面板标题、折叠标题、黄色提示。 */
|
||||||
|
export const FLOAT_BTN_META = {
|
||||||
|
back_top: { title: '返回顶部', panel: '返回顶部', warn: '此按钮只能放置在最顶部或者最底部!' },
|
||||||
|
qrcode: { title: '当前页面二维码', panel: '当前页面二维码' },
|
||||||
|
add: { title: 'Add发布内容', panel: '发布投稿', warn: '此按钮尽量放在上方,否则会显示遮挡' },
|
||||||
|
theme_mode: { title: '日夜主题切换', panel: '日夜主题切换' },
|
||||||
|
pay_vip: { title: 'VIP购买', panel: 'VIP购买' },
|
||||||
|
service_qq: { title: 'QQ客服', panel: 'QQ客服' },
|
||||||
|
service_wechat: { title: '微信客服', panel: '微信客服' },
|
||||||
|
more: { title: '更多按钮', panel: '添加更多自定义按钮' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多按钮」出厂默认项:不预设任何第三方指向,只给一个图标按钮。 */
|
||||||
|
export function giftMoreButton() {
|
||||||
|
return {
|
||||||
|
desc: '',
|
||||||
|
icon: 'jiosvg-gift-color',
|
||||||
|
link: { url: '', target: '' },
|
||||||
|
hover: '',
|
||||||
|
hover_width: 200,
|
||||||
|
pc_s: true,
|
||||||
|
m_s: true,
|
||||||
|
color: { color: '', bg: 'rgba(255, 111, 6, 0.2)' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「添加按钮」新增项默认值。 */
|
||||||
|
export function defaultMoreButton() {
|
||||||
|
return {
|
||||||
|
desc: '',
|
||||||
|
icon: 'fa fas fa-heart',
|
||||||
|
link: { url: '', target: '' },
|
||||||
|
hover: '',
|
||||||
|
hover_width: 200,
|
||||||
|
pc_s: true,
|
||||||
|
m_s: true,
|
||||||
|
color: { color: '', bg: '' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultItems() {
|
||||||
|
return [
|
||||||
|
{ type: 'more', buttons: [giftMoreButton()] },
|
||||||
|
{ type: 'pay_vip', pc_s: true, m_s: true, color: { color: '#f2c97d', bg: 'rgba(62,62,67,0.9)' } },
|
||||||
|
{ type: 'add', pc_s: true, m_s: false, color: emptyColor(), icon: 'fa fas fa-pencil-alt' },
|
||||||
|
{ type: 'service_qq', pc_s: false, m_s: false, color: emptyColor(), qq: '' },
|
||||||
|
{ type: 'service_wechat', pc_s: true, m_s: true, color: emptyColor(), wechat_img: '' },
|
||||||
|
{ type: 'theme_mode', pc_s: false, m_s: false, color: emptyColor() },
|
||||||
|
{ type: 'qrcode', pc_s: true, m_s: false, color: emptyColor(), desc: '在手机上浏览此页面' },
|
||||||
|
{ type: 'back_top', pc_s: true, m_s: true, color: emptyColor() },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultSettings() {
|
||||||
|
return {
|
||||||
|
style: 'round',
|
||||||
|
position: 'bottom',
|
||||||
|
filter_blur: ['m_s'],
|
||||||
|
scroll_hide: false,
|
||||||
|
scroll_down_hide: true,
|
||||||
|
items: defaultItems(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化颜色组:确保 color/bg 都为字符串。 */
|
||||||
|
export function normalizeColor(color) {
|
||||||
|
const c = color && typeof color === 'object' ? color : {}
|
||||||
|
return { color: c.color || '', bg: c.bg || '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用默认值补齐服务端返回的按钮(按服务端顺序)。 */
|
||||||
|
export function normalizeItems(items) {
|
||||||
|
const defaults = Object.fromEntries(defaultItems().map((item) => [item.type, item]))
|
||||||
|
const source = Array.isArray(items) && items.length ? items : defaultItems()
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const raw of source) {
|
||||||
|
const base = defaults[raw?.type]
|
||||||
|
if (!base || seen.has(raw.type)) continue
|
||||||
|
seen.add(raw.type)
|
||||||
|
if (raw.type === 'more') {
|
||||||
|
const buttons = (Array.isArray(raw.buttons) && raw.buttons.length ? raw.buttons : base.buttons).map((btn) => ({
|
||||||
|
...defaultMoreButton(),
|
||||||
|
...btn,
|
||||||
|
link: { url: btn?.link?.url || '', target: btn?.link?.target || '' },
|
||||||
|
color: normalizeColor(btn?.color),
|
||||||
|
}))
|
||||||
|
out.push({ type: 'more', buttons })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.push({ ...base, ...raw, color: normalizeColor(raw.color) })
|
||||||
|
}
|
||||||
|
for (const item of defaultItems()) {
|
||||||
|
if (!seen.has(item.type)) out.push(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-array-table">
|
|
||||||
<n-data-table
|
|
||||||
:columns="tableColumns"
|
|
||||||
:data="modelValue || []"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
max-height="400"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-button
|
|
||||||
size="small"
|
|
||||||
dashed
|
|
||||||
block
|
|
||||||
class="aksrt-array-table__add"
|
|
||||||
@click="addItem"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-plus" :size="12" />
|
|
||||||
{{ addText || '添加行' }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* AksArrayTable — table-style editor for array data.
|
|
||||||
* Renders rows in a data table with inline editing.
|
|
||||||
*/
|
|
||||||
import { computed, h } from 'vue'
|
|
||||||
import { NInput, NSwitch, NButton, NSelect, NInputNumber } from 'naive-ui'
|
|
||||||
import FaIcon from './FaIcon.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: { type: Array, default: () => [] },
|
|
||||||
/** Column definitions: [{ key, label, type?, options?, width? }] */
|
|
||||||
columns: { type: Array, required: true },
|
|
||||||
defaults: { type: Object, default: () => ({}) },
|
|
||||||
addText: { type: String, default: '' },
|
|
||||||
maxRows: { type: Number, default: Infinity },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
function updateCell(rowIndex, key, value) {
|
|
||||||
const list = [...(props.modelValue || [])]
|
|
||||||
list[rowIndex] = { ...list[rowIndex], [key]: value }
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeItem(index) {
|
|
||||||
const list = (props.modelValue || []).filter((_, i) => i !== index)
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
function addItem() {
|
|
||||||
if ((props.modelValue || []).length >= props.maxRows) return
|
|
||||||
const item = {}
|
|
||||||
for (const col of props.columns) {
|
|
||||||
item[col.key] = col.default ?? (col.type === 'switch' ? false : '')
|
|
||||||
}
|
|
||||||
emit('update:modelValue', [...(props.modelValue || []), { ...item, ...props.defaults }])
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveUp(index) {
|
|
||||||
if (index <= 0) return
|
|
||||||
const list = [...(props.modelValue || [])]
|
|
||||||
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveDown(index) {
|
|
||||||
const list = [...(props.modelValue || [])]
|
|
||||||
if (index >= list.length - 1) return
|
|
||||||
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableColumns = computed(() => {
|
|
||||||
const cols = props.columns.map(col => ({
|
|
||||||
title: col.label,
|
|
||||||
key: col.key,
|
|
||||||
width: col.width || 150,
|
|
||||||
render(row, rowIndex) {
|
|
||||||
const val = row[col.key]
|
|
||||||
const update = (v) => updateCell(rowIndex, col.key, v)
|
|
||||||
|
|
||||||
if (col.type === 'switch') {
|
|
||||||
return h(NSwitch, { value: val, size: 'small', 'onUpdate:value': update })
|
|
||||||
}
|
|
||||||
if (col.type === 'select') {
|
|
||||||
return h(NSelect, { value: val, options: col.options || [], size: 'small', 'onUpdate:value': update })
|
|
||||||
}
|
|
||||||
if (col.type === 'number') {
|
|
||||||
return h(NInputNumber, { value: val, size: 'small', style: 'width:100%', 'onUpdate:value': update })
|
|
||||||
}
|
|
||||||
return h(NInput, { value: val, size: 'small', 'onUpdate:value': update })
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Add actions column
|
|
||||||
cols.push({
|
|
||||||
title: '操作',
|
|
||||||
key: '_actions',
|
|
||||||
width: 100,
|
|
||||||
render(row, rowIndex) {
|
|
||||||
return h('div', { style: 'display:flex;gap:2px' }, [
|
|
||||||
rowIndex > 0 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveUp(rowIndex) }, {
|
|
||||||
default: () => h(FaIcon, { icon: 'fas fa-arrow-up', size: 12 }),
|
|
||||||
}) : null,
|
|
||||||
rowIndex < (props.modelValue?.length || 0) - 1 ? h(NButton, { size: 'tiny', quaternary: true, onClick: () => moveDown(rowIndex) }, {
|
|
||||||
default: () => h(FaIcon, { icon: 'fas fa-arrow-down', size: 12 }),
|
|
||||||
}) : null,
|
|
||||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => removeItem(rowIndex) }, {
|
|
||||||
default: () => h(FaIcon, { icon: 'fas fa-trash', size: 12 }),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return cols
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-array-table__add {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,185 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-repeater">
|
|
||||||
<div v-if="modelValue && modelValue.length" class="aksrt-repeater__list">
|
|
||||||
<div
|
|
||||||
v-for="(item, index) in modelValue"
|
|
||||||
:key="index"
|
|
||||||
class="aksrt-repeater__item"
|
|
||||||
>
|
|
||||||
<div class="aksrt-repeater__item-header">
|
|
||||||
<span class="aksrt-repeater__item-index">#{{ index + 1 }}</span>
|
|
||||||
<div class="aksrt-repeater__item-actions">
|
|
||||||
<n-button
|
|
||||||
v-if="index > 0"
|
|
||||||
size="tiny"
|
|
||||||
quaternary
|
|
||||||
@click="moveUp(index)"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-arrow-up" :size="12" />
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
v-if="index < modelValue.length - 1"
|
|
||||||
size="tiny"
|
|
||||||
quaternary
|
|
||||||
@click="moveDown(index)"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-arrow-down" :size="12" />
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
size="tiny"
|
|
||||||
quaternary
|
|
||||||
type="error"
|
|
||||||
@click="removeItem(index)"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-trash" :size="12" />
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-repeater__item-fields">
|
|
||||||
<AksField
|
|
||||||
v-for="field in fields"
|
|
||||||
:key="field.key"
|
|
||||||
:schema="field"
|
|
||||||
:model-value="item[field.key]"
|
|
||||||
@update:model-value="updateField(index, field.key, $event)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="aksrt-repeater__empty">
|
|
||||||
<FaIcon icon="fas fa-inbox" :size="20" />
|
|
||||||
<span>{{ emptyText || '暂无数据' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-button
|
|
||||||
size="small"
|
|
||||||
dashed
|
|
||||||
block
|
|
||||||
class="aksrt-repeater__add"
|
|
||||||
@click="addItem"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-plus" :size="12" />
|
|
||||||
{{ addText || '添加项目' }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* AksRepeaterField — dynamic add/remove rows of structured data.
|
|
||||||
* Each row renders fields based on the provided field schemas.
|
|
||||||
*/
|
|
||||||
import AksField from './AksField.vue'
|
|
||||||
import FaIcon from './FaIcon.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: { type: Array, default: () => [] },
|
|
||||||
/** Array of field schemas for each row */
|
|
||||||
fields: { type: Array, required: true },
|
|
||||||
/** Default value for a new item */
|
|
||||||
defaults: { type: Object, default: () => ({}) },
|
|
||||||
emptyText: { type: String, default: '' },
|
|
||||||
addText: { type: String, default: '' },
|
|
||||||
maxItems: { type: Number, default: Infinity },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
|
||||||
|
|
||||||
function createItem() {
|
|
||||||
const item = {}
|
|
||||||
for (const field of props.fields) {
|
|
||||||
item[field.key] = field.default ?? (field.type === 'switch' ? false : '')
|
|
||||||
}
|
|
||||||
return { ...item, ...props.defaults }
|
|
||||||
}
|
|
||||||
|
|
||||||
function addItem() {
|
|
||||||
if (props.modelValue.length >= props.maxItems) return
|
|
||||||
const list = [...props.modelValue, createItem()]
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeItem(index) {
|
|
||||||
const list = props.modelValue.filter((_, i) => i !== index)
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveUp(index) {
|
|
||||||
if (index <= 0) return
|
|
||||||
const list = [...props.modelValue]
|
|
||||||
;[list[index - 1], list[index]] = [list[index], list[index - 1]]
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveDown(index) {
|
|
||||||
if (index >= props.modelValue.length - 1) return
|
|
||||||
const list = [...props.modelValue]
|
|
||||||
;[list[index], list[index + 1]] = [list[index + 1], list[index]]
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateField(index, key, value) {
|
|
||||||
const list = [...props.modelValue]
|
|
||||||
list[index] = { ...list[index], [key]: value }
|
|
||||||
emit('update:modelValue', list)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-repeater__list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-repeater__item {
|
|
||||||
border: 1px solid #eef1f6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #fafbfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-repeater__item-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-repeater__item-index {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1890ff;
|
|
||||||
background: #e6f4ff;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-repeater__item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-repeater__item-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-repeater__empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
color: #c0c4cc;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-repeater__add {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -11,16 +11,16 @@
|
|||||||
* FaIcon — Font Awesome 5 icon wrapper component.
|
* FaIcon — Font Awesome 5 icon wrapper component.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* <FaIcon icon="fas fa-cog" />
|
* <FaIcon icon="fa fas fa-cog" />
|
||||||
* <FaIcon icon="fa-cog" /> → defaults to "fas" prefix
|
* <FaIcon icon="fa-cog" /> → defaults to solid FA5
|
||||||
* <FaIcon icon="fab fa-github" />
|
* <FaIcon icon="fa fab fa-github" />
|
||||||
* <FaIcon :size="18" icon="far fa-heart" />
|
* <FaIcon :size="18" icon="fa fas fa-heart" />
|
||||||
* <FaIcon spin icon="fas fa-spinner" />
|
* <FaIcon spin icon="fa fas fa-spinner" />
|
||||||
*/
|
*/
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/** FA5 class string, e.g. "fas fa-cog", "fab fa-github", or just "fa-cog" */
|
/** FA5 class string, e.g. "fa fas fa-cog", "fa fab fa-github", or just "fa-cog" */
|
||||||
icon: { type: String, required: true },
|
icon: { type: String, required: true },
|
||||||
/** Pixel size — maps to font-size */
|
/** Pixel size — maps to font-size */
|
||||||
size: { type: [Number, String], default: null },
|
size: { type: [Number, String], default: null },
|
||||||
@@ -41,10 +41,10 @@ const props = defineProps({
|
|||||||
const iconClass = computed(() => {
|
const iconClass = computed(() => {
|
||||||
const parts = []
|
const parts = []
|
||||||
|
|
||||||
// Auto-prefix: if only "fa-xxx" is given, default to "fas"
|
// Auto-prefix: if only "fa-circle" is given, use the solid FA5 style.
|
||||||
const raw = props.icon.trim()
|
const raw = props.icon.trim()
|
||||||
if (raw.startsWith('fa-')) {
|
if (raw.startsWith('fa-')) {
|
||||||
parts.push('fas', raw)
|
parts.push('fa', 'fas', raw)
|
||||||
} else {
|
} else {
|
||||||
parts.push(...raw.split(/\s+/))
|
parts.push(...raw.split(/\s+/))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-fa-icon-picker">
|
<div class="jio-fa-icon-picker">
|
||||||
<n-popover trigger="click" placement="bottom-start" :width="360" :style="{ padding: 0 }">
|
<n-popover v-model:show="popoverShow" trigger="click" placement="bottom-start" :width="460" :style="{ padding: 0 }">
|
||||||
<template #trigger>
|
<template #trigger>
|
||||||
<n-input
|
<n-input
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
@@ -10,48 +10,81 @@
|
|||||||
@clear="$emit('update:modelValue', '')"
|
@clear="$emit('update:modelValue', '')"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<FaIcon v-if="modelValue" :icon="modelValue" />
|
<IconValue v-if="modelValue" :value="modelValue" />
|
||||||
|
<template v-else></template>
|
||||||
</template>
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<FaIcon icon="fas fa-chevron-down" :size="12" />
|
<FaIcon icon="fa fas fa-chevron-down" :size="12" />
|
||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="aksrt-icon-picker__panel">
|
<div class="jio-icon-picker__panel" @click="onPanelClick">
|
||||||
<div class="aksrt-icon-picker__search">
|
<div class="jio-icon-picker__search">
|
||||||
<n-input
|
<n-input v-model:value="search" placeholder="搜索图标..." size="small" clearable>
|
||||||
v-model:value="search"
|
|
||||||
placeholder="搜索图标..."
|
|
||||||
size="small"
|
|
||||||
clearable
|
|
||||||
>
|
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<FaIcon icon="fas fa-search" :size="12" />
|
<FaIcon icon="fa fas fa-search" :size="12" />
|
||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="aksrt-icon-picker__tabs">
|
<div class="jio-icon-picker__tabs">
|
||||||
<n-radio-group v-model:value="activePrefix" size="small">
|
<button
|
||||||
<n-radio-button value="fas">Solid</n-radio-button>
|
v-for="tab in tabs"
|
||||||
<n-radio-button value="far">Regular</n-radio-button>
|
:key="tab.id"
|
||||||
<n-radio-button value="fab">Brands</n-radio-button>
|
type="button"
|
||||||
</n-radio-group>
|
class="jio-icon-picker__tab"
|
||||||
|
:class="{ 'is-active': activeTab === tab.id }"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>
|
||||||
|
{{ tab.title }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="aksrt-icon-picker__grid" ref="gridRef">
|
<!-- Custom SVG tab -->
|
||||||
|
<div v-if="activeTab === 'custom'" class="jio-icon-picker__custom">
|
||||||
|
<n-input v-model:value="customCode" type="textarea" placeholder='<svg viewBox="0 0 1024 1024"><path d="…"></path></svg>' :rows="4" />
|
||||||
|
<div class="jio-icon-picker__custom-actions">
|
||||||
|
<span class="jio-icon-picker__custom-preview" v-html="customPreview"></span>
|
||||||
|
<n-button size="small" type="primary" :disabled="!customPreview" @click="insertCustom">
|
||||||
|
确认插入
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="sessionCustom.length"
|
||||||
|
class="jio-icon-picker__grid jio-icon-picker__grid--session"
|
||||||
|
data-jio-icon-grid="custom"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(svg, i) in sessionCustom"
|
||||||
|
:key="i"
|
||||||
|
class="jio-icon-picker__item"
|
||||||
|
:class="{ 'is-active': modelValue === `customsvg-${svg}` }"
|
||||||
|
title="customsvg"
|
||||||
|
:data-icon="`customsvg-${svg}`"
|
||||||
|
v-html="cleanSvg(svg)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Catalog tab -->
|
||||||
|
<div v-else class="jio-icon-picker__grid" data-jio-icon-grid="catalog">
|
||||||
<div
|
<div
|
||||||
v-for="icon in filteredIcons"
|
v-for="icon in filteredIcons"
|
||||||
:key="icon"
|
:key="icon"
|
||||||
class="aksrt-icon-picker__item"
|
class="jio-icon-picker__item"
|
||||||
:class="{ 'is-active': modelValue === `${activePrefix} ${icon}` }"
|
:class="{ 'is-active': modelValue === composeIconValue(activeGroup, icon) }"
|
||||||
:title="icon"
|
:title="icon"
|
||||||
@click="selectIcon(icon)"
|
:data-icon="composeIconValue(activeGroup, icon)"
|
||||||
>
|
>
|
||||||
<i :class="[activePrefix, icon]"></i>
|
<i v-if="activeGroup.type === 'fa'" :class="[activeTab, 'fa-' + icon]"></i>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="jio-icon-picker__preview-svg"
|
||||||
|
v-html="jiosvgMarkup(`jiosvg-${icon}`)"
|
||||||
|
></span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filteredIcons.length === 0" class="aksrt-icon-picker__empty">
|
<div v-if="filteredIcons.length === 0" class="jio-icon-picker__empty">
|
||||||
未找到匹配图标
|
未找到匹配图标
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,11 +95,19 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* FaIconPicker — searchable FA5 icon picker.
|
* FaIconPicker — universal icon picker backed by the server catalog
|
||||||
* Renders an input that opens a popover grid of FA5 icons.
|
* (GET /jio/v1/icons/catalog): full FA5 free set, the theme's built-in
|
||||||
|
* SVG sprite keys and inline custom SVG values.
|
||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import FaIcon from './FaIcon.vue'
|
import FaIcon from './FaIcon.vue'
|
||||||
|
import IconValue from './IconValue.vue'
|
||||||
|
import api from '@/api/index.js'
|
||||||
|
import {
|
||||||
|
cleanSvg,
|
||||||
|
jiosvgMarkup,
|
||||||
|
composeIconValue,
|
||||||
|
} from '@/utils/icons.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: '' },
|
modelValue: { type: String, default: '' },
|
||||||
@@ -75,112 +116,113 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const FALLBACK_TABS = [
|
||||||
|
{ id: 'fas', title: 'Solid', type: 'fa', icons: [] },
|
||||||
|
{ id: 'far', title: 'Regular', type: 'fa', icons: [] },
|
||||||
|
{ id: 'fab', title: 'Brands', type: 'fa', icons: [] },
|
||||||
|
{ id: 'jiosvg', title: '内置 SVG', type: 'svg', icons: [] },
|
||||||
|
{ id: 'custom', title: '自定义', type: 'custom', icons: [] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabs = ref(FALLBACK_TABS)
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const activePrefix = ref('fas')
|
const activeTab = ref('fas')
|
||||||
const gridRef = ref(null)
|
const customCode = ref('')
|
||||||
|
const sessionCustom = ref([])
|
||||||
|
const popoverShow = ref(false)
|
||||||
|
|
||||||
// Common FA5 icons subset — a curated list covering most admin UI needs.
|
const activeGroup = computed(() => tabs.value.find(t => t.id === activeTab.value) || tabs.value[0])
|
||||||
// Full FA5 has 1600+ icons; this subset has ~200 of the most useful ones.
|
|
||||||
const SOLID_ICONS = [
|
|
||||||
'fa-home', 'fa-cog', 'fa-cogs', 'fa-wrench', 'fa-sliders-h', 'fa-tools',
|
|
||||||
'fa-search', 'fa-filter', 'fa-sort', 'fa-sync', 'fa-redo', 'fa-undo',
|
|
||||||
'fa-plus', 'fa-minus', 'fa-times', 'fa-check', 'fa-exclamation', 'fa-info',
|
|
||||||
'fa-question', 'fa-bell', 'fa-envelope', 'fa-comment', 'fa-comments',
|
|
||||||
'fa-user', 'fa-users', 'fa-user-plus', 'fa-user-shield', 'fa-user-circle',
|
|
||||||
'fa-lock', 'fa-unlock', 'fa-key', 'fa-shield-alt', 'fa-fingerprint',
|
|
||||||
'fa-globe', 'fa-globe-americas', 'fa-link', 'fa-external-link-alt', 'fa-anchor',
|
|
||||||
'fa-image', 'fa-images', 'fa-camera', 'fa-video', 'fa-music', 'fa-file',
|
|
||||||
'fa-folder', 'fa-folder-open', 'fa-download', 'upload', 'fa-cloud',
|
|
||||||
'fa-database', 'fa-server', 'fa-hdd', 'fa-memory', 'fa-microchip',
|
|
||||||
'fa-desktop', 'fa-laptop', 'fa-mobile-alt', 'fa-tablet-alt',
|
|
||||||
'fa-chart-bar', 'fa-chart-line', 'fa-chart-pie', 'fa-chart-area',
|
|
||||||
'fa-tachometer-alt', 'fa-tasks', 'fa-clipboard', 'fa-clipboard-list',
|
|
||||||
'fa-list', 'fa-list-alt', 'fa-list-ol', 'fa-list-ul', 'fa-th', 'fa-th-large',
|
|
||||||
'fa-tags', 'fa-tag', 'fa-bookmark', 'fa-star', 'fa-heart', 'fa-thumbs-up',
|
|
||||||
'fa-share', 'fa-share-alt', 'fa-external-link-alt', 'fa-clipboard-check',
|
|
||||||
'fa-eye', 'fa-eye-slash', 'fa-lightbulb', 'fa-magic', 'fa-palette',
|
|
||||||
'fa-paint-brush', 'fa-font', 'fa-text-height', 'fa-bold', 'fa-italic',
|
|
||||||
'fa-code', 'fa-terminal', 'fa-bug', 'fa-exclamation-triangle',
|
|
||||||
'fa-exclamation-circle', 'fa-check-circle', 'fa-info-circle',
|
|
||||||
'fa-spinner', 'fa-circle-notch', 'fa-hourglass-half', 'fa-clock',
|
|
||||||
'fa-calendar', 'fa-calendar-alt', 'fa-history', 'fa-hourglass',
|
|
||||||
'fa-map-marker-alt', 'fa-map', 'fa-directions', 'fa-compass',
|
|
||||||
'fa-shopping-cart', 'fa-shopping-bag', 'fa-credit-card', 'fa-money-bill',
|
|
||||||
'fa-coins', 'fa-dollar-sign', 'fa-wallet', 'fa-receipt',
|
|
||||||
'fa-newspaper', 'fa-rss', 'fa-podcast', 'fa-megaphone',
|
|
||||||
'fa-flag', 'fa-trophy', 'fa-award', 'fa-medal', 'fa-gift',
|
|
||||||
'fa-fire', 'fa-bolt', 'fa-sun', 'fa-moon', 'fa-cloud-sun',
|
|
||||||
'fa-male', 'fa-female', 'fa-child', 'fa-id-card',
|
|
||||||
'fa-paper-plane', 'fa-paperclip', 'fa-at', 'fa-hashtag',
|
|
||||||
'fa-ellipsis-h', 'fa-ellipsis-v', 'fa-bars', 'fa-columns',
|
|
||||||
'fa-window-maximize', 'fa-window-minimize', 'fa-window-close',
|
|
||||||
'fa-expand', 'fa-compress', 'fa-arrows-alt', 'fa-crosshairs',
|
|
||||||
'fa-sign-in-alt', 'fa-sign-out-alt', 'fa-door-open',
|
|
||||||
'fa-plug', 'fa-power-off', 'fa-redo-alt', 'fa-sync-alt',
|
|
||||||
]
|
|
||||||
|
|
||||||
const REGULAR_ICONS = [
|
|
||||||
'fa-heart', 'fa-star', 'fa-bookmark', 'fa-user', 'fa-envelope',
|
|
||||||
'fa-comment', 'fa-comments', 'fa-bell', 'fa-clock', 'fa-calendar',
|
|
||||||
'fa-clipboard', 'fa-file', 'fa-folder', 'fa-image', 'fa-eye',
|
|
||||||
'fa-thumbs-up', 'fa-flag', 'fa-share-square', 'fa-hand-point-right',
|
|
||||||
'fa-life-ring', 'fa-lightbulb', 'fa-question-circle', 'fa-check-circle',
|
|
||||||
'fa-times-circle', 'fa-dot-circle', 'fa-circle', 'fa-square',
|
|
||||||
'fa-moon', 'fa-sun', 'fa-snowflake', 'fa-lemon', 'fa-gem',
|
|
||||||
'fa-copy', 'fa-edit', 'fa-trash-alt', 'fa-save', 'fa-window-maximize',
|
|
||||||
'fa-window-minimize', 'fa-window-close', 'fa-caret-square-right',
|
|
||||||
'fa-address-book', 'fa-address-card', 'fa-id-badge',
|
|
||||||
'fa-building', 'fa-chart-bar', 'fa-newspaper', 'fa-map',
|
|
||||||
]
|
|
||||||
|
|
||||||
const BRAND_ICONS = [
|
|
||||||
'fa-github', 'fa-gitlab', 'fa-bitbucket', 'fa-google', 'fa-google-plus',
|
|
||||||
'fa-facebook', 'fa-facebook-f', 'fa-twitter', 'fa-x-twitter',
|
|
||||||
'fa-instagram', 'fa-linkedin', 'fa-linkedin-in', 'fa-youtube',
|
|
||||||
'fa-weixin', 'fa-qq', 'fa-weibo', 'fa-telegram', 'fa-telegram-plane',
|
|
||||||
'fa-discord', 'fa-slack', 'fa-whatsapp', 'fa-skype',
|
|
||||||
'fa-apple', 'fa-windows', 'fa-linux', 'fa-android', 'fa-chrome',
|
|
||||||
'fa-firefox', 'fa-edge', 'fa-safari', 'fa-opera',
|
|
||||||
'fa-html5', 'fa-css3-alt', 'fa-js', 'fa-js-square', 'fa-node',
|
|
||||||
'fa-react', 'fa-vuejs', 'fa-angular', 'fa-php', 'fa-python',
|
|
||||||
'fa-java', 'fa-rust', 'fa-golang', 'fa-swift',
|
|
||||||
'fa-docker', 'fa-aws', 'fa-digital-ocean', 'fa-cloudflare',
|
|
||||||
'fa-wordpress', 'fa-joomla', 'fa-drupal', 'fa-shopify',
|
|
||||||
'fa-paypal', 'fa-stripe', 'fa-alipay', 'fa-weixin',
|
|
||||||
'fa-tiktok', 'fa-snapchat', 'fa-pinterest', 'fa-reddit',
|
|
||||||
'fa-stack-overflow', 'fa-codepen', 'fa-npm', 'fa-yarn',
|
|
||||||
'fa-twitch', 'fa-spotify', 'fa-soundcloud', 'fa-music',
|
|
||||||
'fa-bilibili',
|
|
||||||
]
|
|
||||||
|
|
||||||
const iconSets = { fas: SOLID_ICONS, far: REGULAR_ICONS, fab: BRAND_ICONS }
|
|
||||||
|
|
||||||
const filteredIcons = computed(() => {
|
const filteredIcons = computed(() => {
|
||||||
const set = iconSets[activePrefix.value] || SOLID_ICONS
|
const group = activeGroup.value
|
||||||
|
if (!group || group.type === 'custom') return []
|
||||||
const q = search.value.toLowerCase().replace(/\s+/g, '')
|
const q = search.value.toLowerCase().replace(/\s+/g, '')
|
||||||
if (!q) return set
|
if (!q) return group.icons
|
||||||
return set.filter(i => i.includes(q))
|
return group.icons.filter(i => i.includes(q))
|
||||||
})
|
})
|
||||||
|
|
||||||
function selectIcon(icon) {
|
const customPreview = computed(() => cleanSvg(customCode.value))
|
||||||
emit('update:modelValue', `${activePrefix.value} ${icon}`)
|
|
||||||
|
function selectIcon(value) {
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
popoverShow.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delegated on the panel: survives any DOM replacement inside the grids and
|
||||||
|
// works for both the catalog and session grids.
|
||||||
|
function onPanelClick(event) {
|
||||||
|
const item = event.target.closest('[data-icon]')
|
||||||
|
if (item) selectIcon(item.dataset.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertCustom() {
|
||||||
|
const svg = cleanSvg(customCode.value)
|
||||||
|
if (!svg) return
|
||||||
|
if (!sessionCustom.value.includes(svg)) sessionCustom.value.push(svg)
|
||||||
|
emit('update:modelValue', `customsvg-${svg}`)
|
||||||
|
customCode.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.getIconCatalog()
|
||||||
|
if (res?.success && Array.isArray(res.groups) && res.groups.length) {
|
||||||
|
tabs.value = res.groups
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Keep fallback tabs; the panel stays usable once the endpoint answers.
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-icon-picker__panel {
|
.jio-icon-picker__panel {
|
||||||
|
/* The popover teleports to <body>, outside #jio-admin-app where the
|
||||||
|
palette is declared — re-declare every token the panel consumes. */
|
||||||
|
--jio-color-primary: #18A058;
|
||||||
|
--jio-color-primary-hover: #36AD6A;
|
||||||
|
--jio-color-primary-soft: rgba(24, 160, 88, 0.12);
|
||||||
|
--jio-color-text-secondary: #465149;
|
||||||
|
--jio-color-muted: #6F7A72;
|
||||||
|
--jio-color-border: #E2E8E3;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__search {
|
.jio-icon-picker__search {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__tabs {
|
.jio-icon-picker__tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__grid {
|
.jio-icon-picker__tab {
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
border: 1px solid var(--jio-color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--jio-color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__tab:hover {
|
||||||
|
border-color: var(--jio-color-primary);
|
||||||
|
color: var(--jio-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__tab.is-active {
|
||||||
|
border-color: var(--jio-color-primary);
|
||||||
|
background: var(--jio-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(8, 1fr);
|
grid-template-columns: repeat(8, 1fr);
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -188,7 +230,7 @@ function selectIcon(icon) {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__item {
|
.jio-icon-picker__item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -197,25 +239,60 @@ function selectIcon(icon) {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #555;
|
color: var(--jio-color-text-secondary);
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__item:hover {
|
.jio-icon-picker__item:hover {
|
||||||
background: #f0f5ff;
|
background: var(--jio-color-primary-soft);
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__item.is-active {
|
.jio-icon-picker__item.is-active {
|
||||||
background: #1890ff;
|
background: var(--jio-color-primary);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-icon-picker__empty {
|
.jio-icon-picker__item :deep(svg),
|
||||||
|
.jio-icon-picker__preview-svg :deep(svg),
|
||||||
|
.jio-icon-picker__preview-svg svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__preview-svg {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__empty {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: #999;
|
color: var(--jio-color-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__custom-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__custom-preview {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px dashed var(--jio-color-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-picker__grid--session {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--jio-color-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<!-- FA class value -->
|
||||||
|
<i v-if="isFaValue(value)" :class="faClass" aria-hidden="true"></i>
|
||||||
|
<!-- Built-in sprite value -->
|
||||||
|
<span
|
||||||
|
v-else-if="isJiosvgValue(value)"
|
||||||
|
class="jio-icon-value__svg"
|
||||||
|
v-html="jiosvgMarkup(value)"
|
||||||
|
></span>
|
||||||
|
<!-- Inline custom SVG value -->
|
||||||
|
<span
|
||||||
|
v-else-if="isCustomSvgValue(value)"
|
||||||
|
class="jio-icon-value__svg"
|
||||||
|
v-html="cleanSvg(value.slice(10))"
|
||||||
|
></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* IconValue — renders any icon-picker value ("fa fas fa-star", "jiosvg-key",
|
||||||
|
* "customsvg-<svg>") as inline preview markup.
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { cleanSvg, isFaValue, isJiosvgValue, isCustomSvgValue, jiosvgMarkup } from '@/utils/icons.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
value: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const faClass = computed(() => {
|
||||||
|
const raw = String(props.value || '').trim()
|
||||||
|
if (raw.startsWith('fa-')) return `fa fas ${raw}`
|
||||||
|
return raw
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-icon-value__svg {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-icon-value__svg :deep(svg),
|
||||||
|
.jio-icon-value__svg svg {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+5
-5
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="editorRef" class="aks-code-editor" :class="{ 'is-disabled': disabled }" />
|
<div ref="editorRef" class="jio-code-editor" :class="{ 'is-disabled': disabled }" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -86,19 +86,19 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aks-code-editor {
|
.jio-code-editor {
|
||||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
border: 1px solid var(--n-border-color, #e0e0e6);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: calc(var(--rows, 8) * 22px);
|
min-height: calc(var(--rows, 8) * 22px);
|
||||||
}
|
}
|
||||||
.aks-code-editor :deep(.cm-editor) {
|
.jio-code-editor :deep(.cm-editor) {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.aks-code-editor :deep(.cm-editor.cm-focused) {
|
.jio-code-editor :deep(.cm-editor.cm-focused) {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
.aks-code-editor.is-disabled {
|
.jio-code-editor.is-disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<n-color-picker
|
||||||
|
:value="pickerValue"
|
||||||
|
:modes="modes"
|
||||||
|
:show-alpha="alpha"
|
||||||
|
:swatches="swatches"
|
||||||
|
:actions="clearable ? ['clear'] : undefined"
|
||||||
|
:disabled="disabled"
|
||||||
|
@update:value="onUpdate"
|
||||||
|
>
|
||||||
|
<template v-if="$slots.trigger" #trigger="slotProps">
|
||||||
|
<slot name="trigger" v-bind="slotProps" />
|
||||||
|
</template>
|
||||||
|
</n-color-picker>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 后台唯一取色器:所有颜色字段都经由此组件,输出与
|
||||||
|
* PHP `Jio\Core\Color::sanitize($value, $allow_alpha)` 同一规则的规范值。
|
||||||
|
* 清空 / 非法值统一回传空串。
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { normalizeColor } from '@/utils/color.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
alpha: { type: Boolean, default: false },
|
||||||
|
clearable: { type: Boolean, default: false },
|
||||||
|
swatches: { type: Array, default: undefined },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const modes = computed(() => (props.alpha ? ['hex', 'rgb'] : ['hex']))
|
||||||
|
const pickerValue = computed(() => normalizeColor(props.modelValue, props.alpha) || null)
|
||||||
|
|
||||||
|
function onUpdate(value) {
|
||||||
|
emit('update:modelValue', normalizeColor(value, props.alpha))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Between number (min/max pair) -->
|
||||||
|
<div class="jio-composite jio-composite--between">
|
||||||
|
<n-input-number
|
||||||
|
:value="betweenValue('min')"
|
||||||
|
:min="schema.min"
|
||||||
|
:max="schema.max"
|
||||||
|
:step="schema.step || 1"
|
||||||
|
:placeholder="schema.minPlaceholder || '最小'"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
style="flex: 1"
|
||||||
|
@update:value="update('min', $event)"
|
||||||
|
/>
|
||||||
|
<span class="jio-composite__sep">—</span>
|
||||||
|
<n-input-number
|
||||||
|
:value="betweenValue('max')"
|
||||||
|
:min="schema.min"
|
||||||
|
:max="schema.max"
|
||||||
|
:step="schema.step || 1"
|
||||||
|
:placeholder="schema.maxPlaceholder || '最大'"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
style="flex: 1"
|
||||||
|
@update:value="update('max', $event)"
|
||||||
|
/>
|
||||||
|
<span v-if="schema.unit" class="jio-composite__unit">{{ schema.unit }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/** 对象型复合字段:between_number(min / max 数值区间)。 */
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
schema: { type: Object, required: true },
|
||||||
|
modelValue: { default: null },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const current = computed(() => (
|
||||||
|
props.modelValue && typeof props.modelValue === 'object' && !Array.isArray(props.modelValue)
|
||||||
|
? props.modelValue
|
||||||
|
: {}
|
||||||
|
))
|
||||||
|
|
||||||
|
function update(part, value) {
|
||||||
|
emit('update:modelValue', { ...current.value, [part]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function betweenValue(part) {
|
||||||
|
return current.value[part] !== undefined ? current.value[part] : (props.schema.default?.[part] ?? 0)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-composite {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-composite--between {
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-composite__sep {
|
||||||
|
color: var(--jio-color-border-strong);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-composite__unit {
|
||||||
|
color: var(--jio-color-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large.
Load diff
+45
-45
@@ -1,32 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-field-group" :class="{ 'is-collapsible': collapsible, 'is-collapsed': collapsible && collapsed }">
|
<div class="jio-field-group" :class="{ 'is-collapsible': collapsible, 'is-collapsed': collapsible && collapsed }">
|
||||||
<div
|
<div
|
||||||
v-if="title"
|
v-if="title"
|
||||||
class="aksrt-field-group__header"
|
class="jio-field-group__header"
|
||||||
:class="{ 'is-clickable': collapsible }"
|
:class="{ 'is-clickable': collapsible }"
|
||||||
@click="collapsible ? toggleCollapse() : undefined"
|
@click="collapsible ? toggleCollapse() : undefined"
|
||||||
>
|
>
|
||||||
<div class="aksrt-field-group__header-left">
|
<div class="jio-field-group__header-left">
|
||||||
<FaIcon v-if="icon" :icon="icon" :size="16" class="aksrt-field-group__icon" />
|
<FaIcon v-if="icon" :icon="icon" :size="16" class="jio-field-group__icon" />
|
||||||
<div>
|
<div>
|
||||||
<h3 class="aksrt-field-group__title">{{ title }}</h3>
|
<h3 class="jio-field-group__title">{{ title }}</h3>
|
||||||
<p v-if="description" class="aksrt-field-group__desc">{{ description }}</p>
|
<p v-if="description" class="jio-field-group__desc">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="aksrt-field-group__header-right">
|
<div class="jio-field-group__header-right">
|
||||||
<div v-if="$slots.actions" class="aksrt-field-group__actions" @click.stop>
|
<div v-if="$slots.actions" class="jio-field-group__actions" @click.stop>
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="collapsible"
|
v-if="collapsible"
|
||||||
type="button"
|
type="button"
|
||||||
class="aksrt-field-group__collapse-btn"
|
class="jio-field-group__collapse-btn"
|
||||||
:aria-expanded="(!collapsed).toString()"
|
:aria-expanded="(!collapsed).toString()"
|
||||||
:title="collapsed ? '展开配置' : '收起配置'"
|
:title="collapsed ? '展开配置' : '收起配置'"
|
||||||
@click.stop="toggleCollapse"
|
@click.stop="toggleCollapse"
|
||||||
>
|
>
|
||||||
<FaIcon
|
<FaIcon
|
||||||
:icon="collapsed ? 'fas fa-chevron-right' : 'fas fa-chevron-down'"
|
:icon="collapsed ? 'fa fas fa-chevron-right' : 'fa fas fa-chevron-down'"
|
||||||
:size="12"
|
:size="12"
|
||||||
/>
|
/>
|
||||||
<span>{{ collapsed ? '展开' : '收起' }}</span>
|
<span>{{ collapsed ? '展开' : '收起' }}</span>
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="aksrt-collapse">
|
<transition name="jio-collapse">
|
||||||
<div v-show="!collapsed" class="aksrt-field-group__body">
|
<div v-show="!collapsed" class="jio-field-group__body">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
@@ -44,11 +44,10 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* AksFieldGroup — card-style container for grouping related form fields.
|
* JioFieldGroup — card-style container for grouping related form fields.
|
||||||
* Supports collapsible sections, icon headers, and description text.
|
* Supports collapsible sections, icon headers, and description text.
|
||||||
*
|
*
|
||||||
* defaultCollapsed only seeds initial state. Parent should remount the group
|
* defaultCollapsed only seeds the initial state; afterwards the user toggles freely.
|
||||||
* (e.g. via :key when a channel turns on) if it wants to force expand again.
|
|
||||||
*/
|
*/
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import FaIcon from './FaIcon.vue'
|
import FaIcon from './FaIcon.vue'
|
||||||
@@ -70,15 +69,16 @@ function toggleCollapse() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-field-group {
|
.jio-field-group {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
border: 1px solid #eef1f6;
|
border: 1px solid var(--jio-color-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: var(--jio-color-surface);
|
||||||
|
box-shadow: var(--jio-shadow-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__header {
|
.jio-field-group__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -86,66 +86,66 @@ function toggleCollapse() {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__header.is-clickable {
|
.jio-field-group__header.is-clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__header.is-clickable:hover .aksrt-field-group__title {
|
.jio-field-group__header.is-clickable:hover .jio-field-group__title {
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group.is-collapsed .aksrt-field-group__header {
|
.jio-field-group.is-collapsed .jio-field-group__header {
|
||||||
padding-bottom: 18px;
|
padding-bottom: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__header-left {
|
.jio-field-group__header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__header-right {
|
.jio-field-group__header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__icon {
|
.jio-field-group__icon {
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__title {
|
.jio-field-group__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__desc {
|
.jio-field-group__desc {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8a909c;
|
color: var(--jio-color-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__actions {
|
.jio-field-group__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__collapse-btn {
|
.jio-field-group__collapse-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border: 1px solid #e8ecf2;
|
border: 1px solid var(--jio-color-border);
|
||||||
background: #f7f9fc;
|
background: var(--jio-color-canvas);
|
||||||
color: #5c6370;
|
color: var(--jio-color-text-secondary);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -153,25 +153,25 @@ function toggleCollapse() {
|
|||||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__collapse-btn:hover {
|
.jio-field-group__collapse-btn:hover {
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
border-color: #91caff;
|
border-color: var(--jio-color-primary);
|
||||||
background: #f0f7ff;
|
background: var(--jio-color-primary-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-field-group__body {
|
.jio-field-group__body {
|
||||||
padding: 16px 20px 20px;
|
padding: 16px 20px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Collapse transition */
|
/* Collapse transition */
|
||||||
.aksrt-collapse-enter-active,
|
.jio-collapse-enter-active,
|
||||||
.aksrt-collapse-leave-active {
|
.jio-collapse-leave-active {
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-collapse-enter-from,
|
.jio-collapse-enter-from,
|
||||||
.aksrt-collapse-leave-to {
|
.jio-collapse-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
+132
-135
@@ -1,30 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-form-renderer">
|
<div class="jio-form-renderer">
|
||||||
<!-- Page header with title and description -->
|
<!-- Page header with title and description -->
|
||||||
<div v-if="title" class="aksrt-form-renderer__header">
|
<div v-if="title" class="jio-form-renderer__header">
|
||||||
<div class="aksrt-form-renderer__header-left">
|
<div class="jio-form-renderer__header-left">
|
||||||
<FaIcon v-if="icon" :icon="icon" :size="22" class="aksrt-form-renderer__header-icon" />
|
<FaIcon v-if="icon" :icon="icon" :size="22" class="jio-form-renderer__header-icon" />
|
||||||
<div>
|
<div>
|
||||||
<h1 class="aksrt-form-renderer__title">{{ title }}</h1>
|
<h1 class="jio-form-renderer__title">{{ title }}</h1>
|
||||||
<p v-if="description" class="aksrt-form-renderer__desc">{{ description }}</p>
|
<p v-if="description" class="jio-form-renderer__desc">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showSearch" class="aksrt-form-renderer__search">
|
<div v-if="showSearch" class="jio-form-renderer__search">
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="searchQuery"
|
v-model:value="searchQuery"
|
||||||
placeholder="搜索设置项..."
|
placeholder="搜索设置项..."
|
||||||
size="small"
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
|
name="jio-settings-search"
|
||||||
|
:input-props="searchInputProps"
|
||||||
>
|
>
|
||||||
<template #prefix>
|
<template #prefix>
|
||||||
<FaIcon icon="fas fa-search" :size="12" />
|
<FaIcon icon="fa fas fa-search" :size="12" />
|
||||||
</template>
|
</template>
|
||||||
</n-input>
|
</n-input>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<div v-if="loading" class="aksrt-form-renderer__loading">
|
<div v-if="loading" class="jio-form-renderer__loading">
|
||||||
<n-spin size="medium" />
|
<n-spin size="medium" />
|
||||||
<span>加载中...</span>
|
<span>加载中...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +47,7 @@
|
|||||||
<!-- Form content -->
|
<!-- Form content -->
|
||||||
<template v-else-if="schema && schema.length">
|
<template v-else-if="schema && schema.length">
|
||||||
<template v-for="group in filteredSchema" :key="groupKey(group)">
|
<template v-for="group in filteredSchema" :key="groupKey(group)">
|
||||||
<AksFieldGroup
|
<JioFieldGroup
|
||||||
v-if="isGroupVisible(group)"
|
v-if="isGroupVisible(group)"
|
||||||
:title="group.title"
|
:title="group.title"
|
||||||
:description="group.description"
|
:description="group.description"
|
||||||
@@ -53,27 +55,63 @@
|
|||||||
:collapsible="!!group.collapsible"
|
:collapsible="!!group.collapsible"
|
||||||
:default-collapsed="resolveGroupCollapsed(group)"
|
:default-collapsed="resolveGroupCollapsed(group)"
|
||||||
>
|
>
|
||||||
<template v-if="group.fields">
|
<template v-if="group.fields && !hasGroupSlot(group)">
|
||||||
<template v-for="field in group.fields" :key="field.key">
|
<template v-for="field in group.fields" :key="field.key">
|
||||||
<template v-if="isFieldVisible(field)">
|
<template v-if="isFieldVisible(field)">
|
||||||
|
<!-- Tabbed: renders tabs whose sub-fields are flat top-level keys -->
|
||||||
|
<div
|
||||||
|
v-if="field.type === 'tabbed' && (field.tabs || []).length"
|
||||||
|
class="jio-form-renderer__tabbed"
|
||||||
|
>
|
||||||
|
<n-tabs type="line" animated>
|
||||||
|
<n-tab-pane
|
||||||
|
v-for="tab in field.tabs"
|
||||||
|
:key="tab.key || tab.title"
|
||||||
|
:name="tabName(tab)"
|
||||||
|
:tab="tabName(tab)"
|
||||||
|
>
|
||||||
|
<template v-for="sub in (tab.fields || [])" :key="sub.key">
|
||||||
|
<JioField
|
||||||
|
v-if="isFieldVisible(sub)"
|
||||||
|
:schema="sub"
|
||||||
|
:model-value="getFieldValue(sub.key)"
|
||||||
|
:form-data="formData"
|
||||||
|
:error-message="errors?.[sub.key]"
|
||||||
|
@update:model-value="setFieldValue(sub.key, $event)"
|
||||||
|
@action="emit('field-action', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</div>
|
||||||
|
<JioSorterField
|
||||||
|
v-else-if="field.type === 'custom' && field.renderer === 'sorter'"
|
||||||
|
:model-value="getFieldValue(field.key) || {}"
|
||||||
|
:options="field.options || []"
|
||||||
|
@update:model-value="setFieldValue(field.key, $event)"
|
||||||
|
/>
|
||||||
<slot
|
<slot
|
||||||
v-if="field.type === 'custom'"
|
v-else-if="field.type === 'custom'"
|
||||||
:name="`field-${field.key}`"
|
:name="`field-${field.key}`"
|
||||||
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
|
v-bind="{ field, value: getFieldValue(field.key), setValue: (value) => setFieldValue(field.key, value), formData }"
|
||||||
>
|
>
|
||||||
<AksField
|
<JioField
|
||||||
:schema="field"
|
:schema="field"
|
||||||
:model-value="getFieldValue(field.key)"
|
:model-value="getFieldValue(field.key)"
|
||||||
|
:form-data="formData"
|
||||||
:error-message="errors?.[field.key]"
|
:error-message="errors?.[field.key]"
|
||||||
@update:model-value="setFieldValue(field.key, $event)"
|
@update:model-value="setFieldValue(field.key, $event)"
|
||||||
|
@action="emit('field-action', $event)"
|
||||||
/>
|
/>
|
||||||
</slot>
|
</slot>
|
||||||
<AksField
|
<JioField
|
||||||
v-else
|
v-else
|
||||||
:schema="field"
|
:schema="field"
|
||||||
:model-value="getFieldValue(field.key)"
|
:model-value="getFieldValue(field.key)"
|
||||||
|
:form-data="formData"
|
||||||
:error-message="errors?.[field.key]"
|
:error-message="errors?.[field.key]"
|
||||||
@update:model-value="setFieldValue(field.key, $event)"
|
@update:model-value="setFieldValue(field.key, $event)"
|
||||||
|
@action="emit('field-action', $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
@@ -81,43 +119,24 @@
|
|||||||
|
|
||||||
<!-- Custom group content slot -->
|
<!-- Custom group content slot -->
|
||||||
<slot :name="`group-${group.key}`" v-bind="{ group, formData }" />
|
<slot :name="`group-${group.key}`" v-bind="{ group, formData }" />
|
||||||
</AksFieldGroup>
|
</JioFieldGroup>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state -->
|
<!-- Empty state -->
|
||||||
<div v-else class="aksrt-form-renderer__empty">
|
<div v-else class="jio-form-renderer__empty">
|
||||||
<FaIcon icon="fas fa-cog" :size="32" />
|
<FaIcon icon="fa fas fa-cog" :size="32" />
|
||||||
<p>暂无配置项</p>
|
<p>暂无配置项</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sticky save bar -->
|
<!-- Sticky save bar -->
|
||||||
<div v-if="dirty" class="aksrt-form-renderer__save-bar">
|
<JioSaveBar :dirty="dirty" :saving="saving" @save="$emit('save')" @reload="$emit('reload')" />
|
||||||
<div class="aksrt-form-renderer__save-bar__inner">
|
|
||||||
<div class="aksrt-form-renderer__save-bar__info">
|
|
||||||
<FaIcon icon="fas fa-exclamation-circle" :size="14" />
|
|
||||||
<span>您有未保存的更改</span>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-renderer__save-bar__actions">
|
|
||||||
<n-button size="small" @click="$emit('reload')">放弃更改</n-button>
|
|
||||||
<n-button
|
|
||||||
size="small"
|
|
||||||
type="primary"
|
|
||||||
:loading="saving"
|
|
||||||
@click="$emit('save')"
|
|
||||||
>
|
|
||||||
<FaIcon v-if="!saving" icon="fas fa-save" :size="12" />
|
|
||||||
保存设置
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* AksFormRenderer — the core schema-driven form engine.
|
* JioFormRenderer — the core schema-driven form engine.
|
||||||
*
|
*
|
||||||
* Takes a schema definition (array of field groups) and a reactive form data object,
|
* Takes a schema definition (array of field groups) and a reactive form data object,
|
||||||
* and renders a complete settings page with:
|
* and renders a complete settings page with:
|
||||||
@@ -127,10 +146,12 @@
|
|||||||
* - Dependency-based field visibility
|
* - Dependency-based field visibility
|
||||||
* - Loading and error states
|
* - Loading and error states
|
||||||
*/
|
*/
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, useSlots } from 'vue'
|
||||||
import FaIcon from './FaIcon.vue'
|
import FaIcon from './FaIcon.vue'
|
||||||
import AksFieldGroup from './AksFieldGroup.vue'
|
import JioSaveBar from './JioSaveBar.vue'
|
||||||
import AksField from './AksField.vue'
|
import JioFieldGroup from './JioFieldGroup.vue'
|
||||||
|
import JioField from './JioField.vue'
|
||||||
|
import JioSorterField from './JioSorterField.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
/** Page title */
|
/** Page title */
|
||||||
@@ -161,10 +182,23 @@ const props = defineProps({
|
|||||||
errors: { type: Object, default: () => ({}) },
|
errors: { type: Object, default: () => ({}) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:formData', 'save', 'reload'])
|
const emit = defineEmits(['update:formData', 'save', 'reload', 'field-action'])
|
||||||
|
const slots = useSlots()
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// 同一页面上存在大量 type="password" 的凭证字段,Chrome 会把整页当成登录
|
||||||
|
// 表单,并把保存的账号灌进第一个文本框(也就是这个搜索框)。改名成搜索语义,
|
||||||
|
// 并显式关掉各类自动填充,才能让浏览器不再识别它。
|
||||||
|
const searchInputProps = {
|
||||||
|
autocomplete: 'off',
|
||||||
|
autocorrect: 'off',
|
||||||
|
autocapitalize: 'off',
|
||||||
|
spellcheck: 'false',
|
||||||
|
'data-lpignore': 'true',
|
||||||
|
'data-1p-ignore': 'true',
|
||||||
|
}
|
||||||
|
|
||||||
/** Evaluate dependsOn rule against current form data */
|
/** Evaluate dependsOn rule against current form data */
|
||||||
function matchDependsOn(dependsOn) {
|
function matchDependsOn(dependsOn) {
|
||||||
if (!dependsOn) return true
|
if (!dependsOn) return true
|
||||||
@@ -172,8 +206,16 @@ function matchDependsOn(dependsOn) {
|
|||||||
return !!props.formData[dependsOn]
|
return !!props.formData[dependsOn]
|
||||||
}
|
}
|
||||||
if (typeof dependsOn === 'object') {
|
if (typeof dependsOn === 'object') {
|
||||||
const { key, value, values, not } = dependsOn
|
if (Array.isArray(dependsOn.all)) return dependsOn.all.every(matchDependsOn)
|
||||||
|
if (Array.isArray(dependsOn.any)) return dependsOn.any.some(matchDependsOn)
|
||||||
|
const { key, value, values, not, operator } = dependsOn
|
||||||
const currentVal = props.formData[key]
|
const currentVal = props.formData[key]
|
||||||
|
if (operator === 'truthy') return !!currentVal
|
||||||
|
if (operator === 'falsy') return !currentVal
|
||||||
|
if (operator === 'not_empty') return Array.isArray(currentVal) ? currentVal.length > 0 : currentVal !== '' && currentVal != null
|
||||||
|
if (operator === 'empty') return Array.isArray(currentVal) ? currentVal.length === 0 : currentVal === '' || currentVal == null
|
||||||
|
if (operator === 'eq') return currentVal === value
|
||||||
|
if (operator === 'neq') return currentVal !== value
|
||||||
if (not) {
|
if (not) {
|
||||||
if (Array.isArray(values)) {
|
if (Array.isArray(values)) {
|
||||||
// eslint-disable-next-line eqeqeq
|
// eslint-disable-next-line eqeqeq
|
||||||
@@ -194,36 +236,39 @@ function matchDependsOn(dependsOn) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasGroupSlot(group) {
|
||||||
|
return !!slots[`group-${group.key}`]
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if a field should be visible based on its dependsOn rule */
|
/** Check if a field should be visible based on its dependsOn rule */
|
||||||
function isFieldVisible(field) {
|
function isFieldVisible(field) {
|
||||||
return matchDependsOn(field.dependsOn)
|
return matchDependsOn(field.dependsOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab label. `titleKey` binds the label to a live form value (e.g. the VIP level
|
||||||
|
* display name) so renaming a level renames its tab; `titleFallback` is used
|
||||||
|
* while that value is still empty.
|
||||||
|
*/
|
||||||
|
function tabName(tab) {
|
||||||
|
if (!tab) return ''
|
||||||
|
const dynamic = tab.titleKey ? props.formData?.[tab.titleKey] : ''
|
||||||
|
const label = typeof dynamic === 'string' ? dynamic.trim() : ''
|
||||||
|
return label || tab.titleFallback || tab.title || tab.key || ''
|
||||||
|
}
|
||||||
|
|
||||||
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
/** Whole group can be hidden (e.g. optional advanced blocks). */
|
||||||
function isGroupVisible(group) {
|
function isGroupVisible(group) {
|
||||||
return matchDependsOn(group.dependsOn)
|
return matchDependsOn(group.dependsOn)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 可折叠分组的初始状态:defaultCollapsed 为真时默认收起。 */
|
||||||
* Collapse preference for a group.
|
|
||||||
* - expandWhen: form key; when truthy, start expanded (channel just enabled → open form)
|
|
||||||
* - defaultCollapsed: static default
|
|
||||||
*/
|
|
||||||
function resolveGroupCollapsed(group) {
|
function resolveGroupCollapsed(group) {
|
||||||
if (group.expandWhen) {
|
|
||||||
const on = !!props.formData?.[group.expandWhen]
|
|
||||||
return !on
|
|
||||||
}
|
|
||||||
return !!group.defaultCollapsed
|
return !!group.defaultCollapsed
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stable key; remount when expandWhen flips so newly enabled channels open expanded. */
|
|
||||||
function groupKey(group) {
|
function groupKey(group) {
|
||||||
const base = group.key || group.title || 'group'
|
return group.key || group.title || 'group'
|
||||||
if (group.expandWhen) {
|
|
||||||
return `${base}:${props.formData?.[group.expandWhen] ? 'on' : 'off'}`
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get field value from formData */
|
/** Get field value from formData */
|
||||||
@@ -253,6 +298,15 @@ const filteredSchema = computed(() => {
|
|||||||
// Filter fields by label, key, or help text matching query
|
// Filter fields by label, key, or help text matching query
|
||||||
const matchingFields = group.fields.filter(field => {
|
const matchingFields = group.fields.filter(field => {
|
||||||
if (groupMatch) return true
|
if (groupMatch) return true
|
||||||
|
if (field.type === 'tabbed' && Array.isArray(field.tabs)) {
|
||||||
|
return (field.label || '').toLowerCase().includes(q)
|
||||||
|
|| field.tabs.some(tab => (tab.fields || []).some(sub =>
|
||||||
|
(sub.label || '').toLowerCase().includes(q)
|
||||||
|
|| (sub.key || '').toLowerCase().includes(q)
|
||||||
|
|| (sub.help || '').toLowerCase().includes(q)
|
||||||
|
|| (sub.description || '').toLowerCase().includes(q)
|
||||||
|
))
|
||||||
|
}
|
||||||
return (field.label || '').toLowerCase().includes(q)
|
return (field.label || '').toLowerCase().includes(q)
|
||||||
|| (field.key || '').toLowerCase().includes(q)
|
|| (field.key || '').toLowerCase().includes(q)
|
||||||
|| (field.help || '').toLowerCase().includes(q)
|
|| (field.help || '').toLowerCase().includes(q)
|
||||||
@@ -268,7 +322,7 @@ const filteredSchema = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-form-renderer__header {
|
.jio-form-renderer__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -277,132 +331,75 @@ const filteredSchema = computed(() => {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__header-left {
|
.jio-form-renderer__header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__header-icon {
|
.jio-form-renderer__header-icon {
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__title {
|
.jio-form-renderer__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__desc {
|
.jio-form-renderer__desc {
|
||||||
margin: 4px 0 0;
|
margin: 4px 0 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #7a7f8a;
|
color: var(--jio-color-muted);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__search {
|
.jio-form-renderer__tabbed {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-form-renderer__search {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 280px;
|
max-width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__loading {
|
.jio-form-renderer__loading {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
color: #8a909c;
|
color: var(--jio-color-muted);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__empty {
|
.jio-form-renderer__empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
color: #c0c4cc;
|
color: var(--jio-color-border-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__empty p {
|
.jio-form-renderer__empty p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky save bar */
|
|
||||||
.aksrt-form-renderer__save-bar {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 1000;
|
|
||||||
background: #fff;
|
|
||||||
border-top: 1px solid #eef1f6;
|
|
||||||
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
|
|
||||||
animation: slideUp 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__inner {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 12px 28px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__info {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
color: #d48806;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile */
|
/* Mobile */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.aksrt-form-renderer__header {
|
.jio-form-renderer__header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__search {
|
.jio-form-renderer__search {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__inner {
|
|
||||||
flex-direction: column;
|
|
||||||
padding: 10px 14px;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__actions {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-renderer__save-bar__actions .n-button {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jio-gallery-field">
|
||||||
|
<div v-if="attachmentIds.length" class="jio-gallery-field__grid">
|
||||||
|
<article
|
||||||
|
v-for="(attachmentId, index) in attachmentIds"
|
||||||
|
:key="attachmentId"
|
||||||
|
class="jio-gallery-field__item"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="previews[attachmentId]?.url"
|
||||||
|
:src="previews[attachmentId].url"
|
||||||
|
:alt="previews[attachmentId].alt || `附件 ${attachmentId}`"
|
||||||
|
class="jio-gallery-field__image"
|
||||||
|
/>
|
||||||
|
<div v-else class="jio-gallery-field__placeholder">
|
||||||
|
<FaIcon icon="fa fas fa-image" :size="22" />
|
||||||
|
<span>#{{ attachmentId }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jio-gallery-field__actions">
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
quaternary
|
||||||
|
:disabled="index === 0"
|
||||||
|
aria-label="向前移动"
|
||||||
|
@click="move(index, -1)"
|
||||||
|
>
|
||||||
|
<FaIcon icon="fa fas fa-arrow-left" />
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
quaternary
|
||||||
|
:disabled="index === attachmentIds.length - 1"
|
||||||
|
aria-label="向后移动"
|
||||||
|
@click="move(index, 1)"
|
||||||
|
>
|
||||||
|
<FaIcon icon="fa fas fa-arrow-right" />
|
||||||
|
</n-button>
|
||||||
|
<n-button
|
||||||
|
size="tiny"
|
||||||
|
quaternary
|
||||||
|
type="error"
|
||||||
|
aria-label="移除图片"
|
||||||
|
@click="remove(attachmentId)"
|
||||||
|
>
|
||||||
|
<FaIcon icon="fa fas fa-trash-alt" />
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="jio-gallery-field__empty">
|
||||||
|
<FaIcon icon="fa fas fa-images" :size="28" />
|
||||||
|
<span>尚未选择图片</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jio-gallery-field__buttons">
|
||||||
|
<n-button type="primary" secondary @click="openMediaLibrary">
|
||||||
|
<FaIcon icon="fa fas fa-plus" />
|
||||||
|
{{ attachmentIds.length ? '编辑图片' : '选择图片' }}
|
||||||
|
</n-button>
|
||||||
|
<n-button v-if="attachmentIds.length" type="error" quaternary @click="clear">
|
||||||
|
清空图片
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="mediaError" class="jio-gallery-field__error">{{ mediaError }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import FaIcon from './FaIcon.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, default: () => [] },
|
||||||
|
title: { type: String, default: '选择图片' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const previews = ref({})
|
||||||
|
const mediaError = ref('')
|
||||||
|
let previewRequest = 0
|
||||||
|
|
||||||
|
const attachmentIds = computed(() => {
|
||||||
|
const seen = new Set()
|
||||||
|
return props.modelValue
|
||||||
|
.map(value => Number.parseInt(value, 10))
|
||||||
|
.filter(value => value > 0 && !seen.has(value) && seen.add(value))
|
||||||
|
})
|
||||||
|
|
||||||
|
function update(ids) {
|
||||||
|
emit('update:modelValue', [...ids])
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(index, direction) {
|
||||||
|
const ids = [...attachmentIds.value]
|
||||||
|
const target = index + direction
|
||||||
|
if (target < 0 || target >= ids.length) return
|
||||||
|
;[ids[index], ids[target]] = [ids[target], ids[index]]
|
||||||
|
update(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(attachmentId) {
|
||||||
|
update(attachmentIds.value.filter(id => id !== attachmentId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
update([])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydratePreviews() {
|
||||||
|
const request = ++previewRequest
|
||||||
|
const media = window.wp?.media
|
||||||
|
if (!media) return
|
||||||
|
|
||||||
|
const entries = await Promise.all(attachmentIds.value.map(async attachmentId => {
|
||||||
|
const attachment = media.attachment(attachmentId)
|
||||||
|
try {
|
||||||
|
if (!attachment.get('url')) await attachment.fetch()
|
||||||
|
} catch (error) {
|
||||||
|
return [attachmentId, null]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = attachment.get('sizes') || {}
|
||||||
|
return [attachmentId, {
|
||||||
|
url: sizes.medium?.url || attachment.get('url') || '',
|
||||||
|
alt: attachment.get('alt') || attachment.get('title') || '',
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (request === previewRequest) {
|
||||||
|
previews.value = Object.fromEntries(entries.filter(([, preview]) => preview))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMediaLibrary() {
|
||||||
|
const media = window.wp?.media
|
||||||
|
if (!media) {
|
||||||
|
mediaError.value = '当前后台未加载 WordPress 媒体库。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaError.value = ''
|
||||||
|
const frame = media({
|
||||||
|
title: props.title,
|
||||||
|
button: { text: '使用所选图片' },
|
||||||
|
library: { type: 'image' },
|
||||||
|
multiple: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
frame.on('open', () => {
|
||||||
|
const selection = frame.state().get('selection')
|
||||||
|
attachmentIds.value.forEach(attachmentId => {
|
||||||
|
const attachment = media.attachment(attachmentId)
|
||||||
|
attachment.fetch()
|
||||||
|
selection.add(attachment)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
frame.on('select', () => {
|
||||||
|
const ids = frame.state().get('selection').toJSON()
|
||||||
|
.map(attachment => Number.parseInt(attachment.id, 10))
|
||||||
|
.filter(attachmentId => attachmentId > 0)
|
||||||
|
update([...new Set(ids)])
|
||||||
|
})
|
||||||
|
|
||||||
|
frame.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(attachmentIds, hydratePreviews, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-gallery-field__grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-gallery-field__item {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--jio-color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--jio-color-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-gallery-field__image {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 90px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-gallery-field__placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 90px;
|
||||||
|
height: 90px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--jio-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-gallery-field__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px;
|
||||||
|
border-top: 1px solid var(--jio-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-gallery-field__empty {
|
||||||
|
display: flex;
|
||||||
|
min-height: 116px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px dashed var(--jio-color-border-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--jio-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-gallery-field__buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-gallery-field__error {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: #d03050;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jio-image-select" role="radiogroup">
|
||||||
|
<button
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="opt.value"
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
class="jio-image-select__item"
|
||||||
|
:class="{ 'is-active': modelValue === opt.value }"
|
||||||
|
:aria-checked="modelValue === opt.value"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="emit('update:modelValue', opt.value)"
|
||||||
|
>
|
||||||
|
<img v-if="opt.image" :src="opt.image" :alt="opt.label" class="jio-image-select__img" :style="imgStyle(opt)" />
|
||||||
|
<span v-else class="jio-image-select__placeholder" :style="imgStyle(opt)">
|
||||||
|
<FaIcon :icon="opt.icon || 'fa fas fa-image'" :size="20" />
|
||||||
|
</span>
|
||||||
|
<span class="jio-image-select__label">{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/** image_select:带预览图的单选。 */
|
||||||
|
import FaIcon from './FaIcon.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { default: null },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
// Preview width in px. Layout strips (header/footer) are very wide and only
|
||||||
|
// need enough width to be legible; near-square previews want more room.
|
||||||
|
previewWidth: { type: [Number, String], default: 140 },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
// An option may override the group width via its own `width`.
|
||||||
|
function imgStyle(opt) {
|
||||||
|
const width = opt && opt.width ? opt.width : props.previewWidth
|
||||||
|
return { '--jio-image-select-max': typeof width === 'number' ? `${width}px` : width }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-image-select {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-image-select__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid var(--jio-color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--jio-color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s ease, background-color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-image-select__item:hover,
|
||||||
|
.jio-image-select__item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--jio-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-image-select__item.is-active {
|
||||||
|
border-color: var(--jio-color-primary);
|
||||||
|
background: var(--jio-color-primary-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-image-select__item:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-image-select__img {
|
||||||
|
display: block;
|
||||||
|
/* Preview images are wide layout strips (header 597x57, footer 800x190, slide
|
||||||
|
align 200x101), so let them keep their own aspect ratio inside the card.
|
||||||
|
A fixed square box with object-fit:cover crops the layout to an
|
||||||
|
unrecognisable sliver — the width is capped instead and the height follows. */
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--jio-image-select-max, 140px);
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--jio-color-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-image-select__placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--jio-image-select-max, 140px);
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--jio-color-canvas);
|
||||||
|
color: var(--jio-color-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-image-select__label {
|
||||||
|
color: var(--jio-color-text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.jio-image-select__item {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+45
-36
@@ -1,28 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-media-field">
|
<div class="jio-media-field">
|
||||||
<div v-if="modelValue" class="aksrt-media-field__preview">
|
<div v-if="modelValue" class="jio-media-field__preview">
|
||||||
<img
|
<img
|
||||||
v-if="isImage"
|
v-if="isImage"
|
||||||
:src="modelValue"
|
:src="modelValue"
|
||||||
:alt="placeholder"
|
:alt="placeholder"
|
||||||
class="aksrt-media-field__image"
|
class="jio-media-field__image"
|
||||||
/>
|
/>
|
||||||
<div v-else class="aksrt-media-field__file">
|
<div v-else class="jio-media-field__file">
|
||||||
<FaIcon icon="fas fa-file" :size="24" />
|
<FaIcon icon="fa fas fa-file" :size="24" />
|
||||||
<span class="aksrt-media-field__filename">{{ fileName }}</span>
|
<span class="jio-media-field__filename">{{ fileName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="aksrt-media-field__overlay">
|
<div class="jio-media-field__overlay">
|
||||||
<n-button size="tiny" quaternary @click="openMediaLibrary">
|
<n-button size="tiny" secondary circle title="更换" @click="openMediaLibrary">
|
||||||
<FaIcon icon="fas fa-exchange-alt" /> 更换
|
<FaIcon icon="fa fas fa-exchange-alt" />
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button size="tiny" quaternary type="error" @click="clearMedia">
|
<n-button size="tiny" secondary circle type="error" title="移除" @click="clearMedia">
|
||||||
<FaIcon icon="fas fa-trash" /> 移除
|
<FaIcon icon="fa fas fa-trash-alt" />
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="aksrt-media-field__empty" @click="openMediaLibrary">
|
<div v-else class="jio-media-field__empty" @click="openMediaLibrary">
|
||||||
<FaIcon icon="fas fa-cloud-upload-alt" :size="28" />
|
<FaIcon icon="fa fas fa-cloud-upload-alt" :size="28" />
|
||||||
<span>{{ placeholder }}</span>
|
<span>{{ placeholder }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:placeholder="'或直接输入 URL'"
|
:placeholder="'或直接输入 URL'"
|
||||||
size="small"
|
size="small"
|
||||||
class="aksrt-media-field__url"
|
class="jio-media-field__url"
|
||||||
@update:value="emit('update:modelValue', $event)"
|
@update:value="emit('update:modelValue', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* AksMediaField — image/file selector with preview.
|
* JioMediaField — image/file selector with preview.
|
||||||
*
|
*
|
||||||
* In the WordPress admin context, this can open the WP Media Library.
|
* In the WordPress admin context, this can open the WP Media Library.
|
||||||
* For now, it supports direct URL input with preview.
|
* For now, it supports direct URL input with preview.
|
||||||
@@ -95,73 +95,82 @@ function clearMedia() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-media-field__preview {
|
.jio-media-field__preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 72px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 4px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid #eef1f6;
|
border: 1px solid var(--jio-color-border);
|
||||||
|
background: var(--jio-color-canvas);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__image {
|
.jio-media-field__image {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: auto;
|
||||||
max-height: 200px;
|
height: auto;
|
||||||
object-fit: cover;
|
max-width: 100%;
|
||||||
|
max-height: 120px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__file {
|
.jio-media-field__file {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: #f8fafc;
|
background: var(--jio-color-canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__filename {
|
.jio-media-field__filename {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #555;
|
color: var(--jio-color-text-secondary);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__overlay {
|
.jio-media-field__overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px;
|
padding: 4px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__preview:hover .aksrt-media-field__overlay {
|
.jio-media-field__preview:hover .jio-media-field__overlay {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__empty {
|
.jio-media-field__empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
border: 2px dashed #d9dce3;
|
border: 2px dashed var(--jio-color-border-strong);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #8a909c;
|
color: var(--jio-color-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__empty:hover {
|
.jio-media-field__empty:hover {
|
||||||
border-color: #1890ff;
|
border-color: var(--jio-color-primary);
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
background: #f0f7ff;
|
background: var(--jio-color-primary-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-media-field__url {
|
.jio-media-field__url {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jio-repeater-list">
|
||||||
|
<draggable
|
||||||
|
v-if="rows.length"
|
||||||
|
v-model="rows"
|
||||||
|
v-bind="SORTABLE_LIST_ATTRS"
|
||||||
|
:item-key="rowKey"
|
||||||
|
:disabled="schema.disabled"
|
||||||
|
class="jio-sortable"
|
||||||
|
>
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="jio-sortable__item">
|
||||||
|
<div class="jio-sortable__head">
|
||||||
|
<JioSortHandle />
|
||||||
|
<span class="jio-sortable__icon" :class="preview(element).iconClass">
|
||||||
|
<span v-if="preview(element).customIcon" class="jio-repeater-list__icon-html" v-html="preview(element).customIcon" />
|
||||||
|
<i v-else-if="preview(element).icon" :class="preview(element).icon" aria-hidden="true" />
|
||||||
|
<FaIcon v-else icon="fa fas fa-star" :size="16" />
|
||||||
|
</span>
|
||||||
|
<div class="jio-sortable__main">
|
||||||
|
<span class="jio-sortable__title">{{ preview(element).title }}</span>
|
||||||
|
<span class="jio-sortable__desc">{{ preview(element).desc }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="jio-sortable__actions">
|
||||||
|
<n-button size="tiny" quaternary type="primary" :disabled="schema.disabled" @click="openModal(index)">
|
||||||
|
<template #icon><FaIcon icon="fa fas fa-edit" :size="12" /></template>
|
||||||
|
编辑
|
||||||
|
</n-button>
|
||||||
|
<n-button size="tiny" quaternary type="error" :disabled="schema.disabled || reachedMinimum" @click="remove(index)">
|
||||||
|
<template #icon><FaIcon icon="fa fas fa-trash-alt" :size="12" /></template>
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<div v-else class="jio-sortable__empty">
|
||||||
|
<FaIcon icon="fa fas fa-inbox" :size="18" />
|
||||||
|
<span>{{ schema.emptyText || '暂无数据' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-button size="small" dashed block :disabled="schema.disabled || reachedMaximum" @click="openModal(-1)">
|
||||||
|
<template #icon><FaIcon icon="fa fas fa-plus" :size="12" /></template>
|
||||||
|
{{ schema.button_title || schema.addText || '添加一项' }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="modalShow"
|
||||||
|
:mask-closable="false"
|
||||||
|
preset="card"
|
||||||
|
:title="editingIndex >= 0 ? (schema.editText || '编辑项目') : (schema.addText || '添加项目')"
|
||||||
|
class="jio-modal-card"
|
||||||
|
style="max-width: 640px; width: 92vw;"
|
||||||
|
>
|
||||||
|
<div class="jio-repeater-list__modal-fields">
|
||||||
|
<JioField
|
||||||
|
v-for="field in draftFields"
|
||||||
|
:key="field.key"
|
||||||
|
:schema="field"
|
||||||
|
:model-value="draft[field.key]"
|
||||||
|
:form-data="formData"
|
||||||
|
@update:model-value="draft = { ...draft, [field.key]: $event }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="jio-modal-footer">
|
||||||
|
<n-button @click="closeModal">取消</n-button>
|
||||||
|
<n-button type="primary" @click="confirmModal">确定</n-button>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/** repeater / group 的 list-modal 形态:可拖拽卡片列表 + 弹窗编辑单行。 */
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
import FaIcon from './FaIcon.vue'
|
||||||
|
import JioField from './JioField.vue'
|
||||||
|
import JioSortHandle from './JioSortHandle.vue'
|
||||||
|
import { matchDependency } from './field-dependency.js'
|
||||||
|
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
|
||||||
|
import { createRepeaterRow, createRepeaterRowId, repeaterRowPreview } from './repeater-rows.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
schema: { type: Object, required: true },
|
||||||
|
modelValue: { default: null },
|
||||||
|
formData: { type: Object, default: () => ({}) },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const rows = computed({
|
||||||
|
get: () => (Array.isArray(props.modelValue) ? props.modelValue : []),
|
||||||
|
set: (value) => emit('update:modelValue', Array.isArray(value) ? value : []),
|
||||||
|
})
|
||||||
|
const reachedMinimum = computed(() => rows.value.length <= Number(props.schema.min || 0))
|
||||||
|
const reachedMaximum = computed(() => Number(props.schema.max || 0) > 0 && rows.value.length >= Number(props.schema.max))
|
||||||
|
|
||||||
|
const keys = new WeakMap()
|
||||||
|
let keySeed = 0
|
||||||
|
function rowKey(row) {
|
||||||
|
if (!row || typeof row !== 'object') return String(row)
|
||||||
|
if (row._id) return row._id
|
||||||
|
if (!keys.has(row)) keys.set(row, `rpt_list_${++keySeed}`)
|
||||||
|
return keys.get(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(row) {
|
||||||
|
return repeaterRowPreview(row, props.schema.listPreview || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(index) {
|
||||||
|
if (reachedMinimum.value) return
|
||||||
|
const next = [...rows.value]
|
||||||
|
next.splice(index, 1)
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalShow = ref(false)
|
||||||
|
const editingIndex = ref(-1)
|
||||||
|
const draft = ref({})
|
||||||
|
const draftFields = computed(() => (props.schema.fields || []).filter(field => matchDependency(field.dependsOn, draft.value)))
|
||||||
|
|
||||||
|
function openModal(index) {
|
||||||
|
if (props.schema.disabled) return
|
||||||
|
if (index < 0 && reachedMaximum.value) return
|
||||||
|
const base = createRepeaterRow(props.schema.fields)
|
||||||
|
draft.value = index >= 0 ? { ...base, ...(rows.value[index] || {}) } : base
|
||||||
|
editingIndex.value = index
|
||||||
|
modalShow.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalShow.value = false
|
||||||
|
editingIndex.value = -1
|
||||||
|
draft.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmModal() {
|
||||||
|
const next = [...rows.value]
|
||||||
|
const row = { ...draft.value }
|
||||||
|
if (!row._id) row._id = createRepeaterRowId()
|
||||||
|
if (editingIndex.value >= 0 && editingIndex.value < next.length) {
|
||||||
|
next[editingIndex.value] = row
|
||||||
|
} else {
|
||||||
|
next.push(row)
|
||||||
|
}
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
closeModal()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-repeater-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-repeater-list__icon-html {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-repeater-list__icon-html :deep(svg),
|
||||||
|
.jio-repeater-list__icon-html :deep(img) {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-repeater-list__modal-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-height: min(70vh, 560px);
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="dirty" class="jio-form-renderer__save-bar">
|
||||||
|
<div class="jio-form-renderer__save-bar__inner">
|
||||||
|
<div class="jio-form-renderer__save-bar__info">
|
||||||
|
<FaIcon icon="fa fas fa-exclamation-circle" :size="14" />
|
||||||
|
<span>您有未保存的更改</span>
|
||||||
|
</div>
|
||||||
|
<div class="jio-form-renderer__save-bar__actions">
|
||||||
|
<n-button size="small" :disabled="saving" @click="emit('reload')">放弃更改</n-button>
|
||||||
|
<n-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
<FaIcon v-if="!saving" icon="fa fas fa-save" :size="12" />
|
||||||
|
保存设置
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* JioSaveBar — the admin-wide sticky save bar (shown only when dirty).
|
||||||
|
* Shared by JioFormRenderer (schema pages) and custom settings pages.
|
||||||
|
*/
|
||||||
|
import FaIcon from './FaIcon.vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
dirty: { type: Boolean, default: false },
|
||||||
|
saving: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['save', 'reload'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-form-renderer__save-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--jio-color-surface);
|
||||||
|
border-top: 1px solid var(--jio-color-border);
|
||||||
|
box-shadow: var(--jio-shadow-floating);
|
||||||
|
animation: slideUp 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-form-renderer__save-bar__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px 28px;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-form-renderer__save-bar__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #d48806;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-form-renderer__save-bar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.jio-form-renderer__save-bar__inner {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 14px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-form-renderer__save-bar__actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-form-renderer__save-bar__actions .n-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="jio-secret-clear">
|
||||||
|
<n-button size="small" secondary :type="marked ? 'warning' : 'error'" @click="$emit('toggle')">
|
||||||
|
{{ marked ? '取消清除' : '清除已配置密钥' }}
|
||||||
|
</n-button>
|
||||||
|
<span v-if="marked" class="jio-secret-clear__hint">保存后将删除此密钥</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* 已配置密钥的「清除 / 取消清除」操作,input 与 textarea 两种 secret 字段共用。
|
||||||
|
*/
|
||||||
|
defineProps({
|
||||||
|
/** 当前存在已配置密钥('***')或已标记清除时显示 */
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
/** 是否已标记为保存时清除 */
|
||||||
|
marked: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['toggle'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-secret-clear {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-secret-clear__hint {
|
||||||
|
color: var(--jio-color-warning, #d97706);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<draggable
|
||||||
|
v-model="values"
|
||||||
|
v-bind="SORTABLE_LIST_ATTRS"
|
||||||
|
:item-key="valueKey"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="jio-sortable jio-sortable--compact jio-selected-order"
|
||||||
|
>
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<div class="jio-sortable__item">
|
||||||
|
<div class="jio-sortable__head">
|
||||||
|
<JioSortHandle />
|
||||||
|
<span class="jio-sortable__index">{{ index + 1 }}</span>
|
||||||
|
<span class="jio-sortable__title">{{ labelOf(element) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/** 多选 + sortable 的已选值排序列表(select / async_select 下方)。 */
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
import JioSortHandle from './JioSortHandle.vue'
|
||||||
|
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Array, required: true },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
disabled: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const values = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (next) => emit('update:modelValue', next),
|
||||||
|
})
|
||||||
|
|
||||||
|
function valueKey(value) {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelOf(value) {
|
||||||
|
return props.options.find(option => option.value === value)?.label || String(value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-selected-order {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<button type="button" class="jio-sortable__handle" :title="label" :aria-label="label">
|
||||||
|
<i class="fa fas fa-grip-vertical" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/** 拖拽排序手柄;SORTABLE_LIST_ATTRS.handle 指向它的类名。 */
|
||||||
|
defineProps({
|
||||||
|
label: { type: String, default: '拖拽排序' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<div class="jio-sorter">
|
||||||
|
<div
|
||||||
|
v-for="column in COLUMNS"
|
||||||
|
:key="column.key"
|
||||||
|
class="jio-sorter__column"
|
||||||
|
:class="`jio-sorter__column--${column.key}`"
|
||||||
|
>
|
||||||
|
<div class="jio-sorter__title">
|
||||||
|
<span>{{ column.title }}</span>
|
||||||
|
<n-tag size="small" :type="column.tagType" :bordered="false">{{ columns[column.key].length }}</n-tag>
|
||||||
|
</div>
|
||||||
|
<draggable
|
||||||
|
v-model="columns[column.key]"
|
||||||
|
v-bind="SORTABLE_LIST_ATTRS"
|
||||||
|
item-key="value"
|
||||||
|
group="jio-sorter"
|
||||||
|
class="jio-sortable jio-sortable--compact jio-sorter__list"
|
||||||
|
@change="commit"
|
||||||
|
>
|
||||||
|
<template #item="{ element }">
|
||||||
|
<div class="jio-sortable__item">
|
||||||
|
<div class="jio-sortable__head">
|
||||||
|
<JioSortHandle />
|
||||||
|
<i v-if="element.icon" :class="element.icon" class="jio-sorter__icon" aria-hidden="true"></i>
|
||||||
|
<span class="jio-sortable__title">{{ element.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</draggable>
|
||||||
|
<div v-if="columns[column.key].length === 0" class="jio-sortable__empty jio-sorter__empty">
|
||||||
|
{{ column.empty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/** 启用 / 未启用两列拖拽排序器;样式见 styles/sortable-list.css。 */
|
||||||
|
import { reactive, watch } from 'vue'
|
||||||
|
import draggable from 'vuedraggable'
|
||||||
|
import JioSortHandle from './JioSortHandle.vue'
|
||||||
|
import { SORTABLE_LIST_ATTRS } from './sortable-options.js'
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
{ key: 'enabled', title: '已启用', tagType: 'success', empty: '拖拽到此处启用' },
|
||||||
|
{ key: 'disabled', title: '未启用', tagType: 'default', empty: '拖拽到此处停用' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Object, default: () => ({}) },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const columns = reactive({ enabled: [], disabled: [] })
|
||||||
|
let syncing = false
|
||||||
|
|
||||||
|
function keysOf(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(String)
|
||||||
|
return value && typeof value === 'object' ? Object.keys(value) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncColumns() {
|
||||||
|
syncing = true
|
||||||
|
const byKey = new Map(props.options.map(option => [String(option.value), option]))
|
||||||
|
const used = new Set()
|
||||||
|
const normalize = keys => keys.filter(key => byKey.has(key) && !used.has(key)).map(key => {
|
||||||
|
used.add(key)
|
||||||
|
return byKey.get(key)
|
||||||
|
})
|
||||||
|
columns.enabled = normalize(keysOf(props.modelValue?.enabled))
|
||||||
|
columns.disabled = normalize(keysOf(props.modelValue?.disabled))
|
||||||
|
for (const option of props.options) {
|
||||||
|
if (!used.has(String(option.value))) columns.disabled.push(option)
|
||||||
|
}
|
||||||
|
syncing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
if (syncing) return
|
||||||
|
const toMap = items => Object.fromEntries(items.map(item => [String(item.value), String(item.label)]))
|
||||||
|
emit('update:modelValue', {
|
||||||
|
enabled: toMap(columns.enabled),
|
||||||
|
disabled: toMap(columns.disabled),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.modelValue, props.options], syncColumns, { immediate: true, deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jio-sorter {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-sorter__column {
|
||||||
|
position: relative;
|
||||||
|
min-height: 210px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--jio-color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--jio-color-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-sorter__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--jio-color-text);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空列也要留出可放置区域,拖拽目标不能塌成 0 高度。 */
|
||||||
|
.jio-sorter__list {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-sorter__empty {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 12px;
|
||||||
|
top: 46px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jio-sorter__icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 16px;
|
||||||
|
color: var(--jio-color-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.jio-sorter {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* schema 字段的 dependsOn 规则求值。sourceValue 是规则引用的数据对象
|
||||||
|
* (fieldset 值 / repeater 行 / 弹窗草稿)。
|
||||||
|
*/
|
||||||
|
export function matchDependency(rule, sourceValue) {
|
||||||
|
if (!rule) return true
|
||||||
|
const source = sourceValue && typeof sourceValue === 'object' ? sourceValue : {}
|
||||||
|
if (rule.all) return rule.all.every(item => matchDependency(item, source))
|
||||||
|
if (rule.any) return rule.any.some(item => matchDependency(item, source))
|
||||||
|
const value = source[rule.key]
|
||||||
|
if (rule.operator === 'truthy') return !!value
|
||||||
|
if (rule.operator === 'falsy') return !value
|
||||||
|
if (rule.operator === 'not_empty') return Array.isArray(value) ? value.length > 0 : value !== '' && value != null
|
||||||
|
if (rule.operator === 'empty') return Array.isArray(value) ? value.length === 0 : value === '' || value == null
|
||||||
|
if (rule.operator === 'eq') return value === rule.value
|
||||||
|
if (rule.operator === 'neq') return value !== rule.value
|
||||||
|
return rule.value === undefined || value === rule.value
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 把 accordion / fieldset 的 `schema.sections` 声明展开成「块标题 + 字段定义」。
|
||||||
|
*
|
||||||
|
* 参考主题把「升级价格」这类复合配置拆成若干可折叠块,每块用 keys 声明自己
|
||||||
|
* 包含哪些字段。这里是纯函数,方便在 Node 契约测试里直接用真实 schema 校验,
|
||||||
|
* 避免渲染层的一个小疏忽让整块手风琴静默退化成平铺。
|
||||||
|
*/
|
||||||
|
export function buildFieldsetSections(schema) {
|
||||||
|
const declared = schema && Array.isArray(schema.sections) ? schema.sections : []
|
||||||
|
if (!declared.length) return []
|
||||||
|
|
||||||
|
const byKey = {}
|
||||||
|
const nested = Array.isArray(schema.fields) ? schema.fields : []
|
||||||
|
for (const field of nested) {
|
||||||
|
if (field && field.key) byKey[field.key] = field
|
||||||
|
}
|
||||||
|
|
||||||
|
return declared.map((section) => ({
|
||||||
|
title: String((section && section.title) || ''),
|
||||||
|
fields: (Array.isArray(section && section.keys) ? section.keys : [])
|
||||||
|
.map((key) => byKey[key])
|
||||||
|
.filter(Boolean),
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* repeater / group 行的纯函数:新行默认值、行 ID、listPreview 摘要与预览。
|
||||||
|
* inline 与 list-modal 两种渲染共用。
|
||||||
|
*/
|
||||||
|
let rowIdCounter = 0
|
||||||
|
|
||||||
|
export function createRepeaterRowId() {
|
||||||
|
return `rpt_${Date.now()}_${++rowIdCounter}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRepeaterRow(fields) {
|
||||||
|
const row = { _id: createRepeaterRowId() }
|
||||||
|
for (const field of (Array.isArray(fields) ? fields : [])) {
|
||||||
|
// UI-only chrome rows (notice / alert) are presentation only.
|
||||||
|
if (field.ui_only) continue
|
||||||
|
if (field.default !== undefined) {
|
||||||
|
row[field.key] = field.default
|
||||||
|
} else if (field.type === 'switch') {
|
||||||
|
row[field.key] = false
|
||||||
|
} else if (field.type === 'checkbox' || field.type === 'repeater') {
|
||||||
|
row[field.key] = []
|
||||||
|
} else {
|
||||||
|
row[field.key] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
/** time/unit 形态的时长摘要:0 视为永久,其余按 day/month 补单位。 */
|
||||||
|
function rowDuration(row, cfg) {
|
||||||
|
if (!cfg.descKey) return ''
|
||||||
|
const raw = row[cfg.descKey]
|
||||||
|
if (raw === '' || raw == null) return ''
|
||||||
|
const amount = Number(raw)
|
||||||
|
if (!Number.isFinite(amount)) return String(raw)
|
||||||
|
if (amount <= 0) return '永久'
|
||||||
|
if (!cfg.descUnitKey) return String(raw)
|
||||||
|
const unit = String(row[cfg.descUnitKey] || '')
|
||||||
|
if (unit === 'month') return `${amount} 个月`
|
||||||
|
if (unit === 'day') return `${amount} 天`
|
||||||
|
return String(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未配置 titleKey 时,取首个值为非空字符串/数字的字段作为标题。 */
|
||||||
|
function fallbackTitleKey(row, fields) {
|
||||||
|
if (row.title != null && String(row.title).trim() !== '') return 'title'
|
||||||
|
for (const field of (Array.isArray(fields) ? fields : [])) {
|
||||||
|
if (field.ui_only) continue
|
||||||
|
const value = row[field.key]
|
||||||
|
if ((typeof value === 'string' && value.trim() !== '') || typeof value === 'number') return field.key
|
||||||
|
}
|
||||||
|
return 'title'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 收起态标题行上的一行摘要,取自 listPreview 配置。 */
|
||||||
|
export function repeaterRowSummary(row, cfg = {}, fields = []) {
|
||||||
|
if (!row) return ''
|
||||||
|
const title = String(row[cfg.titleKey || fallbackTitleKey(row, fields)] ?? '').trim()
|
||||||
|
const prefix = String(cfg.prefix || '')
|
||||||
|
if (title === '') return prefix
|
||||||
|
const duration = rowDuration(row, cfg)
|
||||||
|
return duration ? `${prefix}${title} · ${duration}` : `${prefix}${title}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** list-modal 卡片预览:图标 / 自定义图标 HTML / 图标类 / 标题 / 描述。 */
|
||||||
|
export function repeaterRowPreview(row, cfg = {}) {
|
||||||
|
const read = (key) => String(row?.[key] || '').trim()
|
||||||
|
const desc = read(cfg.descKey || 'desc') || read(cfg.urlKey || 'url')
|
||||||
|
return {
|
||||||
|
icon: read(cfg.iconKey || 'icon'),
|
||||||
|
customIcon: read(cfg.customIconKey || 'custom_icon'),
|
||||||
|
iconClass: read(cfg.iconClassKey || 'icon_class'),
|
||||||
|
title: read(cfg.titleKey || 'title') || '未命名卡片',
|
||||||
|
desc: desc || '暂无描述',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 所有后台 vuedraggable 列表共用的 Sortable 选项(v-bind 到 <draggable>)。
|
||||||
|
* 配套样式见 src/styles/sortable-list.css,拖拽手柄统一用 JioSortHandle。
|
||||||
|
*/
|
||||||
|
const prefersReducedMotion = typeof window !== 'undefined'
|
||||||
|
&& typeof window.matchMedia === 'function'
|
||||||
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
|
||||||
|
export const SORTABLE_LIST_ATTRS = Object.freeze({
|
||||||
|
handle: '.jio-sortable__handle',
|
||||||
|
ghostClass: 'jio-sortable__ghost',
|
||||||
|
chosenClass: 'jio-sortable__chosen',
|
||||||
|
dragClass: 'jio-sortable__drag',
|
||||||
|
animation: prefersReducedMotion ? 0 : 180,
|
||||||
|
})
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 可排序卡片列表的折叠状态:所有带展开区的卡片默认收起,点击行头展开/收起。
|
||||||
|
*
|
||||||
|
* 状态按行的稳定 key 记录(而不是下标),拖拽换位后每张卡片保持自己的开合状态。
|
||||||
|
* 行头属性统一由 collapseHeadAttrs 生成:可点击、可键盘操作(Enter / Space)、
|
||||||
|
* 带 aria-expanded,样式见 styles/sortable-list.css。
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export function collapseHeadAttrs(open, toggle) {
|
||||||
|
return {
|
||||||
|
class: 'is-clickable',
|
||||||
|
role: 'button',
|
||||||
|
tabindex: 0,
|
||||||
|
'aria-expanded': open ? 'true' : 'false',
|
||||||
|
onClick: toggle,
|
||||||
|
onKeydown: (event) => {
|
||||||
|
// 只响应行头本身的按键,行内按钮 / 输入框的 Enter、空格不触发折叠。
|
||||||
|
if (event.target !== event.currentTarget) return
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
event.preventDefault()
|
||||||
|
toggle()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCollapsibleItems() {
|
||||||
|
const openKeys = ref(new Set())
|
||||||
|
|
||||||
|
function isOpen(key) {
|
||||||
|
return openKeys.value.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(key) {
|
||||||
|
const next = new Set(openKeys.value)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
openKeys.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function headAttrs(key) {
|
||||||
|
return collapseHeadAttrs(isOpen(key), () => toggle(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isOpen, toggle, headAttrs }
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JioField 的远程选项加载:async_select / taxonomy_select / user_select / page_select。
|
||||||
|
* 仅按 schema.type 在挂载时拉取对应数据源。
|
||||||
|
*/
|
||||||
|
export function useFieldRemoteOptions(props) {
|
||||||
|
const asyncOptions = ref([])
|
||||||
|
const asyncLoading = ref(false)
|
||||||
|
const taxonomyOptions = ref([])
|
||||||
|
const taxonomyLoading = ref(false)
|
||||||
|
const userOptions = ref([])
|
||||||
|
const userLoading = ref(false)
|
||||||
|
const pageOptions = ref([])
|
||||||
|
const pageLoading = ref(false)
|
||||||
|
let asyncTimer = null
|
||||||
|
let userTimer = null
|
||||||
|
let pageTimer = null
|
||||||
|
|
||||||
|
async function loadAsyncOptions(query = '') {
|
||||||
|
asyncLoading.value = true
|
||||||
|
try {
|
||||||
|
const selected = Array.isArray(props.modelValue) ? props.modelValue : (props.modelValue ? [props.modelValue] : [])
|
||||||
|
const response = await api.searchPageDisplayOptions(props.schema.source, query, query ? [] : selected)
|
||||||
|
asyncOptions.value = response?.data || []
|
||||||
|
} catch (error) {
|
||||||
|
asyncOptions.value = []
|
||||||
|
} finally {
|
||||||
|
asyncLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAsyncSearch(query) {
|
||||||
|
clearTimeout(asyncTimer)
|
||||||
|
asyncTimer = setTimeout(() => loadAsyncOptions(query), 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTaxonomyTerms() {
|
||||||
|
if (!props.schema.taxonomy) return
|
||||||
|
taxonomyLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.getCategories(props.schema.taxonomy || 'category')
|
||||||
|
const items = res?.data || res || []
|
||||||
|
taxonomyOptions.value = (Array.isArray(items) ? items : []).map(t => ({
|
||||||
|
label: t.name + (t.count ? ' (' + t.count + ')' : ''),
|
||||||
|
value: t.term_id,
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[JioField] taxonomy load failed:', e)
|
||||||
|
taxonomyOptions.value = []
|
||||||
|
} finally {
|
||||||
|
taxonomyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers(query = '') {
|
||||||
|
userLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.searchUsers(query, 20)
|
||||||
|
const items = res?.data || res || []
|
||||||
|
userOptions.value = (Array.isArray(items) ? items : []).map(u => ({
|
||||||
|
label: u.display_name + ' (' + u.user_login + ')',
|
||||||
|
value: u.id,
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[JioField] user load failed:', e)
|
||||||
|
userOptions.value = []
|
||||||
|
} finally {
|
||||||
|
userLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUserSearch(query) {
|
||||||
|
clearTimeout(userTimer)
|
||||||
|
userTimer = setTimeout(() => loadUsers(query || ''), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPages(query = '') {
|
||||||
|
pageLoading.value = true
|
||||||
|
try {
|
||||||
|
const selected = props.modelValue ? [props.modelValue] : []
|
||||||
|
const res = await api.searchPageDisplayOptions('pages', query, query ? [] : selected)
|
||||||
|
const items = res?.data || res || []
|
||||||
|
pageOptions.value = Array.isArray(items) ? items : []
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[JioField] page load failed:', e)
|
||||||
|
pageOptions.value = []
|
||||||
|
} finally {
|
||||||
|
pageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageSearch(query) {
|
||||||
|
clearTimeout(pageTimer)
|
||||||
|
pageTimer = setTimeout(() => loadPages(query), 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const type = props.schema.type
|
||||||
|
if (type === 'taxonomy_select') loadTaxonomyTerms()
|
||||||
|
if (type === 'user_select') loadUsers()
|
||||||
|
if (type === 'async_select') loadAsyncOptions()
|
||||||
|
if (type === 'page_select') loadPages()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
asyncOptions,
|
||||||
|
asyncLoading,
|
||||||
|
handleAsyncSearch,
|
||||||
|
taxonomyOptions,
|
||||||
|
taxonomyLoading,
|
||||||
|
userOptions,
|
||||||
|
userLoading,
|
||||||
|
handleUserSearch,
|
||||||
|
pageOptions,
|
||||||
|
pageLoading,
|
||||||
|
handlePageSearch,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-module-canvas">
|
|
||||||
<div class="aksrt-module-canvas__toolbar">
|
|
||||||
<n-space>
|
|
||||||
<n-dropdown :options="addModuleOptions" trigger="click" @select="handleAddModule">
|
|
||||||
<n-button type="primary" size="small">
|
|
||||||
<template #icon><FaIcon icon="fas fa-plus" :size="12" /></template>
|
|
||||||
添加模块
|
|
||||||
</n-button>
|
|
||||||
</n-dropdown>
|
|
||||||
<n-button size="small" @click="$emit('pick-preset')">
|
|
||||||
<template #icon><FaIcon icon="fas fa-magic" :size="12" /></template>
|
|
||||||
应用预设
|
|
||||||
</n-button>
|
|
||||||
<n-button size="small" @click="$emit('export')">
|
|
||||||
<template #icon><FaIcon icon="fas fa-download" :size="12" /></template>
|
|
||||||
导出
|
|
||||||
</n-button>
|
|
||||||
<n-button size="small" @click="$emit('import')">
|
|
||||||
<template #icon><FaIcon icon="fas fa-upload" :size="12" /></template>
|
|
||||||
导入
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="modules.length === 0" class="aksrt-module-canvas__empty">
|
|
||||||
<FaIcon icon="fas fa-th-large" :size="32" color="#c0c4cc" />
|
|
||||||
<p>暂无首页模块,点击"添加模块"或"应用预设"开始配置。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="aksrt-module-canvas__list">
|
|
||||||
<div
|
|
||||||
v-for="(mod, index) in modules"
|
|
||||||
:key="mod.id || index"
|
|
||||||
class="aksrt-module-canvas__item"
|
|
||||||
:class="{
|
|
||||||
'aksrt-module-canvas__item--selected': selectedIndex === index,
|
|
||||||
'aksrt-module-canvas__item--disabled': !mod.enabled,
|
|
||||||
'aksrt-module-canvas__item--dragging': dragIndex === index,
|
|
||||||
'aksrt-module-canvas__item--drag-over': dragOverIndex === index && dragOverIndex !== dragIndex,
|
|
||||||
}"
|
|
||||||
:draggable="true"
|
|
||||||
@click="$emit('select', index)"
|
|
||||||
@dragstart="handleDragStart(index, $event)"
|
|
||||||
@dragover.prevent="handleDragOver(index)"
|
|
||||||
@dragleave="handleDragLeave"
|
|
||||||
@drop.prevent="handleDrop(index)"
|
|
||||||
@dragend="handleDragEnd"
|
|
||||||
>
|
|
||||||
<div class="aksrt-module-canvas__item-handle" title="拖拽排序">
|
|
||||||
<FaIcon icon="fas fa-grip-vertical" :size="12" color="#c0c4cc" />
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-module-canvas__item-icon">
|
|
||||||
<FaIcon :icon="getModuleIcon(mod.type)" :size="14" />
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-module-canvas__item-info">
|
|
||||||
<span class="aksrt-module-canvas__item-label">{{ getModuleLabel(mod.type) }}</span>
|
|
||||||
<span v-if="mod.title" class="aksrt-module-canvas__item-title">{{ mod.title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-module-canvas__item-actions">
|
|
||||||
<n-switch
|
|
||||||
:value="mod.enabled !== false"
|
|
||||||
size="small"
|
|
||||||
@update:value="(val) => $emit('toggle', index, val)"
|
|
||||||
@click.stop
|
|
||||||
/>
|
|
||||||
<n-button
|
|
||||||
size="tiny"
|
|
||||||
quaternary
|
|
||||||
title="复制"
|
|
||||||
@click.stop="$emit('copy', index)"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-copy" :size="11" />
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
size="tiny"
|
|
||||||
quaternary
|
|
||||||
type="error"
|
|
||||||
title="删除"
|
|
||||||
@click.stop="$emit('remove', index)"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-trash" :size="11" />
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
size="tiny"
|
|
||||||
quaternary
|
|
||||||
:disabled="index === 0"
|
|
||||||
title="上移"
|
|
||||||
@click.stop="$emit('move', index, -1)"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-chevron-up" :size="11" />
|
|
||||||
</n-button>
|
|
||||||
<n-button
|
|
||||||
size="tiny"
|
|
||||||
quaternary
|
|
||||||
:disabled="index === modules.length - 1"
|
|
||||||
title="下移"
|
|
||||||
@click.stop="$emit('move', index, 1)"
|
|
||||||
>
|
|
||||||
<FaIcon icon="fas fa-chevron-down" :size="11" />
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
|
|
||||||
import FaIcon from '@/components/form/FaIcon.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modules: { type: Array, default: () => [] },
|
|
||||||
selectedIndex: { type: Number, default: -1 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['select', 'add', 'remove', 'copy', 'toggle', 'move', 'pick-preset', 'export', 'import'])
|
|
||||||
|
|
||||||
// Drag-and-drop state
|
|
||||||
const dragIndex = ref(-1)
|
|
||||||
const dragOverIndex = ref(-1)
|
|
||||||
|
|
||||||
function handleDragStart(index, event) {
|
|
||||||
dragIndex.value = index
|
|
||||||
event.dataTransfer.effectAllowed = 'move'
|
|
||||||
event.dataTransfer.setData('text/plain', String(index))
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragOver(index) {
|
|
||||||
if (dragIndex.value !== index) {
|
|
||||||
dragOverIndex.value = index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragLeave() {
|
|
||||||
dragOverIndex.value = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(targetIndex) {
|
|
||||||
if (dragIndex.value >= 0 && dragIndex.value !== targetIndex) {
|
|
||||||
const offset = targetIndex - dragIndex.value
|
|
||||||
emit('move', dragIndex.value, offset)
|
|
||||||
}
|
|
||||||
dragIndex.value = -1
|
|
||||||
dragOverIndex.value = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDragEnd() {
|
|
||||||
dragIndex.value = -1
|
|
||||||
dragOverIndex.value = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
const addModuleOptions = computed(() =>
|
|
||||||
MODULE_LIBRARY.map(m => ({
|
|
||||||
label: m.label,
|
|
||||||
key: m.type,
|
|
||||||
icon: () => {
|
|
||||||
const iconMap = {
|
|
||||||
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
|
|
||||||
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
|
|
||||||
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
|
|
||||||
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
|
|
||||||
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
|
|
||||||
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
|
|
||||||
'shopping-bag': 'fas fa-shopping-bag',
|
|
||||||
}
|
|
||||||
return h('i', { class: iconMap[m.icon] || 'fas fa-cube', style: 'font-size:12px;margin-right:6px;' })
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
function getModuleIcon(type) {
|
|
||||||
const meta = MODULE_LIBRARY.find(m => m.type === type)
|
|
||||||
const iconMap = {
|
|
||||||
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
|
|
||||||
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
|
|
||||||
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
|
|
||||||
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
|
|
||||||
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
|
|
||||||
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
|
|
||||||
'shopping-bag': 'fas fa-shopping-bag',
|
|
||||||
}
|
|
||||||
return iconMap[meta?.icon] || 'fas fa-cube'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModuleLabel(type) {
|
|
||||||
const meta = MODULE_LIBRARY.find(m => m.type === type)
|
|
||||||
return meta?.label || type
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleAddModule(type) {
|
|
||||||
emit('add', type)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { h } from 'vue'
|
|
||||||
export default {
|
|
||||||
// h is used in addModuleOptions computed icon renderer
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-module-canvas__toolbar {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px 24px;
|
|
||||||
border: 2px dashed #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #999;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__empty p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item:hover {
|
|
||||||
border-color: #d0d0d0;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item--selected {
|
|
||||||
border-color: #1890ff;
|
|
||||||
background: #e6f4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item--dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item--drag-over {
|
|
||||||
border-color: #1890ff;
|
|
||||||
border-width: 2px;
|
|
||||||
background: #f0f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item--disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item-handle {
|
|
||||||
cursor: grab;
|
|
||||||
padding: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item-icon {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #f5f7fa;
|
|
||||||
color: #666;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item-label {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item-title {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #999;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-canvas__item-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-module-inspector">
|
|
||||||
<div v-if="!module" class="aksrt-module-inspector__empty">
|
|
||||||
<FaIcon icon="fas fa-mouse-pointer" :size="24" color="#c0c4cc" />
|
|
||||||
<p>选择左侧模块查看和编辑配置</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="aksrt-module-inspector__header">
|
|
||||||
<FaIcon :icon="getModuleIcon(module.type)" :size="16" />
|
|
||||||
<span class="aksrt-module-inspector__type">{{ getModuleLabel(module.type) }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-scrollbar style="max-height: calc(100vh - 320px);">
|
|
||||||
<n-form
|
|
||||||
:model="localConfig"
|
|
||||||
label-placement="top"
|
|
||||||
size="small"
|
|
||||||
class="aksrt-module-inspector__form"
|
|
||||||
>
|
|
||||||
<!-- Common fields -->
|
|
||||||
<n-form-item label="模块标题">
|
|
||||||
<n-input
|
|
||||||
:value="module.title || ''"
|
|
||||||
placeholder="留空使用默认标题"
|
|
||||||
@update:value="(val) => updateField('title', val)"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="启用模块">
|
|
||||||
<n-switch
|
|
||||||
:value="module.enabled !== false"
|
|
||||||
@update:value="(val) => updateField('enabled', val)"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-divider />
|
|
||||||
|
|
||||||
<!-- Type-specific fields from schema -->
|
|
||||||
<template v-if="metaFields.length > 0">
|
|
||||||
<n-form-item
|
|
||||||
v-for="field in metaFields"
|
|
||||||
:key="field.key"
|
|
||||||
:label="field.label"
|
|
||||||
>
|
|
||||||
<!-- Switch -->
|
|
||||||
<n-switch
|
|
||||||
v-if="field.type === 'switch'"
|
|
||||||
:value="localConfig[field.key] ?? field.default ?? false"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Number -->
|
|
||||||
<n-input-number
|
|
||||||
v-else-if="field.type === 'number'"
|
|
||||||
:value="localConfig[field.key] ?? field.default ?? 0"
|
|
||||||
:min="field.min"
|
|
||||||
:max="field.max"
|
|
||||||
style="width: 100%;"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Text -->
|
|
||||||
<n-input
|
|
||||||
v-else-if="field.type === 'text'"
|
|
||||||
:value="localConfig[field.key] ?? field.default ?? ''"
|
|
||||||
:placeholder="field.placeholder || ''"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Textarea -->
|
|
||||||
<n-input
|
|
||||||
v-else-if="field.type === 'textarea'"
|
|
||||||
type="textarea"
|
|
||||||
:value="localConfig[field.key] ?? field.default ?? ''"
|
|
||||||
:rows="field.rows || 3"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Select -->
|
|
||||||
<n-select
|
|
||||||
v-else-if="field.type === 'select'"
|
|
||||||
:value="localConfig[field.key] ?? field.default"
|
|
||||||
:options="field.options || []"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Radio -->
|
|
||||||
<n-radio-group
|
|
||||||
v-else-if="field.type === 'radio'"
|
|
||||||
:value="localConfig[field.key] ?? field.default"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
>
|
|
||||||
<n-space>
|
|
||||||
<n-radio
|
|
||||||
v-for="opt in field.options"
|
|
||||||
:key="opt.value"
|
|
||||||
:value="opt.value"
|
|
||||||
>
|
|
||||||
{{ opt.label }}
|
|
||||||
</n-radio>
|
|
||||||
</n-space>
|
|
||||||
</n-radio-group>
|
|
||||||
|
|
||||||
<!-- Category (single) -->
|
|
||||||
<n-select
|
|
||||||
v-else-if="field.type === 'cat'"
|
|
||||||
:value="localConfig[field.key] ?? field.default ?? 0"
|
|
||||||
:options="categoryOptions"
|
|
||||||
filterable
|
|
||||||
placeholder="选择分类"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Categories (multiple) -->
|
|
||||||
<n-select
|
|
||||||
v-else-if="field.type === 'cats'"
|
|
||||||
:value="localConfig[field.key] ?? field.default ?? []"
|
|
||||||
:options="categoryOptions"
|
|
||||||
multiple
|
|
||||||
filterable
|
|
||||||
placeholder="选择分类"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Media Picker -->
|
|
||||||
<div v-else-if="field.type === 'media'" class="aksrt-module-inspector__media">
|
|
||||||
<div v-if="localConfig[field.key]" class="aksrt-module-inspector__media-preview">
|
|
||||||
<img :src="localConfig[field.key]" alt="" />
|
|
||||||
<n-button size="tiny" quaternary type="error" @click="updateConfig(field.key, '')">
|
|
||||||
移除
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<n-input
|
|
||||||
v-else
|
|
||||||
:value="localConfig[field.key] ?? ''"
|
|
||||||
placeholder="输入图片 URL 或点击下方选择"
|
|
||||||
@update:value="(val) => updateConfig(field.key, val)"
|
|
||||||
/>
|
|
||||||
<n-button size="tiny" quaternary @click="openMediaPicker(field.key)">
|
|
||||||
<template #icon><FaIcon icon="fas fa-image" :size="12" /></template>
|
|
||||||
从媒体库选择
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Repeater -->
|
|
||||||
<div v-else-if="field.type === 'repeater'" class="aksrt-module-inspector__repeater">
|
|
||||||
<div
|
|
||||||
v-for="(item, idx) in (localConfig[field.key] ?? [])"
|
|
||||||
:key="idx"
|
|
||||||
class="aksrt-module-inspector__repeater-item"
|
|
||||||
>
|
|
||||||
<div class="aksrt-module-inspector__repeater-fields">
|
|
||||||
<n-input
|
|
||||||
v-for="sub in (field.fields || [])"
|
|
||||||
:key="sub.key"
|
|
||||||
:value="item[sub.key] ?? ''"
|
|
||||||
:placeholder="sub.placeholder || sub.label || ''"
|
|
||||||
size="small"
|
|
||||||
@update:value="(val) => updateRepeaterItem(field.key, idx, sub.key, val)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<n-button size="tiny" quaternary type="error" @click="removeRepeaterItem(field.key, idx)">
|
|
||||||
<FaIcon icon="fas fa-times" :size="10" />
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
<n-button size="tiny" quaternary @click="addRepeaterItem(field.key, field.fields)">
|
|
||||||
<template #icon><FaIcon icon="fas fa-plus" :size="10" /></template>
|
|
||||||
添加一项
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</n-form-item>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-text v-else depth="3" style="font-size: 12px;">
|
|
||||||
此模块类型暂无可配置字段。
|
|
||||||
</n-text>
|
|
||||||
</n-form>
|
|
||||||
</n-scrollbar>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- Image URL input modal (replaces native prompt) -->
|
|
||||||
<n-modal v-model:show="showImageUrlInput" preset="card" title="输入图片 URL" style="max-width: 400px;" :bordered="false">
|
|
||||||
<n-input v-model:value="imageUrlValue" placeholder="https://example.com/image.jpg" @keyup.enter="confirmImageUrl" />
|
|
||||||
<template #footer>
|
|
||||||
<n-space justify="end">
|
|
||||||
<n-button @click="showImageUrlInput = false">取消</n-button>
|
|
||||||
<n-button type="primary" @click="confirmImageUrl">确定</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref, watch, onMounted } from 'vue'
|
|
||||||
import { MODULE_LIBRARY } from '@/utils/home-module-schema'
|
|
||||||
import FaIcon from '@/components/form/FaIcon.vue'
|
|
||||||
import api from '@/api/index.js'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
module: { type: Object, default: null },
|
|
||||||
moduleIndex: { type: Number, default: -1 },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update'])
|
|
||||||
|
|
||||||
const categoryOptions = ref([])
|
|
||||||
|
|
||||||
const meta = computed(() => {
|
|
||||||
if (!props.module) return null
|
|
||||||
return MODULE_LIBRARY.find(m => m.type === props.module.type) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const metaFields = computed(() => {
|
|
||||||
return meta.value?.fields || []
|
|
||||||
})
|
|
||||||
|
|
||||||
const localConfig = computed(() => {
|
|
||||||
return props.module?.config || {}
|
|
||||||
})
|
|
||||||
|
|
||||||
function getModuleIcon(type) {
|
|
||||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
|
||||||
const iconMap = {
|
|
||||||
image: 'fas fa-image', rectangle: 'fas fa-square', folder: 'fas fa-folder',
|
|
||||||
switcher: 'fas fa-columns', trophy: 'fas fa-trophy', clock: 'fas fa-clock',
|
|
||||||
tag: 'fas fa-tag', link: 'fas fa-link', code: 'fas fa-code', fire: 'fas fa-fire',
|
|
||||||
grid: 'fas fa-th', 'font-colors': 'fas fa-font', 'th-large': 'fas fa-th-large',
|
|
||||||
swap: 'fas fa-arrows-alt-v', 'question-circle': 'fas fa-question-circle',
|
|
||||||
table: 'fas fa-table', star: 'fas fa-star', handshake: 'fas fa-handshake',
|
|
||||||
'shopping-bag': 'fas fa-shopping-bag',
|
|
||||||
}
|
|
||||||
return iconMap[m?.icon] || 'fas fa-cube'
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModuleLabel(type) {
|
|
||||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
|
||||||
return m?.label || type
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateField(key, value) {
|
|
||||||
emit('update', props.moduleIndex, key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateConfig(key, value) {
|
|
||||||
const newConfig = { ...(props.module?.config || {}), [key]: value }
|
|
||||||
emit('update', props.moduleIndex, 'config', newConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image URL input state (replaces native prompt)
|
|
||||||
const showImageUrlInput = ref(false)
|
|
||||||
const imageUrlFieldKey = ref('')
|
|
||||||
const imageUrlValue = ref('')
|
|
||||||
|
|
||||||
function openMediaPicker(fieldKey) {
|
|
||||||
if (!window.wp || !window.wp.media) {
|
|
||||||
// Fallback: show inline input instead of native prompt
|
|
||||||
imageUrlFieldKey.value = fieldKey
|
|
||||||
imageUrlValue.value = localConfig.value[fieldKey] || ''
|
|
||||||
showImageUrlInput.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const frame = wp.media({
|
|
||||||
title: '选择图片',
|
|
||||||
button: { text: '使用此图片' },
|
|
||||||
multiple: false,
|
|
||||||
library: { type: 'image' },
|
|
||||||
})
|
|
||||||
frame.on('select', function () {
|
|
||||||
const attachment = frame.state().get('selection').first().toJSON()
|
|
||||||
updateConfig(fieldKey, attachment.url)
|
|
||||||
})
|
|
||||||
frame.open()
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmImageUrl() {
|
|
||||||
if (imageUrlValue.value.trim()) {
|
|
||||||
updateConfig(imageUrlFieldKey.value, imageUrlValue.value.trim())
|
|
||||||
}
|
|
||||||
showImageUrlInput.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRepeaterItem(fieldKey, subFields) {
|
|
||||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
|
||||||
const newItem = {}
|
|
||||||
;(subFields || []).forEach(f => { newItem[f.key] = f.default ?? '' })
|
|
||||||
current.push(newItem)
|
|
||||||
updateConfig(fieldKey, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRepeaterItem(fieldKey, index) {
|
|
||||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
|
||||||
current.splice(index, 1)
|
|
||||||
updateConfig(fieldKey, current)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRepeaterItem(fieldKey, index, subKey, value) {
|
|
||||||
const current = [...(props.module?.config?.[fieldKey] || [])]
|
|
||||||
if (current[index]) {
|
|
||||||
current[index] = { ...current[index], [subKey]: value }
|
|
||||||
updateConfig(fieldKey, current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCategories() {
|
|
||||||
try {
|
|
||||||
const res = await api.getHomepageCategories()
|
|
||||||
const cats = res?.data || []
|
|
||||||
categoryOptions.value = cats.map(c => ({
|
|
||||||
label: `${c.name} (${c.count})`,
|
|
||||||
value: c.term_id,
|
|
||||||
}))
|
|
||||||
} catch { /* non-critical */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadCategories)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-module-inspector__empty {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 48px 24px;
|
|
||||||
color: #999;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__empty p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__type {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__form {
|
|
||||||
padding: 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__media {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__media-preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__media-preview img {
|
|
||||||
width: 80px;
|
|
||||||
height: 60px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__repeater {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__repeater-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: #fafafa;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-module-inspector__repeater-fields {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
<template>
|
|
||||||
<n-modal
|
|
||||||
:show="show"
|
|
||||||
title="应用预设模板"
|
|
||||||
preset="dialog"
|
|
||||||
style="width: 560px;"
|
|
||||||
:show-icon="false"
|
|
||||||
@close="$emit('close')"
|
|
||||||
>
|
|
||||||
<div class="aksrt-preset-picker">
|
|
||||||
<p class="aksrt-preset-picker__hint">
|
|
||||||
选择一个预设模板快速配置首页模块。应用预设将替换当前所有模块。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="aksrt-preset-picker__list">
|
|
||||||
<div
|
|
||||||
v-for="preset in presets"
|
|
||||||
:key="preset.key"
|
|
||||||
class="aksrt-preset-picker__item"
|
|
||||||
:class="{ 'aksrt-preset-picker__item--selected': selectedKey === preset.key }"
|
|
||||||
@click="selectedKey = preset.key"
|
|
||||||
>
|
|
||||||
<div class="aksrt-preset-picker__item-header">
|
|
||||||
<strong>{{ preset.label }}</strong>
|
|
||||||
<n-tag size="small" type="info">{{ preset.modules.length }} 个模块</n-tag>
|
|
||||||
</div>
|
|
||||||
<p class="aksrt-preset-picker__item-desc">{{ preset.desc }}</p>
|
|
||||||
<div class="aksrt-preset-picker__item-modules">
|
|
||||||
<n-tag
|
|
||||||
v-for="(mod, i) in preset.modules"
|
|
||||||
:key="i"
|
|
||||||
size="small"
|
|
||||||
:bordered="false"
|
|
||||||
class="aksrt-preset-picker__module-tag"
|
|
||||||
>
|
|
||||||
{{ getModuleLabel(mod.type) }}
|
|
||||||
</n-tag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #action>
|
|
||||||
<n-space>
|
|
||||||
<n-button @click="$emit('close')">取消</n-button>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
:disabled="!selectedKey"
|
|
||||||
@click="handleApply"
|
|
||||||
>
|
|
||||||
应用预设
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { PRESET_TEMPLATES, MODULE_LIBRARY } from '@/utils/home-module-schema'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
show: { type: Boolean, default: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'apply'])
|
|
||||||
const selectedKey = ref('')
|
|
||||||
const presets = PRESET_TEMPLATES
|
|
||||||
|
|
||||||
function getModuleLabel(type) {
|
|
||||||
const m = MODULE_LIBRARY.find(x => x.type === type)
|
|
||||||
return m?.label || type
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleApply() {
|
|
||||||
const preset = presets.find(p => p.key === selectedKey.value)
|
|
||||||
if (preset) {
|
|
||||||
emit('apply', preset)
|
|
||||||
selectedKey.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-preset-picker__hint {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__item {
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__item:hover {
|
|
||||||
border-color: #d0d0d0;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__item--selected {
|
|
||||||
border-color: #1890ff;
|
|
||||||
background: #e6f4ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__item-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__item-header strong {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__item-desc {
|
|
||||||
margin: 0 0 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__item-modules {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-preset-picker__module-tag {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage } from './helpers'
|
|||||||
export const docsSection = {
|
export const docsSection = {
|
||||||
key: 'tools-compat',
|
key: 'tools-compat',
|
||||||
label: '兼容&工具',
|
label: '兼容&工具',
|
||||||
icon: renderFaIcon('fas fa-toolbox'),
|
icon: renderFaIcon('fa fas fa-wrench'),
|
||||||
route: '/tools',
|
route: '/tools',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
import { renderFaIcon, schemaPage } from './helpers'
|
||||||
|
|
||||||
export const extensionsSection = {
|
export const extensionsSection = {
|
||||||
key: 'extensions',
|
key: 'extensions',
|
||||||
label: '扩展&增强',
|
label: '扩展&增强',
|
||||||
icon: renderFaIcon('fas fa-puzzle-piece'),
|
icon: renderFaIcon('fa fas fa-puzzle-piece'),
|
||||||
route: '/extensions',
|
route: '/extensions',
|
||||||
children: [
|
children: [
|
||||||
|
schemaPage({
|
||||||
|
key: 'extensions-system',
|
||||||
|
name: 'ExtensionsSystem',
|
||||||
|
label: '系统工具',
|
||||||
|
route: '/extensions/system',
|
||||||
|
title: '系统工具',
|
||||||
|
desc: 'WordPress 优化、上传优化与编辑器显示的系统级开关。',
|
||||||
|
canonicalDomain: 'extensions',
|
||||||
|
section: 'system',
|
||||||
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'extensions-ip',
|
key: 'extensions-ip',
|
||||||
name: 'ExtensionsIp',
|
name: 'ExtensionsIp',
|
||||||
@@ -32,28 +42,19 @@ export const extensionsSection = {
|
|||||||
label: '百度推送',
|
label: '百度推送',
|
||||||
route: '/extensions/push',
|
route: '/extensions/push',
|
||||||
title: '百度推送',
|
title: '百度推送',
|
||||||
desc: '配置搜索引擎主动推送、批量提交和状态追踪。',
|
desc: '配置百度自动推送、收录通道与触发规则;推送结果与「重新提交」在文章/分类编辑页查看。',
|
||||||
canonicalDomain: 'extensions',
|
canonicalDomain: 'extensions',
|
||||||
section: 'push',
|
section: 'push',
|
||||||
}),
|
}),
|
||||||
customPage({
|
schemaPage({
|
||||||
key: 'extensions-tools',
|
key: 'extensions-security',
|
||||||
name: 'ExtensionsTools',
|
name: 'ExtensionsSecurity',
|
||||||
label: '系统工具',
|
label: '网站安全',
|
||||||
route: '/extensions/tools',
|
route: '/extensions/security',
|
||||||
component: 'ExtensionTools',
|
title: '网站安全',
|
||||||
title: '系统工具',
|
desc: '防刷限制、验证码发送限制与站点安全加固。',
|
||||||
desc: '额外运维工具与扩展能力。',
|
canonicalDomain: 'extensions',
|
||||||
}),
|
section: 'security',
|
||||||
customPage({
|
|
||||||
key: 'extensions-ai',
|
|
||||||
name: 'ExtensionAI',
|
|
||||||
label: 'AI 连接配置',
|
|
||||||
route: '/extensions/ai',
|
|
||||||
component: 'ExtensionAI',
|
|
||||||
status: 'available',
|
|
||||||
title: 'AI 连接配置',
|
|
||||||
desc: '配置 AI 生成连接;SEO 在文章独立 SEO 框,摘要/标签在前台投稿页。',
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { renderFaIcon, schemaPage } from './helpers'
|
||||||
|
|
||||||
|
export const forumSection = {
|
||||||
|
key: 'forum',
|
||||||
|
label: '社区论坛',
|
||||||
|
icon: renderFaIcon('fa fas fa-comments'),
|
||||||
|
route: '/forum',
|
||||||
|
children: [
|
||||||
|
schemaPage({
|
||||||
|
key: 'forum-general',
|
||||||
|
name: 'Forum_general',
|
||||||
|
label: '全局设置',
|
||||||
|
title: '全局设置',
|
||||||
|
route: '/forum/general',
|
||||||
|
canonicalDomain: 'forum',
|
||||||
|
section: 'general',
|
||||||
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'forum-home',
|
||||||
|
name: 'Forum_home',
|
||||||
|
label: '首页与列表',
|
||||||
|
title: '首页与列表',
|
||||||
|
route: '/forum/home',
|
||||||
|
canonicalDomain: 'forum',
|
||||||
|
section: 'home',
|
||||||
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'forum-publishing',
|
||||||
|
name: 'Forum_publishing',
|
||||||
|
label: '发布与审核',
|
||||||
|
title: '发布与审核',
|
||||||
|
route: '/forum/publishing',
|
||||||
|
canonicalDomain: 'forum',
|
||||||
|
section: 'publishing',
|
||||||
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'forum-access',
|
||||||
|
name: 'Forum_access',
|
||||||
|
label: '权限与版主',
|
||||||
|
title: '权限与版主',
|
||||||
|
route: '/forum/access',
|
||||||
|
canonicalDomain: 'forum',
|
||||||
|
section: 'access',
|
||||||
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'forum-replies',
|
||||||
|
name: 'Forum_replies',
|
||||||
|
label: '回复与互动',
|
||||||
|
title: '回复与互动',
|
||||||
|
route: '/forum/replies',
|
||||||
|
canonicalDomain: 'forum',
|
||||||
|
section: 'replies',
|
||||||
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'forum-payment',
|
||||||
|
name: 'Forum_payment',
|
||||||
|
label: '付费与积分',
|
||||||
|
title: '付费与积分',
|
||||||
|
route: '/forum/payment',
|
||||||
|
canonicalDomain: 'forum',
|
||||||
|
section: 'payment',
|
||||||
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'forum-urls',
|
||||||
|
name: 'Forum_urls',
|
||||||
|
label: '链接设置',
|
||||||
|
title: '链接设置',
|
||||||
|
route: '/forum/urls',
|
||||||
|
canonicalDomain: 'forum',
|
||||||
|
section: 'urls',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { renderFaIcon, schemaPage, customPage } from './helpers'
|
|||||||
export const globalSection = {
|
export const globalSection = {
|
||||||
key: 'global',
|
key: 'global',
|
||||||
label: '全局&功能',
|
label: '全局&功能',
|
||||||
icon: renderFaIcon('fas fa-globe'),
|
icon: renderFaIcon('fa fas fa-globe'),
|
||||||
route: '/global',
|
route: '/global',
|
||||||
children: [
|
children: [
|
||||||
schemaPage({
|
schemaPage({
|
||||||
@@ -12,7 +12,7 @@ export const globalSection = {
|
|||||||
label: '常用功能',
|
label: '常用功能',
|
||||||
route: '/global/common',
|
route: '/global/common',
|
||||||
title: '常用功能',
|
title: '常用功能',
|
||||||
desc: '评论、性能、头像源与备案等全局基础配置。',
|
desc: '评论、性能、头像源等全局基础配置。',
|
||||||
canonicalDomain: 'global',
|
canonicalDomain: 'global',
|
||||||
section: 'common',
|
section: 'common',
|
||||||
}),
|
}),
|
||||||
@@ -84,6 +84,16 @@ export const globalSection = {
|
|||||||
canonicalDomain: 'global',
|
canonicalDomain: 'global',
|
||||||
section: 'i18n',
|
section: 'i18n',
|
||||||
}),
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'global-share',
|
||||||
|
name: 'GlobalShare',
|
||||||
|
label: '社交分享',
|
||||||
|
route: '/global/share',
|
||||||
|
title: '社交分享',
|
||||||
|
desc: '配置分享平台、微信 JSAPI 与海报分享。',
|
||||||
|
canonicalDomain: 'global',
|
||||||
|
section: 'share',
|
||||||
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'global-email',
|
key: 'global-email',
|
||||||
name: 'GlobalEmail',
|
name: 'GlobalEmail',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { extensionsSection } from './extensions'
|
|||||||
import { themeInfoSection } from './theme-info'
|
import { themeInfoSection } from './theme-info'
|
||||||
import { docsSection } from './docs'
|
import { docsSection } from './docs'
|
||||||
import { toolsSection } from './tools'
|
import { toolsSection } from './tools'
|
||||||
|
import { forumSection } from './forum'
|
||||||
|
|
||||||
const SECTION_DEFAULT_CHILDREN = {
|
const SECTION_DEFAULT_CHILDREN = {
|
||||||
global: 'global-common',
|
global: 'global-common',
|
||||||
@@ -15,9 +16,10 @@ const SECTION_DEFAULT_CHILDREN = {
|
|||||||
'post-list': 'post-list-page',
|
'post-list': 'post-list-page',
|
||||||
permissions: 'permissions-comment',
|
permissions: 'permissions-comment',
|
||||||
users: 'users-auth',
|
users: 'users-auth',
|
||||||
payment: 'payment-recharge',
|
payment: 'payment-knowledge',
|
||||||
extensions: 'extensions-ip',
|
extensions: 'extensions-system',
|
||||||
tools: 'tools-import-export',
|
tools: 'tools-import-export',
|
||||||
|
forum: 'forum-general',
|
||||||
'theme-info': 'theme-info-dashboard',
|
'theme-info': 'theme-info-dashboard',
|
||||||
docs: 'docs-dashboard',
|
docs: 'docs-dashboard',
|
||||||
}
|
}
|
||||||
@@ -43,6 +45,7 @@ export const ADMIN_SECTIONS = [
|
|||||||
attachSectionDefaultRoute(permissionsSection),
|
attachSectionDefaultRoute(permissionsSection),
|
||||||
attachSectionDefaultRoute(usersSection),
|
attachSectionDefaultRoute(usersSection),
|
||||||
attachSectionDefaultRoute(paymentSection),
|
attachSectionDefaultRoute(paymentSection),
|
||||||
|
attachSectionDefaultRoute(forumSection),
|
||||||
attachSectionDefaultRoute(extensionsSection),
|
attachSectionDefaultRoute(extensionsSection),
|
||||||
attachSectionDefaultRoute(toolsSection),
|
attachSectionDefaultRoute(toolsSection),
|
||||||
attachSectionDefaultRoute(themeInfoSection),
|
attachSectionDefaultRoute(themeInfoSection),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
import { renderFaIcon, schemaPage } from './helpers'
|
||||||
|
|
||||||
export const pageDisplaySection = {
|
export const pageDisplaySection = {
|
||||||
key: 'page-display',
|
key: 'page-display',
|
||||||
label: '页面&显示',
|
label: '页面&显示',
|
||||||
icon: renderFaIcon('fas fa-desktop'),
|
icon: renderFaIcon('fa fas fa-desktop'),
|
||||||
route: '/page-display',
|
route: '/page-display',
|
||||||
children: [
|
children: [
|
||||||
schemaPage({
|
schemaPage({
|
||||||
@@ -12,79 +12,35 @@ export const pageDisplaySection = {
|
|||||||
label: '顶部导航栏',
|
label: '顶部导航栏',
|
||||||
route: '/page-display/header-nav',
|
route: '/page-display/header-nav',
|
||||||
title: '顶部导航栏',
|
title: '顶部导航栏',
|
||||||
desc: '导航栏布局、菜单位置、LOGO 与搜索入口配置。',
|
|
||||||
canonicalDomain: 'page_display',
|
canonicalDomain: 'page_display',
|
||||||
section: 'header-nav',
|
section: 'header-nav',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
|
||||||
key: 'page-display-background',
|
|
||||||
name: 'PageDisplayBackground',
|
|
||||||
label: '全局背景',
|
|
||||||
route: '/page-display/background',
|
|
||||||
title: '全局背景',
|
|
||||||
desc: '配置站点全局背景图、背景色、暗色模式背景和遮罩。',
|
|
||||||
canonicalDomain: 'page_display',
|
|
||||||
section: 'background',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
|
||||||
key: 'page-display-menu-decoration',
|
|
||||||
name: 'PageDisplayMenuDecoration',
|
|
||||||
label: '菜单装饰',
|
|
||||||
route: '/page-display/menu-decoration',
|
|
||||||
title: '菜单装饰',
|
|
||||||
desc: '配置导航菜单的图标、徽章、下拉样式和图文卡片子菜单。',
|
|
||||||
canonicalDomain: 'page_display',
|
|
||||||
section: 'menu-decoration',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'page-display-header-components',
|
key: 'page-display-header-components',
|
||||||
name: 'PageDisplayHeaderComponents',
|
name: 'PageDisplayHeaderComponents',
|
||||||
label: '顶部多功能组件',
|
label: '顶部多功能组件',
|
||||||
route: '/page-display/header-components',
|
route: '/page-display/header-components',
|
||||||
title: '顶部多功能组件',
|
title: '顶部多功能组件',
|
||||||
desc: '配置顶部组件显示范围、轮播参数、搜索覆盖层与图标卡片。幻灯片内容见「Hero 轮播」「Banner 轮播」。',
|
|
||||||
canonicalDomain: 'page_display',
|
canonicalDomain: 'page_display',
|
||||||
section: 'header-components',
|
section: 'header-components',
|
||||||
}),
|
}),
|
||||||
customPage({
|
schemaPage({
|
||||||
key: 'page-display-hero',
|
|
||||||
name: 'PageDisplayHero',
|
|
||||||
label: 'Hero 轮播',
|
|
||||||
route: '/page-display/hero',
|
|
||||||
title: 'Hero 轮播管理',
|
|
||||||
desc: '管理首页顶部 Hero 幻灯片内容(图片/视频、文案、图层、跳转)。显示开关与搜索/卡片配置见「顶部多功能组件」。',
|
|
||||||
component: 'HeroManager',
|
|
||||||
canonicalDomain: 'page_display',
|
|
||||||
section: 'hero',
|
|
||||||
}),
|
|
||||||
customPage({
|
|
||||||
key: 'page-display-banner',
|
|
||||||
name: 'PageDisplayBanner',
|
|
||||||
label: 'Banner 轮播',
|
|
||||||
route: '/page-display/banner',
|
|
||||||
title: 'Banner 管理',
|
|
||||||
desc: '管理首页内容流 Banner 幻灯片(图片、文案、跳转、启用状态)。',
|
|
||||||
component: 'BannerManager',
|
|
||||||
canonicalDomain: 'page_display',
|
|
||||||
section: 'banner',
|
|
||||||
}),
|
|
||||||
customPage({
|
|
||||||
key: 'page-display-footer',
|
key: 'page-display-footer',
|
||||||
name: 'PageDisplayFooter',
|
name: 'PageDisplayFooter',
|
||||||
label: '底部页脚',
|
label: '底部页脚',
|
||||||
route: '/page-display/footer',
|
route: '/page-display/footer',
|
||||||
title: '底部页脚',
|
title: '底部页脚',
|
||||||
desc: '页脚布局、友链、版权信息配置。',
|
canonicalDomain: 'page_display',
|
||||||
component: 'FooterSettings',
|
section: 'footer',
|
||||||
}),
|
}),
|
||||||
customPage({
|
schemaPage({
|
||||||
key: 'page-display-homepage',
|
key: 'page-display-homepage',
|
||||||
name: 'PageDisplayHomepage',
|
name: 'PageDisplayHomepage',
|
||||||
label: '首页配置',
|
label: '首页配置',
|
||||||
route: '/page-display/homepage',
|
route: '/page-display/homepage',
|
||||||
title: '首页配置',
|
title: '首页配置',
|
||||||
desc: '首页内容模型、主列表、分类 Tab 与首页交互配置。',
|
canonicalDomain: 'page_display',
|
||||||
component: 'HomepageSettings',
|
section: 'homepage',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'page-display-category',
|
key: 'page-display-category',
|
||||||
@@ -92,7 +48,6 @@ export const pageDisplaySection = {
|
|||||||
label: '分类页面',
|
label: '分类页面',
|
||||||
route: '/page-display/category',
|
route: '/page-display/category',
|
||||||
title: '分类页面',
|
title: '分类页面',
|
||||||
desc: '分类归档页的头图、简介、列表模式、侧栏配置。',
|
|
||||||
canonicalDomain: 'page_display',
|
canonicalDomain: 'page_display',
|
||||||
section: 'category',
|
section: 'category',
|
||||||
}),
|
}),
|
||||||
@@ -102,17 +57,24 @@ export const pageDisplaySection = {
|
|||||||
label: '标签页面',
|
label: '标签页面',
|
||||||
route: '/page-display/tag',
|
route: '/page-display/tag',
|
||||||
title: '标签页面',
|
title: '标签页面',
|
||||||
desc: '标签归档页的展示样式与配置。',
|
|
||||||
canonicalDomain: 'page_display',
|
canonicalDomain: 'page_display',
|
||||||
section: 'tag',
|
section: 'tag',
|
||||||
}),
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'page-display-topics',
|
||||||
|
name: 'PageDisplayTopics',
|
||||||
|
label: '专题页面',
|
||||||
|
route: '/page-display/topics',
|
||||||
|
title: '专题页面',
|
||||||
|
canonicalDomain: 'page_display',
|
||||||
|
section: 'topics',
|
||||||
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'page-display-user-home',
|
key: 'page-display-user-home',
|
||||||
name: 'PageDisplayUserHome',
|
name: 'PageDisplayUserHome',
|
||||||
label: '用户主页',
|
label: '用户主页',
|
||||||
route: '/page-display/user-home',
|
route: '/page-display/user-home',
|
||||||
title: '用户主页',
|
title: '用户主页',
|
||||||
desc: '用户公开主页的布局与展示配置。',
|
|
||||||
canonicalDomain: 'page_display',
|
canonicalDomain: 'page_display',
|
||||||
section: 'user-home',
|
section: 'user-home',
|
||||||
}),
|
}),
|
||||||
@@ -122,7 +84,6 @@ export const pageDisplaySection = {
|
|||||||
label: '其它页面',
|
label: '其它页面',
|
||||||
route: '/page-display/other',
|
route: '/page-display/other',
|
||||||
title: '其它页面',
|
title: '其它页面',
|
||||||
desc: '404 页面、搜索结果页等其它页面配置。',
|
|
||||||
canonicalDomain: 'page_display',
|
canonicalDomain: 'page_display',
|
||||||
section: 'other',
|
section: 'other',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,11 +1,27 @@
|
|||||||
import { renderFaIcon, schemaPage, customPage } from './helpers'
|
import { renderFaIcon, schemaPage } from './helpers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付&付费 — 子菜单按参考主题划分:
|
||||||
|
* 知识付费 / 余额充值 / VIP会员 / 创作分成 / 推广返佣 / 收款接口 / 提现策略。
|
||||||
|
* 收款接口为 schema 表单页(双下拉式接口选择 + 凭据配置 + 前缀配置)。
|
||||||
|
*/
|
||||||
export const paymentSection = {
|
export const paymentSection = {
|
||||||
key: 'payment',
|
key: 'payment',
|
||||||
label: '支付&付费',
|
label: '支付&付费',
|
||||||
icon: renderFaIcon('fas fa-wallet'),
|
icon: renderFaIcon('fa fas fa-credit-card'),
|
||||||
route: '/payment',
|
route: '/payment',
|
||||||
children: [
|
children: [
|
||||||
|
schemaPage({
|
||||||
|
key: 'payment-knowledge',
|
||||||
|
name: 'PaymentKnowledge',
|
||||||
|
label: '知识付费',
|
||||||
|
route: '/payment/knowledge',
|
||||||
|
status: 'available',
|
||||||
|
title: '知识付费',
|
||||||
|
desc: '免登陆购买、模块显示位置、商品优惠码、客户服务、付费下载与商品预置参数。',
|
||||||
|
canonicalDomain: 'payment',
|
||||||
|
section: 'knowledge',
|
||||||
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-recharge',
|
key: 'payment-recharge',
|
||||||
name: 'PaymentRecharge',
|
name: 'PaymentRecharge',
|
||||||
@@ -13,7 +29,7 @@ export const paymentSection = {
|
|||||||
route: '/payment/recharge',
|
route: '/payment/recharge',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '余额充值',
|
title: '余额充值',
|
||||||
desc: '余额充值策略、记录和手动确认配置。',
|
desc: '余额功能、充值商品选项、卡密充值、充值卡与余额兑换积分。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'recharge',
|
section: 'recharge',
|
||||||
}),
|
}),
|
||||||
@@ -24,41 +40,18 @@ export const paymentSection = {
|
|||||||
route: '/payment/vip',
|
route: '/payment/vip',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: 'VIP会员',
|
title: 'VIP会员',
|
||||||
desc: '会员套餐、会员特权和免扣策略。',
|
desc: '会员等级、会员权益、购买选项、卡密兑换与积分兑换。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'vip',
|
section: 'vip',
|
||||||
}),
|
}),
|
||||||
customPage({
|
|
||||||
key: 'payment-gateway',
|
|
||||||
name: 'PaymentGateway',
|
|
||||||
label: '收款接口',
|
|
||||||
route: '/payment/gateway',
|
|
||||||
component: 'PaymentGateway',
|
|
||||||
status: 'available',
|
|
||||||
title: '收款接口',
|
|
||||||
desc: '支付网关适配器、测试网关和手动确认配置。',
|
|
||||||
canonicalDomain: 'payment',
|
|
||||||
section: 'gateway',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
|
||||||
key: 'payment-order',
|
|
||||||
name: 'PaymentOrder',
|
|
||||||
label: '订单设置',
|
|
||||||
route: '/payment/order',
|
|
||||||
status: 'available',
|
|
||||||
title: '订单设置',
|
|
||||||
desc: '订单前缀、有效期、展示字段和状态文案。',
|
|
||||||
canonicalDomain: 'payment',
|
|
||||||
section: 'order',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-revenue',
|
key: 'payment-revenue',
|
||||||
name: 'PaymentRevenue',
|
name: 'PaymentRevenue',
|
||||||
label: '创作中心',
|
label: '创作分成',
|
||||||
route: '/payment/revenue',
|
route: '/payment/revenue',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '创作中心',
|
title: '创作分成',
|
||||||
desc: '独立配置现金与积分分成比例、结算周期和结算备注文案。',
|
desc: '现金与积分分成比例、结算周期、分成说明与详情页(分成工单在支付中心)。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'revenue',
|
section: 'revenue',
|
||||||
}),
|
}),
|
||||||
@@ -69,20 +62,20 @@ export const paymentSection = {
|
|||||||
route: '/payment/affiliate',
|
route: '/payment/affiliate',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '推广返佣',
|
title: '推广返佣',
|
||||||
desc: '返佣规则、邀请链路和结算策略(返佣工单在支付中心)。',
|
desc: '推广识别模式、分档返佣规则、文案与海报(返佣工单在支付中心)。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'affiliate',
|
section: 'affiliate',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-card-pass',
|
key: 'payment-gateway',
|
||||||
name: 'PaymentCardPass',
|
name: 'PaymentGateway',
|
||||||
label: '卡密设置',
|
label: '收款接口',
|
||||||
route: '/payment/card-pass',
|
route: '/payment/gateway',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '卡密设置',
|
title: '收款接口',
|
||||||
desc: '充值卡类型、卡号规则与兑换文案(卡密工单在支付中心)。',
|
desc: '微信/支付宝接口选择、PayPal/Stripe 开关、货币结算、显示顺序与订单支付时效。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'card-pass',
|
section: 'gateway',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'payment-withdrawal',
|
key: 'payment-withdrawal',
|
||||||
@@ -91,7 +84,7 @@ export const paymentSection = {
|
|||||||
route: '/payment/withdrawal',
|
route: '/payment/withdrawal',
|
||||||
status: 'available',
|
status: 'available',
|
||||||
title: '提现策略',
|
title: '提现策略',
|
||||||
desc: '最低提现、手续费与说明文案(提现工单在支付中心)。',
|
desc: '提现门槛、手续费、每日限制、收款渠道与提现协议(提现工单在支付中心)。',
|
||||||
canonicalDomain: 'payment',
|
canonicalDomain: 'payment',
|
||||||
section: 'withdrawal',
|
section: 'withdrawal',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage, schemaPage } from './helpers'
|
|||||||
export const permissionsSection = {
|
export const permissionsSection = {
|
||||||
key: 'permissions',
|
key: 'permissions',
|
||||||
label: '功能&权限',
|
label: '功能&权限',
|
||||||
icon: renderFaIcon('fas fa-shield-alt'),
|
icon: renderFaIcon('fa fas fa-shield-alt'),
|
||||||
route: '/permissions',
|
route: '/permissions',
|
||||||
children: [
|
children: [
|
||||||
customPage({
|
customPage({
|
||||||
@@ -37,14 +37,13 @@ export const permissionsSection = {
|
|||||||
canonicalDomain: 'permissions',
|
canonicalDomain: 'permissions',
|
||||||
section: 'moderation',
|
section: 'moderation',
|
||||||
}),
|
}),
|
||||||
customPage({
|
schemaPage({
|
||||||
key: 'permissions-attachment',
|
key: 'permissions-attachment',
|
||||||
name: 'PermissionsAttachment',
|
name: 'PermissionsAttachment',
|
||||||
label: '附件配置',
|
label: '附件配置',
|
||||||
route: '/permissions/attachment',
|
route: '/permissions/attachment',
|
||||||
component: 'AttachmentPermissions',
|
|
||||||
title: '附件配置',
|
title: '附件配置',
|
||||||
desc: '管理文件大小、类型、安全限制和审核策略。',
|
desc: '头像图片、上传大小、上传格式与上传行为配置。',
|
||||||
canonicalDomain: 'permissions',
|
canonicalDomain: 'permissions',
|
||||||
section: 'attachment',
|
section: 'attachment',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { renderFaIcon, schemaPage, customPage } from './helpers'
|
|||||||
export const postListSection = {
|
export const postListSection = {
|
||||||
key: 'post-list',
|
key: 'post-list',
|
||||||
label: '文章&列表',
|
label: '文章&列表',
|
||||||
icon: renderFaIcon('fas fa-file-alt'),
|
icon: renderFaIcon('fa fas fa-file-alt'),
|
||||||
route: '/post-list',
|
route: '/post-list',
|
||||||
children: [
|
children: [
|
||||||
schemaPage({
|
schemaPage({
|
||||||
@@ -57,16 +57,6 @@ export const postListSection = {
|
|||||||
canonicalDomain: 'post_list',
|
canonicalDomain: 'post_list',
|
||||||
section: 'feature',
|
section: 'feature',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
|
||||||
key: 'post-list-share',
|
|
||||||
name: 'PostListShare',
|
|
||||||
label: '分享设置',
|
|
||||||
route: '/post-list/share',
|
|
||||||
title: '分享设置',
|
|
||||||
desc: '配置微信分享凭据、海报分享和社交分享选项。',
|
|
||||||
canonicalDomain: 'post_list',
|
|
||||||
section: 'share',
|
|
||||||
}),
|
|
||||||
schemaPage({
|
schemaPage({
|
||||||
key: 'post-list-comment',
|
key: 'post-list-comment',
|
||||||
name: 'PostListComment',
|
name: 'PostListComment',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage } from './helpers'
|
|||||||
export const themeInfoSection = {
|
export const themeInfoSection = {
|
||||||
key: 'tools-compat',
|
key: 'tools-compat',
|
||||||
label: '兼容&工具',
|
label: '兼容&工具',
|
||||||
icon: renderFaIcon('fas fa-toolbox'),
|
icon: renderFaIcon('fa fas fa-wrench'),
|
||||||
route: '/tools',
|
route: '/tools',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { renderFaIcon, customPage, schemaPage } from './helpers'
|
|||||||
export const toolsSection = {
|
export const toolsSection = {
|
||||||
key: 'tools-compat',
|
key: 'tools-compat',
|
||||||
label: '工具',
|
label: '工具',
|
||||||
icon: renderFaIcon('fas fa-toolbox'),
|
icon: renderFaIcon('fa fas fa-wrench'),
|
||||||
route: '/tools',
|
route: '/tools',
|
||||||
children: [
|
children: [
|
||||||
customPage({
|
customPage({
|
||||||
@@ -25,34 +25,15 @@ export const toolsSection = {
|
|||||||
canonicalDomain: 'tools',
|
canonicalDomain: 'tools',
|
||||||
section: 'backup',
|
section: 'backup',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
|
||||||
key: 'tools-frontend-modal-rollout',
|
|
||||||
name: 'ToolsFrontendModalRollout',
|
|
||||||
label: '前台弹窗灰度',
|
|
||||||
route: '/tools/frontend-modal-rollout',
|
|
||||||
title: '前台弹窗灰度',
|
|
||||||
desc: '按领域控制前台弹窗新流程的分阶段发布。',
|
|
||||||
canonicalDomain: 'tools',
|
|
||||||
section: 'frontend-modal-rollout',
|
|
||||||
}),
|
|
||||||
customPage({
|
customPage({
|
||||||
key: 'tools-health',
|
key: 'tools-health',
|
||||||
name: 'ToolsHealth',
|
name: 'ToolsHealth',
|
||||||
label: '健康检查',
|
label: '健康检查',
|
||||||
route: '/tools/health',
|
route: '/tools/health',
|
||||||
title: '健康检查',
|
title: '健康检查',
|
||||||
desc: '查看主题环境、数据表、邮箱验证迁移和系统诊断状态。',
|
desc: '查看主题环境、数据表和系统诊断状态。',
|
||||||
component: 'ToolsHealth',
|
component: 'ToolsHealth',
|
||||||
}),
|
}),
|
||||||
customPage({
|
|
||||||
key: 'tools-email-rollback',
|
|
||||||
name: 'ToolsEmailRollback',
|
|
||||||
label: '邮件回滚',
|
|
||||||
route: '/tools/email-rollback',
|
|
||||||
title: '邮件回滚',
|
|
||||||
desc: '查看 14 天邮箱变更窗口内的账号并执行回滚。',
|
|
||||||
component: 'ToolsEmailRollback',
|
|
||||||
}),
|
|
||||||
customPage({
|
customPage({
|
||||||
key: 'tools-update',
|
key: 'tools-update',
|
||||||
name: 'ToolsUpdate',
|
name: 'ToolsUpdate',
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { renderFaIcon, schemaPage, customPage } from './helpers'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Users SPA keeps config-shaped pages.
|
* Users SPA keeps config-shaped pages.
|
||||||
* Message / referral-code / ban / report queues live under users.php native menus.
|
* Message / referral-code queues live under users.php native menus;
|
||||||
|
* 禁封/举报 is a config page (schema section "report"); ticket queues
|
||||||
|
* (举报&禁封申诉) stay on the native WP users.php page.
|
||||||
*/
|
*/
|
||||||
export const usersSection = {
|
export const usersSection = {
|
||||||
key: 'users',
|
key: 'users',
|
||||||
label: '用户&互动',
|
label: '用户&互动',
|
||||||
icon: renderFaIcon('fas fa-users'),
|
icon: renderFaIcon('fa fas fa-users'),
|
||||||
route: '/users',
|
route: '/users',
|
||||||
children: [
|
children: [
|
||||||
schemaPage({
|
schemaPage({
|
||||||
@@ -30,28 +32,16 @@ export const usersSection = {
|
|||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'oauth',
|
section: 'oauth',
|
||||||
}),
|
}),
|
||||||
customPage({
|
schemaPage({
|
||||||
key: 'users-features',
|
key: 'users-features',
|
||||||
name: 'UsersFeatures',
|
name: 'UsersFeatures',
|
||||||
label: '用户功能',
|
label: '用户功能',
|
||||||
route: '/users/features',
|
route: '/users/features',
|
||||||
component: 'UsersFeatures',
|
|
||||||
title: '用户功能',
|
title: '用户功能',
|
||||||
desc: '用户中心与个人主页模块配置。',
|
desc: '配置用户头像、封面和默认签名。',
|
||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'features',
|
section: 'features',
|
||||||
}),
|
}),
|
||||||
customPage({
|
|
||||||
key: 'users-signin',
|
|
||||||
name: 'UsersSignin',
|
|
||||||
label: '签到奖励',
|
|
||||||
route: '/users/signin',
|
|
||||||
component: 'UsersSignin',
|
|
||||||
title: '签到奖励',
|
|
||||||
desc: '签到规则、连续签到奖励和签到页面配置。',
|
|
||||||
canonicalDomain: 'users',
|
|
||||||
section: 'signin',
|
|
||||||
}),
|
|
||||||
customPage({
|
customPage({
|
||||||
key: 'users-credits',
|
key: 'users-credits',
|
||||||
name: 'UsersCredits',
|
name: 'UsersCredits',
|
||||||
@@ -70,7 +60,7 @@ export const usersSection = {
|
|||||||
route: '/users/verify',
|
route: '/users/verify',
|
||||||
component: 'UsersVerify',
|
component: 'UsersVerify',
|
||||||
title: '身份认证',
|
title: '身份认证',
|
||||||
desc: '配置认证展示、申请类型与申请门槛;审核、驳回和取消认证统一在 WordPress 用户菜单处理。',
|
desc: '配置通用认证申请规则;申请审核和认证资料管理位于 WordPress 用户菜单。',
|
||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'verify',
|
section: 'verify',
|
||||||
}),
|
}),
|
||||||
@@ -118,16 +108,6 @@ export const usersSection = {
|
|||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'sms',
|
section: 'sms',
|
||||||
}),
|
}),
|
||||||
schemaPage({
|
|
||||||
key: 'users-wechat',
|
|
||||||
name: 'UsersWechat',
|
|
||||||
label: '微信通知',
|
|
||||||
route: '/users/wechat',
|
|
||||||
title: '微信通知',
|
|
||||||
desc: '配置微信模板消息、订阅/解绑和站外通知策略。',
|
|
||||||
canonicalDomain: 'users',
|
|
||||||
section: 'wechat',
|
|
||||||
}),
|
|
||||||
customPage({
|
customPage({
|
||||||
key: 'users-wechat-template',
|
key: 'users-wechat-template',
|
||||||
name: 'UsersWechatTemplate',
|
name: 'UsersWechatTemplate',
|
||||||
@@ -140,5 +120,26 @@ export const usersSection = {
|
|||||||
canonicalDomain: 'users',
|
canonicalDomain: 'users',
|
||||||
section: 'wechat-template',
|
section: 'wechat-template',
|
||||||
}),
|
}),
|
||||||
|
schemaPage({
|
||||||
|
key: 'users-ban',
|
||||||
|
name: 'UsersBan',
|
||||||
|
label: '禁封/举报',
|
||||||
|
route: '/users/ban',
|
||||||
|
title: '禁封/举报',
|
||||||
|
desc: '禁封功能、举报功能、预置原因、申诉资料与举报说明配置;举报与申诉工单在 WordPress 用户菜单处理。',
|
||||||
|
canonicalDomain: 'users',
|
||||||
|
section: 'report',
|
||||||
|
}),
|
||||||
|
customPage({
|
||||||
|
key: 'users-ticket',
|
||||||
|
name: 'UsersTicket',
|
||||||
|
label: '工单中心',
|
||||||
|
route: '/users/ticket',
|
||||||
|
title: '工单中心',
|
||||||
|
desc: '工单功能开关、工单号前缀、提交公告与提交表单(图片上传、关联订单、催单冷却)配置,以及部门、状态、预设回复维护;工单处理在 WordPress 用户菜单的「工单管理」中进行。',
|
||||||
|
component: 'UsersTicket',
|
||||||
|
canonicalDomain: 'users',
|
||||||
|
section: 'ticket',
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { ALL_SCHEMAS, CANONICAL_SCHEMA_DOMAINS, normalizeSchemaDomain } from '@/schema/index.js'
|
|
||||||
|
|
||||||
function cloneDefaults(defaults = {}) {
|
|
||||||
return JSON.parse(JSON.stringify(defaults))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSectionDefaults(domain, section) {
|
|
||||||
const domainKey = normalizeSchemaDomain(domain)
|
|
||||||
const sectionSchema = ALL_SCHEMAS[domainKey]?.[section]
|
|
||||||
return cloneDefaults(sectionSchema?.defaults || {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDomainDefaults(domain) {
|
|
||||||
const domainKey = normalizeSchemaDomain(domain)
|
|
||||||
const sections = Object.values(ALL_SCHEMAS[domainKey] || {})
|
|
||||||
|
|
||||||
return sections.reduce((defaults, schema) => {
|
|
||||||
return {
|
|
||||||
...defaults,
|
|
||||||
...cloneDefaults(schema?.defaults || {}),
|
|
||||||
}
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SETTINGS_DEFAULTS_BY_DOMAIN = CANONICAL_SCHEMA_DOMAINS.reduce((defaults, domain) => {
|
|
||||||
defaults[domain] = getDomainDefaults(domain)
|
|
||||||
return defaults
|
|
||||||
}, {})
|
|
||||||
+12
-11
@@ -4,6 +4,7 @@ import NaiveUI from 'naive-ui'
|
|||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
import './styles/sortable-list.css'
|
||||||
import { styleMountTarget } from './style-target'
|
import { styleMountTarget } from './style-target'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
@@ -13,35 +14,35 @@ app.use(NaiveUI)
|
|||||||
|
|
||||||
// 全局错误处理
|
// 全局错误处理
|
||||||
app.config.errorHandler = (err, vm, info) => {
|
app.config.errorHandler = (err, vm, info) => {
|
||||||
console.error('[AKSRT] 全局错误:', err, info)
|
console.error('[Jio] 全局错误:', err, info)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底:立即显示加载状态(防止白屏)
|
// 兜底:立即显示加载状态(防止白屏)
|
||||||
function showFallback(el) {
|
function showFallback(el) {
|
||||||
el.innerHTML = '<div style="padding:40px;text-align:center;color:#666;font-family:sans-serif;">'
|
el.innerHTML = '<div style="padding:40px;text-align:center;color:#666;font-family:sans-serif;">'
|
||||||
+ '<p style="font-size:18px;margin-bottom:8px;">AKSRT 正在加载...</p>'
|
+ '<p style="font-size:18px;margin-bottom:8px;">Jio 正在加载...</p>'
|
||||||
+ '<p style="font-size:13px;color:#999;">如果长时间停留在此页面,请按 F12 查看控制台错误</p>'
|
+ '<p style="font-size:13px;color:#999;">如果长时间停留在此页面,请按 F12 查看控制台错误</p>'
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
}
|
}
|
||||||
showFallback(document.getElementById('aksrt-admin-app') || document.body.appendChild(
|
showFallback(document.getElementById('jio-admin-app') || document.body.appendChild(
|
||||||
Object.assign(document.createElement('div'), { id: 'aksrt-admin-app' })
|
Object.assign(document.createElement('div'), { id: 'jio-admin-app' })
|
||||||
))
|
))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取或创建一个干净的挂载容器
|
* 获取或创建一个干净的挂载容器
|
||||||
* 核心问题:WordPress 后台的 jQuery/脚本会在页面加载后持续操作 DOM,
|
* 核心问题:WordPress 后台的 jQuery/脚本会在页面加载后持续操作 DOM,
|
||||||
* 导致 PHP 输出的 <div id="aksrt-admin-app"> 可能被外部修改或其兄弟节点被移除,
|
* 导致 PHP 输出的 <div id="jio-admin-app"> 可能被外部修改或其兄弟节点被移除,
|
||||||
* Vue patch 时遍历到 null 节点 → nextSibling 报错。
|
* Vue patch 时遍历到 null 节点 → nextSibling 报错。
|
||||||
*
|
*
|
||||||
* 解决方案:在挂载前一刻,动态创建一个全新的容器元素替换旧的,
|
* 解决方案:在挂载前一刻,动态创建一个全新的容器元素替换旧的,
|
||||||
* 确保该节点及其父级在 Vue 接管前不被任何外部脚本污染。
|
* 确保该节点及其父级在 Vue 接管前不被任何外部脚本污染。
|
||||||
*/
|
*/
|
||||||
function getCleanContainer() {
|
function getCleanContainer() {
|
||||||
const existing = document.getElementById('aksrt-admin-app')
|
const existing = document.getElementById('jio-admin-app')
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
// 极端情况:PHP 没有输出容器,手动创建
|
// 极端情况:PHP 没有输出容器,手动创建
|
||||||
const fresh = document.createElement('div')
|
const fresh = document.createElement('div')
|
||||||
fresh.id = 'aksrt-admin-app'
|
fresh.id = 'jio-admin-app'
|
||||||
document.body.appendChild(fresh)
|
document.body.appendChild(fresh)
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
@@ -52,13 +53,13 @@ function getCleanContainer() {
|
|||||||
if (!parent) {
|
if (!parent) {
|
||||||
// 旧容器已脱离文档树,重新创建
|
// 旧容器已脱离文档树,重新创建
|
||||||
const fresh = document.createElement('div')
|
const fresh = document.createElement('div')
|
||||||
fresh.id = 'aksrt-admin-app'
|
fresh.id = 'jio-admin-app'
|
||||||
document.body.appendChild(fresh)
|
document.body.appendChild(fresh)
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
const fresh = document.createElement('div')
|
const fresh = document.createElement('div')
|
||||||
fresh.id = 'aksrt-admin-app'
|
fresh.id = 'jio-admin-app'
|
||||||
|
|
||||||
// 将旧容器的位置信息转移给新容器(保留 fallback 内容)
|
// 将旧容器的位置信息转移给新容器(保留 fallback 内容)
|
||||||
fresh.innerHTML = existing.innerHTML || ''
|
fresh.innerHTML = existing.innerHTML || ''
|
||||||
@@ -90,12 +91,12 @@ router.isReady().then(() => {
|
|||||||
styleMountTarget.value = container
|
styleMountTarget.value = container
|
||||||
app.mount(container)
|
app.mount(container)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[AKSRT] Vue 挂载失败:', err)
|
console.error('[Jio] Vue 挂载失败:', err)
|
||||||
showError(getCleanContainer(), err)
|
showError(getCleanContainer(), err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error('[AKSRT] Router 初始化失败:', err)
|
console.error('[Jio] Router 初始化失败:', err)
|
||||||
})
|
})
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-page-container">
|
<div class="jio-page-container">
|
||||||
<h2 class=”aksrt-page-title”>积分配置</h2>
|
<h2 class="jio-page-title">积分配置</h2>
|
||||||
<p class=”aksrt-page-desc”>
|
<p class="jio-page-desc">
|
||||||
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整已迁移到 WordPress 原生”用户”菜单中管理。
|
配置积分规则与发放策略。可自定义货币名称(如”金币”、”魔石”等)。用户余额与手工调整在 WordPress 原生”用户”菜单中管理。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
<n-card class="jio-section-card">
|
||||||
<div class="aksrt-section-header"><h3>主开关 + 基础设置</h3></div>
|
<div class="jio-section-header"><h3>主开关 + 基础设置</h3></div>
|
||||||
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
|
<n-form :model="cfg" label-placement="left" label-width="200" class="jio-form">
|
||||||
<n-form-item label="启用积分系统">
|
<n-form-item label="启用积分系统">
|
||||||
<n-switch v-model:value="cfg.enabled" />
|
<n-switch v-model:value="cfg.enabled" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="自定义货币名称">
|
<n-form-item label="自定义货币名称">
|
||||||
<n-input v-model:value="cfg.currency_name" placeholder="积分" maxlength="20" />
|
<n-input v-model:value="cfg.currency_name" placeholder="积分" maxlength="20" />
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
<span class="aksrt-hint-text">自定义货币显示名称,如"金币"、"魔石"等。留空则使用默认名称"积分"。</span>
|
<span class="jio-hint-text">自定义货币显示名称,如"金币"、"魔石"等。留空则使用默认名称"积分"。</span>
|
||||||
</template>
|
</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="注册赠送积分">
|
<n-form-item label="注册赠送积分">
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
<n-card class="jio-section-card">
|
||||||
<div class="aksrt-section-header"><h3>每日签到</h3></div>
|
<div class="jio-section-header"><h3>每日签到</h3></div>
|
||||||
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="aksrt-form">
|
<n-form :model="cfg.daily_signin" label-placement="left" label-width="200" class="jio-form">
|
||||||
<n-form-item label="启用">
|
<n-form-item label="启用">
|
||||||
<n-switch v-model:value="cfg.daily_signin.enabled" />
|
<n-switch v-model:value="cfg.daily_signin.enabled" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -40,15 +40,15 @@
|
|||||||
:autosize="{ minRows: 2 }"
|
:autosize="{ minRows: 2 }"
|
||||||
/>
|
/>
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
<span class="aksrt-hint-text">连续 N 天时额外发放的积分。</span>
|
<span class="jio-hint-text">连续 N 天时额外发放的积分。</span>
|
||||||
</template>
|
</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
<n-card class="jio-section-card">
|
||||||
<div class="aksrt-section-header"><h3>评论奖励</h3></div>
|
<div class="jio-section-header"><h3>评论奖励</h3></div>
|
||||||
<n-form :model="cfg.comment_reward" label-placement="left" label-width="200" class="aksrt-form">
|
<n-form :model="cfg.comment_reward" label-placement="left" label-width="200" class="jio-form">
|
||||||
<n-form-item label="启用">
|
<n-form-item label="启用">
|
||||||
<n-switch v-model:value="cfg.comment_reward.enabled" />
|
<n-switch v-model:value="cfg.comment_reward.enabled" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -67,9 +67,9 @@
|
|||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
<n-card class="jio-section-card">
|
||||||
<div class="aksrt-section-header"><h3>Ref 推广奖励</h3></div>
|
<div class="jio-section-header"><h3>Ref 推广奖励</h3></div>
|
||||||
<n-form :model="cfg.referral_reward" label-placement="left" label-width="200" class="aksrt-form">
|
<n-form :model="cfg.referral_reward" label-placement="left" label-width="200" class="jio-form">
|
||||||
<n-form-item label="启用">
|
<n-form-item label="启用">
|
||||||
<n-switch v-model:value="cfg.referral_reward.enabled" />
|
<n-switch v-model:value="cfg.referral_reward.enabled" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -91,68 +91,88 @@
|
|||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
<n-card class="jio-section-card">
|
||||||
<div class="aksrt-section-header"><h3>下载消费(参考)</h3></div>
|
<div class="jio-section-header"><h3>下载消费(参考)</h3></div>
|
||||||
<n-form :model="cfg" label-placement="left" label-width="200" class="aksrt-form">
|
<n-form :model="cfg" label-placement="left" label-width="200" class="jio-form">
|
||||||
<n-form-item :label="`默认积分价参考(${cfg.currency_name || '积分'})`">
|
<n-form-item :label="`默认积分价参考(${cfg.currency_name || '积分'})`">
|
||||||
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
|
<n-input-number v-model:value="cfg.download_default_cost" :min="0" :max="100000" />
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
<span class="aksrt-hint-text">兼容字段。实际价格以文章「付费内容设置」的积分价/现金价为准;下载解锁走收银台订单,不按次扣费。</span>
|
<span class="jio-hint-text">实际价格以文章「付费内容设置」的积分价或现金价为准;下载解锁走收银台订单,不按次扣费。</span>
|
||||||
</template>
|
</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<div class="aksrt-form-footer">
|
<JioSaveBar :dirty="dirty" :saving="saving" @save="saveCfg" @reload="loadCfg" />
|
||||||
<n-button type="primary" :loading="saving" @click="saveCfg">保存配置</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { computed, ref, reactive, onMounted } from 'vue'
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import JioSaveBar from '@/components/form/JioSaveBar.vue'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
// ---------- 配置 ----------
|
function defaultCfg() {
|
||||||
const cfg = reactive({
|
return {
|
||||||
enabled: true,
|
|
||||||
currency_name: '',
|
|
||||||
register_bonus: 10,
|
|
||||||
daily_signin: { enabled: true, base: 1, streak: { 3: 2, 7: 5, 30: 20 } },
|
|
||||||
comment_reward: { enabled: true, amount: 1, daily_cap: 5, min_length: 10, on_approved: true },
|
|
||||||
referral_reward: {
|
|
||||||
enabled: true,
|
enabled: true,
|
||||||
promoter_amount: 5,
|
currency_name: '',
|
||||||
referred_amount: 2,
|
register_bonus: 10,
|
||||||
level_points: 0,
|
daily_signin: { enabled: true, base: 1, streak: { 3: 2, 7: 5, 30: 20 } },
|
||||||
require_verify: true,
|
comment_reward: { enabled: true, amount: 1, daily_cap: 5, min_length: 10, on_approved: true },
|
||||||
ip_daily_cap: 3,
|
referral_reward: {
|
||||||
},
|
enabled: true,
|
||||||
download_default_cost: 0,
|
promoter_amount: 5,
|
||||||
})
|
referred_amount: 2,
|
||||||
|
level_points: 0,
|
||||||
|
require_verify: true,
|
||||||
|
ip_daily_cap: 3,
|
||||||
|
},
|
||||||
|
download_default_cost: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cfg = reactive(defaultCfg())
|
||||||
const streakJson = ref('{}')
|
const streakJson = ref('{}')
|
||||||
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
const initial = ref('')
|
||||||
|
const snapshot = computed(() => JSON.stringify({ cfg, streakJson: streakJson.value }))
|
||||||
|
const dirty = computed(() => !loading.value && initial.value !== '' && snapshot.value !== initial.value)
|
||||||
|
|
||||||
async function loadCfg() {
|
async function loadCfg() {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.getCreditSettings()
|
const response = await api.getCreditSettings()
|
||||||
Object.assign(cfg, data)
|
const data = response?.data || response || {}
|
||||||
|
Object.assign(cfg, defaultCfg(), data)
|
||||||
streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0)
|
streakJson.value = JSON.stringify(cfg.daily_signin?.streak || {}, null, 0)
|
||||||
|
initial.value = snapshot.value
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e.message || '加载失败')
|
message.error(e.message || '加载失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveCfg() {
|
async function saveCfg() {
|
||||||
|
let streak
|
||||||
|
try {
|
||||||
|
streak = JSON.parse(streakJson.value || '{}')
|
||||||
|
} catch (error) {
|
||||||
|
message.error('连续奖励 JSON 格式错误')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!streak || typeof streak !== 'object' || Array.isArray(streak)) {
|
||||||
|
message.error('连续奖励必须是 JSON 对象')
|
||||||
|
return
|
||||||
|
}
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
let streak = {}
|
await api.saveCreditSettings({ ...cfg, daily_signin: { ...cfg.daily_signin, streak } })
|
||||||
try { streak = JSON.parse(streakJson.value || '{}') } catch (_) { /* ignore */ }
|
|
||||||
cfg.daily_signin.streak = streak
|
cfg.daily_signin.streak = streak
|
||||||
await api.saveCreditSettings(cfg)
|
initial.value = snapshot.value
|
||||||
message.success('已保存')
|
message.success('已保存')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e.message || '保存失败')
|
message.error(e.message || '保存失败')
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-schema-page">
|
<div class="jio-schema-page">
|
||||||
<AksFormRenderer
|
<JioFormRenderer
|
||||||
:title="pageMeta.title"
|
:title="pageMeta.title"
|
||||||
:description="pageMeta.description"
|
:description="pageMeta.description"
|
||||||
:icon="pageMeta.icon"
|
:icon="pageMeta.icon"
|
||||||
:schema="pageMeta.groups"
|
:schema="pageMeta.groups"
|
||||||
:form-data="formData"
|
:form-data="formData"
|
||||||
:dirty="dirty"
|
:dirty="isSmsPage ? isSmsSettingsDirty : dirty"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:load-error="loadError"
|
:load-error="loadError"
|
||||||
@@ -14,33 +14,33 @@
|
|||||||
@update:form-data="formData = $event"
|
@update:form-data="formData = $event"
|
||||||
@save="handleSave"
|
@save="handleSave"
|
||||||
@reload="handleReload"
|
@reload="handleReload"
|
||||||
|
@field-action="handleFieldAction"
|
||||||
>
|
>
|
||||||
<template
|
<template
|
||||||
v-if="isPermissionsLogin"
|
v-if="isPermissionsLogin"
|
||||||
#field-tencent="{ value, setValue }"
|
#group-providers
|
||||||
>
|
>
|
||||||
<TencentConfig :model-value="value || {}" @update:model-value="setValue" />
|
<n-tabs
|
||||||
</template>
|
v-model:value="captchaProviderTab"
|
||||||
|
type="segment"
|
||||||
<template
|
animated
|
||||||
v-if="isPermissionsLogin"
|
class="jio-tabs jio-captcha-provider-tabs"
|
||||||
#field-aliyun="{ value, setValue }"
|
>
|
||||||
>
|
<n-tab-pane
|
||||||
<AliyunConfig :model-value="value || {}" @update:model-value="setValue" />
|
v-for="provider in captchaProviders"
|
||||||
</template>
|
:key="provider.key"
|
||||||
|
:name="provider.key"
|
||||||
<template
|
:tab="provider.label"
|
||||||
v-if="isPermissionsLogin"
|
>
|
||||||
#field-cloudflare="{ value, setValue }"
|
<component
|
||||||
>
|
:is="provider.component"
|
||||||
<CloudflareConfig :model-value="value || {}" @update:model-value="setValue" />
|
:model-value="getCaptchaProviderConfig(provider.key)"
|
||||||
</template>
|
@update:model-value="setCaptchaProviderConfig(provider.key, $event)"
|
||||||
|
@clear-secret="markCaptchaSecretForClear(provider.key, $event)"
|
||||||
<template
|
@restore-secret="unmarkCaptchaSecretForClear(provider.key, $event)"
|
||||||
v-if="isPermissionsLogin"
|
/>
|
||||||
#field-geetest="{ value, setValue }"
|
</n-tab-pane>
|
||||||
>
|
</n-tabs>
|
||||||
<GeetestConfig :model-value="value || {}" @update:model-value="setValue" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
@@ -48,18 +48,18 @@
|
|||||||
#field-geoip_block_countries="{ value, setValue }"
|
#field-geoip_block_countries="{ value, setValue }"
|
||||||
>
|
>
|
||||||
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
|
<n-dynamic-tags :value="Array.isArray(value) ? value : []" @update:value="setValue" />
|
||||||
<p class="aksrt-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
|
<p class="jio-schema-page__field-hint">使用 ISO 国家代码,如 CN / US / RU。</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Slot for custom group content (override per page if needed) -->
|
<!-- Slot for custom group content (override per page if needed) -->
|
||||||
<template v-for="(_, name) in $slots" #[name]="slotData">
|
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||||||
<slot :name="name" v-bind="slotData" />
|
<slot :name="name" v-bind="slotData" />
|
||||||
</template>
|
</template>
|
||||||
</AksFormRenderer>
|
</JioFormRenderer>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isPermissionsLogin && !loading"
|
v-if="isPermissionsLogin && !loading"
|
||||||
class="aksrt-schema-page__tool-actions"
|
class="jio-schema-page__tool-actions"
|
||||||
>
|
>
|
||||||
<n-button
|
<n-button
|
||||||
:disabled="!formData?.enabled || !formData?.active_provider"
|
:disabled="!formData?.enabled || !formData?.active_provider"
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<n-spin />
|
<n-spin />
|
||||||
<p style="margin-top:10px;color:#999;">加载中...</p>
|
<p style="margin-top:10px;color:#999;">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? '#18a058' : '#d03050', textAlign:'center', padding:'20px'}">
|
<div v-if="testResult && !testLoading" :style="{color: testResult.success ? 'var(--jio-color-primary)' : '#d03050', textAlign:'center', padding:'20px'}">
|
||||||
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
|
<p style="font-size:16px;margin:0;">{{ testResult.message }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
|
<div v-if="!testLoading && !testResult" id="captcha-test-box" style="min-height:150px;"></div>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
* SchemaPage — generic Schema-driven settings page.
|
* SchemaPage — generic Schema-driven settings page.
|
||||||
*
|
*
|
||||||
* Reads the schema from the Vue-side schema registry, fetches/saves settings
|
* Reads the schema from the Vue-side schema registry, fetches/saves settings
|
||||||
* via the API client, and renders using AksFormRenderer.
|
* via the API client, and renders using JioFormRenderer.
|
||||||
*
|
*
|
||||||
* Usage (in router or parent):
|
* Usage (in router or parent):
|
||||||
* <SchemaPage domain="global" section="logo" />
|
* <SchemaPage domain="global" section="logo" />
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
import { computed, watch, ref, nextTick, onBeforeUnmount } from 'vue'
|
import { computed, watch, ref, nextTick, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useMessage } from 'naive-ui'
|
import { useMessage } from 'naive-ui'
|
||||||
import AksFormRenderer from '@/components/form/AksFormRenderer.vue'
|
import JioFormRenderer from '@/components/form/JioFormRenderer.vue'
|
||||||
import { useSectionForm } from '@/composables/useSectionForm'
|
import { useSectionForm } from '@/composables/useSectionForm'
|
||||||
import { getSchema, normalizeSchemaDomain } from '@/schema/index.js'
|
import { getSchema, normalizeSchemaDomain } from '@/schema/index.js'
|
||||||
import api from '@/api/index.js'
|
import api from '@/api/index.js'
|
||||||
@@ -130,12 +130,32 @@ const message = useMessage()
|
|||||||
const showTestModal = ref(false)
|
const showTestModal = ref(false)
|
||||||
const testLoading = ref(false)
|
const testLoading = ref(false)
|
||||||
const testResult = ref(null)
|
const testResult = ref(null)
|
||||||
|
const serverSchema = ref(null)
|
||||||
|
const smsSavedSettings = ref('')
|
||||||
|
const captchaSecretsToClear = ref(new Set())
|
||||||
|
const captchaProviders = [
|
||||||
|
{ key: 'tencent', label: '腾讯云 Captcha', component: TencentConfig },
|
||||||
|
{ key: 'aliyun', label: '阿里云 CAPTCHA2.0', component: AliyunConfig },
|
||||||
|
{ key: 'cloudflare', label: 'Cloudflare Turnstile', component: CloudflareConfig },
|
||||||
|
{ key: 'geetest', label: '极验 Geetest', component: GeetestConfig },
|
||||||
|
]
|
||||||
|
const captchaProviderTab = ref('tencent')
|
||||||
|
|
||||||
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
|
/** Resolve domain/section from props or route meta (canonicalDomain + section, with legacy fallback) */
|
||||||
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
|
const resolvedDomain = computed(() => normalizeSchemaDomain(props.domain || route.meta?.canonicalDomain || route.meta?.schemaDomain || resolveDomainFromPath(route.path)))
|
||||||
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
|
const resolvedSection = computed(() => props.section || route.meta?.section || route.meta?.schemaSection || resolveSectionFromPath(route.path))
|
||||||
|
const isEmailPage = computed(() => resolvedDomain.value === 'global' && resolvedSection.value === 'email')
|
||||||
|
const isSmsPage = computed(() => resolvedDomain.value === 'users' && resolvedSection.value === 'sms')
|
||||||
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
|
const isPermissionsLogin = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'login')
|
||||||
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
|
const isPermissionsModeration = computed(() => resolvedDomain.value === 'permissions' && resolvedSection.value === 'moderation')
|
||||||
|
const isSmsSettingsDirty = computed(() => smsSavedSettings.value !== '' && smsSavedSettings.value !== smsSettingsSnapshot(formData.value))
|
||||||
|
|
||||||
|
function smsSettingsSnapshot(data) {
|
||||||
|
const settings = { ...(data || {}) }
|
||||||
|
delete settings.sms_test_phone
|
||||||
|
delete settings.sms_test_send
|
||||||
|
return JSON.stringify(settings)
|
||||||
|
}
|
||||||
|
|
||||||
function resolveDomainFromPath(path) {
|
function resolveDomainFromPath(path) {
|
||||||
const parts = path.split('/').filter(Boolean)
|
const parts = path.split('/').filter(Boolean)
|
||||||
@@ -150,7 +170,9 @@ function resolveSectionFromPath(path) {
|
|||||||
|
|
||||||
/** Load schema from the Vue-side registry */
|
/** Load schema from the Vue-side registry */
|
||||||
const pageMeta = computed(() => {
|
const pageMeta = computed(() => {
|
||||||
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
|
const schema = (isEmailPage.value || ['page_display', 'forum'].includes(resolvedDomain.value))
|
||||||
|
? serverSchema.value
|
||||||
|
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||||
if (schema && schema.groups && schema.groups.length > 0) {
|
if (schema && schema.groups && schema.groups.length > 0) {
|
||||||
return {
|
return {
|
||||||
title: schema.title || '',
|
title: schema.title || '',
|
||||||
@@ -170,7 +192,9 @@ const pageMeta = computed(() => {
|
|||||||
|
|
||||||
/** Default values from schema */
|
/** Default values from schema */
|
||||||
const defaults = computed(() => {
|
const defaults = computed(() => {
|
||||||
const schema = getSchema(resolvedDomain.value, resolvedSection.value)
|
const schema = (isEmailPage.value || ['page_display', 'forum'].includes(resolvedDomain.value))
|
||||||
|
? serverSchema.value
|
||||||
|
: getSchema(resolvedDomain.value, resolvedSection.value)
|
||||||
if (!schema) return {}
|
if (!schema) return {}
|
||||||
const d = {}
|
const d = {}
|
||||||
for (const group of schema.groups || []) {
|
for (const group of schema.groups || []) {
|
||||||
@@ -188,14 +212,80 @@ const {
|
|||||||
formData, loading, saving, loadError, dirty,
|
formData, loading, saving, loadError, dirty,
|
||||||
save: doSave, reload,
|
save: doSave, reload,
|
||||||
} = useSectionForm({
|
} = useSectionForm({
|
||||||
fetch: () => api.getSchemaPage(resolvedDomain.value, resolvedSection.value),
|
fetch: async () => {
|
||||||
submit: (data) => api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, data),
|
const response = await api.getSchemaPage(resolvedDomain.value, resolvedSection.value)
|
||||||
|
serverSchema.value = response?.schema || serverSchema.value
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
submit: async (data) => {
|
||||||
|
const payload = { ...data }
|
||||||
|
const clearSecrets = []
|
||||||
|
Object.keys(payload).forEach((key) => {
|
||||||
|
if (payload[key] === '__JIO_CLEAR_SECRET__') {
|
||||||
|
clearSecrets.push(key)
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (isSmsPage.value) {
|
||||||
|
delete payload.sms_test_phone
|
||||||
|
delete payload.sms_test_send
|
||||||
|
}
|
||||||
|
if (clearSecrets.length) payload.__clear_secrets = clearSecrets
|
||||||
|
if (isPermissionsLogin.value && captchaSecretsToClear.value.size) {
|
||||||
|
payload.__clear_secrets = [...(payload.__clear_secrets || []), ...captchaSecretsToClear.value]
|
||||||
|
}
|
||||||
|
const response = await api.saveSchemaPage(resolvedDomain.value, resolvedSection.value, payload)
|
||||||
|
clearSecrets.forEach((key) => { data[key] = '' })
|
||||||
|
if (isPermissionsLogin.value) captchaSecretsToClear.value = new Set()
|
||||||
|
return response
|
||||||
|
},
|
||||||
defaults: defaults.value,
|
defaults: defaults.value,
|
||||||
|
onLoaded: (data) => {
|
||||||
|
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(data)
|
||||||
|
},
|
||||||
onSaved: () => {
|
onSaved: () => {
|
||||||
|
if (isSmsPage.value) smsSavedSettings.value = smsSettingsSnapshot(formData.value)
|
||||||
message.success('设置已保存')
|
message.success('设置已保存')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function handleFieldAction(key) {
|
||||||
|
if (key === 'sms_test_send' && isSmsPage.value) {
|
||||||
|
const phone = String(formData.value?.sms_test_phone || '').trim()
|
||||||
|
if (!phone) {
|
||||||
|
message.warning('请先填写测试手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isSmsSettingsDirty.value) {
|
||||||
|
message.warning('请先保存当前配置,再发送测试短信')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await api.testSms(phone)
|
||||||
|
message.success(response?.message || '测试短信已发送')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.message || '测试短信发送失败')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (key !== 'email_test_send' || !isEmailPage.value) return
|
||||||
|
const to = String(formData.value?.email_test_to || '').trim()
|
||||||
|
if (!to) {
|
||||||
|
message.warning('请先填写测试收件地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dirty.value) {
|
||||||
|
message.warning('请先保存当前配置,再发送测试邮件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await api.testMail(to)
|
||||||
|
message.success(response?.message || '测试邮件已发送')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.message || '测试邮件发送失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
await doSave()
|
await doSave()
|
||||||
@@ -204,12 +294,38 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReload() {
|
async function handleReload() {
|
||||||
closeTestModal()
|
closeTestModal()
|
||||||
reload()
|
captchaSecretsToClear.value = new Set()
|
||||||
|
await reload()
|
||||||
|
syncCaptchaProviderTab()
|
||||||
message.info('已放弃更改')
|
message.info('已放弃更改')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markCaptchaSecretForClear(provider, field) {
|
||||||
|
const path = `${provider}.${field}`
|
||||||
|
const next = new Set(captchaSecretsToClear.value)
|
||||||
|
next.add(path)
|
||||||
|
captchaSecretsToClear.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function unmarkCaptchaSecretForClear(provider, field) {
|
||||||
|
const path = `${provider}.${field}`
|
||||||
|
if (!captchaSecretsToClear.value.has(path)) return
|
||||||
|
const next = new Set(captchaSecretsToClear.value)
|
||||||
|
next.delete(path)
|
||||||
|
captchaSecretsToClear.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaptchaProviderConfig(provider, value) {
|
||||||
|
formData.value = { ...formData.value, [provider]: value || {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCaptchaProviderTab() {
|
||||||
|
const provider = formData.value?.active_provider
|
||||||
|
captchaProviderTab.value = captchaProviders.some(({ key }) => key === provider) ? provider : 'tencent'
|
||||||
|
}
|
||||||
|
|
||||||
function getCaptchaProviderConfig(provider) {
|
function getCaptchaProviderConfig(provider) {
|
||||||
if (!formData.value || !provider) return {}
|
if (!formData.value || !provider) return {}
|
||||||
const cfg = formData.value[provider]
|
const cfg = formData.value[provider]
|
||||||
@@ -232,9 +348,8 @@ function handleCaptchaTest() {
|
|||||||
message.warning(`请先填写必填字段: ${missing.join(', ')}`)
|
message.warning(`请先填写必填字段: ${missing.join(', ')}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (dirty.value) {
|
||||||
if (provider === 'tencent') {
|
message.warning('请先保存当前配置,再执行真实服务端验签。')
|
||||||
loadTencentTest(cfg)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,52 +358,20 @@ function handleCaptchaTest() {
|
|||||||
testLoading.value = true
|
testLoading.value = true
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
createTestIframe(provider, cfg)
|
loadProviderSDK(provider, cfg)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTencentTest(cfg) {
|
async function submitCaptchaTest(provider, token) {
|
||||||
const sdkSrc = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
testLoading.value = true
|
||||||
|
try {
|
||||||
if (typeof window.TencentCaptcha !== 'undefined') {
|
const response = await api.testCaptcha(provider, token)
|
||||||
try {
|
testResult.value = { success: true, message: response?.message || '服务端同源验签通过' }
|
||||||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
} catch (error) {
|
||||||
if (res.ret === 0) message.success('腾讯云验证码测试成功!配置正确。')
|
testResult.value = { success: false, message: error?.message || '服务端验签失败' }
|
||||||
else message.warning('验证已取消。')
|
} finally {
|
||||||
}, { userLanguage: 'zh-cn' }).show()
|
|
||||||
} catch (error) {
|
|
||||||
message.error('初始化失败: ' + error.message)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingScript = document.querySelector('script[src="' + sdkSrc + '"]')
|
|
||||||
if (existingScript) {
|
|
||||||
existingScript.addEventListener('load', function() { loadTencentTest(cfg) }, { once: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = sdkSrc
|
|
||||||
script.onload = function () {
|
|
||||||
try {
|
|
||||||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
|
||||||
if (res.ret === 0) message.success('腾讯云验证码测试成功!')
|
|
||||||
else message.warning('验证已取消。')
|
|
||||||
}, { userLanguage: 'zh-cn' }).show()
|
|
||||||
} catch (error) {
|
|
||||||
message.error('初始化失败: ' + error.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
script.onerror = function () { message.error('加载腾讯验证码SDK失败') }
|
|
||||||
document.head.appendChild(script)
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestIframe(provider, cfg) {
|
|
||||||
setTimeout(() => {
|
|
||||||
testLoading.value = false
|
testLoading.value = false
|
||||||
loadProviderSDK(provider, cfg)
|
}
|
||||||
}, 100)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProviderSDK(provider, cfg) {
|
function loadProviderSDK(provider, cfg) {
|
||||||
@@ -307,28 +390,37 @@ function loadProviderSDK(provider, cfg) {
|
|||||||
case 'tencent':
|
case 'tencent':
|
||||||
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
script.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'
|
||||||
script.onload = function () {
|
script.onload = function () {
|
||||||
|
testLoading.value = false
|
||||||
try {
|
try {
|
||||||
new window.TencentCaptcha(cfg.app_id, function(res) {
|
new window.TencentCaptcha(cfg.app_id, function(res) {
|
||||||
testResult.value = { success: res.ret === 0, message: res.ret === 0 ? '验证成功!' : '验证已取消。' }
|
if (res.ret === 0) {
|
||||||
|
submitCaptchaTest('tencent', {
|
||||||
|
tencent_ticket: res.ticket || '',
|
||||||
|
tencent_randstr: res.randstr || '',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
testResult.value = { success: false, message: '验证已取消。' }
|
||||||
|
}
|
||||||
}, { userLanguage: 'zh-cn' }).show()
|
}, { userLanguage: 'zh-cn' }).show()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'cloudflare':
|
case 'cloudflare':
|
||||||
window.onTurnstileTestLoad = function() {
|
window.onTurnstileTestLoad = function() {
|
||||||
|
testLoading.value = false
|
||||||
try {
|
try {
|
||||||
window.turnstile.render('#captcha-test-box', {
|
window.turnstile.render('#captcha-test-box', {
|
||||||
sitekey: cfg.site_key,
|
sitekey: cfg.site_key,
|
||||||
theme: 'auto',
|
theme: cfg.theme || 'auto',
|
||||||
size: cfg.size || 'normal',
|
size: cfg.size || 'normal',
|
||||||
callback: function() {
|
action: 'jio-admin-test',
|
||||||
testResult.value = { success: true, message: '验证成功!' }
|
cdata: 'admin-captcha-test',
|
||||||
message.success('Cloudflare Turnstile 测试成功!')
|
callback: function(token) {
|
||||||
setTimeout(closeTestModal, 1500)
|
submitCaptchaTest('cloudflare', { cf_turnstile_response: token })
|
||||||
},
|
},
|
||||||
'error-callback': function() {
|
'error-callback': function() {
|
||||||
testResult.value = { success: false, message: '验证失败' }
|
testResult.value = { success: false, message: '验证失败' }
|
||||||
@@ -339,19 +431,28 @@ function loadProviderSDK(provider, cfg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
|
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileTestLoad'
|
||||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'geetest':
|
case 'geetest':
|
||||||
script.src = 'https://static.geetest.com/v4/gt4.js'
|
script.src = 'https://static.geetest.com/v4/gt4.js'
|
||||||
script.onload = function () {
|
script.onload = function () {
|
||||||
|
testLoading.value = false
|
||||||
try {
|
try {
|
||||||
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
|
window.initGeetest4({ captchaId: cfg.captcha_id, product: cfg.product || 'float' }, function(captchaObj) {
|
||||||
window.__gtTestCaptcha = captchaObj
|
window.__gtTestCaptcha = captchaObj
|
||||||
const onSuccess = function() {
|
const onSuccess = function() {
|
||||||
testResult.value = { success: true, message: '验证成功!' }
|
const result = captchaObj.getValidate ? captchaObj.getValidate() : null
|
||||||
message.success('极验 GeeTest 测试成功!')
|
if (!result) {
|
||||||
setTimeout(closeTestModal, 1500)
|
testResult.value = { success: false, message: '未取得极验验证参数' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitCaptchaTest('geetest', {
|
||||||
|
geetest_lot_number: result.lot_number || '',
|
||||||
|
geetest_captcha_output: result.captcha_output || '',
|
||||||
|
geetest_pass_token: result.pass_token || '',
|
||||||
|
geetest_gen_time: result.gen_time || '',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const onError = function(error) {
|
const onError = function(error) {
|
||||||
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
|
testResult.value = { success: false, message: '出错: ' + (error.msg || '?') }
|
||||||
@@ -369,22 +470,21 @@ function loadProviderSDK(provider, cfg) {
|
|||||||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'aliyun':
|
case 'aliyun':
|
||||||
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
|
window.AliyunCaptchaConfig = { region: cfg.region || 'cn', prefix: cfg.prefix }
|
||||||
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
|
script.src = 'https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js'
|
||||||
script.onload = function () {
|
script.onload = function () {
|
||||||
|
testLoading.value = false
|
||||||
try {
|
try {
|
||||||
window.initAliyunCaptcha({
|
window.initAliyunCaptcha({
|
||||||
SceneId: cfg.scene_id,
|
SceneId: cfg.scene_id,
|
||||||
mode: 'embed',
|
mode: 'embed',
|
||||||
element: '#captcha-test-box',
|
element: '#captcha-test-box',
|
||||||
success: function() {
|
success: function(captchaVerifyParam) {
|
||||||
testResult.value = { success: true, message: '验证成功!' }
|
submitCaptchaTest('aliyun', { aliyun_captcha_verify_param: String(captchaVerifyParam || '') })
|
||||||
message.success('阿里云测试成功!')
|
|
||||||
setTimeout(closeTestModal, 1500)
|
|
||||||
},
|
},
|
||||||
fail: function(result) {
|
fail: function(result) {
|
||||||
testResult.value = { success: false, message: String(result) }
|
testResult.value = { success: false, message: String(result) }
|
||||||
@@ -397,7 +497,7 @@ function loadProviderSDK(provider, cfg) {
|
|||||||
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
testResult.value = { success: false, message: '初始化失败: ' + error.message }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
script.onerror = function () { testResult.value = { success: false, message: '加载SDK失败' } }
|
script.onerror = function () { testLoading.value = false; testResult.value = { success: false, message: '加载SDK失败' } }
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@@ -423,9 +523,12 @@ function closeTestModal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => formData.value?.active_provider, syncCaptchaProviderTab)
|
||||||
|
|
||||||
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
|
watch([resolvedDomain, resolvedSection], ([nextDomain, nextSection], [prevDomain, prevSection]) => {
|
||||||
if (nextDomain !== prevDomain || nextSection !== prevSection) {
|
if (nextDomain !== prevDomain || nextSection !== prevSection) {
|
||||||
closeTestModal()
|
closeTestModal()
|
||||||
|
serverSchema.value = null
|
||||||
reload()
|
reload()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -436,21 +539,21 @@ onBeforeUnmount(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-schema-page {
|
.jio-schema-page {
|
||||||
max-width: 1100px;
|
max-width: none;
|
||||||
margin: 0 auto;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-schema-page__tool-actions {
|
.jio-schema-page__tool-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-schema-page__field-hint {
|
.jio-schema-page__field-hint {
|
||||||
margin: 8px 0 0;
|
margin: 8px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8a909c;
|
color: var(--jio-color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.captcha-test-overlay {
|
.captcha-test-overlay {
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container content-page">
|
|
||||||
<PageHeader
|
|
||||||
title="Banner 管理"
|
|
||||||
desc="管理首页内容流 Banner 幻灯片(图片、文案、跳转、启用状态)。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<strong>Banner 显示开关</strong>
|
|
||||||
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(轮播参数)</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<n-form label-placement="left" label-width="180" class="aksrt-form">
|
|
||||||
<n-form-item label="显示首页 Banner">
|
|
||||||
<div class="aksrt-inline-setting">
|
|
||||||
<n-switch v-model:value="bannerEnabled" :disabled="settingsLoading || settingsSaving" />
|
|
||||||
<span class="aksrt-inline-setting__hint">关闭后,首页 Banner 区块将不再渲染。</span>
|
|
||||||
</div>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<div class="aksrt-inline-setting__actions">
|
|
||||||
<n-button type="primary" :loading="settingsSaving" :disabled="settingsLoading" @click="saveBannerSettings">保存 Banner 开关</n-button>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<div class="aksrt-card-header-row__left">
|
|
||||||
<span class="header-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
|
|
||||||
<rect width="18" height="14" x="3" y="5" rx="2"/>
|
|
||||||
<path d="M3 10h18"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<strong class="aksrt-card-header-row__title">Banner 列表 ({{ banners.length }})</strong>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="small" @click="openCreate">+ 添加 Banner</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
v-if="banners.length"
|
|
||||||
:columns="columns"
|
|
||||||
:data="banners"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="暂无 Banner,添加后即可在首页使用。" />
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
|
|
||||||
<n-modal
|
|
||||||
v-model:show="modalShow"
|
|
||||||
:mask-closable="false"
|
|
||||||
preset="card"
|
|
||||||
:title="editing.id ? '编辑 Banner' : '添加 Banner'"
|
|
||||||
class="aksrt-modal-card"
|
|
||||||
>
|
|
||||||
<n-form :model="editing" label-placement="top" class="aksrt-form">
|
|
||||||
<n-form-item label="标题">
|
|
||||||
<n-input v-model:value="editing.title" placeholder="开启文字后显示的主标题" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="副标题">
|
|
||||||
<n-input v-model:value="editing.subtitle" placeholder="开启文字后显示的副标题" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="Banner 图片">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input v-model:value="editing.image_url" placeholder="https://..." />
|
|
||||||
<n-button secondary @click="pickImage">媒体库</n-button>
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<div v-if="editing.image_url" class="aksrt-modal-preview">
|
|
||||||
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-form-item label="跳转链接">
|
|
||||||
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<div class="aksrt-modal-grid">
|
|
||||||
<n-form-item label="显示文字">
|
|
||||||
<n-switch v-model:value="editing.show_text" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="启用">
|
|
||||||
<n-switch v-model:value="editing.enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="显示元信息">
|
|
||||||
<n-switch v-model:value="editing.show_meta" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="排序">
|
|
||||||
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="aksrt-modal-footer">
|
|
||||||
<n-button @click="modalShow = false">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { h, onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import { openMediaPicker } from '@/utils/media'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
function goHeaderComponents() {
|
|
||||||
router.push('/page-display/header-components')
|
|
||||||
}
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const settingsLoading = ref(false)
|
|
||||||
const settingsSaving = ref(false)
|
|
||||||
const banners = ref([])
|
|
||||||
const bannerEnabled = ref(true)
|
|
||||||
|
|
||||||
const modalShow = ref(false)
|
|
||||||
const editing = ref(emptyForm())
|
|
||||||
|
|
||||||
function emptyForm() {
|
|
||||||
return {
|
|
||||||
id: 0,
|
|
||||||
title: '',
|
|
||||||
subtitle: '',
|
|
||||||
image_url: '',
|
|
||||||
link_url: '',
|
|
||||||
show_text: true,
|
|
||||||
enabled: true,
|
|
||||||
show_meta: false,
|
|
||||||
sort_order: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '预览',
|
|
||||||
key: 'image_url',
|
|
||||||
width: 90,
|
|
||||||
render(row) {
|
|
||||||
if (!row.image_url) {
|
|
||||||
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
|
|
||||||
}
|
|
||||||
return h(NImage, {
|
|
||||||
src: row.image_url,
|
|
||||||
width: 64,
|
|
||||||
height: 36,
|
|
||||||
objectFit: 'cover',
|
|
||||||
previewSrc: row.image_url,
|
|
||||||
style: 'border-radius:4px;display:block;',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标题',
|
|
||||||
key: 'title',
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
render(row) {
|
|
||||||
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '文字',
|
|
||||||
key: 'show_text',
|
|
||||||
width: 70,
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
render(row) {
|
|
||||||
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
|
|
||||||
default: () => (row.show_text ? '显示' : '隐藏'),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '排序',
|
|
||||||
key: 'sort_order',
|
|
||||||
width: 64,
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '元信息',
|
|
||||||
key: 'show_meta',
|
|
||||||
width: 78,
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
render(row) {
|
|
||||||
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
|
|
||||||
default: () => (row.show_meta ? '显示' : '隐藏'),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'enabled',
|
|
||||||
width: 84,
|
|
||||||
render(row) {
|
|
||||||
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
|
|
||||||
default: () => (row.enabled ? '启用' : '禁用'),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 130,
|
|
||||||
render(row) {
|
|
||||||
return h('div', { style: 'display:flex;gap:6px;' }, [
|
|
||||||
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
|
||||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
async function loadBanners() {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const res = await api.getHomepageBanners()
|
|
||||||
if (res.code === 'success') {
|
|
||||||
banners.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '加载失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBannerSettings() {
|
|
||||||
settingsLoading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.getHomepageSettings()
|
|
||||||
const data = res?.data || {}
|
|
||||||
bannerEnabled.value = data.homepage_module_banner_enabled !== false
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '加载 Banner 开关失败')
|
|
||||||
} finally {
|
|
||||||
settingsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveBannerSettings() {
|
|
||||||
settingsSaving.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.saveHomepageSettings({
|
|
||||||
homepage_module_banner_enabled: bannerEnabled.value,
|
|
||||||
})
|
|
||||||
if (res?.code === 'success') {
|
|
||||||
message.success('Banner 开关已保存')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(res?.message || '保存 Banner 开关失败')
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '保存 Banner 开关失败')
|
|
||||||
} finally {
|
|
||||||
settingsSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
editing.value = emptyForm()
|
|
||||||
modalShow.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row) {
|
|
||||||
editing.value = {
|
|
||||||
id: row.id,
|
|
||||||
title: row.title || '',
|
|
||||||
subtitle: row.subtitle || '',
|
|
||||||
image_url: row.image_url || '',
|
|
||||||
link_url: row.link_url || '',
|
|
||||||
show_text: row.show_text !== false,
|
|
||||||
enabled: !!row.enabled,
|
|
||||||
show_meta: row.show_meta === true,
|
|
||||||
sort_order: Number(row.sort_order) || 0,
|
|
||||||
}
|
|
||||||
modalShow.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickImage() {
|
|
||||||
try {
|
|
||||||
const media = await openMediaPicker({
|
|
||||||
title: '选择 Banner 图片',
|
|
||||||
buttonText: '使用这张 Banner 图',
|
|
||||||
})
|
|
||||||
if (media?.url) {
|
|
||||||
editing.value.image_url = media.url
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '打开媒体库失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!editing.value.image_url) {
|
|
||||||
message.warning('请填写 Banner 图片')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
if (editing.value.id) {
|
|
||||||
await api.updateHomepageBanner(editing.value.id, editing.value)
|
|
||||||
message.success('已保存')
|
|
||||||
} else {
|
|
||||||
await api.createHomepageBanner(editing.value)
|
|
||||||
message.success('已添加')
|
|
||||||
}
|
|
||||||
modalShow.value = false
|
|
||||||
await loadBanners()
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(row) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定删除 Banner「${row.title || '未命名'}」?`,
|
|
||||||
positiveText: '删除',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await api.deleteHomepageBanner(row.id)
|
|
||||||
message.success('已删除')
|
|
||||||
await loadBanners()
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadBanners()
|
|
||||||
loadBannerSettings()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-inline-setting {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-inline-setting__hint {
|
|
||||||
color: #7a7f8a;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-inline-setting__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-modal-preview {
|
|
||||||
margin: -8px 0 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #eef1f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-modal-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.aksrt-modal-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,986 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container content-page">
|
|
||||||
<PageHeader title="页脚配置" desc="管理页脚区域的联系方式、快速链接、更多链接、友情链接和版权信息。">
|
|
||||||
<template #actions>
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
|
||||||
<n-tabs
|
|
||||||
v-model:value="activeTab"
|
|
||||||
type="line"
|
|
||||||
animated
|
|
||||||
class="aksrt-tabs"
|
|
||||||
>
|
|
||||||
<!-- ============== 品牌与Logo ============== -->
|
|
||||||
<n-tab-pane name="brand" tab="品牌与Logo">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></span>
|
|
||||||
<h3>品牌列(第一列)</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
|
||||||
<n-form-item label="显示设备">
|
|
||||||
<n-checkbox-group v-model:value="form.footer_t1_m_s">
|
|
||||||
<n-space>
|
|
||||||
<n-checkbox value="pc" label="PC" />
|
|
||||||
<n-checkbox value="mobile" label="移动端" />
|
|
||||||
</n-space>
|
|
||||||
</n-checkbox-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="页脚 Logo(日间)">
|
|
||||||
<div class="aksrt-media-row">
|
|
||||||
<n-input v-model:value="form.footer_t1_img" placeholder="图片 URL" readonly style="flex: 1;" />
|
|
||||||
<n-button size="small" @click="pickMedia('footer_t1_img')">选择</n-button>
|
|
||||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img" @click="form.footer_t1_img = ''">清除</n-button>
|
|
||||||
</div>
|
|
||||||
<img v-if="form.footer_t1_img" :src="form.footer_t1_img" class="aksrt-media-preview" alt="footer logo day" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="页脚 Logo(夜间)">
|
|
||||||
<div class="aksrt-media-row">
|
|
||||||
<n-input v-model:value="form.footer_t1_img_dark" placeholder="图片 URL" readonly style="flex: 1;" />
|
|
||||||
<n-button size="small" @click="pickMedia('footer_t1_img_dark')">选择</n-button>
|
|
||||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_t1_img_dark" @click="form.footer_t1_img_dark = ''">清除</n-button>
|
|
||||||
</div>
|
|
||||||
<img v-if="form.footer_t1_img_dark" :src="form.footer_t1_img_dark" class="aksrt-media-preview" alt="footer logo night" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="品牌标题">
|
|
||||||
<n-input v-model:value="form.footer_t1_t" placeholder="留空则使用站点名称" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="品牌列自定义代码">
|
|
||||||
<n-input v-model:value="form.fcode_t1_code" type="textarea" :rows="3" placeholder="可选,显示在品牌区域底部" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- ============== 联系方式 ============== -->
|
|
||||||
<n-tab-pane name="contacts" tab="联系方式">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<div class="aksrt-card-header-row__left">
|
|
||||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg></span>
|
|
||||||
<strong class="aksrt-card-header-row__title">联系方式图标</strong>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="small" @click="addContact">+ 添加图标</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="section-desc">页脚中的社交平台或联系入口。</p>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
v-if="form.contacts.length"
|
|
||||||
:columns="contactColumns"
|
|
||||||
:data="form.contacts"
|
|
||||||
:row-key="(row) => row._uid"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="暂无联系方式图标" />
|
|
||||||
|
|
||||||
<n-divider>标准联系字段</n-divider>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
|
||||||
<n-form-item label="显示设备">
|
|
||||||
<n-checkbox-group v-model:value="form.footer_contact_m_s">
|
|
||||||
<n-space>
|
|
||||||
<n-checkbox value="pc" label="PC" />
|
|
||||||
<n-checkbox value="mobile" label="移动端" />
|
|
||||||
</n-space>
|
|
||||||
</n-checkbox-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="微信二维码">
|
|
||||||
<div class="aksrt-media-row">
|
|
||||||
<n-input v-model:value="form.footer_contact_wechat_img" placeholder="图片 URL" readonly style="flex: 1;" />
|
|
||||||
<n-button size="small" @click="pickMedia('footer_contact_wechat_img')">选择</n-button>
|
|
||||||
<n-button size="small" quaternary type="error" :disabled="!form.footer_contact_wechat_img" @click="form.footer_contact_wechat_img = ''">清除</n-button>
|
|
||||||
</div>
|
|
||||||
<img v-if="form.footer_contact_wechat_img" :src="form.footer_contact_wechat_img" class="aksrt-media-preview" alt="wechat qr" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="QQ">
|
|
||||||
<n-input v-model:value="form.footer_contact_qq" placeholder="QQ 号或链接" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="微博">
|
|
||||||
<n-input v-model:value="form.footer_contact_weibo" placeholder="微博链接" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="邮箱">
|
|
||||||
<n-input v-model:value="form.footer_contact_email" placeholder="邮箱地址" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="链接 HTML(fcode_t2_code_1)">
|
|
||||||
<n-input v-model:value="form.fcode_t2_code_1" type="textarea" :rows="3" placeholder="推荐直接写 <a href="/">首页</a> 等链接 HTML;为空时回退快速链接/友链/热门分类" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="链接下方自定义代码">
|
|
||||||
<n-input v-model:value="form.fcode_t2_code_2" type="textarea" :rows="2" placeholder="备案号等 HTML" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<n-divider>更多联系项</n-divider>
|
|
||||||
<n-card class="aksrt-section-card" embedded>
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<strong class="aksrt-card-header-row__title">扩展联系项</strong>
|
|
||||||
<n-button type="primary" size="small" @click="addContactMore">+ 添加</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="section-desc">每项含描述、图标、链接、图片(hover 显示),用于补充标准联系方式。</p>
|
|
||||||
<n-data-table
|
|
||||||
v-if="form.footer_contact_more.length"
|
|
||||||
:columns="contactMoreColumns"
|
|
||||||
:data="form.footer_contact_more"
|
|
||||||
:row-key="(_, i) => i"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="暂无扩展联系项" />
|
|
||||||
</n-card>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- ============== 快速链接 ============== -->
|
|
||||||
<n-tab-pane name="quick" tab="快速链接">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<div class="aksrt-card-header-row__left">
|
|
||||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
|
|
||||||
<strong class="aksrt-card-header-row__title">快速链接</strong>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="small" @click="openLinkEditor('quick_links')">+ 添加链接</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="section-desc">页脚第二列的常用导航链接。</p>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
v-if="form.quick_links.length"
|
|
||||||
:columns="createLinkColumns('quick_links')"
|
|
||||||
:data="form.quick_links"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="暂无快速链接" />
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- ============== 更多链接 ============== -->
|
|
||||||
<n-tab-pane name="more" tab="更多链接">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<div class="aksrt-card-header-row__left">
|
|
||||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
|
|
||||||
<strong class="aksrt-card-header-row__title">更多链接</strong>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="small" @click="openLinkEditor('more_links')">+ 添加链接</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="section-desc">页脚补充链接区域。</p>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
v-if="form.more_links.length"
|
|
||||||
:columns="createLinkColumns('more_links')"
|
|
||||||
:data="form.more_links"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="暂无更多链接" />
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- ============== 友情链接 ============== -->
|
|
||||||
<n-tab-pane name="friends" tab="友情链接">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<div class="aksrt-card-header-row__left">
|
|
||||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
|
|
||||||
<strong class="aksrt-card-header-row__title">友情链接 ({{ friendLinks.length }})</strong>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="small" @click="openFriendEditor()">+ 添加友链</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="section-desc">站点友情链接列表,支持 Logo 头像、名称、URL 与简短描述。</p>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
v-if="friendLinks.length"
|
|
||||||
:columns="friendColumns"
|
|
||||||
:data="friendLinks"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="暂无友情链接" />
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- ============== 二维码图片组 ============== -->
|
|
||||||
<n-tab-pane name="mini_qr" tab="二维码图片组">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<div class="aksrt-card-header-row__left">
|
|
||||||
<span class="header-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
|
|
||||||
<strong class="aksrt-card-header-row__title">二维码图片组(最多 4 个)</strong>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="small" :disabled="form.footer_mini_img.length >= 4" @click="addMiniImg">+ 添加</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="section-desc">显示在页脚第三列区域,每项含文字描述和二维码图片。</p>
|
|
||||||
|
|
||||||
<n-form :model="form" label-placement="left" label-width="120" class="aksrt-form">
|
|
||||||
<n-form-item label="显示设备">
|
|
||||||
<n-checkbox-group v-model:value="form.footer_mini_img_m_s">
|
|
||||||
<n-space>
|
|
||||||
<n-checkbox value="pc" label="PC" />
|
|
||||||
<n-checkbox value="mobile" label="移动端" />
|
|
||||||
</n-space>
|
|
||||||
</n-checkbox-group>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<div v-for="(item, idx) in form.footer_mini_img" :key="idx" class="aksrt-mini-img-row">
|
|
||||||
<n-space align="center" :size="12" style="width: 100%;">
|
|
||||||
<n-input v-model:value="item.text" placeholder="文字描述" style="width: 160px;" />
|
|
||||||
<div class="aksrt-media-row" style="flex: 1;">
|
|
||||||
<n-input v-model:value="item.img" placeholder="二维码图片 URL" readonly style="flex: 1;" />
|
|
||||||
<n-button size="small" @click="pickMiniImg(idx)">选择</n-button>
|
|
||||||
</div>
|
|
||||||
<n-button size="small" quaternary type="error" @click="removeMiniImg(idx)">删除</n-button>
|
|
||||||
</n-space>
|
|
||||||
<img v-if="item.img" :src="item.img" class="aksrt-media-preview aksrt-media-preview--sm" :alt="item.text || 'qr'" />
|
|
||||||
</div>
|
|
||||||
<n-empty v-if="!form.footer_mini_img.length" description="暂无二维码图片" />
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- ============== 自定义代码 ============== -->
|
|
||||||
<n-tab-pane name="custom_code" tab="自定义代码">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg></span>
|
|
||||||
<h3>底部自定义代码</h3>
|
|
||||||
</div>
|
|
||||||
<p class="section-desc">插入在页脚底部区域(版权信息之后),支持 HTML。</p>
|
|
||||||
|
|
||||||
<n-form :model="form" label-placement="top" class="aksrt-form">
|
|
||||||
<n-form-item label="fcode_customize_code">
|
|
||||||
<n-input v-model:value="form.fcode_customize_code" type="textarea" :rows="6" placeholder="自定义 HTML 代码,显示在页脚最底部" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<!-- ============== 版权与杂项 ============== -->
|
|
||||||
<n-tab-pane name="misc" tab="版权与杂项">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<div class="aksrt-section-header">
|
|
||||||
<span class="section-icon"><svg xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
|
||||||
<h3>其他设置</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
|
|
||||||
<n-form-item label="页脚布局模板">
|
|
||||||
<n-radio-group v-model:value="form.layout_template">
|
|
||||||
<n-radio-button value="template_1">模板一(Zibll)</n-radio-button>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="热门分类数量">
|
|
||||||
<n-space align="center" :size="10">
|
|
||||||
<n-input-number v-model:value="form.hot_categories" :min="1" :max="20" style="width: 120px;" />
|
|
||||||
<span class="aksrt-hint-text">范围 1 - 20</span>
|
|
||||||
</n-space>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="自定义版权文案">
|
|
||||||
<n-input
|
|
||||||
v-model:value="form.copyright_text"
|
|
||||||
type="textarea"
|
|
||||||
:rows="3"
|
|
||||||
placeholder="留空则使用默认版权信息"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存页脚配置</n-button>
|
|
||||||
</div>
|
|
||||||
</n-spin>
|
|
||||||
|
|
||||||
<!-- ===== 联系方式 Modal ===== -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showContactModal"
|
|
||||||
:mask-closable="false"
|
|
||||||
preset="card"
|
|
||||||
title="编辑联系方式图标"
|
|
||||||
class="aksrt-modal-card"
|
|
||||||
>
|
|
||||||
<n-form :model="editingContact" label-placement="top" class="aksrt-form">
|
|
||||||
<n-form-item label="图标类型">
|
|
||||||
<n-select v-model:value="editingContact.icon" :options="iconOptions" placeholder="选择图标" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="链接地址">
|
|
||||||
<n-input v-model:value="editingContact.url" placeholder="https://..." />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="标题提示">
|
|
||||||
<n-input v-model:value="editingContact.title" placeholder="鼠标悬停提示文案" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="启用状态">
|
|
||||||
<n-switch v-model:value="editingContact.enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="aksrt-modal-footer">
|
|
||||||
<n-button @click="showContactModal = false">取消</n-button>
|
|
||||||
<n-button type="primary" @click="saveContact">保存</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<!-- ===== 链接 Modal ===== -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showLinkModal"
|
|
||||||
:mask-closable="false"
|
|
||||||
preset="card"
|
|
||||||
:title="editingLinkType === 'quick_links' ? '编辑快速链接' : '编辑更多链接'"
|
|
||||||
class="aksrt-modal-card"
|
|
||||||
>
|
|
||||||
<n-form :model="editingLink" label-placement="top" class="aksrt-form">
|
|
||||||
<n-form-item label="链接名称">
|
|
||||||
<n-input v-model:value="editingLink.name" placeholder="例如:首页" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="链接地址">
|
|
||||||
<n-input v-model:value="editingLink.url" placeholder="https:// 或 /path" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="启用状态">
|
|
||||||
<n-switch v-model:value="editingLink.enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="aksrt-modal-footer">
|
|
||||||
<n-button @click="showLinkModal = false">取消</n-button>
|
|
||||||
<n-button type="primary" @click="saveLink">保存</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<!-- ===== 友情链接 Modal ===== -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showFriendModal"
|
|
||||||
:mask-closable="false"
|
|
||||||
preset="card"
|
|
||||||
title="添加友情链接"
|
|
||||||
class="aksrt-modal-card"
|
|
||||||
>
|
|
||||||
<n-form :model="editingFriend" label-placement="top" class="aksrt-form">
|
|
||||||
<n-form-item label="站点名称">
|
|
||||||
<n-input v-model:value="editingFriend.name" placeholder="如:某人的博客" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="站点 URL">
|
|
||||||
<n-input v-model:value="editingFriend.url" placeholder="https://" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="Logo 图片 URL">
|
|
||||||
<n-input v-model:value="editingFriend.logo" placeholder="图标 URL(可选,方形 PNG 推荐)" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="描述">
|
|
||||||
<n-input v-model:value="editingFriend.description" placeholder="一句话简介(可选)" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="aksrt-modal-footer">
|
|
||||||
<n-button @click="showFriendModal = false">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="addingFriend" @click="saveFriend">保存</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
<!-- ===== 扩展联系项 Modal ===== -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showContactMoreModal"
|
|
||||||
:mask-closable="false"
|
|
||||||
preset="card"
|
|
||||||
title="编辑扩展联系项"
|
|
||||||
class="aksrt-modal-card"
|
|
||||||
>
|
|
||||||
<n-form :model="editingContactMore" label-placement="top" class="aksrt-form">
|
|
||||||
<n-form-item label="描述">
|
|
||||||
<n-input v-model:value="editingContactMore.desc" placeholder="如:微信公众号" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="图标类名">
|
|
||||||
<n-input v-model:value="editingContactMore.icon" placeholder="如:fab fa-weixin" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="链接地址">
|
|
||||||
<n-input v-model:value="editingContactMore.link" placeholder="https://..." />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="Hover 图片">
|
|
||||||
<n-input v-model:value="editingContactMore.img" placeholder="图片 URL(可选,hover 显示)" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
<template #footer>
|
|
||||||
<div class="aksrt-modal-footer">
|
|
||||||
<n-button @click="showContactMoreModal = false">取消</n-button>
|
|
||||||
<n-button type="primary" @click="saveContactMore">保存</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { NAvatar, NButton, NTag, useDialog, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import { openMediaPicker } from '@/utils/media'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const activeTab = ref('contacts')
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
contacts: [],
|
|
||||||
quick_links: [],
|
|
||||||
more_links: [],
|
|
||||||
hot_categories: 5,
|
|
||||||
copyright_text: '',
|
|
||||||
layout_template: 'template_1',
|
|
||||||
// §2.3 三列结构化字段
|
|
||||||
footer_t1_m_s: ['pc', 'mobile'],
|
|
||||||
footer_t1_img: '',
|
|
||||||
footer_t1_img_dark: '',
|
|
||||||
footer_t1_t: '',
|
|
||||||
fcode_t1_code: '',
|
|
||||||
footer_contact_wechat_img: '',
|
|
||||||
footer_contact_qq: '',
|
|
||||||
footer_contact_weibo: '',
|
|
||||||
footer_contact_email: '',
|
|
||||||
footer_contact_m_s: ['pc', 'mobile'],
|
|
||||||
footer_contact_more: [],
|
|
||||||
fcode_t2_code_1: '',
|
|
||||||
fcode_t2_code_2: '',
|
|
||||||
footer_mini_img_m_s: ['pc', 'mobile'],
|
|
||||||
footer_mini_img: [],
|
|
||||||
fcode_customize_code: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// ============== 联系方式 ==============
|
|
||||||
const showContactModal = ref(false)
|
|
||||||
const editingContactIndex = ref(-1)
|
|
||||||
const editingContact = reactive({
|
|
||||||
icon: '',
|
|
||||||
url: '',
|
|
||||||
title: '',
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
let contactUid = 0
|
|
||||||
|
|
||||||
const iconOptions = [
|
|
||||||
{ label: 'GitHub', value: 'github' },
|
|
||||||
{ label: 'X (Twitter)', value: 'x' },
|
|
||||||
{ label: 'Email', value: 'email' },
|
|
||||||
{ label: '微信', value: 'wechat' },
|
|
||||||
{ label: 'QQ', value: 'qq' },
|
|
||||||
{ label: '微博', value: 'weibo' },
|
|
||||||
{ label: 'Telegram', value: 'telegram' },
|
|
||||||
{ label: 'Discord', value: 'discord' },
|
|
||||||
{ label: 'Bilibili', value: 'bilibili' },
|
|
||||||
{ label: 'LinkedIn', value: 'linkedin' },
|
|
||||||
{ label: 'Facebook', value: 'facebook' },
|
|
||||||
{ label: 'YouTube', value: 'youtube' },
|
|
||||||
{ label: 'Instagram', value: 'instagram' },
|
|
||||||
{ label: 'RSS / 网站', value: 'web' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function addContact() {
|
|
||||||
Object.assign(editingContact, {
|
|
||||||
icon: '',
|
|
||||||
url: '',
|
|
||||||
title: '',
|
|
||||||
enabled: true,
|
|
||||||
})
|
|
||||||
editingContactIndex.value = -1
|
|
||||||
showContactModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function editContact(row, index) {
|
|
||||||
Object.assign(editingContact, {
|
|
||||||
icon: row.icon,
|
|
||||||
url: row.url,
|
|
||||||
title: row.title,
|
|
||||||
enabled: row.enabled,
|
|
||||||
})
|
|
||||||
editingContactIndex.value = index
|
|
||||||
showContactModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveContact() {
|
|
||||||
if (!editingContact.icon || !editingContact.url) {
|
|
||||||
message.warning('请填写图标类型和链接地址')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = {
|
|
||||||
_uid: editingContactIndex.value >= 0 ? form.contacts[editingContactIndex.value]._uid : ++contactUid,
|
|
||||||
icon: editingContact.icon,
|
|
||||||
url: editingContact.url,
|
|
||||||
title: editingContact.title,
|
|
||||||
enabled: editingContact.enabled,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingContactIndex.value >= 0) {
|
|
||||||
form.contacts[editingContactIndex.value] = item
|
|
||||||
} else {
|
|
||||||
form.contacts.push(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
showContactModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteContact(index) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认删除',
|
|
||||||
content: '确定删除这个联系方式图标吗?',
|
|
||||||
positiveText: '删除',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: () => {
|
|
||||||
form.contacts.splice(index, 1)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const contactColumns = [
|
|
||||||
{
|
|
||||||
title: '平台',
|
|
||||||
key: 'icon',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => iconOptions.find((item) => item.value === row.icon)?.label || row.icon,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '链接',
|
|
||||||
key: 'url',
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '提示',
|
|
||||||
key: 'title',
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'enabled',
|
|
||||||
width: 80,
|
|
||||||
render: (row) => h(
|
|
||||||
NTag,
|
|
||||||
{ size: 'small', type: row.enabled ? 'success' : 'default' },
|
|
||||||
{ default: () => (row.enabled ? '启用' : '禁用') }
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 130,
|
|
||||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
|
||||||
h(NButton, { size: 'tiny', onClick: () => editContact(form.contacts[index], index) }, { default: () => '编辑' }),
|
|
||||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteContact(index) }, { default: () => '删除' }),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ============== 快速 / 更多链接 ==============
|
|
||||||
const showLinkModal = ref(false)
|
|
||||||
const editingLinkType = ref('quick_links')
|
|
||||||
const editingLinkIndex = ref(-1)
|
|
||||||
const editingLink = reactive({
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
enabled: true,
|
|
||||||
sort_order: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
function openLinkEditor(type, row = null, index = -1) {
|
|
||||||
editingLinkType.value = type
|
|
||||||
editingLinkIndex.value = index
|
|
||||||
|
|
||||||
Object.assign(editingLink, row || {
|
|
||||||
id: '',
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
enabled: true,
|
|
||||||
sort_order: type === 'quick_links' ? form.quick_links.length : form.more_links.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
showLinkModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveLink() {
|
|
||||||
if (!editingLink.name || !editingLink.url) {
|
|
||||||
message.warning('请填写链接名称和链接地址')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = form[editingLinkType.value]
|
|
||||||
const item = {
|
|
||||||
id: editingLink.id || `link_${Date.now()}`,
|
|
||||||
name: editingLink.name,
|
|
||||||
url: editingLink.url,
|
|
||||||
enabled: editingLink.enabled,
|
|
||||||
sort_order: editingLink.sort_order ?? target.length,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editingLinkIndex.value >= 0) {
|
|
||||||
target[editingLinkIndex.value] = item
|
|
||||||
} else {
|
|
||||||
target.push(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
showLinkModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteLink(type, index) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认删除',
|
|
||||||
content: '确定删除这个链接吗?',
|
|
||||||
positiveText: '删除',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: () => {
|
|
||||||
form[type].splice(index, 1)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function createLinkColumns(type) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
title: '名称',
|
|
||||||
key: 'name',
|
|
||||||
width: 160,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'URL',
|
|
||||||
key: 'url',
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'enabled',
|
|
||||||
width: 80,
|
|
||||||
render: (row) => h(
|
|
||||||
NTag,
|
|
||||||
{ size: 'small', type: row.enabled ? 'success' : 'default' },
|
|
||||||
{ default: () => (row.enabled ? '启用' : '禁用') }
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 130,
|
|
||||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
|
||||||
h(NButton, { size: 'tiny', onClick: () => openLinkEditor(type, form[type][index], index) }, { default: () => '编辑' }),
|
|
||||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => deleteLink(type, index) }, { default: () => '删除' }),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 友情链接 ==============
|
|
||||||
const friendLinks = ref([])
|
|
||||||
const showFriendModal = ref(false)
|
|
||||||
const addingFriend = ref(false)
|
|
||||||
const editingFriend = reactive({
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
logo: '',
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const friendColumns = [
|
|
||||||
{
|
|
||||||
title: 'Logo',
|
|
||||||
key: 'logo',
|
|
||||||
width: 60,
|
|
||||||
render: (row) => row.logo ? h(NAvatar, { src: row.logo, size: 32, round: true }) : h('span', { style: 'color:#bbb;font-size:12px;' }, '—'),
|
|
||||||
},
|
|
||||||
{ title: '名称', key: 'name', width: 140, ellipsis: { tooltip: true } },
|
|
||||||
{ title: 'URL', key: 'url', ellipsis: { tooltip: true } },
|
|
||||||
{ title: '描述', key: 'description', ellipsis: { tooltip: true }, className: 'aksrt-table-mobile-hide' },
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 90,
|
|
||||||
render: (row) => h(NButton, {
|
|
||||||
quaternary: true,
|
|
||||||
type: 'error',
|
|
||||||
size: 'tiny',
|
|
||||||
onClick: () => deleteFriend(row),
|
|
||||||
}, { default: () => '删除' }),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function openFriendEditor() {
|
|
||||||
Object.assign(editingFriend, {
|
|
||||||
name: '',
|
|
||||||
url: '',
|
|
||||||
logo: '',
|
|
||||||
description: '',
|
|
||||||
})
|
|
||||||
showFriendModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFriendLinks() {
|
|
||||||
try {
|
|
||||||
const res = await api.getFriendLinks()
|
|
||||||
if (res.code === 'success') friendLinks.value = res.data || []
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[FriendLinks] load failed:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveFriend() {
|
|
||||||
if (!editingFriend.name || !editingFriend.url) {
|
|
||||||
message.warning('请填写站点名称和 URL')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addingFriend.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.addFriendLink({
|
|
||||||
name: editingFriend.name,
|
|
||||||
url: editingFriend.url,
|
|
||||||
logo: editingFriend.logo,
|
|
||||||
description: editingFriend.description,
|
|
||||||
sort_order: friendLinks.value.length,
|
|
||||||
})
|
|
||||||
if (res.code === 'success') {
|
|
||||||
message.success(res.message || '已添加')
|
|
||||||
showFriendModal.value = false
|
|
||||||
await loadFriendLinks()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '添加失败')
|
|
||||||
} finally {
|
|
||||||
addingFriend.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteFriend(row) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定删除友链「${row.name}」?`,
|
|
||||||
positiveText: '删除',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await api.deleteFriendLink(row.id)
|
|
||||||
message.success('已删除')
|
|
||||||
await loadFriendLinks()
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 加载 / 保存 ==============
|
|
||||||
async function loadSettings() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const [footerRes] = await Promise.all([
|
|
||||||
api.getFooterSettings(),
|
|
||||||
loadFriendLinks(),
|
|
||||||
])
|
|
||||||
if (footerRes.code === 'success' && footerRes.data) {
|
|
||||||
const data = footerRes.data
|
|
||||||
form.contacts = (data.contacts || []).map((item) => ({ ...item, _uid: ++contactUid }))
|
|
||||||
form.quick_links = data.quick_links || []
|
|
||||||
form.more_links = data.more_links || []
|
|
||||||
form.hot_categories = data.hot_categories ?? 5
|
|
||||||
form.copyright_text = data.copyright_text || ''
|
|
||||||
form.layout_template = 'template_1'
|
|
||||||
// §2.3 三列结构化字段
|
|
||||||
form.footer_t1_m_s = Array.isArray(data.footer_t1_m_s) ? data.footer_t1_m_s : ['pc', 'mobile']
|
|
||||||
form.footer_t1_img = data.footer_t1_img || ''
|
|
||||||
form.footer_t1_img_dark = data.footer_t1_img_dark || ''
|
|
||||||
form.footer_t1_t = data.footer_t1_t || ''
|
|
||||||
form.fcode_t1_code = data.fcode_t1_code || ''
|
|
||||||
form.footer_contact_wechat_img = data.footer_contact_wechat_img || ''
|
|
||||||
form.footer_contact_qq = data.footer_contact_qq || ''
|
|
||||||
form.footer_contact_weibo = data.footer_contact_weibo || ''
|
|
||||||
form.footer_contact_email = data.footer_contact_email || ''
|
|
||||||
form.footer_contact_m_s = Array.isArray(data.footer_contact_m_s) ? data.footer_contact_m_s : ['pc', 'mobile']
|
|
||||||
form.footer_contact_more = Array.isArray(data.footer_contact_more) ? data.footer_contact_more : []
|
|
||||||
form.fcode_t2_code_1 = data.fcode_t2_code_1 || ''
|
|
||||||
form.fcode_t2_code_2 = data.fcode_t2_code_2 || ''
|
|
||||||
form.footer_mini_img_m_s = Array.isArray(data.footer_mini_img_m_s) ? data.footer_mini_img_m_s : ['pc', 'mobile']
|
|
||||||
form.footer_mini_img = Array.isArray(data.footer_mini_img) ? data.footer_mini_img : []
|
|
||||||
form.fcode_customize_code = data.fcode_customize_code || ''
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Footer] load failed:', error)
|
|
||||||
message.error(error?.message || '加载失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
contacts: form.contacts.map(({ _uid, ...rest }) => rest),
|
|
||||||
quick_links: form.quick_links,
|
|
||||||
more_links: form.more_links,
|
|
||||||
hot_categories: form.hot_categories,
|
|
||||||
copyright_text: form.copyright_text,
|
|
||||||
layout_template: form.layout_template,
|
|
||||||
// §2.3 三列结构化字段
|
|
||||||
footer_t1_m_s: form.footer_t1_m_s,
|
|
||||||
footer_t1_img: form.footer_t1_img,
|
|
||||||
footer_t1_img_dark: form.footer_t1_img_dark,
|
|
||||||
footer_t1_t: form.footer_t1_t,
|
|
||||||
fcode_t1_code: form.fcode_t1_code,
|
|
||||||
footer_contact_wechat_img: form.footer_contact_wechat_img,
|
|
||||||
footer_contact_qq: form.footer_contact_qq,
|
|
||||||
footer_contact_weibo: form.footer_contact_weibo,
|
|
||||||
footer_contact_email: form.footer_contact_email,
|
|
||||||
footer_contact_m_s: form.footer_contact_m_s,
|
|
||||||
footer_contact_more: form.footer_contact_more,
|
|
||||||
fcode_t2_code_1: form.fcode_t2_code_1,
|
|
||||||
fcode_t2_code_2: form.fcode_t2_code_2,
|
|
||||||
footer_mini_img_m_s: form.footer_mini_img_m_s,
|
|
||||||
footer_mini_img: form.footer_mini_img,
|
|
||||||
fcode_customize_code: form.fcode_customize_code,
|
|
||||||
}
|
|
||||||
const res = await api.saveFooterSettings(payload)
|
|
||||||
if (res.code === 'success') {
|
|
||||||
message.success(res.message || '保存成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '保存失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Footer] save failed:', error)
|
|
||||||
message.error(error?.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 媒体选择 ==============
|
|
||||||
async function pickMedia(field) {
|
|
||||||
try {
|
|
||||||
const media = await openMediaPicker({ title: '选择图片' })
|
|
||||||
if (media?.url) {
|
|
||||||
form[field] = media.url
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message) message.warning(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============== 扩展联系项 ==============
|
|
||||||
function addContactMore() {
|
|
||||||
form.footer_contact_more.push({ desc: '', icon: '', link: '', img: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeContactMore(index) {
|
|
||||||
form.footer_contact_more.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showContactMoreModal = ref(false)
|
|
||||||
const editingContactMoreIndex = ref(-1)
|
|
||||||
const editingContactMore = reactive({ desc: '', icon: '', link: '', img: '' })
|
|
||||||
|
|
||||||
function editContactMore(row, index) {
|
|
||||||
Object.assign(editingContactMore, row)
|
|
||||||
editingContactMoreIndex.value = index
|
|
||||||
showContactMoreModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveContactMore() {
|
|
||||||
const item = { ...editingContactMore }
|
|
||||||
if (editingContactMoreIndex.value >= 0) {
|
|
||||||
form.footer_contact_more[editingContactMoreIndex.value] = item
|
|
||||||
}
|
|
||||||
showContactMoreModal.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const contactMoreColumns = [
|
|
||||||
{ title: '描述', key: 'desc', width: 120 },
|
|
||||||
{ title: '图标', key: 'icon', width: 100 },
|
|
||||||
{ title: '链接', key: 'link', ellipsis: { tooltip: true } },
|
|
||||||
{ title: '图片', key: 'img', width: 60, render: (row) => row.img ? h('img', { src: row.img, style: 'width:32px;height:32px;object-fit:cover;border-radius:4px;' }) : h('span', { style: 'color:#bbb;' }, '—') },
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 130,
|
|
||||||
render: (_, index) => h('div', { style: 'display:flex;gap:6px;' }, [
|
|
||||||
h(NButton, { size: 'tiny', onClick: () => editContactMore(form.footer_contact_more[index], index) }, { default: () => '编辑' }),
|
|
||||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => removeContactMore(index) }, { default: () => '删除' }),
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// ============== 二维码图片组 ==============
|
|
||||||
function addMiniImg() {
|
|
||||||
if (form.footer_mini_img.length < 4) {
|
|
||||||
form.footer_mini_img.push({ text: '', img: '' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeMiniImg(idx) {
|
|
||||||
form.footer_mini_img.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickMiniImg(idx) {
|
|
||||||
try {
|
|
||||||
const media = await openMediaPicker({ title: '选择二维码图片' })
|
|
||||||
if (media?.url) {
|
|
||||||
form.footer_mini_img[idx].img = media.url
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message) message.warning(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadSettings)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.aksrt-media-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.aksrt-media-preview {
|
|
||||||
display: block;
|
|
||||||
max-width: 200px;
|
|
||||||
max-height: 80px;
|
|
||||||
margin-top: 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--n-border-color, #e0e0e6);
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.aksrt-media-preview--sm {
|
|
||||||
max-width: 64px;
|
|
||||||
max-height: 64px;
|
|
||||||
}
|
|
||||||
.aksrt-mini-img-row {
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid var(--n-border-color, #e0e0e6);
|
|
||||||
}
|
|
||||||
.aksrt-mini-img-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,31 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-page-container content-page">
|
<div class="jio-page-container content-page">
|
||||||
<PageHeader title="底部Tab配置" desc="自定义移动端底部导航栏的按钮,支持排序、图标配置和返回顶部功能。">
|
<PageHeader title="底部Tab配置" desc="自定义移动端底部导航栏的按钮,支持排序、图标配置和返回顶部功能。" />
|
||||||
<template #actions>
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||||
<!-- 基础设置 -->
|
<!-- 基础设置 -->
|
||||||
<n-card class="aksrt-section-card">
|
<n-card class="jio-section-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="aksrt-card-header-row">
|
<div class="jio-card-header-row">
|
||||||
<div class="aksrt-card-header-row__left">
|
<div class="jio-card-header-row__left">
|
||||||
<span class="header-icon"><i class="fas fa-cog"></i></span>
|
<span class="header-icon"><i class="fa fas fa-cog"></i></span>
|
||||||
<strong class="aksrt-card-header-row__title">基础设置</strong>
|
<strong class="jio-card-header-row__title">基础设置</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<n-form :model="form" label-placement="left" label-width="140" class="aksrt-form">
|
<n-form :model="form" label-placement="left" label-width="140" class="jio-form">
|
||||||
<n-form-item label="启用底部Tab">
|
<n-form-item label="启用底部Tab">
|
||||||
<n-switch v-model:value="form.enabled" />
|
<n-switch v-model:value="form.enabled" />
|
||||||
<span class="aksrt-hint-text" style="margin-left: 12px;">移动端底部导航栏总开关</span>
|
<span class="jio-hint-text" style="margin-left: 12px;">移动端底部导航栏总开关</span>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="向下滚动隐藏">
|
<n-form-item label="向下滚动隐藏">
|
||||||
<n-switch v-model:value="form.scroll_down_hide" />
|
<n-switch v-model:value="form.scroll_down_hide" />
|
||||||
<span class="aksrt-hint-text" style="margin-left: 12px;">页面向下滚动时隐藏,向上滚动时显示</span>
|
<span class="jio-hint-text" style="margin-left: 12px;">页面向下滚动时隐藏,向上滚动时显示</span>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="文章页专属Tab">
|
<n-form-item label="文章页专属Tab">
|
||||||
<n-select
|
<n-select
|
||||||
@@ -36,21 +32,21 @@
|
|||||||
]"
|
]"
|
||||||
style="width: 240px;"
|
style="width: 240px;"
|
||||||
/>
|
/>
|
||||||
<span class="aksrt-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
|
<span class="jio-hint-text" style="margin-left: 12px;">文章详情页底部显示的Tab按钮类型</span>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<!-- Tab按钮配置 -->
|
<!-- Tab按钮配置 -->
|
||||||
<n-card class="aksrt-section-card">
|
<n-card class="jio-section-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="aksrt-card-header-row">
|
<div class="jio-card-header-row">
|
||||||
<div class="aksrt-card-header-row__left">
|
<div class="jio-card-header-row__left">
|
||||||
<span class="header-icon"><i class="fas fa-list-ul"></i></span>
|
<span class="header-icon"><i class="fa fas fa-list-ul"></i></span>
|
||||||
<strong class="aksrt-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
|
<strong class="jio-card-header-row__title">Tab按钮配置 ({{ form.items.length }})</strong>
|
||||||
</div>
|
</div>
|
||||||
<n-button type="primary" size="small" @click="addItem">
|
<n-button type="primary" size="small" @click="addItem">
|
||||||
<template #icon><i class="fas fa-plus"></i></template>
|
<template #icon><i class="fa fas fa-plus"></i></template>
|
||||||
添加按钮
|
添加按钮
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,64 +57,58 @@
|
|||||||
<draggable
|
<draggable
|
||||||
v-if="form.items.length"
|
v-if="form.items.length"
|
||||||
v-model="form.items"
|
v-model="form.items"
|
||||||
|
v-bind="SORTABLE_LIST_ATTRS"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
handle=".tabbar-sort__handle"
|
class="jio-sortable"
|
||||||
ghost-class="tabbar-sort__ghost"
|
|
||||||
animation="180"
|
|
||||||
class="tabbar-sort"
|
|
||||||
@end="resetSortOrder"
|
@end="resetSortOrder"
|
||||||
>
|
>
|
||||||
<template #item="{ element, index }">
|
<template #item="{ element, index }">
|
||||||
<div class="tabbar-sort__item">
|
<div class="jio-sortable__item">
|
||||||
<div class="tabbar-sort__main">
|
<div class="jio-sortable__head">
|
||||||
<button type="button" class="tabbar-sort__handle" aria-label="拖拽排序">
|
<JioSortHandle />
|
||||||
<i class="fas fa-grip-vertical"></i>
|
<span class="jio-sortable__icon">
|
||||||
</button>
|
<IconValue v-if="element.icon" :value="element.icon" />
|
||||||
<span class="tabbar-sort__icon">
|
|
||||||
<i v-if="element.icon" :class="element.icon"></i>
|
|
||||||
</span>
|
</span>
|
||||||
<div class="tabbar-sort__meta">
|
<div class="jio-sortable__main">
|
||||||
<div class="tabbar-sort__title-row">
|
<div class="jio-sortable__title-row">
|
||||||
<strong>{{ element.text || '按钮' }}</strong>
|
<span class="jio-sortable__title">{{ element.text || '按钮' }}</span>
|
||||||
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
|
<n-tag size="small" :bordered="false">{{ typeLabelMap[element.type] || element.type }}</n-tag>
|
||||||
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
|
<n-tag v-if="element.badge" size="small" type="error" :bordered="false">徽标 {{ element.badge }}</n-tag>
|
||||||
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
|
<n-tag v-if="element.type === 'home' && element.ontop" size="small" type="success" :bordered="false">返回顶部</n-tag>
|
||||||
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
|
<n-tag size="small" :type="element.enabled ? 'success' : 'default'" :bordered="false">{{ element.enabled ? '启用' : '禁用' }}</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="tabbar-sort__desc">{{ getItemSummary(element) }}</div>
|
<div class="jio-sortable__desc">{{ getItemSummary(element) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="jio-sortable__actions">
|
||||||
|
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
|
||||||
|
<template #icon><i class="fa fas fa-edit" style="font-size:12px;" /></template>
|
||||||
|
编辑
|
||||||
|
</n-button>
|
||||||
|
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
|
||||||
|
<template #icon><i class="fa fas fa-trash-alt" style="font-size:12px;" /></template>
|
||||||
|
删除
|
||||||
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="tabbar-sort__actions">
|
|
||||||
<n-button size="tiny" quaternary type="primary" @click="editItem(index)">
|
|
||||||
<template #icon><i class="fas fa-edit" style="font-size:12px;" /></template>
|
|
||||||
编辑
|
|
||||||
</n-button>
|
|
||||||
<n-button size="tiny" quaternary type="error" @click="deleteItem(index)">
|
|
||||||
<template #icon><i class="fas fa-trash-alt" style="font-size:12px;" /></template>
|
|
||||||
删除
|
|
||||||
</n-button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
<n-empty v-else description="暂无Tab按钮,请点击上方按钮添加" />
|
<n-empty v-else description="暂无Tab按钮,请点击上方按钮添加" />
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存底部Tab配置</n-button>
|
|
||||||
</div>
|
|
||||||
</n-spin>
|
</n-spin>
|
||||||
|
|
||||||
|
<JioSaveBar :dirty="dirty" :saving="saving" @save="handleSave" @reload="loadSettings" />
|
||||||
|
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑弹窗 -->
|
||||||
<n-modal
|
<n-modal
|
||||||
v-model:show="showModal"
|
v-model:show="showModal"
|
||||||
:mask-closable="false"
|
:mask-closable="false"
|
||||||
preset="card"
|
preset="card"
|
||||||
:title="editingIndex >= 0 ? '编辑Tab按钮' : '添加Tab按钮'"
|
:title="editingIndex >= 0 ? '编辑Tab按钮' : '添加Tab按钮'"
|
||||||
class="aksrt-modal-card"
|
class="jio-modal-card"
|
||||||
style="max-width: 520px;"
|
style="max-width: 520px;"
|
||||||
>
|
>
|
||||||
<n-form :model="editingItem" label-placement="top" class="aksrt-form">
|
<n-form :model="editingItem" label-placement="top" class="jio-form">
|
||||||
<n-form-item label="按钮类型">
|
<n-form-item label="按钮类型">
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="editingItem.type"
|
v-model:value="editingItem.type"
|
||||||
@@ -128,16 +118,13 @@
|
|||||||
/>
|
/>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="图标 (Font Awesome)">
|
<n-form-item label="图标 (Font Awesome / 内置 SVG)">
|
||||||
<n-input v-model:value="editingItem.icon" placeholder="例如: fas fa-home" />
|
<FaIconPicker v-model="editingItem.icon" placeholder="选择图标" />
|
||||||
<template #feedback>
|
<template #feedback>
|
||||||
<div class="icon-preview-row">
|
<div class="icon-preview-row">
|
||||||
<span>预览:</span>
|
<span>预览:</span>
|
||||||
<i v-if="editingItem.icon" :class="editingItem.icon" style="font-size: 20px;"></i>
|
<IconValue v-if="editingItem.icon" :value="editingItem.icon" style="font-size: 20px;" />
|
||||||
<span v-else class="muted-text">无图标</span>
|
<span v-else class="muted-text">无图标</span>
|
||||||
<a href="https://fontawesome.com/search?m=free" target="_blank" rel="noopener" style="margin-left: 12px;">
|
|
||||||
<i class="fas fa-external-link-alt"></i> Font Awesome 图标库
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -198,7 +185,7 @@
|
|||||||
<div class="add-items-editor__head">
|
<div class="add-items-editor__head">
|
||||||
<strong>发布菜单项</strong>
|
<strong>发布菜单项</strong>
|
||||||
<n-button size="small" type="primary" @click="appendAddItem">
|
<n-button size="small" type="primary" @click="appendAddItem">
|
||||||
<template #icon><i class="fas fa-plus" /></template>
|
<template #icon><i class="fa fas fa-plus" /></template>
|
||||||
添加子项
|
添加子项
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,22 +206,21 @@
|
|||||||
<div v-if="editingItem.add_items.length" class="add-items-editor__list">
|
<div v-if="editingItem.add_items.length" class="add-items-editor__list">
|
||||||
<draggable
|
<draggable
|
||||||
v-model="editingItem.add_items"
|
v-model="editingItem.add_items"
|
||||||
|
v-bind="SORTABLE_LIST_ATTRS"
|
||||||
item-key="id"
|
item-key="id"
|
||||||
handle=".add-items-editor__drag"
|
class="jio-sortable jio-sortable--compact"
|
||||||
ghost-class="add-items-editor__ghost"
|
|
||||||
animation="180"
|
|
||||||
>
|
>
|
||||||
<template #item="{ element, index: subIndex }">
|
<template #item="{ element, index: subIndex }">
|
||||||
<div class="add-items-editor__row">
|
<div class="jio-sortable__item">
|
||||||
<button type="button" class="add-items-editor__drag" aria-label="拖拽排序">
|
<div class="jio-sortable__head add-items-editor__row">
|
||||||
<i class="fas fa-grip-vertical"></i>
|
<JioSortHandle />
|
||||||
</button>
|
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
|
||||||
<n-input v-model:value="element.label" placeholder="名称,例如:写文章" />
|
<FaIconPicker v-model="element.icon" placeholder="选择图标" />
|
||||||
<n-input v-model:value="element.icon" placeholder="图标,例如:fas fa-pen" />
|
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
|
||||||
<n-input v-model:value="element.url" placeholder="链接,例如:/wp-admin/post-new.php" />
|
<n-button quaternary type="error" aria-label="删除子项" @click="removeAddItem(subIndex)">
|
||||||
<n-button quaternary type="error" @click="removeAddItem(subIndex)">
|
<template #icon><i class="fa fas fa-trash-alt" /></template>
|
||||||
<template #icon><i class="fas fa-trash-alt" /></template>
|
</n-button>
|
||||||
</n-button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</draggable>
|
</draggable>
|
||||||
@@ -244,7 +230,7 @@
|
|||||||
|
|
||||||
<n-form-item v-if="editingItem.type === 'home'" label="返回顶部功能">
|
<n-form-item v-if="editingItem.type === 'home'" label="返回顶部功能">
|
||||||
<n-switch v-model:value="editingItem.ontop" />
|
<n-switch v-model:value="editingItem.ontop" />
|
||||||
<span class="aksrt-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
|
<span class="jio-hint-text" style="margin-left: 12px;">页面滚动后切换为返回顶部按钮</span>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
|
|
||||||
<n-form-item label="启用状态">
|
<n-form-item label="启用状态">
|
||||||
@@ -253,7 +239,7 @@
|
|||||||
</n-form>
|
</n-form>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="aksrt-modal-footer">
|
<div class="jio-modal-footer">
|
||||||
<n-button @click="showModal = false">取消</n-button>
|
<n-button @click="showModal = false">取消</n-button>
|
||||||
<n-button type="primary" @click="saveItem">保存</n-button>
|
<n-button type="primary" @click="saveItem">保存</n-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,6 +254,11 @@ import { NButton, NTag, useDialog, useMessage } from 'naive-ui'
|
|||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
import PageHeader from '@/components/PageHeader.vue'
|
||||||
|
import JioSaveBar from '@/components/form/JioSaveBar.vue'
|
||||||
|
import FaIconPicker from '@/components/form/FaIconPicker.vue'
|
||||||
|
import IconValue from '@/components/form/IconValue.vue'
|
||||||
|
import JioSortHandle from '@/components/form/JioSortHandle.vue'
|
||||||
|
import { SORTABLE_LIST_ATTRS } from '@/components/form/sortable-options.js'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -284,6 +275,12 @@ const form = reactive({
|
|||||||
footer_tabbar_single: 'default',
|
footer_tabbar_single: 'default',
|
||||||
items: [],
|
items: [],
|
||||||
})
|
})
|
||||||
|
const snapshot = ref('')
|
||||||
|
const serialize = () => JSON.stringify(form)
|
||||||
|
const dirty = computed(() => !loading.value && snapshot.value !== '' && serialize() !== snapshot.value)
|
||||||
|
function markClean() {
|
||||||
|
snapshot.value = serialize()
|
||||||
|
}
|
||||||
|
|
||||||
const editingItem = reactive({
|
const editingItem = reactive({
|
||||||
id: '',
|
id: '',
|
||||||
@@ -327,15 +324,15 @@ const typeLabelMap = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const typeDefaults = {
|
const typeDefaults = {
|
||||||
home: { icon: 'fas fa-home', text: '首页' },
|
home: { icon: 'fa fas fa-home', text: '首页' },
|
||||||
category: { icon: 'fas fa-th-large', text: '分类' },
|
category: { icon: 'fa fas fa-th-large', text: '分类' },
|
||||||
link: { icon: 'fas fa-link', text: '链接' },
|
link: { icon: 'fa fas fa-link', text: '链接' },
|
||||||
add: { icon: 'fas fa-plus', text: '发布' },
|
add: { icon: 'fa fas fa-plus', text: '发布' },
|
||||||
user: { icon: 'fas fa-user', text: '用户中心' },
|
user: { icon: 'fa fas fa-user', text: '用户中心' },
|
||||||
auth: { icon: 'fas fa-user', text: '我的' },
|
auth: { icon: 'fa fas fa-user', text: '我的' },
|
||||||
search: { icon: 'fas fa-search', text: '搜索' },
|
search: { icon: 'fa fas fa-search', text: '搜索' },
|
||||||
msg: { icon: 'fas fa-bell', text: '消息' },
|
msg: { icon: 'fa fas fa-bell', text: '消息' },
|
||||||
pay_vip: { icon: 'fas fa-crown', text: '会员' },
|
pay_vip: { icon: 'fa fas fa-gem', text: '会员' },
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled))
|
const enabledPreviewItems = computed(() => form.items.filter((item) => item.enabled))
|
||||||
@@ -361,10 +358,10 @@ function getItemSummary(item) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addPresets = [
|
const addPresets = [
|
||||||
{ label: '写文章', icon: 'fas fa-pen-fancy', url: '/submit' },
|
{ label: '写文章', icon: 'fa fas fa-pen', url: '/submit' },
|
||||||
{ label: '投稿页', icon: 'fas fa-file-alt', url: '/submit' },
|
{ label: '投稿页', icon: 'fa fas fa-file-alt', url: '/submit' },
|
||||||
{ label: '发布资源', icon: 'fas fa-cloud-upload-alt', url: '/resource-new' },
|
{ label: '发布资源', icon: 'fa fas fa-cloud-upload-alt', url: '/resource-new' },
|
||||||
{ label: '创作中心', icon: 'fas fa-palette', url: '/creator' },
|
{ label: '创作中心', icon: 'fa fas fa-paint-brush', url: '/creator' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function appendAddPreset(preset) {
|
function appendAddPreset(preset) {
|
||||||
@@ -381,7 +378,7 @@ function appendAddItem() {
|
|||||||
editingItem.add_items.push({
|
editingItem.add_items.push({
|
||||||
id: `add_${Date.now()}_${editingItem.add_items.length}`,
|
id: `add_${Date.now()}_${editingItem.add_items.length}`,
|
||||||
label: '',
|
label: '',
|
||||||
icon: 'fas fa-pen',
|
icon: 'fa fas fa-pen',
|
||||||
url: '',
|
url: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -421,7 +418,7 @@ function addItem() {
|
|||||||
Object.assign(editingItem, {
|
Object.assign(editingItem, {
|
||||||
id: `tab_${Date.now()}`,
|
id: `tab_${Date.now()}`,
|
||||||
type: 'link',
|
type: 'link',
|
||||||
icon: 'fas fa-link',
|
icon: 'fa fas fa-link',
|
||||||
text: '链接',
|
text: '链接',
|
||||||
link: '',
|
link: '',
|
||||||
badge: '',
|
badge: '',
|
||||||
@@ -489,7 +486,7 @@ function saveItem() {
|
|||||||
? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({
|
? editingItem.add_items.filter((sub) => sub.label && sub.url).map((sub) => ({
|
||||||
id: sub.id || `add_${Date.now()}`,
|
id: sub.id || `add_${Date.now()}`,
|
||||||
label: sub.label,
|
label: sub.label,
|
||||||
icon: sub.icon || 'fas fa-pen',
|
icon: sub.icon || 'fa fas fa-pen',
|
||||||
url: sub.url,
|
url: sub.url,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
@@ -556,6 +553,7 @@ async function loadSettings() {
|
|||||||
add_items: Array.isArray(item.add_items) ? item.add_items : [],
|
add_items: Array.isArray(item.add_items) ? item.add_items : [],
|
||||||
sort_order: item.sort_order ?? idx,
|
sort_order: item.sort_order ?? idx,
|
||||||
}))
|
}))
|
||||||
|
markClean()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[FooterTabbar] load failed:', e)
|
console.error('[FooterTabbar] load failed:', e)
|
||||||
@@ -585,6 +583,7 @@ async function handleSave() {
|
|||||||
}
|
}
|
||||||
const res = await api.saveFooterTabbarSettings(payload)
|
const res = await api.saveFooterTabbarSettings(payload)
|
||||||
if (res.code === 'success') {
|
if (res.code === 'success') {
|
||||||
|
markClean()
|
||||||
message.success(res.message || '保存成功')
|
message.success(res.message || '保存成功')
|
||||||
} else {
|
} else {
|
||||||
message.error(res.message || '保存失败')
|
message.error(res.message || '保存失败')
|
||||||
@@ -610,7 +609,7 @@ onMounted(loadSettings)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.icon-preview-row a {
|
.icon-preview-row a {
|
||||||
color: var(--n-primary-color);
|
color: var(--jio-color-primary, #18A058);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -619,18 +618,12 @@ onMounted(loadSettings)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.muted-text {
|
.muted-text {
|
||||||
color: #999;
|
color: var(--jio-color-muted, #6F7A72);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-desc {
|
.section-desc {
|
||||||
color: var(--n-text-color-3);
|
color: var(--jio-color-muted, #6F7A72);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
}
|
}
|
||||||
@@ -660,127 +653,19 @@ onMounted(loadSettings)
|
|||||||
|
|
||||||
.add-items-editor__presets-label {
|
.add-items-editor__presets-label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--n-text-color-3, #999);
|
color: var(--jio-color-muted, #6F7A72);
|
||||||
}
|
|
||||||
|
|
||||||
.add-items-editor__list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-items-editor__row {
|
.add-items-editor__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 32px 1fr 1fr 1.2fr 40px;
|
grid-template-columns: 28px 1fr 1fr 1.2fr 34px;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-items-editor__drag {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f3f5f7;
|
|
||||||
color: #6b7280;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-items-editor__ghost {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid var(--n-border-color);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__main {
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__handle {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #f3f5f7;
|
|
||||||
color: #6b7280;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__icon {
|
|
||||||
width: 38px;
|
|
||||||
height: 38px;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(24, 160, 88, 0.12);
|
|
||||||
color: #18a058;
|
|
||||||
font-size: 18px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__meta {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__title-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__desc {
|
|
||||||
color: var(--n-text-color-3);
|
|
||||||
font-size: 13px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__ghost {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.tabbar-sort__item {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabbar-sort__actions {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-items-editor__row {
|
.add-items-editor__row {
|
||||||
grid-template-columns: 32px 1fr;
|
grid-template-columns: 28px 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,518 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container content-page">
|
|
||||||
<PageHeader title="Hero 轮播管理" desc="管理首页 Hero 幻灯片内容(图片/视频、文案、图层、跳转)。标题、副标题可留空。" />
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<strong>显示范围</strong>
|
|
||||||
<n-button size="small" secondary @click="goHeaderComponents">顶部多功能组件(页面类型/搜索/卡片)</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<p class="aksrt-inline-setting__hint" style="margin:0;">
|
|
||||||
幻灯片内容在此维护。页面类型启用、设备规则、搜索覆盖层与图标卡片请到「顶部多功能组件」配置;全不选页面类型即为关闭。
|
|
||||||
</p>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="aksrt-card-header-row">
|
|
||||||
<div class="aksrt-card-header-row__left">
|
|
||||||
<span class="header-icon">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:18px;height:18px" aria-hidden="true">
|
|
||||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/>
|
|
||||||
<circle cx="9" cy="9" r="2"/>
|
|
||||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<strong class="aksrt-card-header-row__title">Hero 列表 ({{ slides.length }})</strong>
|
|
||||||
</div>
|
|
||||||
<n-button type="primary" size="small" @click="openCreate">+ 添加 Hero</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
v-if="slides.length"
|
|
||||||
:columns="columns"
|
|
||||||
:data="slides"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
:bordered="false"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
<n-empty v-else description="暂无 Hero,添加后首页即可使用。" />
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
|
|
||||||
<!-- 编辑/新增 弹窗 -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="modalShow"
|
|
||||||
:mask-closable="false"
|
|
||||||
preset="card"
|
|
||||||
:title="editing.id ? '编辑 Hero' : '添加 Hero'"
|
|
||||||
class="aksrt-modal-card"
|
|
||||||
>
|
|
||||||
<n-form :model="editing" label-placement="top" class="aksrt-form">
|
|
||||||
<n-form-item label="标题">
|
|
||||||
<n-input v-model:value="editing.title" placeholder="可选,主标题" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="副标题">
|
|
||||||
<n-input v-model:value="editing.subtitle" placeholder="可选,副标题" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="背景图 URL">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input v-model:value="editing.image_url" placeholder="https://..." />
|
|
||||||
<n-button secondary @click="pickImage">媒体库</n-button>
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<div v-if="editing.image_url" class="aksrt-modal-preview">
|
|
||||||
<n-image :src="editing.image_url" width="100%" height="160" object-fit="cover" :preview-src="editing.image_url" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-form-item label="跳转链接">
|
|
||||||
<n-input v-model:value="editing.link_url" placeholder="点击后跳转地址,可留空" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="描述文字">
|
|
||||||
<n-input v-model:value="editing.description" type="textarea" :rows="2" placeholder="可选,显示在副标题下方" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="视频背景 URL">
|
|
||||||
<n-input v-model:value="editing.video_url" placeholder="可选,MP4 视频地址替代背景图" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<div class="aksrt-modal-grid">
|
|
||||||
<n-form-item label="链接打开方式">
|
|
||||||
<n-radio-group v-model:value="editing.link_target">
|
|
||||||
<n-radio-button value="_self">当前窗口</n-radio-button>
|
|
||||||
<n-radio-button value="_blank">新窗口</n-radio-button>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="文字对齐">
|
|
||||||
<n-radio-group v-model:value="editing.text_align">
|
|
||||||
<n-radio-button value="left">左</n-radio-button>
|
|
||||||
<n-radio-button value="center">居中</n-radio-button>
|
|
||||||
<n-radio-button value="right">右</n-radio-button>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="设备可见性">
|
|
||||||
<n-radio-group v-model:value="editing.device_visibility">
|
|
||||||
<n-radio-button value="all">全部</n-radio-button>
|
|
||||||
<n-radio-button value="pc">仅电脑</n-radio-button>
|
|
||||||
<n-radio-button value="mobile">仅移动</n-radio-button>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="文字视差">
|
|
||||||
<n-switch v-model:value="editing.text_parallax" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="文字大小(PC)">
|
|
||||||
<n-input v-model:value="editing.text_size_pc" placeholder="如 48px,留空默认" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="文字大小(移动)">
|
|
||||||
<n-input v-model:value="editing.text_size_m" placeholder="如 28px,留空默认" />
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-modal-grid">
|
|
||||||
<n-form-item label="显示文字">
|
|
||||||
<n-switch v-model:value="editing.show_text" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="排序">
|
|
||||||
<n-input-number v-model:value="editing.sort_order" :min="0" style="width:100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="启用">
|
|
||||||
<n-switch v-model:value="editing.enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item label="显示元信息">
|
|
||||||
<n-switch v-model:value="editing.show_meta" />
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 图片图层 -->
|
|
||||||
<n-divider title-placement="left" style="margin:16px 0 12px;">
|
|
||||||
图片图层(视差叠加)
|
|
||||||
</n-divider>
|
|
||||||
<div v-for="(layer, idx) in editing.layers" :key="idx" class="aksrt-layer-item">
|
|
||||||
<div class="aksrt-layer-item__header">
|
|
||||||
<strong>图层 {{ idx + 1 }}</strong>
|
|
||||||
<n-button size="tiny" type="error" quaternary @click="removeLayer(idx)">删除</n-button>
|
|
||||||
</div>
|
|
||||||
<n-form-item label="图片">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input v-model:value="layer.image" placeholder="图片 URL" />
|
|
||||||
<n-button secondary @click="pickLayerImage(idx)">媒体库</n-button>
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
<div class="aksrt-modal-grid">
|
|
||||||
<n-form-item label="原始尺寸">
|
|
||||||
<n-switch v-model:value="layer.free_size" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="对齐方式">
|
|
||||||
<n-select v-model:value="layer.align" :options="[
|
|
||||||
{ label: '居中', value: 'center' },
|
|
||||||
{ label: '左上', value: 'left top' },
|
|
||||||
{ label: '右上', value: 'right top' },
|
|
||||||
{ label: '左下', value: 'left bottom' },
|
|
||||||
{ label: '右下', value: 'right bottom' },
|
|
||||||
]" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="视差">
|
|
||||||
<n-switch v-model:value="layer.parallax" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="视差强度" v-if="layer.parallax">
|
|
||||||
<n-slider v-model:value="layer.parallax_scale" :min="0" :max="1" :step="0.05" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="视差透明度" v-if="layer.parallax">
|
|
||||||
<n-slider v-model:value="layer.parallax_opacity" :min="0" :max="1" :step="0.05" />
|
|
||||||
</n-form-item>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<n-button size="small" dashed block @click="addLayer" style="margin-top:8px;">+ 添加图层</n-button>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="aksrt-modal-footer">
|
|
||||||
<n-button @click="modalShow = false">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</n-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { h, onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { NButton, NImage, NTag, useDialog, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import { openMediaPicker } from '@/utils/media'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
|
|
||||||
function goHeaderComponents() {
|
|
||||||
router.push('/page-display/header-components')
|
|
||||||
}
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const slides = ref([])
|
|
||||||
|
|
||||||
const modalShow = ref(false)
|
|
||||||
const editing = ref(emptyForm())
|
|
||||||
|
|
||||||
function emptyForm() {
|
|
||||||
return {
|
|
||||||
id: 0,
|
|
||||||
title: '',
|
|
||||||
subtitle: '',
|
|
||||||
description: '',
|
|
||||||
image_url: '',
|
|
||||||
video_url: '',
|
|
||||||
link_url: '',
|
|
||||||
link_target: '_self',
|
|
||||||
text_align: 'left',
|
|
||||||
device_visibility: 'all',
|
|
||||||
enabled: true,
|
|
||||||
show_text: true,
|
|
||||||
show_meta: false,
|
|
||||||
sort_order: 0,
|
|
||||||
text_size_pc: '',
|
|
||||||
text_size_m: '',
|
|
||||||
text_parallax: false,
|
|
||||||
layers: [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
title: '预览',
|
|
||||||
key: 'image_url',
|
|
||||||
width: 90,
|
|
||||||
render(row) {
|
|
||||||
if (!row.image_url) {
|
|
||||||
return h('span', { style: 'color:#bbb;font-size:12px;' }, '无')
|
|
||||||
}
|
|
||||||
return h(NImage, {
|
|
||||||
src: row.image_url,
|
|
||||||
width: 64,
|
|
||||||
height: 36,
|
|
||||||
objectFit: 'cover',
|
|
||||||
previewSrc: row.image_url,
|
|
||||||
style: 'border-radius:4px;display:block;',
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '标题',
|
|
||||||
key: 'title',
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
render(row) {
|
|
||||||
return row.title || h('span', { style: 'color:#bbb;' }, '未命名')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '文字',
|
|
||||||
key: 'show_text',
|
|
||||||
width: 70,
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
render(row) {
|
|
||||||
return h(NTag, { size: 'small', type: row.show_text ? 'info' : 'default' }, {
|
|
||||||
default: () => (row.show_text ? '显示' : '隐藏'),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '元信息',
|
|
||||||
key: 'show_meta',
|
|
||||||
width: 78,
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
render(row) {
|
|
||||||
return h(NTag, { size: 'small', type: row.show_meta ? 'info' : 'default' }, {
|
|
||||||
default: () => (row.show_meta ? '显示' : '隐藏'),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '跳转',
|
|
||||||
key: 'link_url',
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
render(row) {
|
|
||||||
return row.link_url || h('span', { style: 'color:#bbb;font-size:12px;' }, '—')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '排序',
|
|
||||||
key: 'sort_order',
|
|
||||||
width: 64,
|
|
||||||
className: 'aksrt-table-mobile-hide',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'enabled',
|
|
||||||
width: 84,
|
|
||||||
render(row) {
|
|
||||||
return h(NTag, { size: 'small', type: row.enabled ? 'success' : 'default' }, {
|
|
||||||
default: () => (row.enabled ? '启用' : '禁用'),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 130,
|
|
||||||
render(row) {
|
|
||||||
return h('div', { style: 'display:flex;gap:6px;' }, [
|
|
||||||
h(NButton, { size: 'tiny', onClick: () => openEdit(row) }, { default: () => '编辑' }),
|
|
||||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, onClick: () => handleDelete(row) }, { default: () => '删除' }),
|
|
||||||
])
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
async function loadSlides() {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const res = await api.getHeroes()
|
|
||||||
if (res.code === 'success') {
|
|
||||||
slides.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '加载失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
editing.value = emptyForm()
|
|
||||||
modalShow.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEdit(row) {
|
|
||||||
editing.value = {
|
|
||||||
id: row.id,
|
|
||||||
title: row.title || '',
|
|
||||||
subtitle: row.subtitle || '',
|
|
||||||
description: row.description || '',
|
|
||||||
image_url: row.image_url || '',
|
|
||||||
video_url: row.video_url || '',
|
|
||||||
link_url: row.link_url || '',
|
|
||||||
link_target: row.link_target || '_self',
|
|
||||||
text_align: row.text_align || 'left',
|
|
||||||
device_visibility: row.device_visibility || 'all',
|
|
||||||
show_text: row.show_text !== false,
|
|
||||||
enabled: !!row.enabled,
|
|
||||||
show_meta: row.show_meta === true,
|
|
||||||
sort_order: Number(row.sort_order) || 0,
|
|
||||||
text_size_pc: row.text_size_pc || '',
|
|
||||||
text_size_m: row.text_size_m || '',
|
|
||||||
text_parallax: !!row.text_parallax,
|
|
||||||
layers: Array.isArray(row.layers) ? row.layers : [],
|
|
||||||
}
|
|
||||||
modalShow.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickImage() {
|
|
||||||
try {
|
|
||||||
const media = await openMediaPicker({
|
|
||||||
title: '选择 Hero 背景图',
|
|
||||||
buttonText: '使用这张 Hero 图',
|
|
||||||
})
|
|
||||||
if (media?.url) {
|
|
||||||
editing.value.image_url = media.url
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '打开媒体库失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addLayer() {
|
|
||||||
editing.value.layers.push({
|
|
||||||
image: '',
|
|
||||||
free_size: false,
|
|
||||||
align: 'center',
|
|
||||||
parallax: false,
|
|
||||||
parallax_scale: 0.3,
|
|
||||||
parallax_opacity: 0.6,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLayer(idx) {
|
|
||||||
editing.value.layers.splice(idx, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickLayerImage(idx) {
|
|
||||||
try {
|
|
||||||
const media = await openMediaPicker({
|
|
||||||
title: '选择图层图片',
|
|
||||||
buttonText: '使用这张图',
|
|
||||||
})
|
|
||||||
if (media?.url) {
|
|
||||||
editing.value.layers[idx].image = media.url
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '打开媒体库失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
if (!editing.value.image_url) {
|
|
||||||
message.warning('请填写 Hero 背景图')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
if (editing.value.id) {
|
|
||||||
await api.updateHero(editing.value.id, editing.value)
|
|
||||||
message.success('已保存')
|
|
||||||
} else {
|
|
||||||
await api.createHero(editing.value)
|
|
||||||
message.success('已添加')
|
|
||||||
}
|
|
||||||
modalShow.value = false
|
|
||||||
await loadSlides()
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete(row) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定删除 Hero「${row.title || '未命名'}」?`,
|
|
||||||
positiveText: '删除',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await api.deleteHero(row.id)
|
|
||||||
message.success('已删除')
|
|
||||||
await loadSlides()
|
|
||||||
} catch (e) {
|
|
||||||
message.error(e.message || '删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadSlides()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-inline-setting {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-inline-setting__hint {
|
|
||||||
color: #7a7f8a;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-inline-setting__actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-modal-preview {
|
|
||||||
margin: -8px 0 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #eef1f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-modal-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-layer-item {
|
|
||||||
border: 1px solid #eef1f6;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 14px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: #fafbfc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-layer-item__header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.aksrt-modal-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,363 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container content-page">
|
|
||||||
<PageHeader
|
|
||||||
title="首页配置"
|
|
||||||
desc="配置首页主列表、分类 Tab 与基础交互。列表/卡片显示请在「文章列表」中设置。"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
|
|
||||||
</template>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
|
||||||
<n-tab-pane name="stream" tab="主列表 & Tab">
|
|
||||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header><strong>首页说明</strong></template>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
|
||||||
<n-form-item label="内容说明">
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
首页主列表只使用普通文章。下载/付费是文章能力(meta),不是第二种内容类型。当前为固定骨架模式,不再使用旧版自由模块画布。
|
|
||||||
</n-alert>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header><strong>主列表</strong></template>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
|
||||||
<n-form-item label="启用主列表">
|
|
||||||
<n-switch v-model:value="form.home_posts_list_s" />
|
|
||||||
<template #feedback><span class="aksrt-hint-text">首页主内容区保留 Zibll 式内容流,不建议关闭。</span></template>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="显示标题">
|
|
||||||
<n-input v-model:value="form.index_list_title" placeholder="最新发布" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="主栏目默认排序">
|
|
||||||
<n-select v-model:value="form.home_list1_orderby_option.orderby" :options="allSortOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="显示排序方式按钮">
|
|
||||||
<n-switch v-model:value="form.home_list1_orderby_s" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="form.home_list1_orderby_s" label="排序栏样式">
|
|
||||||
<n-radio-group v-model:value="form.home_list_sort_dropdown">
|
|
||||||
<n-space size="large">
|
|
||||||
<n-radio-button :value="false">内联链接</n-radio-button>
|
|
||||||
<n-radio-button :value="true">下拉菜单</n-radio-button>
|
|
||||||
</n-space>
|
|
||||||
</n-radio-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="form.home_list1_orderby_s" label="排序方式选项">
|
|
||||||
<n-select
|
|
||||||
v-model:value="form.home_list1_orderby_option.options"
|
|
||||||
:options="allSortOptions"
|
|
||||||
multiple
|
|
||||||
placeholder="选择排序选项"
|
|
||||||
/>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header><strong>排除规则</strong></template>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
|
||||||
<n-form-item label="排除文章">
|
|
||||||
<n-input
|
|
||||||
v-model:value="excludePostIdsText"
|
|
||||||
type="textarea"
|
|
||||||
placeholder="逗号分隔的文章 ID,如 12,34,56"
|
|
||||||
:rows="2"
|
|
||||||
/>
|
|
||||||
<template #feedback><span class="aksrt-hint-text">主列表和首页 Tab 查询均排除这些文章。</span></template>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="排除分类">
|
|
||||||
<n-input
|
|
||||||
v-model:value="excludeCategoryIdsText"
|
|
||||||
type="textarea"
|
|
||||||
placeholder="逗号分隔的分类 ID,如 5,8"
|
|
||||||
:rows="2"
|
|
||||||
/>
|
|
||||||
<template #feedback><span class="aksrt-hint-text">主列表排除这些分类,首页 Tab 不能绑定已排除分类。</span></template>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header>
|
|
||||||
<div style="display:flex; align-items:center; justify-content:space-between;">
|
|
||||||
<strong>更多栏目</strong>
|
|
||||||
<n-button size="small" @click="addTab">+ 添加栏目</n-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form" style="margin-bottom:16px;">
|
|
||||||
<n-form-item label="Tab 数量上限">
|
|
||||||
<n-input-number v-model:value="form.homepage_tab_limit" :min="1" :max="12" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="每 Tab 文章数">
|
|
||||||
<n-input-number v-model:value="form.homepage_tab_posts_per_page" :min="1" :max="20" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<div v-if="form.home_lists.length === 0" class="homepage-settings__empty-tabs">
|
|
||||||
<n-text depth="3">暂无更多栏目,点击“添加栏目”创建分类 Tab。</n-text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-for="(tab, index) in form.home_lists" :key="index" class="homepage-settings__tab-row">
|
|
||||||
<div class="homepage-settings__tab-fields">
|
|
||||||
<n-select
|
|
||||||
:value="tab.term_id"
|
|
||||||
:options="categoryOptions"
|
|
||||||
placeholder="分类"
|
|
||||||
filterable
|
|
||||||
style="flex:2;"
|
|
||||||
@update:value="(val) => updateTab(index, 'term_id', val)"
|
|
||||||
/>
|
|
||||||
<n-input
|
|
||||||
:value="tab.title"
|
|
||||||
placeholder="自定义标题"
|
|
||||||
style="flex:1;"
|
|
||||||
@update:value="(val) => updateTab(index, 'title', val)"
|
|
||||||
/>
|
|
||||||
<n-select
|
|
||||||
:value="tab.orderby"
|
|
||||||
:options="allSortOptions"
|
|
||||||
style="flex:1;"
|
|
||||||
@update:value="(val) => updateTab(index, 'orderby', val)"
|
|
||||||
/>
|
|
||||||
<n-switch
|
|
||||||
:value="tab.enabled"
|
|
||||||
@update:value="(val) => updateTab(index, 'enabled', val)"
|
|
||||||
/>
|
|
||||||
<n-button size="small" quaternary type="error" @click="removeTab(index)">删除</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
</n-tab-pane>
|
|
||||||
|
|
||||||
<n-tab-pane name="cards" tab="卡片 & 交互">
|
|
||||||
<n-spin :show="loading" style="width:100%; min-height:200px;">
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header><strong>列表显示</strong></template>
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
首页文章列表/卡片模式、样式变体与缩略图比例已统一到「文章&列表 → 文章列表」设置(对齐 Zibll 全局 list_type / list_card_home)。此处不再单独维护 homepage_card_style / 默认视图。
|
|
||||||
</n-alert>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card" style="margin-bottom:16px;">
|
|
||||||
<template #header><strong>交互设置</strong></template>
|
|
||||||
<n-form :model="form" label-placement="left" label-width="160" class="aksrt-form">
|
|
||||||
<n-form-item label="排序栏 AJAX">
|
|
||||||
<n-switch v-model:value="form.homepage_sort_ajax_enabled" />
|
|
||||||
<template #feedback><span class="aksrt-hint-text">关闭后排序链接使用整页跳转。</span></template>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="Tab 吸顶">
|
|
||||||
<n-switch v-model:value="form.homepage_sticky_tabs_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="迷你侧栏列表">
|
|
||||||
<n-switch v-model:value="form.homepage_sidebar_mini_enabled" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-section-card">
|
|
||||||
<template #header><strong>固定骨架说明</strong></template>
|
|
||||||
<n-space vertical>
|
|
||||||
<n-alert type="warning" :show-icon="false">
|
|
||||||
Hero 与 Banner 已作为固定首页骨架的一部分维护,不再由旧版首页模块编排驱动。
|
|
||||||
</n-alert>
|
|
||||||
<n-alert type="info" :show-icon="false">
|
|
||||||
如需调整首页首屏运营区,请前往对应管理页配置幻灯片内容或顶部组件参数。
|
|
||||||
</n-alert>
|
|
||||||
<n-space>
|
|
||||||
<n-button secondary type="primary" @click="router.push('/page-display/hero')">管理 Hero 轮播</n-button>
|
|
||||||
<n-button secondary type="primary" @click="router.push('/page-display/banner')">管理 Banner 轮播</n-button>
|
|
||||||
<n-button secondary @click="router.push('/page-display/header-components')">
|
|
||||||
顶部多功能组件
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
</n-tab-pane>
|
|
||||||
</n-tabs>
|
|
||||||
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<n-button type="primary" :loading="saving" @click="handleSave">保存设置</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
const activeTab = ref('stream')
|
|
||||||
const categoryOptions = ref([])
|
|
||||||
|
|
||||||
const allSortOptions = [
|
|
||||||
{ label: '最近更新', value: 'modified' },
|
|
||||||
{ label: '发布时间', value: 'date' },
|
|
||||||
{ label: '浏览量', value: 'views' },
|
|
||||||
{ label: '点赞数', value: 'likes' },
|
|
||||||
{ label: '评论数', value: 'comments' },
|
|
||||||
{ label: '收藏数', value: 'favorites' },
|
|
||||||
{ label: '下载量', value: 'downloads' },
|
|
||||||
{ label: '随机', value: 'rand' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
home_posts_list_s: true,
|
|
||||||
home_list1_orderby_s: true,
|
|
||||||
home_list1_orderby_option: {
|
|
||||||
orderby: 'modified',
|
|
||||||
options: ['modified', 'date', 'views', 'likes', 'comments'],
|
|
||||||
},
|
|
||||||
index_list_title: '最新发布',
|
|
||||||
home_exclude_posts: [],
|
|
||||||
home_exclude_cats: [],
|
|
||||||
home_lists: [],
|
|
||||||
homepage_card_style: 'default',
|
|
||||||
homepage_multi_thumb_enabled: true,
|
|
||||||
homepage_multi_thumb_min_images: 3,
|
|
||||||
homepage_sidebar_mini_enabled: true,
|
|
||||||
homepage_default_view: 'list',
|
|
||||||
homepage_sort_ajax_enabled: true,
|
|
||||||
homepage_sticky_tabs_enabled: true,
|
|
||||||
home_list_sort_dropdown: false,
|
|
||||||
homepage_tab_posts_per_page: 6,
|
|
||||||
homepage_tab_limit: 4,
|
|
||||||
})
|
|
||||||
|
|
||||||
const excludePostIdsText = computed({
|
|
||||||
get: () => (form.home_exclude_posts || []).join(','),
|
|
||||||
set: (val) => {
|
|
||||||
form.home_exclude_posts = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const excludeCategoryIdsText = computed({
|
|
||||||
get: () => (form.home_exclude_cats || []).join(','),
|
|
||||||
set: (val) => {
|
|
||||||
form.home_exclude_cats = val.split(',').map(s => parseInt(s.trim(), 10)).filter(n => n > 0)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
function addTab() {
|
|
||||||
form.home_lists.push({ term_id: 0, title: '', orderby: 'date', enabled: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTab(index) {
|
|
||||||
form.home_lists.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTab(index, field, value) {
|
|
||||||
form.home_lists[index][field] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCategories() {
|
|
||||||
try {
|
|
||||||
const res = await api.getHomepageCategories()
|
|
||||||
const cats = res?.data || []
|
|
||||||
categoryOptions.value = cats.map(c => ({
|
|
||||||
label: `${c.name} (${c.count})`,
|
|
||||||
value: c.term_id || c.id,
|
|
||||||
}))
|
|
||||||
} catch {
|
|
||||||
// non-critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettings() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.getHomepageSettings()
|
|
||||||
const d = res?.data || {}
|
|
||||||
|
|
||||||
form.home_posts_list_s = d.home_posts_list_s !== false
|
|
||||||
form.home_list1_orderby_s = d.home_list1_orderby_s !== false
|
|
||||||
const orderOpt = d.home_list1_orderby_option && typeof d.home_list1_orderby_option === 'object' ? d.home_list1_orderby_option : {}
|
|
||||||
form.home_list1_orderby_option = {
|
|
||||||
orderby: orderOpt.orderby || 'date',
|
|
||||||
options: Array.isArray(orderOpt.options) && orderOpt.options.length
|
|
||||||
? orderOpt.options
|
|
||||||
: ['modified', 'date', 'views', 'likes', 'comments'],
|
|
||||||
}
|
|
||||||
form.index_list_title = d.index_list_title || '最新发布'
|
|
||||||
form.home_exclude_posts = Array.isArray(d.home_exclude_posts) ? d.home_exclude_posts : []
|
|
||||||
form.home_exclude_cats = Array.isArray(d.home_exclude_cats) ? d.home_exclude_cats : []
|
|
||||||
form.home_lists = Array.isArray(d.home_lists) ? d.home_lists : []
|
|
||||||
form.homepage_card_style = d.homepage_card_style || 'default'
|
|
||||||
form.homepage_multi_thumb_enabled = d.homepage_multi_thumb_enabled !== false
|
|
||||||
form.homepage_multi_thumb_min_images = d.homepage_multi_thumb_min_images || 3
|
|
||||||
form.homepage_sidebar_mini_enabled = d.homepage_sidebar_mini_enabled !== false
|
|
||||||
form.homepage_default_view = d.homepage_default_view || 'list'
|
|
||||||
form.homepage_sort_ajax_enabled = d.homepage_sort_ajax_enabled !== false
|
|
||||||
form.homepage_sticky_tabs_enabled = d.homepage_sticky_tabs_enabled !== false
|
|
||||||
form.home_list_sort_dropdown = !!d.home_list_sort_dropdown
|
|
||||||
form.homepage_tab_posts_per_page = d.homepage_tab_posts_per_page || 6
|
|
||||||
form.homepage_tab_limit = d.homepage_tab_limit || 4
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '加载设置失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
|
||||||
saving.value = true
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
...form,
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await api.saveHomepageSettings(payload)
|
|
||||||
if (res?.code === 'success') {
|
|
||||||
message.success('首页设置已保存')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
throw new Error(res?.message || '保存失败')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '保存失败')
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadCategories()
|
|
||||||
loadSettings()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.homepage-settings__empty-tabs {
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
border: 1px dashed var(--n-border-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.homepage-settings__tab-row {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.homepage-settings__tab-fields {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-form-actions {
|
|
||||||
margin-top: 16px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-page-container content-page">
|
<div class="jio-page-container content-page">
|
||||||
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
|
<PageHeader title="通知管理" desc="管理通知内容开关、user 徽标统计口径和自动清理策略。">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-space>
|
<n-space>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
<n-spin :show="loading" style="width: 100%; min-height: 200px;">
|
||||||
<n-card title="通知内容开关" class="aksrt-section-card">
|
<n-card title="通知内容开关" class="jio-section-card">
|
||||||
<n-form :model="settings" label-placement="left" label-width="180">
|
<n-form :model="settings" label-placement="left" label-width="180">
|
||||||
<n-form-item label="评论回复通知">
|
<n-form-item label="评论回复通知">
|
||||||
<n-switch v-model:value="settings.enabled_types.comment_reply" />
|
<n-switch v-model:value="settings.enabled_types.comment_reply" />
|
||||||
@@ -21,12 +21,6 @@
|
|||||||
<n-form-item label="资源下载就绪通知">
|
<n-form-item label="资源下载就绪通知">
|
||||||
<n-switch v-model:value="settings.enabled_types.download_ready" />
|
<n-switch v-model:value="settings.enabled_types.download_ready" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="积分增加通知">
|
|
||||||
<n-switch v-model:value="settings.enabled_types.credits_added" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="积分扣减通知">
|
|
||||||
<n-switch v-model:value="settings.enabled_types.credits_deducted" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="邮箱待验证提醒">
|
<n-form-item label="邮箱待验证提醒">
|
||||||
<n-switch v-model:value="settings.enabled_types.verify_email" />
|
<n-switch v-model:value="settings.enabled_types.verify_email" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -41,21 +35,21 @@
|
|||||||
|
|
||||||
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
|
<n-grid :cols="2" :x-gap="16" responsive="screen" item-responsive style="margin-top: 16px;">
|
||||||
<n-grid-item span="24 l:10">
|
<n-grid-item span="24 l:10">
|
||||||
<n-card title="user 徽标规则" class="aksrt-section-card">
|
<n-card title="user 徽标规则" class="jio-section-card">
|
||||||
<n-form :model="settings" label-placement="left" label-width="180">
|
<n-form :model="settings" label-placement="left" label-width="180">
|
||||||
<n-form-item label="启用 user 动态徽标">
|
<n-form-item label="启用 user 动态徽标">
|
||||||
<n-switch v-model:value="settings.user_badge_enabled" />
|
<n-switch v-model:value="settings.user_badge_enabled" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="徽标包含状态提醒">
|
<n-form-item label="徽标包含状态提醒">
|
||||||
<n-switch v-model:value="settings.badge_include_stateful" />
|
<n-switch v-model:value="settings.badge_include_stateful" />
|
||||||
<span class="aksrt-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
|
<span class="jio-hint-text" style="margin-left: 12px;">未读持久化通知之外,叠加邮箱待验证、待审核评论等即时提醒</span>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
|
|
||||||
<n-grid-item span="24 l:14">
|
<n-grid-item span="24 l:14">
|
||||||
<n-card title="通知清理策略" class="aksrt-section-card">
|
<n-card title="通知清理策略" class="jio-section-card">
|
||||||
<n-form :model="settings" label-placement="left" label-width="180">
|
<n-form :model="settings" label-placement="left" label-width="180">
|
||||||
<n-form-item label="已读通知保留天数">
|
<n-form-item label="已读通知保留天数">
|
||||||
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
|
<n-input-number v-model:value="settings.read_retention_days" :min="1" :max="365" style="width: 180px;" />
|
||||||
@@ -90,8 +84,6 @@ const settings = reactive({
|
|||||||
comment_reply: true,
|
comment_reply: true,
|
||||||
comment_approved: true,
|
comment_approved: true,
|
||||||
download_ready: true,
|
download_ready: true,
|
||||||
credits_added: true,
|
|
||||||
credits_deducted: true,
|
|
||||||
verify_email: true,
|
verify_email: true,
|
||||||
pending_comments: true,
|
pending_comments: true,
|
||||||
upload_moderation: true,
|
upload_moderation: true,
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-page-container">
|
<div class="jio-page-container">
|
||||||
<PageHeader title="文档中心" desc="主题使用文档、开发指南、迁移说明和更新日志。" />
|
<PageHeader title="文档中心" desc="主题使用文档、开发指南、迁移说明和更新日志。" />
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<n-card title="常用操作" size="small" class="aksrt-docs__section">
|
<n-card title="常用操作" size="small" class="jio-docs__section">
|
||||||
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
<n-grid :cols="2" :x-gap="12" :y-gap="12">
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="aksrt-docs__action">
|
<div class="jio-docs__action">
|
||||||
<FaIcon icon="fas fa-palette" :size="16" />
|
<FaIcon icon="fa fas fa-paint-brush" :size="16" />
|
||||||
<div>
|
<div>
|
||||||
<strong>全局设置</strong>
|
<strong>全局设置</strong>
|
||||||
<p>Logo、背景、菜单装饰、自定义代码</p>
|
<p>Logo、导航栏、自定义代码</p>
|
||||||
<router-link to="/global/common">前往配置 →</router-link>
|
<router-link to="/global/common">前往配置 →</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="aksrt-docs__action">
|
<div class="jio-docs__action">
|
||||||
<FaIcon icon="fas fa-home" :size="16" />
|
<FaIcon icon="fa fas fa-home" :size="16" />
|
||||||
<div>
|
<div>
|
||||||
<strong>首页配置</strong>
|
<strong>首页配置</strong>
|
||||||
<p>首页模块、布局、排序和预设模板</p>
|
<p>首页模块、布局、排序和预设模板</p>
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="aksrt-docs__action">
|
<div class="jio-docs__action">
|
||||||
<FaIcon icon="fas fa-users" :size="16" />
|
<FaIcon icon="fa fas fa-users" :size="16" />
|
||||||
<div>
|
<div>
|
||||||
<strong>用户管理</strong>
|
<strong>用户管理</strong>
|
||||||
<p>登录注册、OAuth、签到、积分</p>
|
<p>登录注册、OAuth、签到、积分</p>
|
||||||
@@ -36,11 +36,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
<div class="aksrt-docs__action">
|
<div class="jio-docs__action">
|
||||||
<FaIcon icon="fas fa-tools" :size="16" />
|
<FaIcon icon="fa fas fa-wrench" :size="16" />
|
||||||
<div>
|
<div>
|
||||||
<strong>系统工具</strong>
|
<strong>工具</strong>
|
||||||
<p>导入导出、健康检查、迁移状态</p>
|
<p>导入导出、健康检查、备份与更新</p>
|
||||||
<router-link to="/tools/import-export">前往配置 →</router-link>
|
<router-link to="/tools/import-export">前往配置 →</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,30 +49,30 @@
|
|||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
<!-- Reference Docs -->
|
<!-- Reference Docs -->
|
||||||
<div class="aksrt-docs__grid">
|
<div class="jio-docs__grid">
|
||||||
<n-card
|
<n-card
|
||||||
v-for="doc in docs"
|
v-for="doc in docs"
|
||||||
:key="doc.key"
|
:key="doc.key"
|
||||||
class="aksrt-docs__card"
|
class="jio-docs__card"
|
||||||
size="small"
|
size="small"
|
||||||
hoverable
|
hoverable
|
||||||
@click="openDoc(doc)"
|
@click="openDoc(doc)"
|
||||||
>
|
>
|
||||||
<div class="aksrt-docs__card-inner">
|
<div class="jio-docs__card-inner">
|
||||||
<div class="aksrt-docs__card-icon" :style="{ background: doc.color }">
|
<div class="jio-docs__card-icon" :style="{ background: doc.color }">
|
||||||
<FaIcon :icon="doc.icon" :size="20" color="#fff" />
|
<FaIcon :icon="doc.icon" :size="20" color="#fff" />
|
||||||
</div>
|
</div>
|
||||||
<div class="aksrt-docs__card-body">
|
<div class="jio-docs__card-body">
|
||||||
<h4 class="aksrt-docs__card-title">{{ doc.title }}</h4>
|
<h4 class="jio-docs__card-title">{{ doc.title }}</h4>
|
||||||
<p class="aksrt-docs__card-desc">{{ doc.description }}</p>
|
<p class="jio-docs__card-desc">{{ doc.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<FaIcon icon="fas fa-external-link-alt" :size="12" class="aksrt-docs__card-arrow" />
|
<FaIcon icon="fa fas fa-external-link-alt" :size="12" class="jio-docs__card-arrow" />
|
||||||
</div>
|
</div>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Version Info -->
|
<!-- Version Info -->
|
||||||
<n-card title="当前版本" size="small" class="aksrt-docs__section">
|
<n-card title="当前版本" size="small" class="jio-docs__section">
|
||||||
<n-descriptions :column="2" bordered label-placement="left" size="small">
|
<n-descriptions :column="2" bordered label-placement="left" size="small">
|
||||||
<n-descriptions-item label="主题版本">
|
<n-descriptions-item label="主题版本">
|
||||||
<n-tag type="success" size="small">{{ themeVersion }}</n-tag>
|
<n-tag type="success" size="small">{{ themeVersion }}</n-tag>
|
||||||
@@ -91,16 +91,16 @@ import PageHeader from '@/components/PageHeader.vue'
|
|||||||
import FaIcon from '@/components/form/FaIcon.vue'
|
import FaIcon from '@/components/form/FaIcon.vue'
|
||||||
import api from '@/api/index.js'
|
import api from '@/api/index.js'
|
||||||
|
|
||||||
const themeVersion = ref(window.aksrtAdminData?.themeVersion || '3.4.0-dev')
|
const themeVersion = ref(window.jioAdminData?.themeVersion || '3.4.0-dev')
|
||||||
const dbSchemaVersion = ref('—')
|
const dbSchemaVersion = ref('—')
|
||||||
|
|
||||||
const docs = ref([
|
const docs = ref([
|
||||||
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fas fa-sitemap', color: '#1890ff', file: 'admin-ia-map.md' },
|
{ key: 'admin-ia-map', title: '后台 IA 蓝图', description: '完整的后台信息架构映射,12 个顶级分区及子页面清单。', icon: 'fa fas fa-sitemap', color: 'var(--jio-color-primary)', file: 'admin-ia-map.md' },
|
||||||
{ key: 'zibll-gap-matrix', title: '对标差异矩阵', description: 'AKSRT 当前 → Zibll 参考 → AKSRT 目标三层对照表。', icon: 'fas fa-columns', color: '#722ed1', file: 'zibll-gap-matrix.md' },
|
{ key: 'legacy-gap-matrix', title: '对标差异矩阵', description: 'Jio 当前 → 原主题参考 → Jio 目标三层对照表。', icon: 'fa fas fa-columns', color: '#722ed1', file: 'legacy-gap-matrix.md' },
|
||||||
{ key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' },
|
{ key: 'frontend-page-matrix', title: '前端页面矩阵', description: '前端模板、CSS、JS 资产映射与覆盖范围。', icon: 'fa fas fa-desktop', color: '#13c2c2', file: 'frontend-page-matrix.md' },
|
||||||
{ key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fas fa-clipboard-check', color: '#52c41a', file: 'manual-acceptance-baseline.md' },
|
{ key: 'manual-acceptance-baseline', title: '验收基线', description: '每个 Phase 的人工验收标准和检查清单。', icon: 'fa fas fa-clipboard', color: '#52c41a', file: 'manual-acceptance-baseline.md' },
|
||||||
{ key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' },
|
{ key: 'feature-domain-map', title: '功能域地图', description: '业务域划分、依赖关系和模块边界说明。', icon: 'fa fas fa-map', color: '#fa8c16', file: 'feature-domain-map.md' },
|
||||||
{ key: 'migration-map', title: '迁移地图', description: '设置 key 迁移、旧选项兼容和 Schema 版本对照。', icon: 'fas fa-exchange-alt', color: '#eb2f96', file: 'migration-map.md' },
|
{ key: 'migration-map', title: '迁移地图', description: '设置 key 迁移、旧选项兼容和 Schema 版本对照。', icon: 'fa fas fa-exchange-alt', color: '#eb2f96', file: 'migration-map.md' },
|
||||||
])
|
])
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -117,35 +117,35 @@ onMounted(async () => {
|
|||||||
|
|
||||||
function openDoc(doc) {
|
function openDoc(doc) {
|
||||||
// Link to local docs directory (admin can open in new tab)
|
// Link to local docs directory (admin can open in new tab)
|
||||||
const base = window.aksrtAdminData?.siteUrl || window.location.origin
|
const base = window.jioAdminData?.siteUrl || window.location.origin
|
||||||
window.open(`${base}/wp-content/themes/AKSRT-theme/docs/${doc.file}`, '_blank')
|
window.open(`${base}/wp-content/themes/Jio/docs/${doc.file}`, '_blank')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.aksrt-docs__grid {
|
.jio-docs__grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card {
|
.jio-docs__card {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: box-shadow 0.2s;
|
transition: box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card:hover {
|
.jio-docs__card:hover {
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card-inner {
|
.jio-docs__card-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card-icon {
|
.jio-docs__card-icon {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -155,38 +155,38 @@ function openDoc(doc) {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card-body {
|
.jio-docs__card-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card-title {
|
.jio-docs__card-title {
|
||||||
margin: 0 0 2px;
|
margin: 0 0 2px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card-desc {
|
.jio-docs__card-desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8a909c;
|
color: var(--jio-color-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__card-arrow {
|
.jio-docs__card-arrow {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: #c0c4cc;
|
color: #c0c4cc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__section {
|
.jio-docs__section {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__action {
|
.jio-docs__action {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -195,31 +195,31 @@ function openDoc(doc) {
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__action i {
|
.jio-docs__action i {
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__action strong {
|
.jio-docs__action strong {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #1a1a2e;
|
color: var(--jio-color-text);
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__action p {
|
.jio-docs__action p {
|
||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #8a909c;
|
color: var(--jio-color-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__action a {
|
.jio-docs__action a {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #1890ff;
|
color: var(--jio-color-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.aksrt-docs__action a:hover {
|
.jio-docs__action a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,316 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="AI 连接配置" desc="配置 AI 生成能力的连接方式。优先使用 WordPress AI Connectors;也可填写 OpenAI 兼容接口。SEO 生成入口在文章「独立 SEO」框,摘要/标签入口在前台投稿页。" />
|
|
||||||
|
|
||||||
<div v-if="loadError" class="aksrt-ai__form-card aksrt-ai__load-error" role="alert">
|
|
||||||
<strong>加载失败:</strong>{{ loadError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__status">
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--off'">
|
|
||||||
<i class="fas fa-robot"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">可用状态</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.available ? '可用' : '不可用' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.wp_ai_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
|
||||||
<i class="fas fa-plug"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">WP AI Client</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.wp_ai_available ? '已就绪' : '不可用' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon" :class="status.theme_available ? 'aksrt-ai__status-icon--ok' : 'aksrt-ai__status-icon--warn'">
|
|
||||||
<i class="fas fa-key"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">主题 Provider</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ status.theme_available ? '已配置' : '未配置' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-card">
|
|
||||||
<div class="aksrt-ai__status-icon aksrt-ai__status-icon--info">
|
|
||||||
<i class="fas fa-route"></i>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-ai__status-info">
|
|
||||||
<div class="aksrt-ai__status-label">当前后端</div>
|
|
||||||
<div class="aksrt-ai__status-value">{{ backendLabel }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__form-card">
|
|
||||||
<h3>主题 Provider 配置(回退链路)</h3>
|
|
||||||
<p class="aksrt-form-hint">已配置主题 Provider 时优先走下方接口;未配置时才尝试 WordPress AI Connectors。NewAPI/兼容网关请填到 <code>/v1</code>(也可只填域名,保存时会自动补全)。</p>
|
|
||||||
<div class="aksrt-form-group">
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" v-model="form.enabled"> 启用主题 AI Provider
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group">
|
|
||||||
<label>AI Provider</label>
|
|
||||||
<select v-model="form.provider" class="aksrt-select">
|
|
||||||
<option value="none">不使用</option>
|
|
||||||
<option value="openai">OpenAI 兼容接口</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>API Key</label>
|
|
||||||
<input v-model="form.api_key" type="password" class="aksrt-input" placeholder="sk-...">
|
|
||||||
<p class="aksrt-form-hint">保存后不会回显完整密钥</p>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider === 'openai'">
|
|
||||||
<label>API Base URL</label>
|
|
||||||
<input v-model="form.api_base" type="text" class="aksrt-input" placeholder="https://llm.example.com/v1">
|
|
||||||
<p class="aksrt-form-hint">示例:https://api.openai.com/v1 或 https://你的网关/v1(不要填到 /chat/completions)</p>
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>模型</label>
|
|
||||||
<input v-model="form.model" type="text" class="aksrt-input" placeholder="gpt-3.5-turbo">
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>最大 Token 数</label>
|
|
||||||
<input v-model.number="form.max_tokens" type="number" min="100" max="4000" class="aksrt-input">
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-group" v-if="form.provider !== 'none'">
|
|
||||||
<label>每分钟请求限制</label>
|
|
||||||
<input v-model.number="form.rate_limit" type="number" min="1" max="100" class="aksrt-input">
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-form-actions">
|
|
||||||
<button class="aksrt-button aksrt-button--primary" :disabled="saving" @click="saveSettings">
|
|
||||||
{{ saving ? '保存中...' : '保存设置' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__form-card">
|
|
||||||
<h3>已接通能力</h3>
|
|
||||||
<table class="aksrt-table aksrt-table--sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>能力</th>
|
|
||||||
<th>入口</th>
|
|
||||||
<th>状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="feat in features" :key="feat.key">
|
|
||||||
<td>{{ feat.name }}</td>
|
|
||||||
<td>{{ feat.scene }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="aksrt-badge" :class="feat.ready ? 'aksrt-badge--success' : 'aksrt-badge--warning'">
|
|
||||||
{{ feat.ready ? '可用' : '未就绪' }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="aksrt-form-hint">SEO 开关与提示词请到「全局 → SEO」配置(seo_ai_enabled / ai_seo_*_prompt)。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="aksrt-ai__form-card aksrt-ai__notice">
|
|
||||||
<h3><i class="fas fa-exclamation-triangle"></i> 重要说明</h3>
|
|
||||||
<ul>
|
|
||||||
<li>AI 输出仅作为<strong>建议</strong>,不会自动写库;需用户点「更新/提交」才保存</li>
|
|
||||||
<li>主题 API Key 已配置时优先使用;否则回退 WordPress AI Connectors</li>
|
|
||||||
<li>生成接口要求登录与编辑/投稿权限;配置接口仍需管理员</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
|
||||||
import PageHeader from '@/components/PageHeader.vue';
|
|
||||||
import api from '@/api';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ExtensionAI',
|
|
||||||
components: { PageHeader },
|
|
||||||
setup() {
|
|
||||||
const status = ref({
|
|
||||||
enabled: false,
|
|
||||||
provider: 'none',
|
|
||||||
configured: false,
|
|
||||||
model: '',
|
|
||||||
api_key: '',
|
|
||||||
rate_limit: 10,
|
|
||||||
available: false,
|
|
||||||
backend: 'none',
|
|
||||||
wp_ai_available: false,
|
|
||||||
theme_available: false,
|
|
||||||
seo_ai_enabled: false,
|
|
||||||
write_assist: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
enabled: false,
|
|
||||||
provider: 'none',
|
|
||||||
api_key: '',
|
|
||||||
api_base: 'https://api.openai.com/v1',
|
|
||||||
model: 'gpt-3.5-turbo',
|
|
||||||
max_tokens: 1000,
|
|
||||||
rate_limit: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
const saving = ref(false);
|
|
||||||
const loadError = ref('');
|
|
||||||
|
|
||||||
const backendLabel = computed(() => {
|
|
||||||
const map = { wp_ai: 'WP AI Client', theme: '主题 Provider', none: '无' };
|
|
||||||
return map[status.value.backend] || status.value.backend || '无';
|
|
||||||
});
|
|
||||||
|
|
||||||
const features = computed(() => {
|
|
||||||
const avail = !!status.value.available;
|
|
||||||
const seoReady = avail && !!status.value.seo_ai_enabled;
|
|
||||||
return [
|
|
||||||
{ key: 'seo', name: 'AI 生成 SEO', scene: '文章/分类「独立 SEO」框', ready: seoReady },
|
|
||||||
{ key: 'summary', name: 'AI 生成摘要', scene: '前台投稿页', ready: avail },
|
|
||||||
{ key: 'tags', name: 'AI 建议标签', scene: '前台投稿页', ready: avail },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
function hydrateFromStatus(data) {
|
|
||||||
if (!data || typeof data !== 'object') return;
|
|
||||||
status.value = {
|
|
||||||
enabled: !!data.enabled,
|
|
||||||
provider: data.provider || 'none',
|
|
||||||
configured: !!data.configured,
|
|
||||||
model: data.model || '',
|
|
||||||
api_key: '',
|
|
||||||
rate_limit: data.rate_limit || 10,
|
|
||||||
available: !!data.available,
|
|
||||||
backend: data.backend || 'none',
|
|
||||||
wp_ai_available: !!data.wp_ai_available,
|
|
||||||
theme_available: !!data.theme_available,
|
|
||||||
seo_ai_enabled: !!data.seo_ai_enabled,
|
|
||||||
write_assist: !!data.write_assist,
|
|
||||||
};
|
|
||||||
form.enabled = !!data.enabled;
|
|
||||||
form.provider = data.provider || 'none';
|
|
||||||
form.model = data.model || 'gpt-3.5-turbo';
|
|
||||||
form.rate_limit = data.rate_limit || 10;
|
|
||||||
if (data.api_base) form.api_base = data.api_base;
|
|
||||||
if (typeof data.max_tokens === 'number' && data.max_tokens > 0) {
|
|
||||||
form.max_tokens = data.max_tokens;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStatus() {
|
|
||||||
loadError.value = '';
|
|
||||||
try {
|
|
||||||
const json = await api.getAiStatus();
|
|
||||||
hydrateFromStatus(json.data || {});
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Load AI status failed:', e);
|
|
||||||
loadError.value = e?.message || '加载 AI 状态失败';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSettings() {
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
const payload = {
|
|
||||||
enabled: form.enabled,
|
|
||||||
provider: form.provider,
|
|
||||||
api_base: form.api_base,
|
|
||||||
model: form.model,
|
|
||||||
max_tokens: form.max_tokens,
|
|
||||||
rate_limit: form.rate_limit,
|
|
||||||
};
|
|
||||||
// Only send api_key when the user entered a new one.
|
|
||||||
if (form.api_key && form.api_key !== '***') {
|
|
||||||
payload.api_key = form.api_key;
|
|
||||||
}
|
|
||||||
const json = await api.updateAiSettings(payload);
|
|
||||||
alert(json.message || '设置已保存');
|
|
||||||
await loadStatus();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Save failed:', e);
|
|
||||||
alert(e?.message || '保存失败');
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => loadStatus());
|
|
||||||
|
|
||||||
return { status, form, saving, loadError, features, backendLabel, saveSettings };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-ai__status {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__status-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
.aksrt-ai__status-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__status-icon--ok { background: #d4edda; color: #155724; }
|
|
||||||
.aksrt-ai__status-icon--warn { background: #fff3cd; color: #856404; }
|
|
||||||
.aksrt-ai__status-icon--off { background: #f8d7da; color: #721c24; }
|
|
||||||
.aksrt-ai__status-icon--info { background: #d1ecf1; color: #0c5460; }
|
|
||||||
.aksrt-ai__status-value { font-size: 18px; font-weight: 600; color: #1a1a2e; }
|
|
||||||
.aksrt-ai__status-label { font-size: 12px; color: #888; }
|
|
||||||
.aksrt-ai__form-card {
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__form-card h3 { margin: 0 0 16px; font-size: 16px; font-weight: 600; }
|
|
||||||
.aksrt-form-hint { font-size: 12px; color: #888; margin-top: 4px; }
|
|
||||||
.aksrt-ai__load-error {
|
|
||||||
background: #fdecea;
|
|
||||||
border-color: #f5c2c0;
|
|
||||||
color: #842029;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.aksrt-ai__notice { background: #fff8e1; border-color: #ffe082; }
|
|
||||||
.aksrt-ai__notice h3 { color: #e65100; }
|
|
||||||
.aksrt-ai__notice ul { margin: 0; padding-left: 20px; }
|
|
||||||
.aksrt-ai__notice li { margin-bottom: 8px; font-size: 14px; }
|
|
||||||
.aksrt-ai__notice code {
|
|
||||||
background: rgba(0,0,0,0.06);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.aksrt-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
|
|
||||||
.aksrt-badge--success { background: #d4edda; color: #155724; }
|
|
||||||
.aksrt-badge--warning { background: #fff3cd; color: #856404; }
|
|
||||||
.aksrt-table { width: 100%; border-collapse: collapse; }
|
|
||||||
.aksrt-table th, .aksrt-table td { border-bottom: 1px solid #eee; padding: 10px 8px; text-align: left; }
|
|
||||||
.aksrt-form-group { margin-bottom: 14px; }
|
|
||||||
.aksrt-input, .aksrt-select { width: 100%; max-width: 480px; padding: 8px 10px; }
|
|
||||||
.aksrt-button { padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer; }
|
|
||||||
.aksrt-button--primary { background: #1677ff; color: #fff; }
|
|
||||||
.aksrt-button:disabled { opacity: .6; cursor: not-allowed; }
|
|
||||||
</style>
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="系统工具" desc="额外运维工具与扩展能力。" />
|
|
||||||
|
|
||||||
<!-- Health Overview -->
|
|
||||||
<n-card title="系统健康" size="small" class="aksrt-tools__section">
|
|
||||||
<n-grid :cols="4" :x-gap="12" :y-gap="12">
|
|
||||||
<n-grid-item v-for="item in healthItems" :key="item.key">
|
|
||||||
<div class="aksrt-tools__health-card" :class="'aksrt-tools__health-card--' + item.status">
|
|
||||||
<FaIcon :icon="item.icon" :size="18" :color="item.color" />
|
|
||||||
<div>
|
|
||||||
<strong>{{ item.value }}</strong>
|
|
||||||
<p>{{ item.label }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Tool Categories -->
|
|
||||||
<n-card title="工具集合" size="small" class="aksrt-tools__section">
|
|
||||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
|
||||||
<n-grid-item v-for="tool in tools" :key="tool.key">
|
|
||||||
<div class="aksrt-tools__tool-card" @click="navigateTo(tool.route)">
|
|
||||||
<div class="aksrt-tools__tool-icon" :style="{ background: tool.bg }">
|
|
||||||
<FaIcon :icon="tool.icon" :size="18" color="#fff" />
|
|
||||||
</div>
|
|
||||||
<div class="aksrt-tools__tool-body">
|
|
||||||
<strong>{{ tool.title }}</strong>
|
|
||||||
<p>{{ tool.desc }}</p>
|
|
||||||
</div>
|
|
||||||
<FaIcon icon="fas fa-chevron-right" :size="12" color="#c0c4cc" />
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Service Status -->
|
|
||||||
<n-card title="服务状态" size="small" class="aksrt-tools__section">
|
|
||||||
<n-list bordered size="small">
|
|
||||||
<n-list-item v-for="svc in services" :key="svc.key">
|
|
||||||
<n-thing :title="svc.name" :description="svc.description">
|
|
||||||
<template #avatar>
|
|
||||||
<FaIcon :icon="svc.icon" :size="16" :color="svc.color" />
|
|
||||||
</template>
|
|
||||||
<template #header-extra>
|
|
||||||
<n-tag :type="svc.available ? 'success' : 'warning'" size="small">
|
|
||||||
{{ svc.available ? '可用' : '未配置' }}
|
|
||||||
</n-tag>
|
|
||||||
</template>
|
|
||||||
</n-thing>
|
|
||||||
</n-list-item>
|
|
||||||
</n-list>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<!-- Quick Maintenance -->
|
|
||||||
<n-card title="手动维护" size="small" class="aksrt-tools__section">
|
|
||||||
<n-space>
|
|
||||||
<n-button @click="handleFlushCache">
|
|
||||||
<template #icon><FaIcon icon="fas fa-broom" :size="12" /></template>
|
|
||||||
清除缓存
|
|
||||||
</n-button>
|
|
||||||
<n-button @click="handleOptimizeDb">
|
|
||||||
<template #icon><FaIcon icon="fas fa-database" :size="12" /></template>
|
|
||||||
优化数据表
|
|
||||||
</n-button>
|
|
||||||
<n-button @click="handleRebuildIndex">
|
|
||||||
<template #icon><FaIcon icon="fas fa-sync-alt" :size="12" /></template>
|
|
||||||
重建索引
|
|
||||||
</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import FaIcon from '@/components/form/FaIcon.vue'
|
|
||||||
import api from '@/api/index.js'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const healthItems = ref([
|
|
||||||
{ key: 'php', icon: 'fas fa-code', color: '#1890ff', value: '—', label: 'PHP 版本', status: 'ok' },
|
|
||||||
{ key: 'wp', icon: 'fab fa-wordpress', color: '#722ed1', value: '—', label: 'WordPress', status: 'ok' },
|
|
||||||
{ key: 'db', icon: 'fas fa-database', color: '#52c41a', value: '—', label: '数据库', status: 'ok' },
|
|
||||||
{ key: 'uploads', icon: 'fas fa-folder-open', color: '#fa8c16', value: '—', label: '上传目录', status: 'ok' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const tools = ref([
|
|
||||||
{ key: 'import-export', icon: 'fas fa-exchange-alt', bg: '#1890ff', title: '导入导出', desc: '设置导入、导出和重置', route: '/tools/import-export' },
|
|
||||||
{ key: 'health', icon: 'fas fa-heartbeat', bg: '#52c41a', title: '健康检查', desc: '系统状态检测和诊断', route: '/tools/health' },
|
|
||||||
{ key: 'email-rollback', icon: 'fas fa-undo', bg: '#fa8c16', title: '邮箱回滚', desc: '邮箱变更 14 天内回滚', route: '/tools/email-rollback' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const services = ref([
|
|
||||||
{ key: 'sms', icon: 'fas fa-sms', color: '#1890ff', name: '短信服务', description: '验证码和通知短信', available: false },
|
|
||||||
{ key: 'wechat', icon: 'fab fa-weixin', color: '#52c41a', name: '微信通知', description: '微信模板消息和订阅通知', available: false },
|
|
||||||
{ key: 'captcha', icon: 'fas fa-shield-alt', color: '#722ed1', name: '验证码服务', description: '人机验证(极验/腾讯/阿里云)', available: false },
|
|
||||||
{ key: 'push', icon: 'fas fa-paper-plane', color: '#fa8c16', name: '搜索引擎推送', description: '百度/Google 主动推送', available: false },
|
|
||||||
{ key: 'audit', icon: 'fas fa-shield-virus', color: '#f5222d', name: '内容审核', description: '第三方内容安全审核 API', available: false },
|
|
||||||
])
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.request('/tools/health')
|
|
||||||
if (res?.data) {
|
|
||||||
const d = res.data
|
|
||||||
if (d.php_version) healthItems.value[0].value = d.php_version
|
|
||||||
if (d.wp_version) healthItems.value[1].value = d.wp_version
|
|
||||||
if (d.db_status) {
|
|
||||||
healthItems.value[2].value = d.db_status
|
|
||||||
healthItems.value[2].status = d.db_ok ? 'ok' : 'warn'
|
|
||||||
}
|
|
||||||
if (d.upload_dir) {
|
|
||||||
healthItems.value[3].value = d.upload_dir
|
|
||||||
healthItems.value[3].status = d.upload_ok ? 'ok' : 'warn'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Endpoint may not exist yet
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check service availability from structured status
|
|
||||||
try {
|
|
||||||
const settingsRes = await api.request('/settings/extensions')
|
|
||||||
if (settingsRes?.data) {
|
|
||||||
const s = settingsRes.data
|
|
||||||
// Prefer structured services if available
|
|
||||||
const svc = settingsRes.services || {}
|
|
||||||
services.value[0].available = !!(svc.sms?.available ?? s.sms_enabled)
|
|
||||||
services.value[0].status = svc.sms?.provider || (s.sms_enabled ? '已启用' : '未配置')
|
|
||||||
services.value[1].available = !!(svc.wechat?.available ?? s.wechat_enabled)
|
|
||||||
services.value[1].status = svc.wechat?.provider || (s.wechat_enabled ? '已启用' : '未配置')
|
|
||||||
services.value[2].available = !!(svc.captcha?.available ?? svc.captcha?.configured)
|
|
||||||
services.value[2].status = svc.captcha?.provider || (svc.captcha?.enabled ? '已启用' : '未配置')
|
|
||||||
services.value[3].available = !!(svc.push?.available ?? s.baidu_push_enabled)
|
|
||||||
services.value[3].status = svc.push?.provider || (s.baidu_push_enabled ? '已启用' : '未配置')
|
|
||||||
services.value[4].available = !!(svc.audit?.available ?? s.content_audit_enabled)
|
|
||||||
services.value[4].status = svc.audit?.provider || (s.content_audit_enabled ? '已启用' : '未配置')
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Use defaults
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function navigateTo(route) {
|
|
||||||
router.push(route)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleFlushCache() {
|
|
||||||
message.info('正在清除缓存...')
|
|
||||||
try {
|
|
||||||
const res = await api.request('/tools/maintenance', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ action: 'flush_cache' }),
|
|
||||||
})
|
|
||||||
message.success(res?.message || '缓存已清除')
|
|
||||||
} catch (err) {
|
|
||||||
message.error(err?.message || '清除缓存失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOptimizeDb() {
|
|
||||||
message.info('正在优化数据表...')
|
|
||||||
try {
|
|
||||||
const res = await api.request('/tools/maintenance', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ action: 'optimize_db' }),
|
|
||||||
})
|
|
||||||
message.success(res?.message || '数据表已优化')
|
|
||||||
} catch (err) {
|
|
||||||
message.error(err?.message || '优化数据表失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRebuildIndex() {
|
|
||||||
message.info('正在重建索引...')
|
|
||||||
try {
|
|
||||||
const res = await api.request('/tools/maintenance', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ action: 'rebuild_index' }),
|
|
||||||
})
|
|
||||||
message.success(res?.message || '索引重建完成')
|
|
||||||
} catch (err) {
|
|
||||||
message.error(err?.message || '重建索引失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-tools__section {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__health-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fafafa;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__health-card strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__health-card p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8a909c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__health-card--warn {
|
|
||||||
border-color: #faad14;
|
|
||||||
background: #fffbe6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__tool-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fafafa;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__tool-card:hover {
|
|
||||||
border-color: #1890ff;
|
|
||||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__tool-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__tool-body {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__tool-body strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #1a1a2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-tools__tool-body p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8a909c;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="aksrt-page-container">
|
<div class="jio-page-container">
|
||||||
<PageHeader :title="title" :desc="desc">
|
<PageHeader :title="title" :desc="desc">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<n-space v-if="links.length">
|
<n-space v-if="links.length">
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<n-card v-if="note" class="aksrt-section-card">
|
<n-card v-if="note" class="jio-section-card">
|
||||||
<n-alert type="info" :show-icon="false">
|
<n-alert type="info" :show-icon="false">
|
||||||
{{ note }}
|
{{ note }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
|
|||||||
@@ -1,530 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="充值卡管理" desc="按状态和时间筛选充值卡,支持单张创建、批量生成、详情查看和当前筛选导出。" />
|
|
||||||
|
|
||||||
<div class="aksrt-charge-card-page__stats">
|
|
||||||
<n-card v-for="card in statCards" :key="card.key" size="small">
|
|
||||||
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-card size="small" title="操作台">
|
|
||||||
<n-space justify="space-between" wrap>
|
|
||||||
<n-space>
|
|
||||||
<n-button type="primary" @click="createVisible = true">创建充值卡</n-button>
|
|
||||||
<n-button @click="batchVisible = true">批量生成</n-button>
|
|
||||||
</n-space>
|
|
||||||
<n-button quaternary @click="handleExport">导出当前筛选</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-charge-card-page__card" size="small" title="充值卡台账">
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<n-grid cols="1 s:2 m:4" :x-gap="12" :y-gap="12" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索卡号、备注、使用人" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-space>
|
|
||||||
<n-button type="primary" @click="handleSearch">筛选</n-button>
|
|
||||||
<n-button @click="resetFilters">重置</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-data-table
|
|
||||||
:columns="columns"
|
|
||||||
:data="rows"
|
|
||||||
:loading="loading"
|
|
||||||
:bordered="false"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
/>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-modal v-model:show="createVisible" preset="card" title="创建充值卡" style="max-width: 520px;">
|
|
||||||
<n-form :model="createForm" label-placement="top">
|
|
||||||
<n-form-item label="卡类型">
|
|
||||||
<n-select v-model:value="createForm.type" :options="typeOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="卡密">
|
|
||||||
<n-input v-model:value="createForm.code" placeholder="手动填写唯一卡密" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item v-if="createForm.type === 'balance'" label="面额">
|
|
||||||
<n-input-number v-model:value="createForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-grid v-if="createForm.type === 'vip'" cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="VIP 等级">
|
|
||||||
<n-select v-model:value="createForm.level" :options="levelOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="天数(0=永久)">
|
|
||||||
<n-input-number v-model:value="createForm.days" :min="0" :max="3650" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-form-item v-if="createForm.type === 'credits'" label="积分">
|
|
||||||
<n-input-number v-model:value="createForm.points" :min="1" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="过期时间">
|
|
||||||
<n-date-picker v-model:value="createForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="备注">
|
|
||||||
<n-input v-model:value="createForm.note" type="textarea" :rows="3" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-space justify="end">
|
|
||||||
<n-button @click="createVisible = false">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="submittingCreate" @click="handleCreate">创建</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-form>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<n-modal v-model:show="batchVisible" preset="card" title="批量生成充值卡" style="max-width: 520px;">
|
|
||||||
<n-form :model="batchForm" label-placement="top">
|
|
||||||
<n-form-item label="卡类型">
|
|
||||||
<n-select v-model:value="batchForm.type" :options="typeOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-grid cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="数量">
|
|
||||||
<n-input-number v-model:value="batchForm.count" :min="1" :max="100" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item v-if="batchForm.type === 'balance'">
|
|
||||||
<n-form-item label="面额">
|
|
||||||
<n-input-number v-model:value="batchForm.amount" :min="0.01" :precision="2" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item v-if="batchForm.type === 'credits'">
|
|
||||||
<n-form-item label="积分">
|
|
||||||
<n-input-number v-model:value="batchForm.points" :min="1" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-grid v-if="batchForm.type === 'vip'" cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="VIP 等级">
|
|
||||||
<n-select v-model:value="batchForm.level" :options="levelOptions" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="天数(0=永久)">
|
|
||||||
<n-input-number v-model:value="batchForm.days" :min="0" :max="3650" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-grid cols="2" :x-gap="12">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="前缀">
|
|
||||||
<n-input v-model:value="batchForm.prefix" placeholder="AK" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-form-item label="随机长度">
|
|
||||||
<n-input-number v-model:value="batchForm.length" :min="8" :max="32" style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-form-item label="过期时间">
|
|
||||||
<n-date-picker v-model:value="batchForm.expiresAt" type="datetime" clearable style="width: 100%;" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item label="备注">
|
|
||||||
<n-input v-model:value="batchForm.note" type="textarea" :rows="3" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-space justify="end">
|
|
||||||
<n-button @click="batchVisible = false">取消</n-button>
|
|
||||||
<n-button type="primary" :loading="submittingBatch" @click="handleBatchCreate">批量生成</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-form>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<ChargeCardDetailDrawer v-model:show="drawerVisible" :card-id="currentCardId" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import ChargeCardDetailDrawer from './components/ChargeCardDetailDrawer.vue'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const dialog = useDialog()
|
|
||||||
const loading = ref(false)
|
|
||||||
const rows = ref([])
|
|
||||||
const createVisible = ref(false)
|
|
||||||
const batchVisible = ref(false)
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
const currentCardId = ref(null)
|
|
||||||
const submittingCreate = ref(false)
|
|
||||||
const submittingBatch = ref(false)
|
|
||||||
|
|
||||||
const stats = reactive({
|
|
||||||
total: 0,
|
|
||||||
active: 0,
|
|
||||||
used: 0,
|
|
||||||
total_amount: 0,
|
|
||||||
used_amount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filters = reactive({
|
|
||||||
keyword: '',
|
|
||||||
status: '',
|
|
||||||
dateRange: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const createForm = reactive({
|
|
||||||
type: 'balance',
|
|
||||||
code: '',
|
|
||||||
amount: 10,
|
|
||||||
level: 1,
|
|
||||||
days: 30,
|
|
||||||
points: 100,
|
|
||||||
expiresAt: null,
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const batchForm = reactive({
|
|
||||||
type: 'balance',
|
|
||||||
count: 20,
|
|
||||||
amount: 10,
|
|
||||||
level: 1,
|
|
||||||
days: 30,
|
|
||||||
points: 100,
|
|
||||||
prefix: 'AK',
|
|
||||||
length: 12,
|
|
||||||
expiresAt: null,
|
|
||||||
note: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const typeOptions = [
|
|
||||||
{ label: '余额卡', value: 'balance' },
|
|
||||||
{ label: '会员卡', value: 'vip' },
|
|
||||||
{ label: '积分卡', value: 'credits' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const levelOptions = [
|
|
||||||
{ label: 'VIP1', value: 1 },
|
|
||||||
{ label: 'VIP2', value: 2 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const pager = reactive({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
itemCount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const optionsState = reactive({
|
|
||||||
statuses: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ key: 'total', label: '总卡数' },
|
|
||||||
{ key: 'active', label: '未使用' },
|
|
||||||
{ key: 'used', label: '已使用' },
|
|
||||||
{ key: 'total_amount', label: '总面额' },
|
|
||||||
{ key: 'used_amount', label: '已兑换金额' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusOptions = computed(() => optionsState.statuses.length
|
|
||||||
? optionsState.statuses
|
|
||||||
: [
|
|
||||||
{ label: '全部状态', value: '' },
|
|
||||||
{ label: '未使用', value: 'active' },
|
|
||||||
{ label: '已使用', value: 'used' },
|
|
||||||
{ label: '已过期', value: 'expired' },
|
|
||||||
{ label: '已禁用', value: 'disabled' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
|
||||||
page: pager.page,
|
|
||||||
pageSize: pager.pageSize,
|
|
||||||
itemCount: pager.itemCount,
|
|
||||||
showSizePicker: true,
|
|
||||||
pageSizes: [10, 20, 50, 100],
|
|
||||||
onUpdatePage: (page) => {
|
|
||||||
pager.page = page
|
|
||||||
loadData()
|
|
||||||
},
|
|
||||||
onUpdatePageSize: (pageSize) => {
|
|
||||||
pager.pageSize = pageSize
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', key: 'id', width: 70 },
|
|
||||||
{ title: '卡密', key: 'code', minWidth: 170, ellipsis: { tooltip: true } },
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
key: 'type_label',
|
|
||||||
width: 90,
|
|
||||||
render: (row) => row.type_label || row.type || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '面额/权益',
|
|
||||||
key: 'amount',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => {
|
|
||||||
if (row.type === 'vip') {
|
|
||||||
const days = Number(row.days || 0)
|
|
||||||
return `VIP${row.level || 1} / ${days > 0 ? `${days}天` : '永久'}`
|
|
||||||
}
|
|
||||||
if (row.type === 'credits') return `${row.points || row.amount || 0} 积分`
|
|
||||||
return row.amount
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'status_label',
|
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
|
||||||
const typeMap = { active: 'success', used: 'warning', expired: 'default', disabled: 'error' }
|
|
||||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '使用人',
|
|
||||||
key: 'used_by_user',
|
|
||||||
width: 160,
|
|
||||||
render: (row) => row.used_by_user?.display_name || '-',
|
|
||||||
},
|
|
||||||
{ title: '创建时间', key: 'created_at', width: 170 },
|
|
||||||
{
|
|
||||||
title: '过期时间',
|
|
||||||
key: 'expires_at',
|
|
||||||
width: 170,
|
|
||||||
render: (row) => row.expires_at || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '备注',
|
|
||||||
key: 'note',
|
|
||||||
minWidth: 160,
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
render: (row) => row.note || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 140,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => {
|
|
||||||
const actions = [
|
|
||||||
h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'primary',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => openDrawer(row.id),
|
|
||||||
}, () => '详情'),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (row.status === 'active') {
|
|
||||||
actions.push(h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'warning',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => handleDisable(row),
|
|
||||||
}, () => '禁用'))
|
|
||||||
}
|
|
||||||
|
|
||||||
return actions
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadData()
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDateValue(value) {
|
|
||||||
const date = new Date(value)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
const hours = `${date.getHours()}`.padStart(2, '0')
|
|
||||||
const minutes = `${date.getMinutes()}`.padStart(2, '0')
|
|
||||||
const seconds = `${date.getSeconds()}`.padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildParams(page = pager.page, pageSize = pager.pageSize) {
|
|
||||||
const params = {
|
|
||||||
page,
|
|
||||||
per_page: pageSize,
|
|
||||||
}
|
|
||||||
if (filters.keyword) params.keyword = filters.keyword
|
|
||||||
if (filters.status) params.status = filters.status
|
|
||||||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
|
||||||
const [start, end] = filters.dateRange
|
|
||||||
params.date_from = formatDateValue(start).slice(0, 10)
|
|
||||||
params.date_to = formatDateValue(end).slice(0, 10)
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request('/payment/charge-cards', { params: buildParams() })
|
|
||||||
const data = res?.data || {}
|
|
||||||
rows.value = data.items || []
|
|
||||||
pager.itemCount = data.total || 0
|
|
||||||
Object.assign(stats, data.stats || {})
|
|
||||||
optionsState.statuses = data.options?.statuses || []
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载充值卡台账失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
filters.keyword = ''
|
|
||||||
filters.status = ''
|
|
||||||
filters.dateRange = null
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreate() {
|
|
||||||
submittingCreate.value = true
|
|
||||||
try {
|
|
||||||
await api.request('/payment/charge-cards', {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
type: createForm.type,
|
|
||||||
code: createForm.code,
|
|
||||||
password: createForm.code,
|
|
||||||
amount: createForm.amount,
|
|
||||||
level: createForm.level,
|
|
||||||
days: createForm.days,
|
|
||||||
points: createForm.points,
|
|
||||||
expires_at: createForm.expiresAt ? formatDateValue(createForm.expiresAt) : '',
|
|
||||||
note: createForm.note,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('充值卡已创建')
|
|
||||||
createVisible.value = false
|
|
||||||
createForm.code = ''
|
|
||||||
createForm.note = ''
|
|
||||||
await loadData()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '创建失败')
|
|
||||||
} finally {
|
|
||||||
submittingCreate.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBatchCreate() {
|
|
||||||
submittingBatch.value = true
|
|
||||||
try {
|
|
||||||
await api.request('/payment/charge-cards/batch', {
|
|
||||||
method: 'POST',
|
|
||||||
data: {
|
|
||||||
type: batchForm.type,
|
|
||||||
count: batchForm.count,
|
|
||||||
amount: batchForm.amount,
|
|
||||||
level: batchForm.level,
|
|
||||||
days: batchForm.days,
|
|
||||||
points: batchForm.points,
|
|
||||||
prefix: batchForm.prefix,
|
|
||||||
length: batchForm.length,
|
|
||||||
expires_at: batchForm.expiresAt ? formatDateValue(batchForm.expiresAt) : '',
|
|
||||||
note: batchForm.note,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
message.success('充值卡已批量生成')
|
|
||||||
batchVisible.value = false
|
|
||||||
await loadData()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '批量生成失败')
|
|
||||||
} finally {
|
|
||||||
submittingBatch.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDisable(row) {
|
|
||||||
dialog.warning({
|
|
||||||
title: '确认禁用',
|
|
||||||
content: `确定要禁用充值卡 ${row.code} 吗?`,
|
|
||||||
positiveText: '禁用',
|
|
||||||
negativeText: '取消',
|
|
||||||
onPositiveClick: async () => {
|
|
||||||
try {
|
|
||||||
await api.request(`/payment/charge-cards/${row.id}/disable`, { method: 'POST' })
|
|
||||||
message.success('充值卡已禁用')
|
|
||||||
await loadData()
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '禁用失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDrawer(id) {
|
|
||||||
currentCardId.value = id
|
|
||||||
drawerVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleExport() {
|
|
||||||
try {
|
|
||||||
const res = await api.request('/payment/charge-cards', { params: buildParams(1, 200) })
|
|
||||||
const items = res?.data?.items || []
|
|
||||||
const header = ['ID', '卡号', '面额', '余额', '状态', '使用人', '创建时间', '过期时间', '备注']
|
|
||||||
const lines = items.map((row) => [
|
|
||||||
row.id,
|
|
||||||
row.code,
|
|
||||||
row.amount,
|
|
||||||
row.balance,
|
|
||||||
row.status_label,
|
|
||||||
row.used_by_user?.display_name || '',
|
|
||||||
row.created_at || '',
|
|
||||||
row.expires_at || '',
|
|
||||||
row.note || '',
|
|
||||||
])
|
|
||||||
downloadCsv('charge-cards.csv', [header, ...lines])
|
|
||||||
message.success('导出完成')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '导出失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCsv(filename, rows) {
|
|
||||||
const csv = `\uFEFF${rows.map((row) => row.map((cell) => `"${String(cell ?? '').replaceAll('"', '""')}"`).join(',')).join('\n')}`
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement('a')
|
|
||||||
link.href = url
|
|
||||||
link.download = filename
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-charge-card-page__stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-charge-card-page__card {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PaymentSectionPage
|
|
||||||
section="gateway"
|
|
||||||
title="收款接口"
|
|
||||||
desc="管理余额、支付宝、微信、PayPal、Stripe、易支付与卡密兑换通道。"
|
|
||||||
note="以渠道开关与凭据是否齐全为准;异步回调由各网关验签。卡密为兑换入口,不进入收银台。"
|
|
||||||
:links="[
|
|
||||||
{ label: '余额充值', path: '/payment/recharge' },
|
|
||||||
{ label: '充值卡', path: '/payment/card-pass' },
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<template #before-form>
|
|
||||||
<n-spin :show="loading">
|
|
||||||
<n-card class="aksrt-section-card" title="网关状态">
|
|
||||||
<n-grid :cols="3" :x-gap="12" :y-gap="12">
|
|
||||||
<n-grid-item v-for="gw in gateways" :key="gw.id">
|
|
||||||
<div class="aksrt-gateway__card">
|
|
||||||
<div class="aksrt-gateway__icon" :class="gw.configured ? 'aksrt-gateway__icon--active' : ''">
|
|
||||||
<i :class="iconFor(gw.id)"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<strong>{{ gw.name }}</strong>
|
|
||||||
<p>{{ gw.configured ? '已配置' : '未配置' }}</p>
|
|
||||||
</div>
|
|
||||||
<n-tag :type="gw.configured ? 'success' : 'default'" size="small" round>
|
|
||||||
{{ gw.configured ? '可用' : '不可用' }}
|
|
||||||
</n-tag>
|
|
||||||
</div>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
<n-empty v-if="!loading && gateways.length === 0" description="暂无已注册的支付网关" />
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card v-if="overview.help_text" class="aksrt-section-card" title="运行说明">
|
|
||||||
<p class="aksrt-gateway__help">{{ overview.help_text }}</p>
|
|
||||||
</n-card>
|
|
||||||
</n-spin>
|
|
||||||
</template>
|
|
||||||
</PaymentSectionPage>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useMessage } from 'naive-ui'
|
|
||||||
import PaymentSectionPage from '@/pages/payment/PaymentSectionPage.vue'
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const gateways = ref([])
|
|
||||||
const overview = ref({})
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
function iconFor(id) {
|
|
||||||
if (id === 'balance') return 'fas fa-wallet'
|
|
||||||
if (id === 'alipay' || id === 'epay_alipay') return 'fab fa-alipay'
|
|
||||||
if (id === 'wechatpay' || id === 'epay_wxpay') return 'fab fa-weixin'
|
|
||||||
if (id === 'paypal') return 'fab fa-paypal'
|
|
||||||
if (id === 'stripe') return 'fab fa-stripe-s'
|
|
||||||
return 'fas fa-credit-card'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const [gwRes, ovRes] = await Promise.all([
|
|
||||||
api.request('/payment/overview').catch(() => null),
|
|
||||||
])
|
|
||||||
if (gwRes?.data?.gateway?.gateways) {
|
|
||||||
const gwList = Object.entries(gwRes.data.gateway.gateways).map(([id, enabled]) => ({ id, name: id, configured: !!enabled }))
|
|
||||||
gateways.value = gwList
|
|
||||||
}
|
|
||||||
if (ovRes?.data?.gateway) {
|
|
||||||
overview.value = ovRes.data.gateway
|
|
||||||
} else if (ovRes?.data) {
|
|
||||||
overview.value = ovRes.data
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.warning('部分支付接口暂不可用')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-gateway__card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--surface-2, #fafafa);
|
|
||||||
border: 1px solid var(--color-line, #f0f0f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: var(--surface-3, #f0f0f0);
|
|
||||||
color: var(--color-ink-4, #8a909c);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__icon--active {
|
|
||||||
background: var(--color-primary-soft, #f0f5ff);
|
|
||||||
color: var(--color-primary, #1890ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__card strong {
|
|
||||||
display: block;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--color-ink, #1a1a2e);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__card p {
|
|
||||||
margin: 2px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-ink-4, #8a909c);
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-gateway__help {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-ink-3, #5c6370);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="aksrt-page-container">
|
|
||||||
<PageHeader title="返佣管理" desc="按状态、类型和时间筛选返佣记录,自定义列展示并在详情中查看处理历史。" />
|
|
||||||
|
|
||||||
<div class="aksrt-rebate-page__stats">
|
|
||||||
<n-card v-for="card in statCards" :key="card.key" size="small">
|
|
||||||
<n-statistic :label="card.label" :value="stats[card.key] || 0" />
|
|
||||||
</n-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<n-card size="small" title="筛选与列控制">
|
|
||||||
<n-space vertical :size="12">
|
|
||||||
<n-grid cols="1 s:2 m:5" :x-gap="12" :y-gap="12" responsive="screen">
|
|
||||||
<n-grid-item>
|
|
||||||
<n-input v-model:value="filters.keyword" clearable placeholder="搜索订单号、用户、备注" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-select v-model:value="filters.status" :options="statusOptions" placeholder="状态" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-select v-model:value="filters.type" :options="typeOptions" placeholder="类型" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-date-picker v-model:value="filters.dateRange" type="daterange" clearable style="width: 100%;" />
|
|
||||||
</n-grid-item>
|
|
||||||
<n-grid-item>
|
|
||||||
<n-space>
|
|
||||||
<n-button type="primary" @click="handleSearch">筛选</n-button>
|
|
||||||
<n-button @click="resetFilters">重置</n-button>
|
|
||||||
</n-space>
|
|
||||||
</n-grid-item>
|
|
||||||
</n-grid>
|
|
||||||
|
|
||||||
<n-checkbox-group v-model:value="visibleColumnKeys">
|
|
||||||
<n-space wrap>
|
|
||||||
<n-checkbox v-for="column in columnChoices" :key="column.value" :value="column.value">
|
|
||||||
{{ column.label }}
|
|
||||||
</n-checkbox>
|
|
||||||
</n-space>
|
|
||||||
</n-checkbox-group>
|
|
||||||
</n-space>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<n-card class="aksrt-rebate-page__card" size="small" title="返佣台账">
|
|
||||||
<n-data-table
|
|
||||||
:columns="tableColumns"
|
|
||||||
:data="rows"
|
|
||||||
:loading="loading"
|
|
||||||
:bordered="false"
|
|
||||||
:pagination="pagination"
|
|
||||||
:row-key="(row) => row.id"
|
|
||||||
/>
|
|
||||||
</n-card>
|
|
||||||
|
|
||||||
<RebateDetailDrawer
|
|
||||||
v-model:show="drawerVisible"
|
|
||||||
:rebate-id="currentRebateId"
|
|
||||||
@updated="loadData"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { NButton, NSpace, NTag, useMessage } from 'naive-ui'
|
|
||||||
import api from '@/api'
|
|
||||||
import PageHeader from '@/components/PageHeader.vue'
|
|
||||||
import RebateDetailDrawer from './components/RebateDetailDrawer.vue'
|
|
||||||
|
|
||||||
const message = useMessage()
|
|
||||||
const loading = ref(false)
|
|
||||||
const rows = ref([])
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
const currentRebateId = ref(null)
|
|
||||||
|
|
||||||
const stats = reactive({
|
|
||||||
total: 0,
|
|
||||||
pending: 0,
|
|
||||||
settled: 0,
|
|
||||||
total_amount: 0,
|
|
||||||
settled_amount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filters = reactive({
|
|
||||||
keyword: '',
|
|
||||||
status: '',
|
|
||||||
type: '',
|
|
||||||
dateRange: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
const pager = reactive({
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
itemCount: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const optionsState = reactive({
|
|
||||||
statuses: [],
|
|
||||||
types: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const statCards = [
|
|
||||||
{ key: 'total', label: '总记录' },
|
|
||||||
{ key: 'pending', label: '待结算' },
|
|
||||||
{ key: 'settled', label: '已结算' },
|
|
||||||
{ key: 'total_amount', label: '总返佣' },
|
|
||||||
{ key: 'settled_amount', label: '已结算金额' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const statusOptions = computed(() => optionsState.statuses.length
|
|
||||||
? optionsState.statuses
|
|
||||||
: [
|
|
||||||
{ label: '全部状态', value: '' },
|
|
||||||
{ label: '待结算', value: 'pending' },
|
|
||||||
{ label: '已结算', value: 'settled' },
|
|
||||||
{ label: '已取消', value: 'cancelled' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const typeOptions = computed(() => optionsState.types.length
|
|
||||||
? optionsState.types
|
|
||||||
: [
|
|
||||||
{ label: '全部类型', value: '' },
|
|
||||||
{ label: '订单返佣', value: 'order' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const columnChoices = [
|
|
||||||
{ label: '来源用户', value: 'source_user' },
|
|
||||||
{ label: '创建时间', value: 'created_at' },
|
|
||||||
{ label: '结算时间', value: 'settled_at' },
|
|
||||||
{ label: '备注', value: 'note' },
|
|
||||||
{ label: '处理记录数', value: 'history_count' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const visibleColumnKeys = ref(['source_user', 'created_at', 'settled_at', 'history_count'])
|
|
||||||
|
|
||||||
const baseColumns = computed(() => {
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', key: 'id', width: 70 },
|
|
||||||
{ title: '订单号', key: 'order_number', minWidth: 180, ellipsis: { tooltip: true } },
|
|
||||||
{
|
|
||||||
title: '返佣对象',
|
|
||||||
key: 'user',
|
|
||||||
width: 160,
|
|
||||||
render: (row) => row.user?.display_name || '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '返佣金额',
|
|
||||||
key: 'amount',
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
key: 'status',
|
|
||||||
width: 100,
|
|
||||||
render: (row) => {
|
|
||||||
const typeMap = { pending: 'warning', settled: 'success', cancelled: 'default' }
|
|
||||||
return h(NTag, { size: 'small', round: true, type: typeMap[row.status] || 'default' }, () => row.status_label)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '类型',
|
|
||||||
key: 'type',
|
|
||||||
width: 100,
|
|
||||||
render: (row) => row.type_label || row.type,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (visibleColumnKeys.value.includes('source_user')) {
|
|
||||||
columns.push({
|
|
||||||
title: '来源用户',
|
|
||||||
key: 'source_user',
|
|
||||||
width: 160,
|
|
||||||
render: (row) => row.source_user?.display_name || '-',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleColumnKeys.value.includes('created_at')) {
|
|
||||||
columns.push({ title: '创建时间', key: 'created_at', width: 170 })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleColumnKeys.value.includes('settled_at')) {
|
|
||||||
columns.push({
|
|
||||||
title: '结算时间',
|
|
||||||
key: 'settled_at',
|
|
||||||
width: 170,
|
|
||||||
render: (row) => row.settled_at || '-',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleColumnKeys.value.includes('note')) {
|
|
||||||
columns.push({
|
|
||||||
title: '备注',
|
|
||||||
key: 'note',
|
|
||||||
minWidth: 160,
|
|
||||||
ellipsis: { tooltip: true },
|
|
||||||
render: (row) => row.note || '-',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visibleColumnKeys.value.includes('history_count')) {
|
|
||||||
columns.push({ title: '处理记录数', key: 'history_count', width: 100 })
|
|
||||||
}
|
|
||||||
|
|
||||||
columns.push({
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
width: 120,
|
|
||||||
render: (row) => h(NSpace, { size: 'small' }, () => [
|
|
||||||
h(NButton, {
|
|
||||||
size: 'tiny',
|
|
||||||
type: 'primary',
|
|
||||||
quaternary: true,
|
|
||||||
onClick: () => openDrawer(row.id),
|
|
||||||
}, () => '详情'),
|
|
||||||
]),
|
|
||||||
})
|
|
||||||
|
|
||||||
return columns
|
|
||||||
})
|
|
||||||
|
|
||||||
const tableColumns = computed(() => baseColumns.value)
|
|
||||||
|
|
||||||
const pagination = computed(() => ({
|
|
||||||
page: pager.page,
|
|
||||||
pageSize: pager.pageSize,
|
|
||||||
itemCount: pager.itemCount,
|
|
||||||
showSizePicker: true,
|
|
||||||
pageSizes: [10, 20, 50, 100],
|
|
||||||
onUpdatePage: (page) => {
|
|
||||||
pager.page = page
|
|
||||||
loadData()
|
|
||||||
},
|
|
||||||
onUpdatePageSize: (pageSize) => {
|
|
||||||
pager.pageSize = pageSize
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
loadData()
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDateValue(value) {
|
|
||||||
const date = new Date(value)
|
|
||||||
const year = date.getFullYear()
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
return `${year}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildParams() {
|
|
||||||
const params = {
|
|
||||||
page: pager.page,
|
|
||||||
per_page: pager.pageSize,
|
|
||||||
}
|
|
||||||
if (filters.keyword) params.keyword = filters.keyword
|
|
||||||
if (filters.status) params.status = filters.status
|
|
||||||
if (filters.type) params.type = filters.type
|
|
||||||
if (Array.isArray(filters.dateRange) && filters.dateRange.length === 2) {
|
|
||||||
params.date_from = formatDateValue(filters.dateRange[0])
|
|
||||||
params.date_to = formatDateValue(filters.dateRange[1])
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await api.request('/payment/rebate/log', { params: buildParams() })
|
|
||||||
const data = res?.data || {}
|
|
||||||
rows.value = data.items || []
|
|
||||||
pager.itemCount = data.total || 0
|
|
||||||
Object.assign(stats, data.stats || {})
|
|
||||||
optionsState.statuses = data.options?.statuses || []
|
|
||||||
optionsState.types = data.options?.types || []
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error?.message || '加载返佣台账失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters() {
|
|
||||||
filters.keyword = ''
|
|
||||||
filters.status = ''
|
|
||||||
filters.type = ''
|
|
||||||
filters.dateRange = null
|
|
||||||
pager.page = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDrawer(id) {
|
|
||||||
currentRebateId.value = id
|
|
||||||
drawerVisible.value = true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.aksrt-rebate-page__stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aksrt-rebate-page__card {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
Loaded 100 of 1788 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user