Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8408b8076 | ||
|
|
7704532e8f | ||
|
|
19a618c9d2 | ||
|
|
51d0877a45 | ||
|
|
09a766c2c8 | ||
|
|
bef074097a | ||
|
|
4abd6ffd5d | ||
|
|
8cc8b4fb94 | ||
|
|
a2ef3542f4 | ||
|
|
07a160e843 | ||
|
|
8970ad52fa | ||
|
|
ac2ecb4663 | ||
|
|
d990637b38 | ||
|
|
a52a6620fc | ||
|
|
1fc806ea7f | ||
|
|
4fdba0fb5d | ||
|
|
4581130b62 | ||
|
|
6464935014 | ||
|
|
39b9a6a1a0 | ||
|
|
0c3f4ece2a | ||
|
|
ee7386f401 | ||
|
|
6f8dc920ca | ||
|
|
e41e9e6d3f | ||
|
|
8e97fd6a9f | ||
|
|
fbb7d4fe24 | ||
|
|
5c162bf6c5 | ||
|
|
8451607f6c | ||
|
|
c148912b73 | ||
|
|
3464f35a01 | ||
|
|
b3ab87f22c | ||
|
|
b9b930936d | ||
|
|
392f94e95c | ||
|
|
4595dfef34 | ||
|
|
0137fd3a4c | ||
|
|
a9dc19a86b | ||
|
|
22242701e4 | ||
|
|
ca7095af4c | ||
|
|
04b099249e | ||
|
|
48d6c31397 | ||
|
|
b06d46b0b3 | ||
|
|
06d814e82f | ||
|
|
acb90007de | ||
|
|
e399f3f76f | ||
|
|
4ee3b541ac | ||
|
|
0ef04804c1 | ||
|
|
cc02f4d907 | ||
|
|
33cb18594e | ||
|
|
9de72cbc5c | ||
|
|
b5d8ccdffe | ||
|
|
6580853ad3 | ||
|
|
32c6a0ad7b | ||
|
|
18a78e5508 | ||
|
|
952abd5629 | ||
|
|
7688f859c7 | ||
|
|
227c537099 | ||
|
|
f1dbb63251 | ||
|
|
c853b919c0 | ||
|
|
047cfb3d0b | ||
|
|
ff071c0159 | ||
|
|
9542b71cc7 | ||
|
|
4f766a76bd | ||
|
|
75bb0bf78e | ||
|
|
4b47e770a3 | ||
|
|
52045e5df2 | ||
|
|
cd86255b13 | ||
|
|
4a3456756f | ||
|
|
9cdcb9c077 | ||
|
|
a041650ba1 | ||
|
|
22e545fef9 | ||
|
|
3ae9004b8b | ||
|
|
c3f7dcc6f1 | ||
|
|
a86b19604d | ||
|
|
24ebfcf2e1 | ||
|
|
aac878c3d4 | ||
|
|
d49210575b | ||
|
|
30a0d7ce68 | ||
|
|
29f4791b66 | ||
|
|
3e77c3976e | ||
|
|
090431b409 | ||
|
|
ef85ed30e4 | ||
|
|
2e26477db5 | ||
|
|
6d2ef9fffc | ||
|
|
ad33b21869 | ||
|
|
93a31b3872 | ||
|
|
d8fbc16fd7 | ||
|
|
dbd7c473c2 | ||
|
|
875d2b2b04 | ||
|
|
e959a99db8 | ||
|
|
a4cb6c3757 | ||
|
|
c367327049 | ||
|
|
7c43fcacc4 | ||
|
|
2ecf4cf3fe | ||
|
|
bc27480e2c | ||
|
|
872d197531 | ||
|
|
b835c1cdfc | ||
|
|
ba072611dc | ||
|
|
98049c4f8c | ||
|
|
64667af5c6 | ||
|
|
4f5cec1b94 | ||
|
|
c367fadf09 | ||
|
|
fde16235ea | ||
|
|
22e60248d2 | ||
|
|
f8c9334e98 | ||
|
|
67acd02ea5 | ||
|
|
c510cc4e2e | ||
|
|
e2f99b4f4b | ||
|
|
5e8daad5a0 | ||
|
|
215cb7c6d5 | ||
|
|
8356e98ce3 | ||
|
|
6ab3bbe289 | ||
|
|
a4c7f8edd5 | ||
|
|
a9c0285e22 | ||
|
|
e6c864a2a6 | ||
|
|
457a95941d | ||
|
|
fc4c14e420 | ||
|
|
8498798a9c | ||
|
|
3953453090 | ||
|
|
2333bd8bab | ||
|
|
eb0b0f85d1 | ||
|
|
db3b5f7157 | ||
|
|
876930b07e | ||
|
|
0a1968a134 | ||
|
|
2f9a3330e5 | ||
|
|
1c771066ea | ||
|
|
e60f1b300a | ||
|
|
13e4aa56ec | ||
|
|
8afd04aa55 | ||
|
|
00b1fefecb | ||
|
|
88632e6e4f | ||
|
|
01ecd63991 | ||
|
|
3598310237 | ||
|
|
6b3286fa5a | ||
|
|
f155976f52 | ||
|
|
db32f31cc2 | ||
|
|
489b1bcd16 | ||
|
|
291535e242 | ||
|
|
1e80470078 | ||
|
|
6f7f8e7632 | ||
|
|
03b1e7163e | ||
|
|
5679b156ca | ||
|
|
3879a47673 | ||
|
|
b34626bd4b | ||
|
|
7005228b52 | ||
|
|
836e210b09 | ||
|
|
41a498657c | ||
|
|
f4224e5d3a | ||
|
|
3267679758 | ||
|
|
7ff7c9914f | ||
|
|
5c28cd7c79 | ||
|
|
d2f2ecf57a | ||
|
|
5a46198bf0 | ||
|
|
ada663128a | ||
|
|
17cb1373e1 | ||
|
|
29f887496a | ||
|
|
b92e5379bc | ||
|
|
767c6b7589 | ||
|
|
f7f6637c45 | ||
|
|
05cf82dbbc | ||
|
|
8ae1e5d0d4 | ||
|
|
4bb21641d9 | ||
|
|
67d917010d | ||
|
|
a5a1976924 | ||
|
|
4806f88d7c | ||
|
|
eb1d75222a | ||
|
|
2705fdbc49 | ||
|
|
ce7f6cee02 | ||
|
|
fbee53c9de | ||
|
|
45f662ecf2 | ||
|
|
148085286d | ||
|
|
f6638ca6c6 | ||
|
|
baee81f459 | ||
|
|
9f51392754 | ||
|
|
8dc0d2d3b8 | ||
|
|
538970797d | ||
|
|
60378fea9b | ||
|
|
f9193a3564 | ||
|
|
db1e9ee27b | ||
|
|
b769d2479c | ||
|
|
af2e1481a6 | ||
|
|
092690791d | ||
|
|
9a10fb6657 | ||
|
|
68284ad2bf | ||
|
|
5e8c17c82f | ||
|
|
50b06dca10 | ||
|
|
11e348a29f | ||
|
|
08fac66fe1 | ||
|
|
8663a829e2 | ||
|
|
2975ba5a7f | ||
|
|
2ade6bfa3c | ||
|
|
46364d3f89 | ||
|
|
2a9c23ebc6 | ||
|
|
adfb1a5cb3 | ||
|
|
ff9ca74ba4 | ||
|
|
cb38114db9 | ||
|
|
e7411eb03f | ||
|
|
6b30e35d07 | ||
|
|
0830d7a3a3 | ||
|
|
6a905419fc | ||
|
|
d8a4160bc7 | ||
|
|
e0f54edcc9 | ||
|
|
3f728cd0b2 | ||
|
|
3291d34fb8 | ||
|
|
02a9d72ef8 | ||
|
|
2cf9129606 | ||
|
|
4c4a3db1eb | ||
|
|
698ca34529 | ||
|
|
133654327d | ||
|
|
260409c071 | ||
|
|
c7d9af0165 | ||
|
|
bda0743520 | ||
|
|
50df363f86 | ||
|
|
c61ae525e1 | ||
|
|
8bf7b20b89 | ||
|
|
1de73b5f1c | ||
|
|
6bdaff3fdf | ||
|
|
9dcd991ddb | ||
|
|
58df0ed9e3 | ||
|
|
b73dc0d410 | ||
|
|
cd3a32f372 | ||
|
|
600fdcb4b4 | ||
|
|
57c50e6876 | ||
|
|
449d0c4965 | ||
|
|
2186f1f3a6 | ||
|
|
533273769d | ||
|
|
cc4856ceac | ||
|
|
3f6d74dee1 | ||
|
|
92bdd32f86 | ||
|
|
545205e28d | ||
|
|
20b4d1425b | ||
|
|
43611ecc55 | ||
|
|
f97cdad353 | ||
|
|
315cb8e663 | ||
|
|
ee174f8265 | ||
|
|
240aa5d6d0 | ||
|
|
d77abbef7c | ||
|
|
1edb65df77 | ||
|
|
baa69e66a1 | ||
|
|
d7f2d09b88 | ||
|
|
e124fbcf68 | ||
|
|
ebff6f8167 | ||
|
|
7f39adf9f5 | ||
|
|
67bd3e6e53 | ||
|
|
c0155b8293 | ||
|
|
175d8936b9 | ||
|
|
081f79a9a1 | ||
|
|
e404fe1049 | ||
|
|
c69115f1b7 | ||
|
|
a564850e23 | ||
|
|
453549ece1 | ||
|
|
be2a754342 | ||
|
|
0cb8a5c2b2 | ||
|
|
40cccee266 | ||
|
|
1abc831df1 | ||
|
|
b20c3ac104 | ||
|
|
172cb05c78 | ||
|
|
2bf561bafe | ||
|
|
0ca12de2f4 | ||
|
|
e3038edd38 | ||
|
|
b14d22b55e | ||
|
|
871c8c021b | ||
|
|
33e7e1e41b | ||
|
|
320c2e4471 | ||
|
|
901431f17a | ||
|
|
fb062b5ec4 | ||
|
|
84488adf12 | ||
|
|
b835e52bcb | ||
|
|
214b58b642 | ||
|
|
df4d3dfd66 | ||
|
|
302cc50a4b | ||
|
|
2f23e30e20 | ||
|
|
e1457a8e57 | ||
|
|
f047dce10c | ||
|
|
ebaa265993 | ||
|
|
88dc4852c6 | ||
|
|
3df3f874db | ||
|
|
692beff782 | ||
|
|
7a9e91b7ce | ||
|
|
00a186b37c | ||
|
|
826d444afa | ||
|
|
3a917324f1 | ||
|
|
6475b7b12d | ||
|
|
f9dd30b8b9 | ||
|
|
f405452c37 | ||
|
|
7500f37846 | ||
|
|
73c29f5264 | ||
|
|
8a2f3378f6 | ||
|
|
8bf0e2260e | ||
|
|
76a63070c5 | ||
|
|
cec1e2d77c | ||
|
|
9c4512db6a | ||
|
|
9ab8795008 | ||
|
|
9ed5448fe6 | ||
|
|
2caeb12024 | ||
|
|
6e669a1377 | ||
|
|
2998c62099 | ||
|
|
dd47143d6e | ||
|
|
c90a191d83 | ||
|
|
115461683b | ||
|
|
9c70eab606 | ||
|
|
5946bdfcf7 | ||
|
|
7b0df1db37 | ||
|
|
73af3affac | ||
|
|
3c3a7fcd55 | ||
|
|
8fa397bb5e | ||
|
|
cc37a42298 | ||
|
|
6551b703cb | ||
|
|
f24b592705 | ||
|
|
10da46beab | ||
|
|
d7911ad09d | ||
|
|
cc5ac2e68a | ||
|
|
080042d941 | ||
|
|
9ff2720721 | ||
|
|
e4e78796a1 | ||
|
|
ba6d0ff13f | ||
|
|
2bc095ecac | ||
|
|
2477667c27 | ||
|
|
e8e7465e4e | ||
|
|
04e1d7dbfa | ||
|
|
b4b802a547 | ||
|
|
ca7f6aa414 | ||
|
|
899978e53d | ||
|
|
cb5f0d1bec | ||
|
|
110c2e0be2 | ||
|
|
93f193c5d6 | ||
|
|
578df0f927 | ||
|
|
28eb43893a | ||
|
|
058b0778a2 | ||
|
|
ea24496b11 | ||
|
|
939cc11d7b | ||
|
|
d3a2671fd7 | ||
|
|
cc973c72c0 | ||
|
|
1d73a40a07 | ||
|
|
a8ff0ef1de | ||
|
|
2c2e19c4ea | ||
|
|
caf9fd89d4 | ||
|
|
f4d58e669b | ||
|
|
5c4a381d87 | ||
|
|
9ec1fb549e | ||
|
|
6df6f6dabc | ||
|
|
60dd86ab88 | ||
|
|
049f895b44 | ||
|
|
102194460c | ||
|
|
4dc497c8ca | ||
|
|
9fa73f50d6 | ||
|
|
6a135c3982 | ||
|
|
5641ea0f7e | ||
|
|
3440f9dd11 | ||
|
|
0b5094dd8f | ||
|
|
863ad5249c | ||
|
|
8bae21dea2 | ||
|
|
1a52cc0817 | ||
|
|
fa82710e6b | ||
|
|
8c462cef17 | ||
|
|
f7b84ea887 | ||
|
|
94b8b8b31c | ||
|
|
22dcae616c | ||
|
|
dc35288a1b | ||
|
|
bca08f8749 | ||
|
|
678bccd4d1 | ||
|
|
f50850f001 | ||
|
|
8c51e9c1cf | ||
|
|
34a9861681 | ||
|
|
f8bddeeaf4 | ||
|
|
f14e1a2ea3 | ||
|
|
7edc07d235 | ||
|
|
a3005d1c02 | ||
|
|
2bf4353b1b | ||
|
|
4ead51a5fe | ||
|
|
ae13afd3d7 | ||
|
|
6bada2aca9 | ||
|
|
1324cf16ce | ||
|
|
584b74e5e1 | ||
|
|
5279fc774a | ||
|
|
8fe256372c | ||
|
|
ea35a12f8b | ||
|
|
f89a04ad2f | ||
|
|
5ad8c5c462 | ||
|
|
ab5a64c6f7 | ||
|
|
a903020610 | ||
|
|
d7489a20be | ||
|
|
365a7f7bc4 | ||
|
|
0798143bad | ||
|
|
8c0daaa5db | ||
|
|
86601fd45f | ||
|
|
e1b2f5155a | ||
|
|
db920660bc | ||
|
|
e962577eca | ||
|
|
730defabca | ||
|
|
fad49a07a8 | ||
|
|
ffe91a1936 | ||
|
|
5cc1512a28 | ||
|
|
3cf676cbbd | ||
|
|
5a90503542 | ||
|
|
62c9eb58be | ||
|
|
524a23b488 | ||
|
|
517aad5e5e | ||
|
|
a34feddd4a | ||
|
|
855d8c0d5d | ||
|
|
29cb0bd57a | ||
|
|
45e1e9b509 | ||
|
|
ec6de70e67 | ||
|
|
4a0dd3b597 | ||
|
|
47bd39d2d5 | ||
|
|
d2e063ea3f | ||
|
|
391369a3d0 | ||
|
|
945a3d3230 | ||
|
|
4e37dc3330 | ||
|
|
3815eab85e | ||
|
|
2934a795ad | ||
|
|
ff0f388daf | ||
|
|
c25aaaca53 | ||
|
|
9da23280e9 | ||
|
|
6b7a4a097d | ||
|
|
60eff4f8ab | ||
|
|
409d8b3f57 | ||
|
|
10e20f896a | ||
|
|
db92d1dfe1 | ||
|
|
841741dc83 | ||
|
|
5035e02357 | ||
|
|
4dd1b83a6b | ||
|
|
f0ee25ca18 | ||
|
|
fed257a720 | ||
|
|
33fc30911e | ||
|
|
4391a69c7d | ||
|
|
3b37249c47 | ||
|
|
358ad38335 | ||
|
|
2e2f3ed2d2 | ||
|
|
3cb8b5b70e | ||
|
|
12dc5dfd82 | ||
|
|
e93d00b79e | ||
|
|
e6771e8e86 | ||
|
|
3e8fa3c5d7 | ||
|
|
73dd411763 | ||
|
|
b9e5bf3fec | ||
|
|
b529430ea2 | ||
|
|
3f78e1d087 | ||
|
|
7586f9d783 | ||
|
|
1cc8ae44c5 | ||
|
|
6e0144d7cd | ||
|
|
592f01eb1f | ||
|
|
f24ed30ae8 | ||
|
|
db9e496b35 | ||
|
|
2e9e8819b6 | ||
|
|
6bcfb6ba0b | ||
|
|
be0f712dd6 | ||
|
|
aefcd2565f | ||
|
|
22853336b6 | ||
|
|
940010dcc2 | ||
|
|
53c887d353 | ||
|
|
158ea8c8ea | ||
|
|
810b4e11f5 | ||
|
|
60a41af3ac | ||
|
|
19a64fbaa5 | ||
|
|
1305753b71 | ||
|
|
ec4b31a46f | ||
|
|
e94915ced3 | ||
|
|
3fff8c8ea0 | ||
|
|
ad8e1fd2c2 | ||
|
|
3054096995 | ||
|
|
3475cdfe49 | ||
|
|
7e3cd93425 | ||
|
|
92ec1e11b1 | ||
|
|
1dd139c1c6 | ||
|
|
24fa3d1c9d | ||
|
|
e10d0534ae | ||
|
|
f64dedfb52 | ||
|
|
b51a847306 | ||
|
|
0ab2302720 | ||
|
|
d77d145405 | ||
|
|
451663b6ff | ||
|
|
efba5d9f14 | ||
|
|
84e768f41a | ||
|
|
f2d5b2d8e4 | ||
|
|
9f1ea75504 | ||
|
|
579a4b7353 | ||
|
|
b6a839a3c6 | ||
|
|
dbbaa44f92 | ||
|
|
9c9cb91593 | ||
|
|
e665877737 | ||
|
|
d54f857929 | ||
|
|
b78cea3caf | ||
|
|
5b6d369e65 | ||
|
|
61c8cf13f7 | ||
|
|
f0d10389d0 | ||
|
|
61562e59f0 | ||
|
|
78a0d7916c | ||
|
|
d75e8381b6 | ||
|
|
343a5ea260 | ||
|
|
203366c662 | ||
|
|
93a13e5eb9 | ||
|
|
a7db793c95 | ||
|
|
55576a7c84 | ||
|
|
ccc72be4b9 | ||
|
|
14e31d4679 | ||
|
|
c78ecbef31 | ||
|
|
7d5bee80b8 | ||
|
|
bed46a58d8 | ||
|
|
5d00618c53 | ||
|
|
9fb43ff2d3 | ||
|
|
71ca2253c9 | ||
|
|
e6d9474df1 | ||
|
|
2e57098ad9 | ||
|
|
5d2465d1ea | ||
|
|
ea30e3f37a | ||
|
|
90f97e846f | ||
|
|
ac6b9ef9eb | ||
|
|
a311ec41c3 | ||
|
|
fb41713930 | ||
|
|
bd207b8d88 | ||
|
|
47f2bfdd23 | ||
|
|
ca90b0bc75 | ||
|
|
785194209b | ||
|
|
ff25847b02 | ||
|
|
a807071ed2 | ||
|
|
fb316e4d5a | ||
|
|
4bf66d9571 | ||
|
|
3f998bdb88 | ||
|
|
bd6d54ac33 | ||
|
|
349e8f60df | ||
|
|
66f49b97e5 | ||
|
|
25825685c3 | ||
|
|
7e1b9b4cee | ||
|
|
22c755b02a | ||
|
|
01cbe69eb4 | ||
|
|
604420f07a | ||
|
|
7e29e6c25c | ||
|
|
9e28e10e5f | ||
|
|
c56f60d4ef | ||
|
|
33520e8fd3 | ||
|
|
e3baf69db5 | ||
|
|
877fcc2740 | ||
|
|
b0de9c026f | ||
|
|
23d956ba15 | ||
|
|
afa49bf1f1 | ||
|
|
85b3606eca | ||
|
|
ba31dd0563 | ||
|
|
1da976ecb6 | ||
|
|
bd12b0a594 | ||
|
|
78f31fb9a3 | ||
|
|
89dc3bf671 | ||
|
|
a0e46a05e5 | ||
|
|
8bc86c8567 | ||
|
|
497530d6a9 | ||
|
|
b9330a9674 | ||
|
|
ce399e4153 | ||
|
|
2334bd846a | ||
|
|
737dd41ff1 | ||
|
|
80a4b3ab5e | ||
|
|
3ea1e279c6 | ||
|
|
6d26634b1b | ||
|
|
b673e22cb7 | ||
|
|
e0b1334d2d | ||
|
|
385c5d9e9e | ||
|
|
6b7536b95c | ||
|
|
23e991722c | ||
|
|
f702a819fa | ||
|
|
d92a883f8a | ||
|
|
b7b9835bf9 | ||
|
|
2817ca1d3f | ||
|
|
950967f101 | ||
|
|
b6b91297ae | ||
|
|
781d25de08 | ||
|
|
14907551d7 | ||
|
|
219fd3009f | ||
|
|
5a165add2c | ||
|
|
b57ea2d628 | ||
|
|
27c4ec759c | ||
|
|
5f1a7f04f1 | ||
|
|
0ed913a114 | ||
|
|
4382351569 | ||
|
|
dc1cbfcf4a | ||
|
|
19fed894d9 | ||
|
|
aaca62f461 | ||
|
|
a2f1ecc6b9 | ||
|
|
568f2dea8a | ||
|
|
84a194db80 | ||
|
|
c3d69d74c2 | ||
|
|
33b79edd18 | ||
|
|
2460278cd9 | ||
|
|
60734cbb03 | ||
|
|
10fa397013 | ||
|
|
20e4da5bb5 | ||
|
|
e3b2552d8d | ||
|
|
ae7d8fb22e | ||
|
|
911c6acad2 | ||
|
|
8c9f4468af | ||
|
|
9b5b324fe8 | ||
|
|
6a038c89ca | ||
|
|
fc0226d4cb | ||
|
|
503a4bac89 | ||
|
|
57c987da88 | ||
|
|
8b862da084 | ||
|
|
f5a43c1cfe | ||
|
|
114def1c11 | ||
|
|
108ad5c7b5 | ||
|
|
ae01e066ca | ||
|
|
2fd9c5fcc7 | ||
|
|
a47a7e9767 | ||
|
|
f58c433fa4 | ||
|
|
c5cd39becc | ||
|
|
8f6d4fc03e | ||
|
|
51841fc608 | ||
|
|
a869467efb | ||
|
|
a2515119b7 | ||
|
|
f62a3175f7 | ||
|
|
08a1f1e5e2 | ||
|
|
e428002898 | ||
|
|
28d97d2221 | ||
|
|
41924a438c | ||
|
|
7c34b214af | ||
|
|
2dd777d74a | ||
|
|
c83d03e775 | ||
|
|
4ccad3bc3c | ||
|
|
d6a69ea157 | ||
|
|
7b75f40e28 | ||
|
|
fa3a199e78 | ||
|
|
87fc08b06e | ||
|
|
89d237e129 | ||
|
|
239733a33e | ||
|
|
229489271d | ||
|
|
1249263b94 | ||
|
|
e643e43828 | ||
|
|
64340036a3 | ||
|
|
cf8727a809 | ||
|
|
695db084e2 | ||
|
|
5068c13c37 | ||
|
|
43604b951d | ||
|
|
9a61f57f9e | ||
|
|
653ce1c511 | ||
|
|
9570e0fd18 | ||
|
|
f25839c4b5 | ||
|
|
d55cfb3737 | ||
|
|
c3ae936caa | ||
|
|
8910e88563 | ||
|
|
6b43583cad | ||
|
|
2f571c6eda | ||
|
|
296a8246ad | ||
|
|
34f0ec4980 | ||
|
|
cf612ce11e | ||
|
|
44420919f6 | ||
|
|
dd9b8f7a63 | ||
|
|
7ba81007a8 | ||
|
|
2dc357e55b | ||
|
|
cb7bb90eb6 | ||
|
|
cbc5521958 | ||
|
|
fbdb7b6c01 | ||
|
|
ce7422a506 | ||
|
|
2b987bdc3d | ||
|
|
8d8df2ab81 | ||
|
|
463b054a0a | ||
|
|
74d883a443 | ||
|
|
36d34b7d1d | ||
|
|
d4dc81a932 | ||
|
|
e3a37d6ad0 | ||
|
|
86ba48c415 | ||
|
|
36278517dd | ||
|
|
fdaf98499f | ||
|
|
dbd51b7fc8 | ||
|
|
8ccfb9eea4 | ||
|
|
d488e9ab40 | ||
|
|
189d1eae11 | ||
|
|
60f430ced8 | ||
|
|
3b15c4b597 | ||
|
|
116f5dc494 | ||
|
|
33e72927f9 | ||
|
|
657dd7c6f5 | ||
|
|
eda8dba8bc | ||
|
|
b31c37951f | ||
|
|
12e63b1dcb | ||
|
|
8029c235e4 | ||
|
|
d310f374b4 | ||
|
|
fedfdd2db9 | ||
|
|
ccf16b823b | ||
|
|
92b24cc9a6 | ||
|
|
fb4cab6a0e | ||
|
|
68fca1bd9f | ||
|
|
48731b227b | ||
|
|
39ade43a72 | ||
|
|
bc6629d400 | ||
|
|
871d0a56b9 | ||
|
|
6a88d21f10 | ||
|
|
ac96249f7d | ||
|
|
3858475c5c | ||
|
|
0f47212477 | ||
|
|
1bc58284a0 | ||
|
|
bd63c356a6 | ||
|
|
420f4a6c66 | ||
|
|
6292cc4130 | ||
|
|
3e59898b09 | ||
|
|
2fc84d249c | ||
|
|
e4ba4cbb2c | ||
|
|
44eb8d17f2 | ||
|
|
7db1d12aed | ||
|
|
01925fc134 | ||
|
|
86fab2c24e | ||
|
|
dd47f3841c | ||
|
|
688eddfa77 | ||
|
|
8460cbe123 | ||
|
|
ea035628aa | ||
|
|
7d29f3d7ea | ||
|
|
864a3c3cc7 | ||
|
|
4eda56acf1 | ||
|
|
72520225d9 | ||
|
|
249499cf14 | ||
|
|
093c89c058 | ||
|
|
d5662230d5 | ||
|
|
e390b88b6c | ||
|
|
3cd4f4f7b3 | ||
|
|
7f7c84d404 | ||
|
|
7451a713e5 | ||
|
|
2435aac81d | ||
|
|
2d14568caf | ||
|
|
475b2a08e2 | ||
|
|
38b435a7ac | ||
|
|
de4b719a52 | ||
|
|
1db4f01e95 | ||
|
|
c20ee82def | ||
|
|
bc44cbac46 | ||
|
|
2a185c8c9e | ||
|
|
d5ebe6c7e8 | ||
|
|
a825060a45 | ||
|
|
60fcc58e30 | ||
|
|
d111694185 | ||
|
|
3ac2dc807f | ||
|
|
e94ae9b2b7 | ||
|
|
3fd7768e28 | ||
|
|
d90a5db1c4 | ||
|
|
15beb497c1 | ||
|
|
e2b0102fdb | ||
|
|
8f77ad333b | ||
|
|
4ae5598c2d | ||
|
|
393d32bcd9 | ||
|
|
a9bfa69e67 | ||
|
|
7633700e66 | ||
|
|
749bcce438 | ||
|
|
f81bf8576a | ||
|
|
afb534d4ee | ||
|
|
65a470cdac | ||
|
|
664d4844c3 | ||
|
|
3cde5b5881 | ||
|
|
764b4cc2e6 | ||
|
|
0bf4f5781a | ||
|
|
677a666d9b | ||
|
|
a25b4ed0f7 | ||
|
|
f50ea81cbc | ||
|
|
31dc0ec0ef | ||
|
|
52b1fce8c1 | ||
|
|
7175f04609 | ||
|
|
50ca6a6269 | ||
|
|
ce1075b9b8 | ||
|
|
aeafb9a6f1 | ||
|
|
3f397a87a2 | ||
|
|
d4873aa627 | ||
|
|
d8b59ecca5 | ||
|
|
f50debc5f7 | ||
|
|
a09016835c | ||
|
|
dd146a09b1 | ||
|
|
7ec9add043 | ||
|
|
246cbc115e | ||
|
|
2f7c122c44 | ||
|
|
b7985f4a6d | ||
|
|
4f65948347 | ||
|
|
176f815519 | ||
|
|
9b3664c532 | ||
|
|
bbe8b73cd4 | ||
|
|
3c68ef4ddf | ||
|
|
6270573095 | ||
|
|
9377a4ead3 | ||
|
|
f0801c2774 | ||
|
|
913a8df7f3 | ||
|
|
f281268efe | ||
|
|
490fa4e2e5 | ||
|
|
d4b0462a5a | ||
|
|
e31ba2c5a5 | ||
|
|
602674573c | ||
|
|
b3e6971ea8 | ||
|
|
b5b4b41b99 | ||
|
|
637b202559 | ||
|
|
fa84291551 | ||
|
|
d49bb5a655 | ||
|
|
f75e08c1bb | ||
|
|
0708b4a262 | ||
|
|
776c0e2ebb | ||
|
|
eecf6d8f8a | ||
|
|
28acf1174a | ||
|
|
490b685f3a | ||
|
|
bbb9c40c50 | ||
|
|
132d0c22d7 | ||
|
|
4cdaa1d1ad | ||
|
|
b6b43b6b0a | ||
|
|
19eea79836 | ||
|
|
a14e39893c | ||
|
|
2f26760135 | ||
|
|
73b229bb95 | ||
|
|
a9570a4dae | ||
|
|
29b937989a | ||
|
|
a5d4b7d54a | ||
|
|
440cae9c6d | ||
|
|
4ad8537113 | ||
|
|
5706645032 | ||
|
|
9fcea1ca1c | ||
|
|
adb077a714 | ||
|
|
c59608ff2c | ||
|
|
0b676ddb74 | ||
|
|
519ce1dc96 | ||
|
|
76604da31b | ||
|
|
e42d765f5c | ||
|
|
b4c29e2226 | ||
|
|
041452f2ff | ||
|
|
e3ecba7133 | ||
|
|
4711bcc4e6 | ||
|
|
0cf4a93f9c | ||
|
|
62a016a2f8 | ||
|
|
96467b9468 | ||
|
|
be687f8ded | ||
|
|
8161066657 | ||
|
|
c0d6a4eb47 | ||
|
|
66c6dfdadc | ||
|
|
49babd731a | ||
|
|
623dfce255 | ||
|
|
4d5fd5cfe6 | ||
|
|
09579ea285 | ||
|
|
a759eb1b6b | ||
|
|
0f4f635686 | ||
|
|
c7ba96457a | ||
|
|
3cae7e4eb5 | ||
|
|
11f8b7856e | ||
|
|
83374ba6cc | ||
|
|
3add5dc5b8 | ||
|
|
1a81a5a2b6 | ||
|
|
afbee83b51 | ||
|
|
cd290b8dc6 | ||
|
|
dc1027804e | ||
|
|
4bf937ffd3 | ||
|
|
d0770517dc | ||
|
|
66ea73e766 | ||
|
|
6ec0a0a028 | ||
|
|
f7328e7c60 | ||
|
|
50c3a7e1ba | ||
|
|
0b9d328bdf | ||
|
|
1548d444c6 | ||
|
|
b4d5fb1170 | ||
|
|
0e828cd44a | ||
|
|
462d02efd5 | ||
|
|
abdea48e8d | ||
|
|
8c1dc50cf3 | ||
|
|
2e313172d1 | ||
|
|
06eb745f8b | ||
|
|
0c002c1ca1 | ||
|
|
cee0464e69 | ||
|
|
ae52141952 | ||
|
|
9d3862b215 | ||
|
|
de1b956d43 | ||
|
|
c92f3ce2b9 | ||
|
|
1aa2dfcdff | ||
|
|
aee562d998 | ||
|
|
5e617f0c16 | ||
|
|
de81a16ceb | ||
|
|
813763a02a | ||
|
|
ea1d06448b | ||
|
|
f7b8159d30 | ||
|
|
0044c01ed0 | ||
|
|
d4fc74e601 | ||
|
|
f6cd60ec61 | ||
|
|
94a5dfedf6 | ||
|
|
3525e434a4 | ||
|
|
632fc0bdfa | ||
|
|
ae48f46c9f | ||
|
|
94f003153d | ||
|
|
251645d2d8 | ||
|
|
67a1278554 | ||
|
|
d45e2b4358 | ||
|
|
4687f17d09 | ||
|
|
8f447e58f4 | ||
|
|
b47323edf3 | ||
|
|
44d05e77cb | ||
|
|
929f50dc55 | ||
|
|
0181656b4f | ||
|
|
a90d5fbe39 | ||
|
|
690f386bff | ||
|
|
00045a9764 | ||
|
|
24575223a0 | ||
|
|
550d8add97 | ||
|
|
82f0479928 | ||
|
|
506d310211 | ||
|
|
f7afcb6cdc | ||
|
|
3e3c7e237a | ||
|
|
29bb641524 | ||
|
|
d92af5559e | ||
|
|
38031b0fa0 | ||
|
|
9eec68f7c5 | ||
|
|
7b50e92baf | ||
|
|
64f4e8ee34 | ||
|
|
0ce42a01e2 | ||
|
|
a546ee4c3b | ||
|
|
6d4748c62a | ||
|
|
525c56fdb1 | ||
|
|
fac3153472 | ||
|
|
b907881740 | ||
|
|
a67f6300f8 | ||
|
|
b8948fdff0 | ||
|
|
2563ad7be3 | ||
|
|
627b9602d1 | ||
|
|
5daaf1a5fa | ||
|
|
a3d04eaad8 | ||
|
|
d67544af5e | ||
|
|
9585c21f14 | ||
|
|
4a7a5ae5b6 | ||
|
|
4088e36f74 | ||
|
|
e8c41bca2d | ||
|
|
8307510fc3 | ||
|
|
011195012b | ||
|
|
f6d1febea8 | ||
|
|
c38e538a95 | ||
|
|
d03f7b3f5d | ||
|
|
53c92d4d37 | ||
|
|
57de615717 | ||
|
|
dcbb6d249f | ||
|
|
966fbd1880 | ||
|
|
68b0f8bdf9 | ||
|
|
10ff84497f | ||
|
|
c3ea54dcd4 | ||
|
|
ab168ee4f4 | ||
|
|
f702d1b988 | ||
|
|
3545ffa522 | ||
|
|
d5a23a7c65 | ||
|
|
26fcec530f | ||
|
|
842cb078fc | ||
|
|
11580f9f83 | ||
|
|
f4dea3f705 | ||
|
|
8b3aae399a | ||
|
|
c73cee3962 | ||
|
|
bcdce8ef6c | ||
|
|
787ab1c90c |
@@ -3,6 +3,8 @@
|
|||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
node_modules.broken-by-path
|
node_modules.broken-by-path
|
||||||
.env.local
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
# 按本机联调环境填写国内 Gateway origin,不提交真实环境地址。
|
# 按本机联调环境填写统一 Gateway origin,不提交真实环境地址。
|
||||||
API_PROXY_TARGET=http://localhost:9000
|
API_PROXY_TARGET=http://localhost:9000
|
||||||
# 海外业务浏览器请求使用独立代理,代理会将 /overseas-api 重写为海外 Gateway 的 /api。
|
|
||||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
|
||||||
# 按本机联调环境填写海外 Gateway origin,不提交真实环境地址。
|
|
||||||
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
|
|
||||||
# Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead.
|
# Local Vite fallback. Container deployments should use CLIENT_WEB_URL instead.
|
||||||
VITE_CLIENT_WEB_URL=http://localhost:5174
|
VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||||
|
|
||||||
|
# Test container deployment (Jenkins environment or untracked .env).
|
||||||
|
# API_UPSTREAM: unified Gateway origin, no /api suffix.
|
||||||
|
# TEST_NETWORK: shared test Docker network.
|
||||||
|
API_UPSTREAM=http://gateway:9000
|
||||||
|
TEST_NETWORK=firstleg-test-network
|
||||||
|
IMAGE_TAG=
|
||||||
|
CLIENT_WEB_URL=
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ dist/
|
|||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
.playwright-cli/
|
.playwright-cli/
|
||||||
|
.idea/
|
||||||
.impeccable/
|
.impeccable/
|
||||||
.codex-temp/
|
.codex-temp/
|
||||||
.codex-tmp/
|
.codex-tmp/
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
## 快速读取顺序
|
## 快速读取顺序
|
||||||
|
|
||||||
1. 用“模块快速路由”选择最小业务模块,进入后读取该目录最近的 `AGENTS.md`;按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不先扫描全部 `src/`。
|
1. 由“模块快速路由”选最小模块并读最近 `AGENTS.md`,按 `routes.ts → pages/ → api/types/stores/composables` 下钻,不扫描全部 `src/`。
|
||||||
2. 页面、布局或交互任务同时读取 `../../docs/Admin-Client前端原型还原验收规则.md` 和后台原型 `../../docs/悦/货代SAAS平台后台/AGENTS.md`。
|
2. Admin 样式唯一入口为 [Admin 界面规范](../../docs/Admin高密度界面规范.md#按任务阅读),按任务只读对应章节;提示小字、加载布局、蓝白风格及密度不再分散查多份规范。样板目录仅在需要视觉参考时查,模块核对记录仅在核对范围、业务例外或验收证据时查。
|
||||||
3. 标准列表或公共组件任务读取 `src/shared/components/AGENTS.md`;国内/海外 HTTP、Token 或刷新任务读取 `src/shared/api/AGENTS.md`;Finance 任务读取 `src/modules/finance/AGENTS.md`;四期任务先读 `../../docs/四期/README.md`。
|
视觉、布局/交互承载变更或真实页面验收再读 [前端验收规则](../../docs/Admin-Client前端原型还原验收规则.md);需原型依据再读 `../../docs/悦/货代SAAS平台后台/AGENTS.md` 并定位片段。纯文案、逻辑或单元测试不触发验收矩阵。
|
||||||
4. 平台/客户层级与权限公共语义读取 `../../docs/平台客户层级与权限契约.md`;双区域 API、海外 Token 和跨区事件读取 `../../docs/跨区域认证与服务通信契约.md`。
|
3. 标准列表/公共组件读 `src/shared/components/AGENTS.md`;HTTP、Token/刷新读 `src/shared/api/AGENTS.md`;Finance 读 `src/modules/finance/AGENTS.md`;海外仓全流程先读 `../../docs/海外仓业务/README.md`。
|
||||||
|
4. 平台/客户层级及权限读 `../../docs/平台客户层级与权限契约.md`;统一 API、Token、会话撤销或可靠事件按需读 `../../docs/统一认证与服务通信契约.md` 对应章节。
|
||||||
|
|
||||||
|
单端仅读本指引、最近模块指引及命中代码;跨服务状态、权限或接口边界不明才加后端契约。预算遵循根指引,先读适用规则、定向查业务表,跳过无关专题。
|
||||||
|
|
||||||
## 项目定位与技术基线
|
## 项目定位与技术基线
|
||||||
|
|
||||||
@@ -28,68 +31,85 @@
|
|||||||
|
|
||||||
- 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。
|
- 依赖方向为 `app → modules/core/shared`、`modules → core/shared`、`core → shared`、`shared → 无上层依赖`。模块间只通过对方公开入口或契约协作,不引用其他模块的页面和内部目录。
|
||||||
- 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。
|
- 路由级页面放 `modules/*/pages/`,模块路由放 `modules/*/routes.ts` 并由 `app/router/index.ts` 装配;新增模块同步维护 `core/navigation/adminNavigation.ts`。
|
||||||
- 领域接口放 `modules/*/api/`;国内、海外公共客户端分别为 `shared/api/http.ts`、`shared/api/overseasHttp.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
|
- 领域接口放 `modules/*/api/`;全部业务共用 `shared/api/http.ts`,具体 Token 与刷新规则见 `shared/api/AGENTS.md`。
|
||||||
- `VITE_API_BASE_URL`、`VITE_OVERSEAS_API_BASE_URL` 分别控制国内、海外浏览器前缀;开发代理上游只由 Vite 读取。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
|
- `VITE_API_BASE_URL` 控制唯一浏览器前缀;`API_PROXY_TARGET` 由 Vite 读取并指向统一 Gateway。环境变量变化同步 `.env.example`,不提交真实地址或凭据。
|
||||||
|
|
||||||
## 模块快速路由
|
## 模块快速路由
|
||||||
|
|
||||||
| 业务 | Admin 入口 | 联查 |
|
按短表定位后按需查 [业务细则](docs/business-index.md);涉及业务行为变更时必须核对对应细则及最近模块指引。从应用根执行 `rg -n -F '任务词或文件名' docs/business-index.md`,只读命中条款。
|
||||||
|---|---|---|
|
|
||||||
| 登录、会话与双区域 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
|
||||||
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
|
||||||
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`file/`、`firstleg/customerstatistics/` |
|
|
||||||
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
|
||||||
| 平台仓库与仓储资源 | `modules/warehouses/`、`modules/warehouse-resources/` | `warehouse/` |
|
|
||||||
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
|
||||||
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
|
||||||
| 订单列表、详情与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/` | `order/`、`firstleg/`、Finance |
|
|
||||||
| 入库、测量与计价主数据 | `modules/inbounds/`、`modules/fba-pricing/`、`modules/dimension-config/`、`modules/fee-items/` | `firstleg/`、`order/`、`file/`、`integration-service/` |
|
|
||||||
| 财务 | `modules/finance/`,继续读该目录 `AGENTS.md` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/` |
|
|
||||||
| 货柜、排柜与装柜清单 | `modules/container-loading/`、`modules/loading-lists/` | `firstleg/loading/`、`firstleg/split/`、`warehouse/`、`file/` |
|
|
||||||
| 智能装柜按入仓码候选与箱数分配 | `modules/container-loading/pages/SmartLoadingWorkbenchView.vue`、`api/platformContainerLoading.ts` | Backend `firstleg/` 的 `PlatformSmartLoadingController`、`SmartLoadingService`;`GlobalSmartLoadingView.vue` 当前未注册路由,不作为正式入口 |
|
|
||||||
| 三期订舱、出仓前船期 | `modules/ocean-bookings/`、`modules/port-schedules/` | `firstleg/oceanbooking/`、`firstleg/portschedule/`;出仓后节点归四期 |
|
|
||||||
| 拖车、报关、国内出仓 | `modules/trucking/`、`modules/customs/`、`modules/outbound-dispatch/` | `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
|
||||||
| 主数据、下载、审计 | `modules/master-data/`、`modules/downloads/`、`modules/audit/` | `warehouse/`、`file/`、`audit-service/` |
|
|
||||||
| 四期海外接柜、入库与库存 | `modules/overseas-warehouse/` | `gateway-overseas/`、`lastleg-service/`、`warehouse-overseas-service/`;不含出库和派送 |
|
|
||||||
| 四期海外采购、卡车、散板 | `modules/overseas-business/` | `gateway-overseas/`、`lastleg-service/` |
|
|
||||||
| 四期合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
|
||||||
| 四期国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;十节点贯通到港、卸船、清关、提柜与还柜,标准见 `../../docs/四期/船节点标准化契约.md` |
|
|
||||||
|
|
||||||
全量验收按业务从 `../../docs/一期/`、`二期/`、`三期/`、`财务管理/` 选择最小契约;四期开发先读 `../../docs/四期/README.md`。进度、排期或验收状态问题再读取进度表和报告。
|
路径从应用根起算,`modules/`、`core/` 从 `src/` 起算;接口片段与符号沿同一行代码定位。模块指引中的后端类名以服务名起算,如 `firstleg/service/X.java` 即 `baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/X.java`。
|
||||||
|
|
||||||
## 四期海外模块内部快速索引
|
| 业务 | 首个入口 |
|
||||||
|
|---|---|
|
||||||
|
| 登录、会话与统一 Token | `modules/auth/AGENTS.md`、`core/auth/` |
|
||||||
|
| 启动与懒加载资源恢复 | `src/main.ts`、`core/routing/navigationRecovery.ts` |
|
||||||
|
| 工作台与指标 | `modules/dashboard/AGENTS.md` |
|
||||||
|
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` |
|
||||||
|
| 船期日历 | `modules/sailing-calendar/AGENTS.md` |
|
||||||
|
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/AGENTS.md`、`modules/overseas-warehouse/` |
|
||||||
|
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts`、`createManualInboundQuote` |
|
||||||
|
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/AGENTS.md` |
|
||||||
|
| 客户、企业审核、客户统计 | `modules/customers/AGENTS.md` |
|
||||||
|
| 供应商、国内整柜 | `modules/suppliers/AGENTS.md`、`modules/domestic-containers/AGENTS.md` |
|
||||||
|
| 平台仓库与仓储资源 | `modules/warehouses/AGENTS.md`、`modules/warehouse-resources/AGENTS.md` |
|
||||||
|
| 平台用户、RBAC、导航 | `modules/iam/AGENTS.md`、`core/access/` |
|
||||||
|
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` |
|
||||||
|
| 消息中心 | `modules/notifications/AGENTS.md` |
|
||||||
|
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/AGENTS.md`、`modules/first-leg-orders/` |
|
||||||
|
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`modules/unified-orders/api/unifiedOrders.ts` |
|
||||||
|
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||||
|
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | `modules/inbounds/AGENTS.md`、`modules/dimension-config/AGENTS.md` |
|
||||||
|
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/DropshipPalletPrintPanel.vue` |
|
||||||
|
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、`modules/inbounds/core/api.ts` |
|
||||||
|
| 客户专属价格簿、柜级阶梯运费与受控调价 | `modules/fba-pricing/AGENTS.md`、`modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue` |
|
||||||
|
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/AGENTS.md` |
|
||||||
|
| 货柜双工作台、排柜、预装/装柜清单、装柜照片与证据、真实柜号后补与整柜报关 | `modules/container-loading/AGENTS.md`、`modules/loading-lists/AGENTS.md` |
|
||||||
|
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`modules/unified-orders/pages/UnifiedOrdersView.vue` |
|
||||||
|
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue`、`modules/container-loading/pages/GlobalSmartLoadingView.vue` |
|
||||||
|
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/AGENTS.md` |
|
||||||
|
| 订舱级拖车预约 | `modules/trucking/AGENTS.md` |
|
||||||
|
| 目的港码头拖车与还空预警 | `modules/container-loading/AGENTS.md` |
|
||||||
|
| 历史报关案件、报关回执与国内出仓 | `modules/customs/AGENTS.md`、`modules/outbound-dispatch/AGENTS.md` |
|
||||||
|
| 主数据、下载、退避任务重试 | `modules/master-data/AGENTS.md`、`modules/downloads/AGENTS.md` |
|
||||||
|
| 费用项目、计费科目 | `modules/fee-items/AGENTS.md` |
|
||||||
|
| 操作日志、审计日志、操作人 | `modules/audit/AGENTS.md` |
|
||||||
|
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/AGENTS.md` |
|
||||||
|
| 海外采购、卡车、散板、海外费用明细与仓租 | `modules/overseas-business/AGENTS.md` |
|
||||||
|
| 海外仓业务合作仓与通用资料 | `modules/warehouses/AGENTS.md`、`modules/overseas-master-data/AGENTS.md` |
|
||||||
|
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/AGENTS.md`、`modules/ocean-bookings/` |
|
||||||
|
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` |
|
||||||
|
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` |
|
||||||
|
|
||||||
| 关键词 | 页面/路由入口 | API 与联查 |
|
## 海外模块内部快速索引(海外仓全流程)
|
||||||
|---|---|---|
|
|
||||||
| 尾程订单 | `modules/overseas-warehouse/pages/LastMileOrdersView.vue`、`LastMileOrderDetailView.vue` | `modules/overseas-warehouse/api/lastMileOrders.ts`;Backend `lastleg-service/` |
|
|
||||||
| 入仓单、入仓计划、FBA 预约 | `modules/overseas-warehouse/pages/InboundOrdersView.vue`、`InboundOrderDetailView.vue` | 列表/预约 `modules/overseas-warehouse/api/inboundOrders.ts`,计划列表/详情 `inboundPlans.ts`;Backend `lastleg-service/inboundplan/` |
|
|
||||||
| 排车查询与智能预排 | `modules/overseas-warehouse/pages/DispatchesView.vue`、`DispatchDetailView.vue`、`components/SmartDispatchDialog.vue` | `modules/overseas-warehouse/api/dispatches.ts`;Backend `lastleg-service/dispatch/`;覆盖详情、候选、草稿柜、托盘即时锁/解锁/整草稿释放和 `pre-arrange`,不据此开放出库编辑、取消、发车、PDA 出库或派送 |
|
|
||||||
| 库存余额、库位与流水 | `modules/overseas-warehouse/pages/InventoryView.vue`、`InventoryDetailView.vue`、`InventoryTransactionsView.vue` | `modules/overseas-warehouse/api/inventory.ts`;业务库存归 `lastleg-service/inventory/`,实物资源归 `warehouse-overseas-service/` |
|
|
||||||
| 托盘、箱唛、产品与换标 | `modules/overseas-warehouse/pages/PalletsView.vue`、`CartonsView.vue`、`ProductsView.vue`、`RelabelOrdersView.vue` 及详情页 | `modules/overseas-warehouse/api/pallets.ts`、`relabelOrders.ts`;路由是否公开以 `modules/overseas-warehouse/routes.ts` 为准 |
|
|
||||||
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
|
||||||
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
|
|
||||||
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
|
|
||||||
|
|
||||||
- 三个海外模块统一使用 `shared/api/overseasHttp.ts`;页面是否已接真实接口先查对应 `api/` 和挂载测试,不以 `PROTOTYPE_MATRIX.md` 或 `CONTRACT_GAPS.md` 代替当前代码与服务端契约。
|
海外仓作业先读 `src/modules/overseas-warehouse/AGENTS.md`;采购、车队等页面/API 联查按需查 `docs/business-index.md` 同名节。
|
||||||
|
|
||||||
## 业务、安全与共享装配
|
## 业务、安全与共享装配
|
||||||
|
|
||||||
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
- 当前只有平台、客户两级;Admin 不显示“租户”。平台仓库是公共主数据,不与客户绑定;客户筛选不得缩小仓库候选。完整语义见平台客户层级与权限契约。
|
||||||
|
- 新建客户时,公司联系电话同时初始化首个管理员登录手机号;创建后公司联系电话只属于企业资料,与首个管理员、成员及平台用户登录手机号独立。编辑客户资料不得提交账号手机号变更,账号手机号改动统一走用户管理并由 IAM/Auth 撤销对应会话。
|
||||||
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
- 菜单、列表路由和详情只读统一使用 `*:list`,写操作使用独立权限;前端守卫不能替代服务端鉴权。供应商固定使用 `platform:supplier:list`。
|
||||||
|
- 平台用户选择 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 时必须选择唯一实际海外收货仓,其他角色不得提交 `overseasWarehouseId`;编辑时保留已绑定仓库并按字符串透传长整型 ID,前端校验不能替代 IAM 服务端约束。
|
||||||
|
- 国内离仓状态按真实货柜展示:`ContainerDetailView.vue` 将权威状态传给 `ContainerCustomsPanel.vue`,页面不提供独立确认离仓按钮;报关完成不等于已离仓。不逐入仓提交离仓、不把拖车状态当阻断门禁或重复追加实际船节点。
|
||||||
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
- `core/navigation/adminNavigation.ts`、`app/router/index.ts` 和模块 `routes.ts` 是共享装配文件;并行任务不得覆盖总控已确认语义,重叠时保留现有改动并回传总控。
|
||||||
- 认证统一经过 `core/auth/auth.ts`、`shared/api/http.ts` 和 `shared/api/overseasHttp.ts`。登录/刷新原子替换国内、海外 Token,海外 401 统一刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
- `core/routing/navigationRecovery.ts` 只处理已识别的动态 import/Chunk 资源过期;普通路由错误直接提示。自动恢复前保护未保存表单,同一目标 30 秒内不得重复刷新或形成重载循环。
|
||||||
|
- 认证统一经过 `core/auth/auth.ts` 和 `shared/api/http.ts`。登录/刷新替换统一 Access/Refresh Token,全部 401 共用刷新并最多重放一次;页面不读写 Token 或手工拼 `Authorization`。
|
||||||
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
- 接入真实接口时删除对应模拟路径,不允许生产逻辑静默回退假数据。客户专属写操作显式选择客户,长整型 ID 按字符串透传,不从本地存储推断。
|
||||||
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
- 订舱代理、拖车承运商和报关代理提交服务端供应商 ID,从公共供应商列表选择 `ACTIVE` 且类别/范围匹配的记录;参数变化同步检查 API、类型、表单、展示、Mock/Fixture、测试和 OpenAPI。
|
||||||
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
- 删除、停用、改权限等高风险操作必须有确认、处理中、成功和失败反馈;失败保留用户上下文。日志、提示和示例不得包含真实账号、Token、隐私或生产数据。
|
||||||
- 页面和交互必须服从对应原型及验收规则;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
|
- 页面外观遵循 Admin 界面规范,业务及操作承载核对当前契约、逐操作确认与原型证据;Admin 当前只验收 `1920×1080`、`100%` 缩放。标准列表和共享组件实现继续读 `src/shared/components/AGENTS.md`。
|
||||||
|
|
||||||
## 开发验收身份
|
## 开发验收身份
|
||||||
|
|
||||||
- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。
|
- 开发环境人工验收身份为 `platform_admin`。密码从未提交的 `CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD` 或 `C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env` 读取;缺失时再向用户索取,不写入仓库、日志或快照。
|
||||||
- 由人工操作真实页面并截图,AI 核对加载、空态、错误态、权限和桌面布局;缺少截图时标记“待人工截图确认”,不自行批量取证。
|
- 视觉验收由人工操作真实页面并截图,AI 核对受影响的状态和桌面布局;缺少截图标记“待人工截图确认”,不自行批量取证。自动检查单独报告“自动检查通过/失败”;纯逻辑且无视觉变化标记“无需视觉验收”,不能据此声称真实页面验收通过。
|
||||||
|
|
||||||
## 命令与质量门禁
|
## 命令与质量门禁
|
||||||
|
|
||||||
|
- 国内仓、客户管理密度默认值统一维护在 `src/styles/adminDensityTokens.css`,通用按钮/页签/描述表/弹框规则统一维护在 `adminDensity.css`;页面只选择公共类与语义参数、保留业务布局,不复制通用外观或在页面重新定义 `--admin-*` 默认值。修改前沿实际引用读取模块 CSS,接管后删除冲突旧声明。
|
||||||
|
- 修改密度样式或接入消费者时更新并运行 `src/styles/adminDensity.test.ts` 维护合同;不得为通过检查删除权限、业务提交或错误处理断言。未接入菜单通过显式密度类隔离。
|
||||||
|
|
||||||
在 `apps/admin-web` 执行:
|
在 `apps/admin-web` 执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -100,7 +120,8 @@ pnpm typecheck
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
- 普通变更至少运行 `pnpm typecheck` 和受影响测试;交付前运行 `pnpm test` 与 `pnpm build`。当前没有 lint 脚本,不声称 lint 通过。
|
- 按 `../../docs/AI工程执行与协作约束.md` 的“验证范围选择”执行;命令表不是每轮清单。文案/纯展示核对差异,局部逻辑跑定向测试,不自动 typecheck、全量测试或构建。类型关系、模板绑定或模块引用可能受影响时,补最小类型检查;公共能力沿实际消费者验证。
|
||||||
|
- 构建和全量测试分别按实际影响触发,先说明扩大范围的原因;不因提交或对话收尾触发。仅复用同配置、同文件范围且相关输入未变的成功编译结果。纯文档/索引只检查路径、链接及索引合同。无 lint 脚本,不声称 lint 通过。
|
||||||
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
- 不手工修改 `dist/`、`node_modules/` 或生成声明;依赖变化同步 `package.json` 与锁文件。
|
||||||
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
- 测试环境全量验收入口为 `../../baiyue-firstleg/scripts/admin-test-env-acceptance.mjs`,模块说明见同仓库 `scripts/admin-test-env-modules/README.md`;它会写入或停用测试数据,只有本轮明确授权才能运行。
|
||||||
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
- `pnpm test/typecheck` 和本地回归脚本不代表真实服务验收完成;HTTP 验收后仍由人工完成页面和截图确认。
|
||||||
|
|||||||
@@ -1,107 +1,62 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent {
|
agent { label "${env.JOB_BASE_NAME?.endsWith('-prod') ? 'java-builder' : 'java-test'}" }
|
||||||
label "${env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'}"
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
DEVELOPMENT_COMPOSE_FILE = 'docker-compose.development.yml'
|
|
||||||
DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'
|
|
||||||
REGISTRY_HOST = '172.31.198.7:10082'
|
|
||||||
REGISTRY_CREDENTIALS = 'gitea-registry-token'
|
|
||||||
NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'
|
|
||||||
NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'
|
|
||||||
}
|
|
||||||
|
|
||||||
options {
|
options {
|
||||||
timestamps()
|
timestamps()
|
||||||
disableConcurrentBuilds()
|
disableConcurrentBuilds()
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
stage('确认提交版本') {
|
stage('检查测试发布参数') {
|
||||||
|
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
env.IS_DEVELOPMENT = env.JOB_BASE_NAME?.endsWith('-dev') ? 'true' : 'false'
|
if (env.JOB_BASE_NAME?.endsWith('-dev')) {
|
||||||
env.SOURCE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
error('此流水线仅支持 test 和本轮授权的 prod 灰度')
|
||||||
|
}
|
||||||
|
env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim()
|
||||||
}
|
}
|
||||||
sh 'git log -1 --oneline'
|
sh '''set -eu
|
||||||
|
export API_UPSTREAM="${API_UPSTREAM:-http://gateway:9000}"
|
||||||
|
export TEST_NETWORK="${TEST_NETWORK:-firstleg-test-network}"
|
||||||
|
: "${CLIENT_WEB_URL:?CLIENT_WEB_URL 未设置}"
|
||||||
|
docker network inspect "$TEST_NETWORK" >/dev/null
|
||||||
|
docker compose -f docker-compose.test.yml config --quiet
|
||||||
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('构建并部署测试前端') {
|
stage('构建并部署测试前端') {
|
||||||
when { expression { env.IS_DEVELOPMENT != 'true' } }
|
when { expression { !env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||||
steps {
|
steps {
|
||||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
sh '''set -eu
|
||||||
sh '''
|
docker compose -f docker-compose.test.yml build --pull=false
|
||||||
set -eu
|
docker compose -f docker-compose.test.yml up -d --wait --wait-timeout 120
|
||||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
|
||||||
rm -rf "$DOCKER_CONFIG"
|
|
||||||
mkdir -p "$DOCKER_CONFIG"
|
|
||||||
cleanup_registry() {
|
|
||||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
|
||||||
rm -rf "$DOCKER_CONFIG"
|
|
||||||
}
|
|
||||||
trap cleanup_registry EXIT HUP INT TERM
|
|
||||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
|
||||||
docker compose -f docker-compose.test.yml build --pull=false
|
|
||||||
docker compose -f docker-compose.test.yml up -d --remove-orphans
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('构建并部署开发前端') {
|
|
||||||
when { expression { env.IS_DEVELOPMENT == 'true' } }
|
|
||||||
steps {
|
|
||||||
withCredentials([usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD')]) {
|
|
||||||
sh '''
|
|
||||||
set -eu
|
|
||||||
export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"
|
|
||||||
rm -rf "$DOCKER_CONFIG"
|
|
||||||
mkdir -p "$DOCKER_CONFIG"
|
|
||||||
cleanup_registry() {
|
|
||||||
docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true
|
|
||||||
rm -rf "$DOCKER_CONFIG"
|
|
||||||
}
|
|
||||||
trap cleanup_registry EXIT HUP INT TERM
|
|
||||||
printf %s "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST"
|
|
||||||
image="admin-web-development:${SOURCE_TAG}"
|
|
||||||
|
|
||||||
docker build \
|
|
||||||
--build-arg NODE_IMAGE="$NODE_BASE_IMAGE" \
|
|
||||||
--build-arg NGINX_IMAGE="$NGINX_BASE_IMAGE" \
|
|
||||||
--build-arg VITE_CLIENT_WEB_URL=http://8.163.49.32:9002 \
|
|
||||||
-t "$image" .
|
|
||||||
export IMAGE_TAG="$SOURCE_TAG"
|
|
||||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d --remove-orphans --pull never
|
|
||||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" ps
|
|
||||||
docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('部署检查') {
|
|
||||||
when { expression { env.IS_DEVELOPMENT != 'true' } }
|
|
||||||
steps {
|
|
||||||
sh '''
|
|
||||||
set -eu
|
|
||||||
docker compose -f docker-compose.test.yml ps
|
|
||||||
docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
docker compose -f docker-compose.test.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
stage('发布临时生产灰度') {
|
||||||
|
when { expression { env.JOB_BASE_NAME?.endsWith('-prod') } }
|
||||||
|
steps {
|
||||||
|
lock(resource: 'firstleg-production-admin-web-deploy') {
|
||||||
|
withCredentials([
|
||||||
|
usernamePassword(credentialsId: 'gitea-registry-token', usernameVariable: 'BUILD_REGISTRY_USER', passwordVariable: 'BUILD_REGISTRY_PASSWORD'),
|
||||||
|
usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'),
|
||||||
|
sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'),
|
||||||
|
string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'),
|
||||||
|
file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS')
|
||||||
|
]) {
|
||||||
|
sh 'sh scripts/deploy-production.sh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post {
|
post {
|
||||||
failure {
|
failure {
|
||||||
sh '''
|
script {
|
||||||
if [ "${IS_DEVELOPMENT:-false}" = "true" ]; then
|
if (!env.JOB_BASE_NAME?.endsWith('-prod')) {
|
||||||
docker logs --tail=100 admin-web-development || true
|
sh 'docker compose -f docker-compose.test.yml logs --tail=100 || true'
|
||||||
else
|
}
|
||||||
docker compose -f docker-compose.test.yml logs --tail=100 || true
|
}
|
||||||
fi
|
|
||||||
'''
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
生产构建:
|
发布资源构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
@@ -23,6 +23,14 @@ pnpm build
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 发布后的页面资源恢复
|
||||||
|
|
||||||
|
`src/core/routing/navigationRecovery.ts` 统一处理路由懒加载、Vite JS/CSS 预加载和 Vue 异步组件的资源加载失败。无未保存编辑时自动重新加载目标页面,保留路径、查询参数和页签;有未保存编辑时先确认,取消后继续编辑。普通接口错误和业务运行错误不触发刷新。
|
||||||
|
|
||||||
|
刷新记录写入当前标签页的 `sessionStorage`,30 秒内最多自动刷新一次,跨路由成功、重定向和整页刷新仍然保留,防止循环刷新。存储不可用或恢复后仍失败时提示手动刷新。`nginx/default.conf.template` 已对 HTML 禁用缓存,并让缺失的 `/assets/` 文件返回真实 404;使用额外 CDN 时也应保持 HTML 每次重新验证,参见 [Vite 资源加载错误处理](https://vite.dev/guide/build#load-error-handling)。
|
||||||
|
|
||||||
|
该机制随新版前端生效,已经打开且尚未加载本次改动的旧标签页需要先刷新一次。发布验收可在旧版页面保持打开的情况下发布新版,再打开尚未访问过的页面或异步弹窗,确认自动恢复以及未保存编辑的取消/确认行为。
|
||||||
|
|
||||||
## 源码结构
|
## 源码结构
|
||||||
|
|
||||||
- `src/app/`:应用壳层、根路由和启动装配。
|
- `src/app/`:应用壳层、根路由和启动装配。
|
||||||
@@ -35,7 +43,7 @@ pnpm test
|
|||||||
|
|
||||||
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
依赖和 `pnpm-lock.yaml` 均保存在当前前端项目中,不需要在仓库根目录安装 Node 依赖。
|
||||||
|
|
||||||
首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。一期 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
|
首页数据看板已作为无权限门槛的默认入口恢复,当前按照后台原型展示明确标注的静态演示数据,不请求业务接口;后续接入真实统计契约时应整体替换演示数据。历史通用占位页仍保留在 `src/prototype/`,但看板本身已迁入正式业务模块。订单与客户 Admin 其他正式路由聚焦登录、客户及注册审核、平台收货仓、快递公司、往来单位、头程订单、内部用户、角色管理和菜单权限;企业注册审核已合并到客户列表,不再保留独立菜单。
|
||||||
|
|
||||||
后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。
|
后台一级菜单及原型内子菜单严格按最新版原型排序;原型未定义位置的现有功能统一归入“通用”。未接入的原型菜单保留为隐藏节点,待对应业务契约和权限码接入后再恢复展示。企业审核、仓库、头程订单、平台角色、菜单及按钮权限资源均接入真实接口;内部用户管理只维护平台员工账号,企业租户成员由商户成员边界管理。运行时不再提供本地演示业务数据或静态角色预览。
|
||||||
|
|
||||||
@@ -46,18 +54,18 @@ pnpm test
|
|||||||
```dotenv
|
```dotenv
|
||||||
VITE_API_BASE_URL=/api
|
VITE_API_BASE_URL=/api
|
||||||
API_PROXY_TARGET=http://localhost:9000
|
API_PROXY_TARGET=http://localhost:9000
|
||||||
VITE_OVERSEAS_API_BASE_URL=/overseas-api
|
|
||||||
OVERSEAS_API_PROXY_TARGET=http://localhost:9300
|
|
||||||
VITE_CLIENT_WEB_URL=http://localhost:5174
|
VITE_CLIENT_WEB_URL=http://localhost:5174
|
||||||
```
|
```
|
||||||
|
|
||||||
国内业务继续使用 `http` 和 `/api` 代理;四期海外业务统一使用 `overseasHttp` 和 `/overseas-api` 代理。Vite 会把 `/overseas-api/platform/...` 重写为海外 Gateway 的 `/api/platform/...`。海外 Token 的签发、缓存和会话刷新由统一认证层维护,业务模块不得读取 Token、手拼 `Authorization` 或复用国内 `http`。
|
全部业务使用共享 `http` 和同源 `/api`,通过统一 Gateway 访问。Access/Refresh Token 与并发刷新由认证层维护,业务模块不得读取 Token 或手拼 `Authorization`。
|
||||||
|
|
||||||
切换开发或测试环境时,只修改未提交的 `.env.local` 中 `API_PROXY_TARGET`、`OVERSEAS_API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`,然后重启 `pnpm dev`;无需修改业务代码。真实环境地址不写入受版本控制的配置模板或说明。
|
本地工具只连接同一测试环境:在未提交 `.env.local` 配置 `API_PROXY_TARGET` 和 `VITE_CLIENT_WEB_URL`;不提供业务服务直连或独立海外代理。
|
||||||
|
|
||||||
客户代登录打开的工作台地址是运行时配置,不需要为每个环境重新构建镜像。Docker 部署时设置容器环境变量 `CLIENT_WEB_URL`;容器启动时会生成 `/runtime-config.js`。本地 Vite 开发使用 `VITE_CLIENT_WEB_URL` 作为兜底。
|
唯一部署入口是 `docker-compose.test.yml` 和 test Jenkins Job。发布前在 Job/未提交环境文件配置 `API_UPSTREAM`(统一 Gateway origin,不含 `/api`)、`TEST_NETWORK`(后端共享测试网络)、`CLIENT_WEB_URL`(客户工作台地址)及 `IMAGE_TAG`。可选 `HOST_PORT` 默认 9001,`NODE_BASE_IMAGE`/`NGINX_BASE_IMAGE` 可指定已授权拉取的基础镜像;私有镜像仓库认证由构建节点配置,不在源码存放凭据。Jenkins 从当前提交生成 IMAGE_TAG,不再按 Job 后缀切换 dev/prod。宿主机反向代理连接 loopback 端口。
|
||||||
|
|
||||||
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。一期真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
容器启动通过 `CLIENT_WEB_URL` 生成 `/runtime-config.js`;本地 Vite 使用 `VITE_CLIENT_WEB_URL`。本地构建通过不代表容器或测试环境已发布。真实 HTTP 验收入口为 `pnpm test:phase4:test --environment test --gateway <统一入口>`,凭据通过环境变量传入,仅在取得对应测试写入授权后执行。
|
||||||
|
|
||||||
|
认证请求使用 `/api/auth/**`,平台 IAM 请求使用 `/api/platform/v1/iam/**`。登录后只从 JWT 读取平台身份,菜单和操作权限通过当前用户授权接口实时加载,不从 JWT 携带或解析角色权限。企业审核调用 `/reviews`,打开审核详情后按租户调用 `/tenants/{tenantId}/qualifications`;营业执照通过 `/api/platform/v1/files/qualifications/{tenantId}/{fileName}` 携带平台 Token 临时读取,关闭预览后释放本地对象地址。客户端自主注册企业审核通过后继续使用注册账号作为管理员,不展示新凭据;Admin 代建企业并由后端创建管理员时,页面必须展示不可通过遮罩、Esc 或关闭按钮跳过的一次性凭据弹窗,运营人员确认保存后才清除账号密码。平台角色查询、新增、编辑、启停、删除和权限全量保存均已按 `RbacController` 接入真实 `/roles` 契约,角色返回的 `permissionIds` 用于授权回显;系统内置角色及仍被成员使用的角色继续由服务端保护。权限资源查询和新增调用 `/permissions`,尚未提供的权限编辑、删除和启停不会模拟成功。订单与客户真实权限资源只创建 `MENU` 与 `BUTTON`,不创建 `PAGE` 或 `API` 类型。其余业务页面需在对应服务契约确认后逐项替换,生产逻辑不得静默回退到模拟数据。
|
||||||
|
|
||||||
仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
|
仓库管理已接入真实平台 Warehouse API,统一维护国内收货仓、FBA 仓和海外仓,支持按租户筛选、详情、新增、编辑和启停,并在更新时保留后端返回的地址、能力和截单时间等完整字段。停用仓库保留历史数据,但不再提供给新业务选择;订单详情使用下单时保存的仓库编码、名称和地址快照,仓库主数据后续修改不改变历史展示。库存和出入库作业由对应业务模块管理,不在仓库主数据页面中模拟。
|
||||||
|
|
||||||
@@ -66,3 +74,7 @@ VITE_CLIENT_WEB_URL=http://localhost:5174
|
|||||||
真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。
|
真实接口错误会保留后端返回的业务错误码、`requestId` 和 `traceId`。登录、企业审核、平台角色及权限资源的主要失败态支持直接复制追踪标识,便于与后端日志关联。
|
||||||
|
|
||||||
登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。
|
登录表单在开发和生产环境均默认留空,不包含联调账号、密码或演示提示。菜单、路由和操作权限只使用真实登录会话返回的服务端权限快照;前端控制仅改善体验,服务端仍须独立校验身份、角色权限和数据范围。接口失败时页面展示真实错误及追踪标识,不回退到假数据或模拟成功。
|
||||||
|
|
||||||
|
同机目标默认值:`API_UPSTREAM=http://gateway:9000`、`TEST_NETWORK=firstleg-test-network`;必须与后端同批 test Compose 配置一致。
|
||||||
|
|
||||||
|
TEST 入口同时转发两个独立 PDA 静态应用:`/pda-domestic/` 到共享网络中的 `pda-domestic-h5-test:80`,`/pda-overseas/` 到 `pda-overseas-h5-test:80`。各自构建、镜像和静态文件由对应 PDA 项目维护,Admin 不打包 PDA 源码。请求时解析 Docker DNS,PDA 未启动不会阻止 Admin 启动。国内 H5 的 `/gateway-api/` 在此转换为同一 `API_UPSTREAM` 的 `/api/`,海外 H5 直接使用既有 `/api/`;所有业务认证与仓绑定仍由统一 Gateway 和服务端校验。发布时先启动 PDA 容器,再验收两个路径的 HTML、资产、登录、权限与缺失资产 404。H5 通过不能替代 Android 真机扫码验收。
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
services:
|
|
||||||
admin-web:
|
|
||||||
image: admin-web-development:${IMAGE_TAG:?IMAGE_TAG is required}
|
|
||||||
container_name: admin-web-development
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "9001:80"
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
environment:
|
|
||||||
CLIENT_WEB_URL: "http://8.163.49.32:9002"
|
|
||||||
API_UPSTREAM: "http://host.docker.internal:9000"
|
|
||||||
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: firstleg-prod-admin-web
|
||||||
|
services:
|
||||||
|
admin-web:
|
||||||
|
image: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}:${IMAGE_TAG:?IMAGE_TAG is required}"
|
||||||
|
container_name: firstleg-prod-admin-web
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${HOST_PORT:-29011}:80"
|
||||||
|
environment:
|
||||||
|
CLIENT_WEB_URL: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
|
||||||
|
API_UPSTREAM: "http://gateway:9000"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
backend:
|
||||||
|
external: true
|
||||||
|
name: firstleg-production-network
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
name: firstleg-test-admin-web
|
||||||
services:
|
services:
|
||||||
admin-web:
|
admin-web:
|
||||||
build:
|
build:
|
||||||
@@ -6,17 +7,23 @@ services:
|
|||||||
args:
|
args:
|
||||||
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
NODE_IMAGE: "${NODE_BASE_IMAGE:-node:22-alpine}"
|
||||||
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
NGINX_IMAGE: "${NGINX_BASE_IMAGE:-nginx:1.27-alpine}"
|
||||||
image: admin-web-test:local
|
image: admin-web-test:${IMAGE_TAG:?IMAGE_TAG is required}
|
||||||
container_name: admin-web-test
|
container_name: admin-web-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/healthz"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-9001}:80"
|
- "127.0.0.1:${HOST_PORT:-9001}:80"
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
environment:
|
environment:
|
||||||
CLIENT_WEB_URL: "${CLIENT_WEB_URL:-http://8.163.30.128:9002}"
|
CLIENT_WEB_URL: "${CLIENT_WEB_URL:?CLIENT_WEB_URL is required}"
|
||||||
# Gateway 与前端容器在同一台测试服务器,通过宿主机内部地址访问。
|
API_UPSTREAM: "${API_UPSTREAM:-http://gateway:9000}"
|
||||||
# 测试环境固定走 Docker 宿主机网关,避免 Jenkins 全局环境变量或
|
networks:
|
||||||
# 工作区残留配置把上游覆盖成公网 IP,造成容器回连公网端口失败。
|
- test-backend
|
||||||
API_UPSTREAM: "http://host.docker.internal:9000"
|
networks:
|
||||||
OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"
|
test-backend:
|
||||||
|
external: true
|
||||||
|
name: "${TEST_NETWORK:-firstleg-test-network}"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Admin业务详细索引
|
||||||
|
|
||||||
|
本文件保存当前页面/API 入口及业务规则,是[应用指引](../AGENTS.md)的按需补充。涉及字段、状态、权限、金额、资源或作业恢复时必须读取受影响条款,不作为普通定位任务的全文首读。
|
||||||
|
|
||||||
|
正文代码路径以工作区 `apps/admin-web/` 应用根起算,源码包缩写与裸文件名沿对应条目的目录解释;明确标注“工作区”的路径从工作区根起算。Markdown 相对链接以本文件所在的 `docs/` 为起点。已知文件直接定位;行为变更同时核对最近模块指引及服务端合同。
|
||||||
|
|
||||||
|
原条款在本文件维护,主 AGENTS 仅保留短入口和通用边界;不要将新业务细节复制回主指引。
|
||||||
|
|
||||||
|
## 模块快速路由
|
||||||
|
|
||||||
|
表中 `modules/`、`core/`、`shared/`、`app/` 和 `main.ts` 均相对 `src/`;裸文件名沿用同一单元格最近目录。模块细节只在最近指引维护,应用层保留入口。
|
||||||
|
|
||||||
|
| 业务 | Admin 入口 | 联查 |
|
||||||
|
|---|---|---|
|
||||||
|
| 登录、会话与统一 Token | `modules/auth/`、`core/auth/` | 国内 `auth/`、`iam/`;海外请求客户端再读 `shared/api/AGENTS.md` |
|
||||||
|
| 启动与懒加载资源恢复 | `main.ts`、`core/routing/navigationRecovery.ts` | 仅识别动态模块/Chunk 资源过期;30 秒内同目标最多自动恢复一次,存在未保存编辑时必须先确认 |
|
||||||
|
| 工作台与指标 | `modules/dashboard/` | 指标所属模块与服务端,不复制领域契约 |
|
||||||
|
| 自营销量、库存与数据采集 | `modules/self-operated/AGENTS.md` | 三个入口 `/self-operated/sales`、`/self-operated/inventory`、`/self-operated/collection`;后端 `lastleg-service/selfoperated/analytics/`,正式合同见 `../../docs/自营业务/README.md`。 |
|
||||||
|
| 船期日历 | `modules/sailing-calendar/` | 当前日历入口覆盖渠道、周模板、单次船期、收货截止时间和按渠道货量统计;后端按 `firstleg/` 船期接口定位,不读取已归档的货柜开发规划 |
|
||||||
|
| 预约管理、预约列表、跨客户同仓预约 | `modules/appointment-calendar/`;入仓单预约沿 `modules/overseas-warehouse/` | 统一规则见 `../../docs/海外仓业务/README.md` 的预约送仓;同仓跨客户共用预约,客户归属在货物明细;到港只读 Firstleg,送仓归 Lastleg |
|
||||||
|
| FBA 无匹配价格、人工定价 | `modules/inbounds/fee/api.ts` 的 `createManualInboundQuote`、`types.ts`;页面消费沿 `InboundFeePanel.vue` 核对 | `POST /manual-quote` 提交计费重量、体积、单价、原因和 `Idempotency-Key`;Backend `PlatformFbaPricingController.manualQuote` → `FbaPricingService.manualQuote`,权限 `firstleg:inbound:confirm-send-fee`。仅 FBA、已有实际测量且当前价格未命中时允许,客户已确认费用禁止改价;`MANUAL_QUOTE` 快照允许价格记录与版本为空,不伪造价格簿关联。API 回归 `modules/inbounds/fee/api.test.ts`,不能以 API 存在代替页面接入完成 |
|
||||||
|
| PDA版本、APK下载、WGT热更新发布与停发 | `modules/pda-releases/pages/PdaReleasesView.vue`、`modules/pda-releases/api/pdaReleases.ts`、`modules/pda-releases/routes.ts` | 系统菜单 `/system/pda-releases`;列表权限 `platform:pda-release:list`,上传/发布/停发权限 `platform:pda-release:manage`。国内/海外按 `domestic/overseas` 分开查询,上传只创建草稿,发布与停发需确认;管理请求使用国内 HTTP 的 `/platform/v1/files/pda-releases`,后端见 `file/AGENTS.md`,设备安装与重启查对应 PDA 的更新工具 |
|
||||||
|
| 客户、企业审核、客户统计 | `modules/customers/` | `iam/`、`auth/`、`file/`、`firstleg/customerstatistics/`;公司联系电话只作企业联系信息,不修改管理员/成员登录手机号且不要求跨企业唯一。审核必填资质仅营业执照与统一社会信用代码;法人身份证选填,可随资质包提交,也可在已有营业执照后单独补传或替换 |
|
||||||
|
| 供应商、国内整柜 | `modules/suppliers/`、`modules/domestic-containers/` | Backend `warehouse/` 及对应 OpenAPI |
|
||||||
|
| 平台仓库与仓储资源 | 仓库主档 `modules/warehouses/`,国内资源 `modules/warehouse-resources/` | 全部仓库 ID、类型、状态、地址和能力归国内 `warehouse/`;海外 `warehouse-overseas-service/` 只维护引用同一 warehouseId 的库位、托盘和锁 |
|
||||||
|
| 平台用户、RBAC、导航 | `modules/iam/`、`core/access/`、`core/navigation/` | `auth/`、`iam/`、平台权限契约 |
|
||||||
|
| 国内/海外 PDA 独立人员与海外仓绑定 | `modules/iam/pages/InternalUsersView.vue`、`modules/iam/api/platformIam.ts` | 国内 `CN_FIRSTLEG_PDA_OPERATOR` 与海外 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 互斥;只有海外角色必须且只能绑定一个 `overseasWarehouseId`,候选来自国内 Warehouse 平台仓库 API 的公共 `OVERSEAS + ACTIVE + RECEIVING` 收货仓 |
|
||||||
|
| 消息中心 | `modules/notifications/` | Backend `iam/.../notification/`;仅顶部消息中心,不进入左侧菜单 |
|
||||||
|
| 订单列表、批量审核预览、详情、商品图片与入库费用 | `modules/unified-orders/`、`modules/first-leg-orders/`;平台商品图片由 `modules/unified-orders/api/unifiedOrders.ts` 按客户与 fileId 下载 | `order/`、`firstleg/`、Finance;旧系统导入固定归属“IT测试公司”,前端不提交或选择客户,逐行结果展示入仓码与失败原因。图片读取归 `file/` 的 `PlatformProductImageController`。Order 状态为 `SUBMITTED` 时统一列表必须保持 `PENDING_REVIEW/APPROVE_ORDER`,不得被已存在的 Firstleg 准备中入库投影覆盖。批量审核弹框通过 `unifiedOrders.ts` 的 `/orders/first-leg/batch-review-preview` 一次取回全部预览,按服务端 `canApprove/blockReason` 展示,不逐票请求详情或用内部 ID 作标题 |
|
||||||
|
| 客户原始申报资料编辑与渠道变更 | `modules/unified-orders/components/OrderDeclarationEditorDialog.vue`、`api/unifiedOrders.ts`、`pages/UnifiedOrdersView.vue` | 使用订单版本和货物 `itemNo` 整体提交,不允许增删货物行;只编辑客户申报快照,不覆盖仓库/PDA 实测、测量审核或计费事实。渠道变化需二次确认并由 Backend `order/` 联查 `firstleg/` 的分柜、同柜、装柜与重新计价门禁;国内收货仓或中转仓变化必须重新下单 |
|
||||||
|
| 入库扣货、解除与凭证历史 | `modules/unified-orders/pages/UnifiedOrdersView.vue`、`modules/inbounds/core/api.ts`、`types.ts` | 页面只消费服务端 `holdFlag/currentHoldReason/canHold/canRelease/actionBlockReason/history`;扣货与解除提交当前入库版本、幂等键、必填原因和可选凭证,凭证下载只走 Firstleg 授权入口。Backend `firstleg/` 的 `InboundHoldService`、`InboundHoldGuard`、`InboundHoldEvidenceService`,文件归 `file/` |
|
||||||
|
| 入库、测量、称重库位状态、客户箱规策略与计价主数据 | 测量弹框/工作区 `modules/inbounds/measurement/InboundMeasurementDialog.vue`、`InboundMeasurementPanel.vue`,称重 `modules/inbounds/weight/components/WeightOcrPanel.vue`;主数据 `modules/fba-pricing/`、`modules/dimension-config/`、`modules/customers/components/CustomerBillingRulesPanel.vue`、`modules/fee-items/` | 客户固定四档箱规,默认计算方式为 `ACTUAL/ADD/MULTIPLY`;未启用默认调整规则时按实测原尺寸计算,仓库修正值替换对应实测边。测量行可选实际尺寸、箱规尺寸策略或客户默认尺寸系数,保存后以 Backend `firstleg/` 返回的外箱尺寸、体积重和计费基数为准,不在页面独立重算。费用确认的箱数、毛重汇总当前 `actualMeasurementRows`,计费体积默认取服务端已保存的 `outerMeasurementRows` 汇总;通过只读 `/fees/preview` 刷新当前重量、体积和人工单价的计费量与金额,预览未完成或失败时不得发送;最终按用户确认值提交,体积来源标记为 `OUTER`;不得用可能滞后的工作区顶层合计覆盖明细。称重页在每张图片卡片只展示服务端 `locationCode/slotStatus/sharedOverflow`,不渲染或自行推导整票预留引导;删除图片须提示槽位仍归本票保留,不得按图片数组下标推导托盘或库位 |
|
||||||
|
| 一件代发托盘打印 | `modules/unified-orders/pages/UnifiedOrdersView.vue` 的“托盘打印”页签、`modules/inbounds/core/DropshipPalletPrintPanel.vue`、`core/api.ts` | 仅一件代发订单展示;测量审核预生成托盘码后由 Admin 电脑端打印,支持本票全部托盘合并打印和单托补打。PDA 只扫码组托,不承担 PDF 下载或打印机连接 |
|
||||||
|
| 入库单扣货、解除、凭证与历史 | `modules/inbounds/pages/InboundOrdersView.vue`、详情工作区、`modules/inbounds/core/api.ts` | 列表展示服务端 `holdFlag/currentHoldReason` 并支持筛选;详情只按 `canHold/canRelease/actionBlockReason` 开放操作。扣货与解除提交当前 `version`、原因和独立幂等键,可选凭证先按明确客户与入库单上传;历史文件只调用专用下载入口,不把上传成功但未提交命令的文件冒充扣货证据 |
|
||||||
|
| 客户专属价格簿、柜级阶梯运费与受控调价 | 列表/上传 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue`,版本详情/仓库映射/确认 `modules/fba-pricing/carrier-rates/pages/CarrierRateDetailView.vue`,路由 `modules/fba-pricing/routes.ts`;`modules/inbounds/fee/`、`modules/unified-orders/components/InboundFeeDetailManager.vue` | 列表与上传后按 `versionId` 进入 `fba-carrier-rate-detail` 独立详情页,价格行只读;回归入口 `modules/fba-pricing/carrier-rates/pages/CarrierRatesView.test.ts`。价格簿页面显式选择公共报价或具体客户;列表、上传、确认只作用于该 `customerId`,不得混合版本。计价优先消费客户专属 ACTIVE 版本,无专属版本才回退公共 ACTIVE;柜级阶梯金额和各客户分摊只展示服务端结果。调整计费量只能在服务端返回的上下 10% 范围内并提交当前费用版本、原因与二次确认;私人仓尾程派送费使用稳定 `PRIVATE_LAST_MILE_DELIVERY` 代码且只对 `PRIVATE` 目的地开放;一件代发须在预装确认前完成试算,计价后保持服务端 `WAIT_PALLETIZE` 状态 |
|
||||||
|
| 财务、客户资金账户与柜级公共费用分摊 | `modules/finance/`,继续读该目录 `AGENTS.md`;客户账户 `pages/FinanceCustomerAccountsView.vue`,柜费 `modules/loading-lists/components/ContainerFeesPanel.vue`、`api/containerFees.ts` | `finance/`、`firstleg/`、`file/`、`docs/财务管理/`;实际、冻结、可用余额、授信与流水只消费 Finance 权威结果,充值审核和手工充扣携带版本与幂等键。跨客户公共柜费只按服务端柜内客户/订单事实分摊,金额必须守恒,创建与审核、驳回、调整、作废均整组原子处理 |
|
||||||
|
| 货柜双工作台、排柜、预装/装柜清单、真实柜号后补与整柜报关 | 国内 `modules/container-loading/pages/DomesticContainersView.vue`、海外 `OverseasContainersView.vue`,共享详情实现 `ContainerDetailView.vue`;预装详情 `modules/loading-lists/pages/PreloadPlanDetailView.vue`,资料/PDA 证据 `components/ContainerOverviewPanel.vue`,报关 `components/ContainerCustomsPanel.vue` | 规则见下文「货柜工作台规则」 |
|
||||||
|
| 合柜报关 / 同柜不可拆组 | `modules/unified-orders/api/coLoadGroups.ts`、`pages/UnifiedOrdersView.vue`;装柜消费 `modules/container-loading/pages/SmartLoadingWorkbenchView.vue` | 前端只提交订单 ID;Backend `firstleg/loading/coload/` 展开并冻结每票全部可用入仓片段。成员数是入仓片段数,不等于订单票数;只要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均整组处理 |
|
||||||
|
| 全局智能装柜工作台、入仓码候选与箱数分配 | `modules/container-loading/components/SmartLoadingDialog.vue` 内嵌 `pages/GlobalSmartLoadingView.vue`,柜次页 `SmartLoadingWorkbenchView.vue`,以及 `smartLoadingChannelGroups.ts`、`smartLoadingItems.ts`、`api/platformContainerLoading.ts`;全局页当前不注册独立路由 | Backend `firstleg/` 的 `/inbound-candidates`、`/containers`、`/inbound-assignments`、`/plans/{planId}/pending-review-assignments`、`/group-assignments`、`/unpicked-items`。`orderStatuses` 最多四项且未传表示全部未完成状态;候选状态多选覆盖待审核、待测量、待分柜、装柜中,状态只用于筛选展示,按钮资格以服务端 `canAssign` 为准;待审核/待测量可提前占位但不得提前确认预装或启动 PDA。计划箱数编辑上限、已用和未用展示只消费服务端 `totalPackageCount/remainingPackageCount`,不得从候选页或本地规格重新拼算。人工加入允许跨渠道,智能推荐仍按柜次渠道。柜次容量和限重取服务端字段;同柜组作为单卡整组分页、分配和移除,任一成员已拣时整组保留 |
|
||||||
|
| 国内货柜履约订舱资料、打印、关联候选与出仓前船期 | `modules/ocean-bookings/` 的 `components/OceanBookingDocumentsPanel.vue`、`OceanBookingPrintDialog.vue`,以及 `modules/container-loading/`、`modules/port-schedules/` | 订舱草稿按当前版本维护资料,放舱后预计到港经 `/estimated-arrival` 按编辑权限和版本单独维护,其余运输/预报/关系方/柜型/附件只读;客服实货量经 `/actual-cargo` 按编辑权限、版本、幂等键和原因补录;打印对草稿与已放舱订舱均开放,按当前已保存资料生成,托运单取委值,提单确认件取手工实值,未填显示“—”且不以委值回退。`platformOceanBookings.ts` 的 `/link-candidates` 只消费服务端分页,`containerCount` 是柜型需求 `quantity` 总和;Backend `firstleg/oceanbooking/`、`file/`、`print/`、`firstleg/portschedule/` |
|
||||||
|
| 订舱级拖车预约 | `modules/trucking/api/platformTrucking.ts` 的 `/candidates`、`components/TruckAppointmentForm.vue`、`pages/TruckAppointmentCreateView.vue`、`TruckAppointmentsView.vue` | 表单只选择服务端返回的 `RELEASED` 订舱与 `TRUCKING + truckingScope=DOMESTIC` 国内拖车供应商,不选择或提交客户、柜次、装柜计划或真实集装箱号;清关提柜固定选择 `OVERSEAS` 海外车队,类型在供应商主档单选维护;提单号和仓库由订舱冻结事实带入。一个订舱最多一张非终态拖车单,重复创建转入现有单继续编辑;PDA 完成不自动推进拖车。附件为平台作用域且不重复提单文件;Backend `TruckAppointmentService`、`truck-appointments-v1.yaml` |
|
||||||
|
| 目的港码头拖车与还空预警 | 海外清关提柜工作台/详情 `modules/container-loading/pages/OverseasContainersView.vue`、`OverseasContainerDetailView.vue`,API `modules/container-loading/api/terminalDrayages.ts`、`containerArrivalTimes.ts` | 与订舱级拖车预约是不同生命周期;按 `containerBookingId` 读取服务端状态、`allowedActions` 和历史,不能复用订舱级拖车表单或自行推进状态。列表“预计到仓”列直接提供人工日期时间选择器,保存时通过 Lastleg 柜级版本和幂等键原子更新该柜全部尾程订单,已有入库计划后禁止修改;“实际到仓”只读 `ll_receiving_container_session.receiving_at`,即海外 PDA 首次开始收货时间。还空截止、超时天/小时只展示服务端基于权威卸柜完成时间加 72 小时计算的结果;Backend `firstleg/terminaldrayage/`、`lastleg-service/`,邮件记录通过 `terminalDrayages.ts` 查询与重试。 |
|
||||||
|
| 历史报关协同与国内出仓 | `modules/customs/`、`modules/outbound-dispatch/` | `modules/customs/` 是历史报关案件/清单/代理提交与回执入口;装柜完成后的整柜退税/买单资料从货柜详情进入,不在旧模块复制;Backend `firstleg/outbound/dispatch/`、`firstleg/customs/`、`file/` |
|
||||||
|
| 主数据、下载、退避任务重试、审计 | `modules/master-data/`、`modules/downloads/pages/DownloadCenterView.vue`、`modules/audit/` | File 导出任务返回 `attemptCount/maxAttempts/nextRetryAt`;仅失败或已进入自动退避的等待任务显示重试,等待任务重试用于立即执行当前投递,不创建新任务;联查 `warehouse/`、`file/`、`audit-service/` |
|
||||||
|
| 海外接柜、入库、库存与库内服务 | `modules/overseas-warehouse/` | `gateway/`、`lastleg-service/`、`warehouse-overseas-service/`;与排车、出库、派送、异常退回和一件代发共同组成海外仓全流程 |
|
||||||
|
| 海外采购、卡车、散板 | `modules/overseas-business/` | `gateway/`、`lastleg-service/` |
|
||||||
|
| 海外仓业务合作仓与通用资料 | 合作仓 `modules/warehouses/`;报价/汇率/车辆 `modules/overseas-master-data/` | 国内 `warehouse/`、海外实际资源与服务 |
|
||||||
|
| 海外仓业务国内出仓后船节点 | `modules/port-schedules/`、`modules/ocean-bookings/`、货柜详情 | `firstleg/portschedule/`、`integration-service/`;预计节点只展示不推进实际进度,手工实际节点必须严格向后追加并防重复,十节点标准见 `../../docs/海外仓业务/船节点标准化契约.md` |
|
||||||
|
| 海外排车、出库与派送 | `modules/overseas-warehouse/AGENTS.md` | 按模块表定位排车、出库、派送页面及 API;履约边界在模块内维护 |
|
||||||
|
| 海外异常退回与客户回访 | `modules/overseas-warehouse/AGENTS.md` | 派送异常退回区别于退货库存;页面、API 与原子推进约束见模块指引 |
|
||||||
|
|
||||||
|
全量验收按业务从 `../../docs/订单与客户/`、`入库测量与计费/`、`国内货柜履约/`、`财务管理/` 选择最小契约;海外跨模块流程或入口不明时读 `../../docs/海外仓业务/README.md`,已定位局部任务直接沿模块入口。进度、排期或验收状态问题再读取对应进度表和报告。
|
||||||
|
|
||||||
|
散货托盘码打印:`modules/loading-lists/components/PalletLabelPrintDialog.vue`、`modules/loading-lists/api/platformLoadingLists.ts`,由货柜详情与装柜清单详情进入;先调用 `/pallet-labels/prepare` 获取服务端按 FBA 仓分组的冻结清单,再通过 `/pallet-labels/print` 下载 PDF。托盘码使用“真实柜号-FBA 地址内部编号-三位序号”,页面同时展示 FBA 仓码和内部编号;支持全部打印或按仓指定张数补打,一件代发托盘不在该清单,前端不生成或重新编号托盘码。
|
||||||
|
|
||||||
|
|
||||||
|
## 货柜工作台规则
|
||||||
|
|
||||||
|
- 国内“送柜报关”只消费 `/containers/domestic` 与服务端 `allowedActions`,承载模拟柜、订舱关联、智能装柜;模拟柜创建选择与智能装柜一致的 9 类计划渠道,未关联订舱前实际渠道为空,订舱关联候选按计划渠道组匹配并同时展示订舱号与提单号、装柜清单页签、出口报关和国内出仓;创建柜次时自动生成唯一预装计划,预装详情先调用 `/loading-plans/{planId}/lifecycle`,完成后深链进入国内货柜详情的装柜清单页签,独立装柜清单导航 `/loading-lists` 复用已确认清单 API,查看进入独立详情路由 `/loading-lists/:planId`,保留返回列表和打印;独立清单详情与货柜清单页签共同使用 `useLoadingListDetail.ts` 加载、校验和处理错误,`LoadingListDetailDrawer.vue` 统一密度、摘要、明细、历史及打印入口,打印实现归 `printLoadingList.ts`,外层只保留返回目标与货柜专属 PDA/报关区域;货柜列表经 `ContainerWorkbenchView.vue` 在国内装柜与海外清关提柜之间按权限切换。订舱关联允许真实集装箱号为空,但确认预装和创建 PDA 任务前必须补齐;历史已建但缺号的任务仍可在预装详情首次补录,保存后恢复 PDA 可进入状态;全买单柜继续使用 `complete-all-buy`。海外“清关提柜”只消费 `/containers/overseas`,承载船节点、目的港清关、DO/PU、码头车队和还空;原预约/实际提柜展示位改为整柜预计/实际到仓,预计到仓在列表内人工快捷维护并经海外接口同步整柜尾程订单,实际到仓只读海外 PDA 首次开始收货时间。还空截止事实由服务端基于卸柜完成时间加 72 小时计算,前端不重算。两端统一使用 `containerBookingId`,列表范围由服务端 `scope=ACTIVE/HISTORY` 分页,禁止浏览器筛选或本地推导写资格。后端为 `LoadingListService`、`firstleg/customs/container/`、`firstleg/terminaldrayage/` 和 `lastleg-service/`,文件归 `file/`
|
||||||
|
|
||||||
|
## 海外模块内部快速索引(海外仓全流程)
|
||||||
|
|
||||||
|
- 海外仓的尾程订单控制与柜级入仓计划转换、退货库存、排车出库、异常回访:读取 `src/modules/overseas-warehouse/AGENTS.md`,字段与流程仅在该模块维护。
|
||||||
|
|
||||||
|
| 关键词 | 页面/路由入口 | API 与联查 |
|
||||||
|
|---|---|---|
|
||||||
|
| 海外采购 | `modules/overseas-business/pages/PurchaseOrdersView.vue`、详情/编辑页 | `modules/overseas-business/api/platformOverseasBusiness.ts` 的 purchase 方法;Backend `lastleg-service/procurement/` |
|
||||||
|
| 美国卡车、本土散板 | `modules/overseas-business/pages/TransportOrdersView.vue`、`TransportOrderFormView.vue` | 同一 API 的 us-truck/looseboard 方法;Backend `lastleg-service/trucking/`、`looseboard/` |
|
||||||
|
| 海外报价、月度汇率、车辆 | `modules/overseas-master-data/pages/OverseasQuotesView.vue`、`ExchangeRatesView.vue`、`VehiclesView.vue` | `modules/overseas-master-data/api/overseasMasterData.ts`;Backend `lastleg-service/masterdata/` |
|
||||||
|
| 司机档案、驾驶证、到期提醒与资格预览 | `modules/overseas-master-data/pages/DriversView.vue`、`DriverDetailView.vue`;最近指引 `modules/overseas-master-data/AGENTS.md` | `api/drivers.ts`;Backend `lastleg-service/fleet/driver/`、File `FLEET_DRIVER_LICENSE` |
|
||||||
@@ -13,3 +13,5 @@
|
|||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,21 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Resolve independent PDA containers at request time; their restart must not block Admin startup.
|
||||||
|
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||||
|
location = /pda-domestic { return 308 /pda-domestic/; }
|
||||||
|
location = /pda-overseas { return 308 /pda-overseas/; }
|
||||||
|
location ^~ /pda-domestic/ {
|
||||||
|
set $pda_domestic_origin http://pda-domestic-h5-test:80;
|
||||||
|
proxy_pass $pda_domestic_origin$request_uri;
|
||||||
|
proxy_set_header Host $proxy_host;
|
||||||
|
}
|
||||||
|
location ^~ /pda-overseas/ {
|
||||||
|
set $pda_overseas_origin http://pda-overseas-h5-test:80;
|
||||||
|
proxy_pass $pda_overseas_origin$request_uri;
|
||||||
|
proxy_set_header Host $proxy_host;
|
||||||
|
}
|
||||||
|
|
||||||
# Docker 容器运行正常的检查地址,供 Jenkins 使用。
|
# Docker 容器运行正常的检查地址,供 Jenkins 使用。
|
||||||
location = /healthz {
|
location = /healthz {
|
||||||
default_type text/plain;
|
default_type text/plain;
|
||||||
@@ -21,27 +36,39 @@ server {
|
|||||||
|
|
||||||
# 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。
|
# 前端代码中的 API 前缀为 /api;保持原始路径转给 Gateway。
|
||||||
location /api/ {
|
location /api/ {
|
||||||
client_max_body_size 12m;
|
client_max_body_size 55m;
|
||||||
proxy_pass ${API_UPSTREAM};
|
proxy_pass ${API_UPSTREAM};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $proxy_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 海外业务使用独立 Gateway;将浏览器前缀改写为 Gateway 的 /api 前缀。
|
# 墨家测量设备使用独立 OpenAPI 前缀,直接转发到统一 Gateway。
|
||||||
location /overseas-api/ {
|
# 该入口不经过 Admin 前端路由,供设备以固定 AppKey/HMAC 调用。
|
||||||
client_max_body_size 12m;
|
location /openapi/ {
|
||||||
rewrite ^/overseas-api/(.*)$ /api/$1 break;
|
client_max_body_size 55m;
|
||||||
proxy_pass ${OVERSEAS_API_UPSTREAM};
|
proxy_pass ${API_UPSTREAM};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $proxy_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Domestic PDA keeps its H5 module-safe prefix; authorization still belongs to the same Gateway.
|
||||||
|
location /gateway-api/ {
|
||||||
|
client_max_body_size 55m;
|
||||||
|
proxy_pass ${API_UPSTREAM}/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $proxy_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# HTML must be revalidated on every visit so a new deployment cannot keep
|
# HTML must be revalidated on every visit so a new deployment cannot keep
|
||||||
# referencing hashed chunks that were removed with the previous image.
|
# referencing hashed chunks that were removed with the previous image.
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsc" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../typescript/bin/tsc" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../typescript/bin/tsc" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../typescript/bin/tsc" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsc
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsc" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\typescript\bin\tsc" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../typescript/bin/tsc" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../typescript/bin/tsserver" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../typescript/bin/tsserver" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../typescript/bin/tsserver" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../typescript/bin/tsserver" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/typescript/bin/tsserver
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\typescript\bin\tsserver" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\typescript\bin\tsserver" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules\typescript\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../typescript/bin/tsserver" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../vite/bin/vite.js" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../vite/bin/vite.js" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../vite/bin/vite.js" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vite/bin/vite.js
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\vite\bin\vite.js" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\vite\bin\vite.js" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules\vite\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vite@8.1.5_@types+node@24.13.3_yaml@2.9.0\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules/vite/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vite@8.1.5_@types+node@24.13.3_yaml@2.9.0/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../vite/bin/vite.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../vitest/vitest.mjs" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../vitest/vitest.mjs" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../vitest/vitest.mjs" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../vitest/vitest.mjs" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vitest/vitest.mjs
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\vitest\vitest.mjs" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\vitest\vitest.mjs" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules\vitest\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules/vitest/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vitest@4.1.10_@types+node@2_684c6d116269d8b9a57007d2e711980e/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../vitest/vitest.mjs" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
|
||||||
basedir_win="$basedir"
|
|
||||||
exe=""
|
|
||||||
msys=""
|
|
||||||
|
|
||||||
case `uname -a` in
|
|
||||||
*CYGWIN*|*MINGW*|*MSYS*)
|
|
||||||
if command -v cygpath > /dev/null 2>&1; then
|
|
||||||
basedir_win=`cygpath -w "$basedir"`
|
|
||||||
fi
|
|
||||||
exe=".exe"
|
|
||||||
msys="true"
|
|
||||||
;;
|
|
||||||
*WSL2*)
|
|
||||||
if command -v wslpath > /dev/null 2>&1; then
|
|
||||||
basedir_win="$(wslpath -w "$basedir" 2> /dev/null)"
|
|
||||||
if [ $? -ne 0 ] || [ -z "$basedir_win" ]; then
|
|
||||||
basedir_win="$basedir"
|
|
||||||
else
|
|
||||||
exe=".exe"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$NODE_PATH" ]; then
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
else
|
|
||||||
export NODE_PATH="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules:$NODE_PATH"
|
|
||||||
fi
|
|
||||||
if [ -n "$exe" ] && [ -x "$basedir/node.exe" ]; then
|
|
||||||
exec "$basedir/node.exe" "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
elif [ -x "$basedir/node" ]; then
|
|
||||||
exec "$basedir/node" "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
elif command -v node >/dev/null 2>&1; then
|
|
||||||
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
elif [ -n "$exe" ] && command -v node.exe >/dev/null 2>&1; then
|
|
||||||
exec node.exe "$basedir_win/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
else
|
|
||||||
exec node "$basedir/../vue-tsc/bin/vue-tsc.js" "$@"
|
|
||||||
fi
|
|
||||||
# cmd-shim-target=C:/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/vue-tsc/bin/vue-tsc.js
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@SETLOCAL
|
|
||||||
@IF NOT DEFINED NODE_PATH (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
) ELSE (
|
|
||||||
@SET "NODE_PATH=C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules;%NODE_PATH%"
|
|
||||||
)
|
|
||||||
@IF EXIST "%~dp0\node.exe" (
|
|
||||||
"%~dp0\node.exe" "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
|
|
||||||
) ELSE (
|
|
||||||
@SET PATHEXT=%PATHEXT:;.JS;=;%
|
|
||||||
node "%~dp0\..\vue-tsc\bin\vue-tsc.js" %*
|
|
||||||
)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
|
|
||||||
|
|
||||||
$exe=""
|
|
||||||
$pathsep=":"
|
|
||||||
$env_node_path=$env:NODE_PATH
|
|
||||||
$new_node_path="C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules\vue-tsc\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\vue-tsc@3.3.8_typescript@5.9.3\node_modules;C:\Users\BY-OY\Desktop\code\chongchong-global-dev\apps\admin-web\node_modules\.pnpm\node_modules"
|
|
||||||
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
|
|
||||||
# Fix case when both the Windows and Linux builds of Node
|
|
||||||
# are installed in the same directory
|
|
||||||
$exe=".exe"
|
|
||||||
$pathsep=";"
|
|
||||||
} else {
|
|
||||||
$new_node_path="/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules/vue-tsc/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/vue-tsc@3.3.8_typescript@5.9.3/node_modules:/mnt/c/Users/BY-OY/Desktop/code/chongchong-global-dev/apps/admin-web/node_modules/.pnpm/node_modules"
|
|
||||||
}
|
|
||||||
if ([string]::IsNullOrEmpty($env_node_path)) {
|
|
||||||
$env:NODE_PATH=$new_node_path
|
|
||||||
} else {
|
|
||||||
$env:NODE_PATH="$new_node_path$pathsep$env_node_path"
|
|
||||||
}
|
|
||||||
|
|
||||||
$ret=0
|
|
||||||
if (Test-Path "$basedir/node$exe") {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
} else {
|
|
||||||
& "$basedir/node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
} else {
|
|
||||||
# Support pipeline input
|
|
||||||
if ($MyInvocation.ExpectingInput) {
|
|
||||||
$input | & "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
} else {
|
|
||||||
& "node$exe" "$basedir/../vue-tsc/bin/vue-tsc.js" $args
|
|
||||||
}
|
|
||||||
$ret=$LASTEXITCODE
|
|
||||||
}
|
|
||||||
$env:NODE_PATH=$env_node_path
|
|
||||||
exit $ret
|
|
||||||
@@ -1,743 +0,0 @@
|
|||||||
{
|
|
||||||
"hoistedDependencies": {
|
|
||||||
"undici-types@7.18.2": {
|
|
||||||
"undici-types": "private"
|
|
||||||
},
|
|
||||||
"@rolldown/pluginutils@1.0.1": {
|
|
||||||
"@rolldown/pluginutils": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-dom@3.5.40": {
|
|
||||||
"@vue/compiler-dom": "private"
|
|
||||||
},
|
|
||||||
"js-beautify@1.15.4": {
|
|
||||||
"js-beautify": "private"
|
|
||||||
},
|
|
||||||
"vue-component-type-helpers@3.3.8": {
|
|
||||||
"vue-component-type-helpers": "private"
|
|
||||||
},
|
|
||||||
"@vue/server-renderer@3.5.40": {
|
|
||||||
"@vue/server-renderer": "private"
|
|
||||||
},
|
|
||||||
"follow-redirects@1.16.0(debug@4.4.3)": {
|
|
||||||
"follow-redirects": "private"
|
|
||||||
},
|
|
||||||
"form-data@4.0.6": {
|
|
||||||
"form-data": "private"
|
|
||||||
},
|
|
||||||
"https-proxy-agent@5.0.1": {
|
|
||||||
"https-proxy-agent": "private"
|
|
||||||
},
|
|
||||||
"proxy-from-env@2.1.0": {
|
|
||||||
"proxy-from-env": "private"
|
|
||||||
},
|
|
||||||
"@ctrl/tinycolor@4.2.0": {
|
|
||||||
"@ctrl/tinycolor": "private"
|
|
||||||
},
|
|
||||||
"@element-plus/icons-vue@2.3.2(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@element-plus/icons-vue": "private"
|
|
||||||
},
|
|
||||||
"@floating-ui/dom@1.8.0": {
|
|
||||||
"@floating-ui/dom": "private"
|
|
||||||
},
|
|
||||||
"@sxzz/popperjs-es@2.11.8": {
|
|
||||||
"@popperjs/core": "private"
|
|
||||||
},
|
|
||||||
"@types/lodash@4.17.24": {
|
|
||||||
"@types/lodash": "private"
|
|
||||||
},
|
|
||||||
"@types/lodash-es@4.17.12": {
|
|
||||||
"@types/lodash-es": "private"
|
|
||||||
},
|
|
||||||
"@vueuse/core@14.3.0(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@vueuse/core": "private"
|
|
||||||
},
|
|
||||||
"async-validator@4.2.5": {
|
|
||||||
"async-validator": "private"
|
|
||||||
},
|
|
||||||
"dayjs@1.11.21": {
|
|
||||||
"dayjs": "private"
|
|
||||||
},
|
|
||||||
"lodash@4.18.1": {
|
|
||||||
"lodash": "private"
|
|
||||||
},
|
|
||||||
"lodash-es@4.18.1": {
|
|
||||||
"lodash-es": "private"
|
|
||||||
},
|
|
||||||
"lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1)": {
|
|
||||||
"lodash-unified": "private"
|
|
||||||
},
|
|
||||||
"memoize-one@6.0.0": {
|
|
||||||
"memoize-one": "private"
|
|
||||||
},
|
|
||||||
"normalize-wheel-es@1.2.0": {
|
|
||||||
"normalize-wheel-es": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/css-color@5.1.11": {
|
|
||||||
"@asamuzakjp/css-color": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/dom-selector@7.1.1": {
|
|
||||||
"@asamuzakjp/dom-selector": "private"
|
|
||||||
},
|
|
||||||
"@bramus/specificity@2.4.2": {
|
|
||||||
"@bramus/specificity": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)": {
|
|
||||||
"@csstools/css-syntax-patches-for-csstree": "private"
|
|
||||||
},
|
|
||||||
"@exodus/bytes@1.15.1": {
|
|
||||||
"@exodus/bytes": "private"
|
|
||||||
},
|
|
||||||
"css-tree@3.2.1": {
|
|
||||||
"css-tree": "private"
|
|
||||||
},
|
|
||||||
"data-urls@7.0.0": {
|
|
||||||
"data-urls": "private"
|
|
||||||
},
|
|
||||||
"decimal.js@10.6.0": {
|
|
||||||
"decimal.js": "private"
|
|
||||||
},
|
|
||||||
"html-encoding-sniffer@6.0.0": {
|
|
||||||
"html-encoding-sniffer": "private"
|
|
||||||
},
|
|
||||||
"is-potential-custom-element-name@1.0.1": {
|
|
||||||
"is-potential-custom-element-name": "private"
|
|
||||||
},
|
|
||||||
"lru-cache@11.5.2": {
|
|
||||||
"lru-cache": "private"
|
|
||||||
},
|
|
||||||
"parse5@8.0.1": {
|
|
||||||
"parse5": "private"
|
|
||||||
},
|
|
||||||
"saxes@6.0.0": {
|
|
||||||
"saxes": "private"
|
|
||||||
},
|
|
||||||
"symbol-tree@3.2.4": {
|
|
||||||
"symbol-tree": "private"
|
|
||||||
},
|
|
||||||
"tough-cookie@6.0.2": {
|
|
||||||
"tough-cookie": "private"
|
|
||||||
},
|
|
||||||
"undici@7.29.0": {
|
|
||||||
"undici": "private"
|
|
||||||
},
|
|
||||||
"w3c-xmlserializer@5.0.0": {
|
|
||||||
"w3c-xmlserializer": "private"
|
|
||||||
},
|
|
||||||
"webidl-conversions@8.0.1": {
|
|
||||||
"webidl-conversions": "private"
|
|
||||||
},
|
|
||||||
"whatwg-mimetype@5.0.0": {
|
|
||||||
"whatwg-mimetype": "private"
|
|
||||||
},
|
|
||||||
"whatwg-url@16.0.1": {
|
|
||||||
"whatwg-url": "private"
|
|
||||||
},
|
|
||||||
"xml-name-validator@5.0.0": {
|
|
||||||
"xml-name-validator": "private"
|
|
||||||
},
|
|
||||||
"@vue/devtools-api@8.2.1": {
|
|
||||||
"@vue/devtools-api": "private"
|
|
||||||
},
|
|
||||||
"nostics@1.2.0": {
|
|
||||||
"nostics": "private"
|
|
||||||
},
|
|
||||||
"local-pkg@1.2.1": {
|
|
||||||
"local-pkg": "private"
|
|
||||||
},
|
|
||||||
"magic-string@0.30.21": {
|
|
||||||
"magic-string": "private"
|
|
||||||
},
|
|
||||||
"picomatch@4.0.5": {
|
|
||||||
"picomatch": "private"
|
|
||||||
},
|
|
||||||
"unimport@5.7.0": {
|
|
||||||
"unimport": "private"
|
|
||||||
},
|
|
||||||
"unplugin@2.3.11": {
|
|
||||||
"unplugin": "private"
|
|
||||||
},
|
|
||||||
"unplugin-utils@0.3.2": {
|
|
||||||
"unplugin-utils": "private"
|
|
||||||
},
|
|
||||||
"chokidar@5.0.0": {
|
|
||||||
"chokidar": "private"
|
|
||||||
},
|
|
||||||
"mlly@1.8.2": {
|
|
||||||
"mlly": "private"
|
|
||||||
},
|
|
||||||
"obug@2.1.4": {
|
|
||||||
"obug": "private"
|
|
||||||
},
|
|
||||||
"tinyglobby@0.2.17": {
|
|
||||||
"tinyglobby": "private"
|
|
||||||
},
|
|
||||||
"lightningcss@1.33.0": {
|
|
||||||
"lightningcss": "private"
|
|
||||||
},
|
|
||||||
"postcss@8.5.23": {
|
|
||||||
"postcss": "private"
|
|
||||||
},
|
|
||||||
"rolldown@1.1.5": {
|
|
||||||
"rolldown": "private"
|
|
||||||
},
|
|
||||||
"yaml@2.9.0": {
|
|
||||||
"yaml": "private"
|
|
||||||
},
|
|
||||||
"@vitest/expect@4.1.10": {
|
|
||||||
"@vitest/expect": "private"
|
|
||||||
},
|
|
||||||
"@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))": {
|
|
||||||
"@vitest/mocker": "private"
|
|
||||||
},
|
|
||||||
"@vitest/pretty-format@4.1.10": {
|
|
||||||
"@vitest/pretty-format": "private"
|
|
||||||
},
|
|
||||||
"@vitest/runner@4.1.10": {
|
|
||||||
"@vitest/runner": "private"
|
|
||||||
},
|
|
||||||
"@vitest/snapshot@4.1.10": {
|
|
||||||
"@vitest/snapshot": "private"
|
|
||||||
},
|
|
||||||
"@vitest/spy@4.1.10": {
|
|
||||||
"@vitest/spy": "private"
|
|
||||||
},
|
|
||||||
"@vitest/utils@4.1.10": {
|
|
||||||
"@vitest/utils": "private"
|
|
||||||
},
|
|
||||||
"es-module-lexer@2.3.1": {
|
|
||||||
"es-module-lexer": "private"
|
|
||||||
},
|
|
||||||
"expect-type@1.4.0": {
|
|
||||||
"expect-type": "private"
|
|
||||||
},
|
|
||||||
"pathe@2.0.3": {
|
|
||||||
"pathe": "private"
|
|
||||||
},
|
|
||||||
"std-env@4.2.0": {
|
|
||||||
"std-env": "private"
|
|
||||||
},
|
|
||||||
"tinybench@2.9.0": {
|
|
||||||
"tinybench": "private"
|
|
||||||
},
|
|
||||||
"tinyexec@1.2.4": {
|
|
||||||
"tinyexec": "private"
|
|
||||||
},
|
|
||||||
"tinyrainbow@3.1.0": {
|
|
||||||
"tinyrainbow": "private"
|
|
||||||
},
|
|
||||||
"why-is-node-running@2.3.0": {
|
|
||||||
"why-is-node-running": "private"
|
|
||||||
},
|
|
||||||
"@babel/generator@8.0.0": {
|
|
||||||
"@babel/generator": "private"
|
|
||||||
},
|
|
||||||
"@vue-macros/common@3.1.4(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@vue-macros/common": "private"
|
|
||||||
},
|
|
||||||
"ast-walker-scope@0.9.0": {
|
|
||||||
"ast-walker-scope": "private"
|
|
||||||
},
|
|
||||||
"json5@2.2.3": {
|
|
||||||
"json5": "private"
|
|
||||||
},
|
|
||||||
"muggle-string@0.4.1": {
|
|
||||||
"muggle-string": "private"
|
|
||||||
},
|
|
||||||
"scule@1.3.0": {
|
|
||||||
"scule": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-sfc@3.5.40": {
|
|
||||||
"@vue/compiler-sfc": "private"
|
|
||||||
},
|
|
||||||
"@volar/typescript@2.4.28": {
|
|
||||||
"@volar/typescript": "private"
|
|
||||||
},
|
|
||||||
"@vue/language-core@3.3.8": {
|
|
||||||
"@vue/language-core": "private"
|
|
||||||
},
|
|
||||||
"@vue/runtime-dom@3.5.40": {
|
|
||||||
"@vue/runtime-dom": "private"
|
|
||||||
},
|
|
||||||
"@vue/shared@3.5.40": {
|
|
||||||
"@vue/shared": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/generational-cache@1.0.1": {
|
|
||||||
"@asamuzakjp/generational-cache": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
|
|
||||||
"@csstools/css-calc": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)": {
|
|
||||||
"@csstools/css-color-parser": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)": {
|
|
||||||
"@csstools/css-parser-algorithms": "private"
|
|
||||||
},
|
|
||||||
"@csstools/css-tokenizer@4.0.0": {
|
|
||||||
"@csstools/css-tokenizer": "private"
|
|
||||||
},
|
|
||||||
"@asamuzakjp/nwsapi@2.3.9": {
|
|
||||||
"@asamuzakjp/nwsapi": "private"
|
|
||||||
},
|
|
||||||
"bidi-js@1.0.3": {
|
|
||||||
"bidi-js": "private"
|
|
||||||
},
|
|
||||||
"@babel/parser@8.0.4": {
|
|
||||||
"@babel/parser": "private"
|
|
||||||
},
|
|
||||||
"@babel/types@8.0.4": {
|
|
||||||
"@babel/types": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/gen-mapping@0.3.13": {
|
|
||||||
"@jridgewell/gen-mapping": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/trace-mapping@0.3.31": {
|
|
||||||
"@jridgewell/trace-mapping": "private"
|
|
||||||
},
|
|
||||||
"@types/jsesc@2.5.1": {
|
|
||||||
"@types/jsesc": "private"
|
|
||||||
},
|
|
||||||
"jsesc@3.1.0": {
|
|
||||||
"jsesc": "private"
|
|
||||||
},
|
|
||||||
"@floating-ui/core@1.8.0": {
|
|
||||||
"@floating-ui/core": "private"
|
|
||||||
},
|
|
||||||
"@floating-ui/utils@0.2.12": {
|
|
||||||
"@floating-ui/utils": "private"
|
|
||||||
},
|
|
||||||
"@standard-schema/spec@1.1.0": {
|
|
||||||
"@standard-schema/spec": "private"
|
|
||||||
},
|
|
||||||
"@types/chai@5.2.3": {
|
|
||||||
"@types/chai": "private"
|
|
||||||
},
|
|
||||||
"chai@6.2.2": {
|
|
||||||
"chai": "private"
|
|
||||||
},
|
|
||||||
"estree-walker@3.0.3": {
|
|
||||||
"estree-walker": "private"
|
|
||||||
},
|
|
||||||
"convert-source-map@2.0.0": {
|
|
||||||
"convert-source-map": "private"
|
|
||||||
},
|
|
||||||
"@volar/language-core@2.4.28": {
|
|
||||||
"@volar/language-core": "private"
|
|
||||||
},
|
|
||||||
"path-browserify@1.0.1": {
|
|
||||||
"path-browserify": "private"
|
|
||||||
},
|
|
||||||
"vscode-uri@3.1.0": {
|
|
||||||
"vscode-uri": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-core@3.5.40": {
|
|
||||||
"@vue/compiler-core": "private"
|
|
||||||
},
|
|
||||||
"@vue/compiler-ssr@3.5.40": {
|
|
||||||
"@vue/compiler-ssr": "private"
|
|
||||||
},
|
|
||||||
"source-map-js@1.2.1": {
|
|
||||||
"source-map-js": "private"
|
|
||||||
},
|
|
||||||
"@vue/devtools-kit@8.2.1": {
|
|
||||||
"@vue/devtools-kit": "private"
|
|
||||||
},
|
|
||||||
"alien-signals@3.2.1": {
|
|
||||||
"alien-signals": "private"
|
|
||||||
},
|
|
||||||
"@vue/reactivity@3.5.40": {
|
|
||||||
"@vue/reactivity": "private"
|
|
||||||
},
|
|
||||||
"@vue/runtime-core@3.5.40": {
|
|
||||||
"@vue/runtime-core": "private"
|
|
||||||
},
|
|
||||||
"csstype@3.2.3": {
|
|
||||||
"csstype": "private"
|
|
||||||
},
|
|
||||||
"ast-kit@2.2.0": {
|
|
||||||
"ast-kit": "private"
|
|
||||||
},
|
|
||||||
"magic-string-ast@1.0.3": {
|
|
||||||
"magic-string-ast": "private"
|
|
||||||
},
|
|
||||||
"@types/web-bluetooth@0.0.21": {
|
|
||||||
"@types/web-bluetooth": "private"
|
|
||||||
},
|
|
||||||
"@vueuse/metadata@14.3.0": {
|
|
||||||
"@vueuse/metadata": "private"
|
|
||||||
},
|
|
||||||
"@vueuse/shared@14.3.0(vue@3.5.40(typescript@5.9.3))": {
|
|
||||||
"@vueuse/shared": "private"
|
|
||||||
},
|
|
||||||
"readdirp@5.0.0": {
|
|
||||||
"readdirp": "private"
|
|
||||||
},
|
|
||||||
"mdn-data@2.27.1": {
|
|
||||||
"mdn-data": "private"
|
|
||||||
},
|
|
||||||
"debug@4.4.3": {
|
|
||||||
"debug": "private"
|
|
||||||
},
|
|
||||||
"asynckit@0.4.0": {
|
|
||||||
"asynckit": "private"
|
|
||||||
},
|
|
||||||
"combined-stream@1.0.8": {
|
|
||||||
"combined-stream": "private"
|
|
||||||
},
|
|
||||||
"es-set-tostringtag@2.1.0": {
|
|
||||||
"es-set-tostringtag": "private"
|
|
||||||
},
|
|
||||||
"hasown@2.0.4": {
|
|
||||||
"hasown": "private"
|
|
||||||
},
|
|
||||||
"mime-types@2.1.35": {
|
|
||||||
"mime-types": "private"
|
|
||||||
},
|
|
||||||
"agent-base@6.0.2": {
|
|
||||||
"agent-base": "private"
|
|
||||||
},
|
|
||||||
"config-chain@1.1.13": {
|
|
||||||
"config-chain": "private"
|
|
||||||
},
|
|
||||||
"editorconfig@1.0.7": {
|
|
||||||
"editorconfig": "private"
|
|
||||||
},
|
|
||||||
"glob@10.5.0": {
|
|
||||||
"glob": "private"
|
|
||||||
},
|
|
||||||
"js-cookie@3.0.8": {
|
|
||||||
"js-cookie": "private"
|
|
||||||
},
|
|
||||||
"nopt@7.2.1": {
|
|
||||||
"nopt": "private"
|
|
||||||
},
|
|
||||||
"detect-libc@2.1.2": {
|
|
||||||
"detect-libc": "private"
|
|
||||||
},
|
|
||||||
"lightningcss-win32-x64-msvc@1.33.0": {
|
|
||||||
"lightningcss-win32-x64-msvc": "private"
|
|
||||||
},
|
|
||||||
"pkg-types@2.3.1": {
|
|
||||||
"pkg-types": "private"
|
|
||||||
},
|
|
||||||
"quansync@0.2.11": {
|
|
||||||
"quansync": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/sourcemap-codec@1.5.5": {
|
|
||||||
"@jridgewell/sourcemap-codec": "private"
|
|
||||||
},
|
|
||||||
"acorn@8.17.0": {
|
|
||||||
"acorn": "private"
|
|
||||||
},
|
|
||||||
"ufo@1.6.4": {
|
|
||||||
"ufo": "private"
|
|
||||||
},
|
|
||||||
"entities@8.0.0": {
|
|
||||||
"entities": "private"
|
|
||||||
},
|
|
||||||
"nanoid@3.3.16": {
|
|
||||||
"nanoid": "private"
|
|
||||||
},
|
|
||||||
"picocolors@1.1.1": {
|
|
||||||
"picocolors": "private"
|
|
||||||
},
|
|
||||||
"@oxc-project/types@0.139.0": {
|
|
||||||
"@oxc-project/types": "private"
|
|
||||||
},
|
|
||||||
"@rolldown/binding-win32-x64-msvc@1.1.5": {
|
|
||||||
"@rolldown/binding-win32-x64-msvc": "private"
|
|
||||||
},
|
|
||||||
"xmlchars@2.2.0": {
|
|
||||||
"xmlchars": "private"
|
|
||||||
},
|
|
||||||
"fdir@6.5.0(picomatch@4.0.5)": {
|
|
||||||
"fdir": "private"
|
|
||||||
},
|
|
||||||
"tldts@7.4.9": {
|
|
||||||
"tldts": "private"
|
|
||||||
},
|
|
||||||
"escape-string-regexp@5.0.0": {
|
|
||||||
"escape-string-regexp": "private"
|
|
||||||
},
|
|
||||||
"strip-literal@3.1.0": {
|
|
||||||
"strip-literal": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/remapping@2.3.5": {
|
|
||||||
"@jridgewell/remapping": "private"
|
|
||||||
},
|
|
||||||
"webpack-virtual-modules@0.6.2": {
|
|
||||||
"webpack-virtual-modules": "private"
|
|
||||||
},
|
|
||||||
"tr46@6.0.0": {
|
|
||||||
"tr46": "private"
|
|
||||||
},
|
|
||||||
"siginfo@2.0.0": {
|
|
||||||
"siginfo": "private"
|
|
||||||
},
|
|
||||||
"stackback@0.0.2": {
|
|
||||||
"stackback": "private"
|
|
||||||
},
|
|
||||||
"@babel/helper-string-parser@8.0.0": {
|
|
||||||
"@babel/helper-string-parser": "private"
|
|
||||||
},
|
|
||||||
"@babel/helper-validator-identifier@8.0.4": {
|
|
||||||
"@babel/helper-validator-identifier": "private"
|
|
||||||
},
|
|
||||||
"@csstools/color-helpers@6.1.0": {
|
|
||||||
"@csstools/color-helpers": "private"
|
|
||||||
},
|
|
||||||
"@types/deep-eql@4.0.2": {
|
|
||||||
"@types/deep-eql": "private"
|
|
||||||
},
|
|
||||||
"assertion-error@2.0.1": {
|
|
||||||
"assertion-error": "private"
|
|
||||||
},
|
|
||||||
"@volar/source-map@2.4.28": {
|
|
||||||
"@volar/source-map": "private"
|
|
||||||
},
|
|
||||||
"@vue/devtools-shared@8.2.1": {
|
|
||||||
"@vue/devtools-shared": "private"
|
|
||||||
},
|
|
||||||
"birpc@2.9.0": {
|
|
||||||
"birpc": "private"
|
|
||||||
},
|
|
||||||
"hookable@5.5.3": {
|
|
||||||
"hookable": "private"
|
|
||||||
},
|
|
||||||
"perfect-debounce@2.1.0": {
|
|
||||||
"perfect-debounce": "private"
|
|
||||||
},
|
|
||||||
"require-from-string@2.0.2": {
|
|
||||||
"require-from-string": "private"
|
|
||||||
},
|
|
||||||
"delayed-stream@1.0.0": {
|
|
||||||
"delayed-stream": "private"
|
|
||||||
},
|
|
||||||
"ini@1.3.8": {
|
|
||||||
"ini": "private"
|
|
||||||
},
|
|
||||||
"proto-list@1.2.4": {
|
|
||||||
"proto-list": "private"
|
|
||||||
},
|
|
||||||
"ms@2.1.3": {
|
|
||||||
"ms": "private"
|
|
||||||
},
|
|
||||||
"@one-ini/wasm@0.1.1": {
|
|
||||||
"@one-ini/wasm": "private"
|
|
||||||
},
|
|
||||||
"commander@10.0.1": {
|
|
||||||
"commander": "private"
|
|
||||||
},
|
|
||||||
"minimatch@9.0.9": {
|
|
||||||
"minimatch": "private"
|
|
||||||
},
|
|
||||||
"semver@7.8.5": {
|
|
||||||
"semver": "private"
|
|
||||||
},
|
|
||||||
"es-errors@1.3.0": {
|
|
||||||
"es-errors": "private"
|
|
||||||
},
|
|
||||||
"get-intrinsic@1.3.0": {
|
|
||||||
"get-intrinsic": "private"
|
|
||||||
},
|
|
||||||
"has-tostringtag@1.0.2": {
|
|
||||||
"has-tostringtag": "private"
|
|
||||||
},
|
|
||||||
"@types/estree@1.0.9": {
|
|
||||||
"@types/estree": "private"
|
|
||||||
},
|
|
||||||
"foreground-child@3.3.1": {
|
|
||||||
"foreground-child": "private"
|
|
||||||
},
|
|
||||||
"jackspeak@3.4.3": {
|
|
||||||
"jackspeak": "private"
|
|
||||||
},
|
|
||||||
"minipass@7.1.3": {
|
|
||||||
"minipass": "private"
|
|
||||||
},
|
|
||||||
"package-json-from-dist@1.0.1": {
|
|
||||||
"package-json-from-dist": "private"
|
|
||||||
},
|
|
||||||
"path-scurry@1.11.1": {
|
|
||||||
"path-scurry": "private"
|
|
||||||
},
|
|
||||||
"function-bind@1.1.2": {
|
|
||||||
"function-bind": "private"
|
|
||||||
},
|
|
||||||
"mime-db@1.52.0": {
|
|
||||||
"mime-db": "private"
|
|
||||||
},
|
|
||||||
"abbrev@2.0.0": {
|
|
||||||
"abbrev": "private"
|
|
||||||
},
|
|
||||||
"confbox@0.1.8": {
|
|
||||||
"confbox": "private"
|
|
||||||
},
|
|
||||||
"exsolve@1.1.0": {
|
|
||||||
"exsolve": "private"
|
|
||||||
},
|
|
||||||
"js-tokens@9.0.1": {
|
|
||||||
"js-tokens": "private"
|
|
||||||
},
|
|
||||||
"tldts-core@7.4.9": {
|
|
||||||
"tldts-core": "private"
|
|
||||||
},
|
|
||||||
"punycode@2.3.1": {
|
|
||||||
"punycode": "private"
|
|
||||||
},
|
|
||||||
"@jridgewell/resolve-uri@3.1.2": {
|
|
||||||
"@jridgewell/resolve-uri": "private"
|
|
||||||
},
|
|
||||||
"cross-spawn@7.0.6": {
|
|
||||||
"cross-spawn": "private"
|
|
||||||
},
|
|
||||||
"signal-exit@4.1.0": {
|
|
||||||
"signal-exit": "private"
|
|
||||||
},
|
|
||||||
"call-bind-apply-helpers@1.0.2": {
|
|
||||||
"call-bind-apply-helpers": "private"
|
|
||||||
},
|
|
||||||
"es-define-property@1.0.1": {
|
|
||||||
"es-define-property": "private"
|
|
||||||
},
|
|
||||||
"es-object-atoms@1.1.2": {
|
|
||||||
"es-object-atoms": "private"
|
|
||||||
},
|
|
||||||
"get-proto@1.0.1": {
|
|
||||||
"get-proto": "private"
|
|
||||||
},
|
|
||||||
"gopd@1.2.0": {
|
|
||||||
"gopd": "private"
|
|
||||||
},
|
|
||||||
"has-symbols@1.1.0": {
|
|
||||||
"has-symbols": "private"
|
|
||||||
},
|
|
||||||
"math-intrinsics@1.1.0": {
|
|
||||||
"math-intrinsics": "private"
|
|
||||||
},
|
|
||||||
"@isaacs/cliui@8.0.2": {
|
|
||||||
"@isaacs/cliui": "private"
|
|
||||||
},
|
|
||||||
"@pkgjs/parseargs@0.11.0": {
|
|
||||||
"@pkgjs/parseargs": "private"
|
|
||||||
},
|
|
||||||
"brace-expansion@2.1.2": {
|
|
||||||
"brace-expansion": "private"
|
|
||||||
},
|
|
||||||
"string-width@5.1.2": {
|
|
||||||
"string-width": "private"
|
|
||||||
},
|
|
||||||
"string-width@4.2.3": {
|
|
||||||
"string-width-cjs": "private"
|
|
||||||
},
|
|
||||||
"strip-ansi@7.2.0": {
|
|
||||||
"strip-ansi": "private"
|
|
||||||
},
|
|
||||||
"strip-ansi@6.0.1": {
|
|
||||||
"strip-ansi-cjs": "private"
|
|
||||||
},
|
|
||||||
"wrap-ansi@8.1.0": {
|
|
||||||
"wrap-ansi": "private"
|
|
||||||
},
|
|
||||||
"wrap-ansi@7.0.0": {
|
|
||||||
"wrap-ansi-cjs": "private"
|
|
||||||
},
|
|
||||||
"balanced-match@1.0.2": {
|
|
||||||
"balanced-match": "private"
|
|
||||||
},
|
|
||||||
"path-key@3.1.1": {
|
|
||||||
"path-key": "private"
|
|
||||||
},
|
|
||||||
"shebang-command@2.0.0": {
|
|
||||||
"shebang-command": "private"
|
|
||||||
},
|
|
||||||
"which@2.0.2": {
|
|
||||||
"which": "private"
|
|
||||||
},
|
|
||||||
"dunder-proto@1.0.1": {
|
|
||||||
"dunder-proto": "private"
|
|
||||||
},
|
|
||||||
"shebang-regex@3.0.0": {
|
|
||||||
"shebang-regex": "private"
|
|
||||||
},
|
|
||||||
"emoji-regex@8.0.0": {
|
|
||||||
"emoji-regex": "private"
|
|
||||||
},
|
|
||||||
"is-fullwidth-code-point@3.0.0": {
|
|
||||||
"is-fullwidth-code-point": "private"
|
|
||||||
},
|
|
||||||
"eastasianwidth@0.2.0": {
|
|
||||||
"eastasianwidth": "private"
|
|
||||||
},
|
|
||||||
"ansi-regex@5.0.1": {
|
|
||||||
"ansi-regex": "private"
|
|
||||||
},
|
|
||||||
"isexe@2.0.0": {
|
|
||||||
"isexe": "private"
|
|
||||||
},
|
|
||||||
"ansi-styles@4.3.0": {
|
|
||||||
"ansi-styles": "private"
|
|
||||||
},
|
|
||||||
"color-convert@2.0.1": {
|
|
||||||
"color-convert": "private"
|
|
||||||
},
|
|
||||||
"color-name@1.1.4": {
|
|
||||||
"color-name": "private"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"hoistPattern": [
|
|
||||||
"*"
|
|
||||||
],
|
|
||||||
"included": {
|
|
||||||
"dependencies": true,
|
|
||||||
"devDependencies": true,
|
|
||||||
"optionalDependencies": true
|
|
||||||
},
|
|
||||||
"injectedDeps": {},
|
|
||||||
"layoutVersion": 5,
|
|
||||||
"nodeLinker": "isolated",
|
|
||||||
"packageManager": "pnpm@11.17.0",
|
|
||||||
"pendingBuilds": [],
|
|
||||||
"publicHoistPattern": [],
|
|
||||||
"prunedAt": "Thu, 20 Aug 2026 06:47:32 GMT",
|
|
||||||
"registries": {
|
|
||||||
"default": "https://registry.npmjs.org/",
|
|
||||||
"@jsr": "https://npm.jsr.io/"
|
|
||||||
},
|
|
||||||
"skipped": [
|
|
||||||
"@emnapi/core@1.11.1",
|
|
||||||
"@emnapi/runtime@1.11.1",
|
|
||||||
"@emnapi/wasi-threads@1.2.2",
|
|
||||||
"@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)",
|
|
||||||
"@rolldown/binding-android-arm64@1.1.5",
|
|
||||||
"@rolldown/binding-darwin-arm64@1.1.5",
|
|
||||||
"@rolldown/binding-darwin-x64@1.1.5",
|
|
||||||
"@rolldown/binding-freebsd-x64@1.1.5",
|
|
||||||
"@rolldown/binding-linux-arm-gnueabihf@1.1.5",
|
|
||||||
"@rolldown/binding-linux-arm64-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-arm64-musl@1.1.5",
|
|
||||||
"@rolldown/binding-linux-ppc64-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-s390x-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-x64-gnu@1.1.5",
|
|
||||||
"@rolldown/binding-linux-x64-musl@1.1.5",
|
|
||||||
"@rolldown/binding-openharmony-arm64@1.1.5",
|
|
||||||
"@rolldown/binding-wasm32-wasi@1.1.5",
|
|
||||||
"@rolldown/binding-win32-arm64-msvc@1.1.5",
|
|
||||||
"@tybys/wasm-util@0.10.3",
|
|
||||||
"fsevents@2.3.3",
|
|
||||||
"lightningcss-android-arm64@1.33.0",
|
|
||||||
"lightningcss-darwin-arm64@1.33.0",
|
|
||||||
"lightningcss-darwin-x64@1.33.0",
|
|
||||||
"lightningcss-freebsd-x64@1.33.0",
|
|
||||||
"lightningcss-linux-arm-gnueabihf@1.33.0",
|
|
||||||
"lightningcss-linux-arm64-gnu@1.33.0",
|
|
||||||
"lightningcss-linux-arm64-musl@1.33.0",
|
|
||||||
"lightningcss-linux-x64-gnu@1.33.0",
|
|
||||||
"lightningcss-linux-x64-musl@1.33.0",
|
|
||||||
"lightningcss-win32-arm64-msvc@1.33.0",
|
|
||||||
"tslib@2.8.1"
|
|
||||||
],
|
|
||||||
"storeDir": "C:\\Users\\BY-OY\\AppData\\Local\\pnpm\\store\\v11",
|
|
||||||
"virtualStoreDir": "C:\\Users\\BY-OY\\Desktop\\code\\chongchong-global-dev\\apps\\admin-web\\node_modules\\.pnpm",
|
|
||||||
"virtualStoreDirMaxLength": 60
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"lastValidatedTimestamp": 1787208452958,
|
|
||||||
"projects": {},
|
|
||||||
"pnpmfiles": [],
|
|
||||||
"settings": {
|
|
||||||
"autoInstallPeers": true,
|
|
||||||
"dedupeDirectDeps": false,
|
|
||||||
"dedupeInjectedDeps": true,
|
|
||||||
"dedupePeerDependents": true,
|
|
||||||
"dedupePeers": false,
|
|
||||||
"dev": true,
|
|
||||||
"excludeLinksFromLockfile": false,
|
|
||||||
"hoistPattern": [
|
|
||||||
"*"
|
|
||||||
],
|
|
||||||
"hoistWorkspacePackages": true,
|
|
||||||
"injectWorkspacePackages": false,
|
|
||||||
"linkWorkspacePackages": false,
|
|
||||||
"minimumReleaseAge": 1440,
|
|
||||||
"minimumReleaseAgeIgnoreMissingTime": true,
|
|
||||||
"nodeLinker": "isolated",
|
|
||||||
"optional": true,
|
|
||||||
"peersSuffixMaxLength": 1000,
|
|
||||||
"preferWorkspacePackages": false,
|
|
||||||
"production": true,
|
|
||||||
"publicHoistPattern": []
|
|
||||||
},
|
|
||||||
"filteredInstall": false
|
|
||||||
}
|
|
||||||
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 149 KiB |
@@ -0,0 +1,92 @@
|
|||||||
|
{
|
||||||
|
"date": "2026-10-09",
|
||||||
|
"testOnly": true,
|
||||||
|
"deployment": {
|
||||||
|
"admin": {
|
||||||
|
"build": 536,
|
||||||
|
"result": "SUCCESS",
|
||||||
|
"commit": "73c29f5"
|
||||||
|
},
|
||||||
|
"firstleg": {
|
||||||
|
"build": 488,
|
||||||
|
"result": "SUCCESS",
|
||||||
|
"commit": "e94271706ce7"
|
||||||
|
},
|
||||||
|
"lastleg": {
|
||||||
|
"build": 234,
|
||||||
|
"result": "SUCCESS",
|
||||||
|
"commit": "7fd7699f6e83"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"databaseReset": {
|
||||||
|
"tables": [
|
||||||
|
"ll_inbound_fba_appointment",
|
||||||
|
"ll_inbound_fba_appointment_item"
|
||||||
|
],
|
||||||
|
"beforeCounts": [
|
||||||
|
49,
|
||||||
|
59
|
||||||
|
],
|
||||||
|
"afterResetCounts": [
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"httpPassed": [
|
||||||
|
"five_customer_one_appointment",
|
||||||
|
"same_command_replay",
|
||||||
|
"duplicate_cargo_conflict",
|
||||||
|
"update_version",
|
||||||
|
"stale_update_conflict",
|
||||||
|
"stale_delete_conflict",
|
||||||
|
"delete_and_calendar_absence",
|
||||||
|
"rebook_after_delete",
|
||||||
|
"two_new_customer_accounts_10_negative_isolation_checks",
|
||||||
|
"nonempty_customer_75_rows_all_owned",
|
||||||
|
"nonempty_customer_forged_filter_stays_scoped",
|
||||||
|
"own_plan_detail_visible_other_plan_hidden",
|
||||||
|
"existing_customer_platform_appointment_forbidden",
|
||||||
|
"browser_deleted_appointment_absence_http_confirmed"
|
||||||
|
],
|
||||||
|
"uiPassed": [
|
||||||
|
"1920x1080_month",
|
||||||
|
"five_customer_detail",
|
||||||
|
"edit_saved",
|
||||||
|
"week_view",
|
||||||
|
"candidate_existing_appointment_disabled",
|
||||||
|
"two_customer_selection",
|
||||||
|
"cross_page_selection_retained",
|
||||||
|
"create_saved",
|
||||||
|
"final_month_top_alignment",
|
||||||
|
"final_week_top_alignment",
|
||||||
|
"delete_confirmation_and_active_dispatch_rejection",
|
||||||
|
"month_week_three_previews_233_total_title",
|
||||||
|
"browser_successful_delete_two_to_one"
|
||||||
|
],
|
||||||
|
"initialCalendarRequests": [
|
||||||
|
{
|
||||||
|
"type": "appointments",
|
||||||
|
"count": 1,
|
||||||
|
"durationMs": 59
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "arrivals",
|
||||||
|
"count": 1,
|
||||||
|
"durationMs": 106
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pending": [],
|
||||||
|
"screenshots": [
|
||||||
|
"month-five-customers.png",
|
||||||
|
"week-five-customers.png",
|
||||||
|
"final-month.png",
|
||||||
|
"compact-month.png",
|
||||||
|
"compact-week.png",
|
||||||
|
"delete-success.png"
|
||||||
|
],
|
||||||
|
"scope": {
|
||||||
|
"admin": "appointment calendar and inbound appointment consumer",
|
||||||
|
"client": "no page or code change; merchant inbound API positive and negative isolation verified",
|
||||||
|
"remainingSample": "QA-UI-20261009-2CUSTOMERS"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 110 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"repo": "html/admin-web", "number": 18, "url": "http://8.163.30.128:10082/html/admin-web/pulls/18", "head": "73c29f5264e46f648713c58b469a8c7bd335cd9c", "branch": "release/appointment-gray-20261009"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
f9dd30b8b9c6b756c4502e4efc33df4ddb59e566
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"repo": "java-project/baiyue-firstleg", "number": 25, "url": "http://8.163.30.128:10082/java-project/baiyue-firstleg/pulls/25", "head": "7fd7699f6e83e77e487aceff914d160cf42376a0", "branch": "release/appointment-gray-20261009"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
f60f44793f289f7612b561f1c470e093f67f4e32
|
||||||
|
After Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,30 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "permissions",
|
||||||
|
"http": 200,
|
||||||
|
"code": "200",
|
||||||
|
"count": 358,
|
||||||
|
"elapsedMs": 115
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "arrivals",
|
||||||
|
"http": 200,
|
||||||
|
"code": "200",
|
||||||
|
"count": 10,
|
||||||
|
"elapsedMs": 297
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "appointments",
|
||||||
|
"http": 200,
|
||||||
|
"code": "200",
|
||||||
|
"count": 0,
|
||||||
|
"elapsedMs": 287
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inboundCandidates",
|
||||||
|
"http": 200,
|
||||||
|
"code": "200",
|
||||||
|
"count": 10,
|
||||||
|
"elapsedMs": 221
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
[{"job": "baiyue-firstleg-prod-lastleg-service", "queue": "https://jenkins.buggoglobal.com/queue/item/7294/", "number": 13, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "baiyue-firstleg-prod-firstleg", "queue": "https://jenkins.buggoglobal.com/queue/item/7295/", "number": 14, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}, {"job": "admin-web-prod", "queue": "https://jenkins.buggoglobal.com/queue/item/7300/", "number": 26, "_class": "org.jenkinsci.plugins.workflow.job.WorkflowRun", "building": false, "result": "SUCCESS"}]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"scope": [
|
||||||
|
"admin-web",
|
||||||
|
"firstleg",
|
||||||
|
"lastleg-service"
|
||||||
|
],
|
||||||
|
"databaseAuthorized": true,
|
||||||
|
"schemaPreflight": {
|
||||||
|
"appointmentRows": 0,
|
||||||
|
"itemRows": 0,
|
||||||
|
"permissionConflicts": 0,
|
||||||
|
"adminRoleCount": 1,
|
||||||
|
"affectedAdminSubjects": 1
|
||||||
|
},
|
||||||
|
"targets": {
|
||||||
|
"backend": "7fd7699f6",
|
||||||
|
"admin": "73c29f5"
|
||||||
|
},
|
||||||
|
"sourceEquivalence": {
|
||||||
|
"backendMergeTreeMatchesTestedSource": true,
|
||||||
|
"adminMergeTreeMatchesTestedSource": true
|
||||||
|
},
|
||||||
|
"sqlExecuted": true,
|
||||||
|
"deployed": true,
|
||||||
|
"pending": null,
|
||||||
|
"releaseBranchesDeleted": [
|
||||||
|
"backend",
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
"browserVerified": true,
|
||||||
|
"grayWriteCrudRetested": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- REVIEW ONLY: gray appointment schema and platform administrator permissions.
|
||||||
|
-- Execute only with explicit gray SQL authorization, after verifying both appointment tables remain empty.
|
||||||
|
-- No DROP TABLE, DELETE, TRUNCATE, or business-data backfill.
|
||||||
|
-- DDL auto-commits; stop immediately on any error. Do not use mysql --force.
|
||||||
|
USE baiyue_lastleg;
|
||||||
|
ALTER TABLE ll_inbound_fba_appointment
|
||||||
|
DROP INDEX uk_ll_fba_appointment_no,
|
||||||
|
DROP INDEX idx_ll_fba_appointment_list,
|
||||||
|
DROP COLUMN customer_id,
|
||||||
|
ADD COLUMN appointment_end_at DATETIME(3) NULL COMMENT '预约结束时间,未明确时为空' AFTER appointment_at,
|
||||||
|
ADD COLUMN version INT NOT NULL DEFAULT 0 COMMENT '并发版本' AFTER appointment_end_at,
|
||||||
|
ADD UNIQUE KEY uk_ll_fba_appointment_no (appointment_no),
|
||||||
|
ADD KEY idx_ll_fba_appointment_list (appointment_at, appointment_status, fba_warehouse_code);
|
||||||
|
ALTER TABLE ll_inbound_fba_appointment_item
|
||||||
|
ADD COLUMN customer_id VARCHAR(128) NOT NULL COMMENT '本票货物客户ID' AFTER appointment_id,
|
||||||
|
ADD KEY idx_ll_fba_appointment_item_cargo (line_id, appointment_id);
|
||||||
|
USE baiyue_iam;
|
||||||
|
START TRANSACTION;
|
||||||
|
INSERT INTO iam_permission
|
||||||
|
(id,tenant_id,app_code,permission_type,parent_id,permission_code,permission_name,route_path,http_method,sort_no,status,built_in,deleted)
|
||||||
|
VALUES
|
||||||
|
(5107280,0,'PLATFORM','MENU',0,'platform:appointment:list','预约列表','/appointment-calendar','GET',740,'ACTIVE',1,0),
|
||||||
|
(5107281,0,'PLATFORM','BUTTON',5107280,'platform:appointment:delete','删除预约',NULL,'DELETE',741,'ACTIVE',1,0);
|
||||||
|
INSERT INTO iam_role_permission (tenant_id,role_id,permission_id,created_at)
|
||||||
|
SELECT 0,r.id,p.id,NOW(3) FROM iam_role r CROSS JOIN iam_permission p
|
||||||
|
WHERE r.id=4000001 AND r.tenant_id=0 AND r.role_code='PLATFORM_ADMIN' AND r.status='ACTIVE' AND r.deleted=0
|
||||||
|
AND p.id IN (5107280,5107281);
|
||||||
|
INSERT INTO iam_security_subject_version (auth_user_id,permission_version,updated_at)
|
||||||
|
SELECT DISTINCT u.auth_user_id,1,NOW(3)
|
||||||
|
FROM iam_membership_role mr
|
||||||
|
JOIN iam_membership m ON m.id=mr.membership_id AND m.tenant_id=mr.tenant_id
|
||||||
|
JOIN iam_user u ON u.id=m.user_id
|
||||||
|
WHERE mr.tenant_id=0 AND mr.role_id=4000001 AND m.deleted=0 AND u.deleted=0
|
||||||
|
ON DUPLICATE KEY UPDATE permission_version=permission_version+1,updated_at=NOW(3);
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ll_inbound_fba_appointment 13
|
||||||
|
ll_inbound_fba_appointment_item 9
|
||||||
|
platform:appointment:list
|
||||||
|
platform:appointment:delete
|
||||||
|
2
|
||||||
@@ -5,20 +5,24 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit -p tsconfig.app.json && vite build",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit -p tsconfig.app.json",
|
||||||
|
"typecheck:all": "vue-tsc --noEmit -p tsconfig.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:phase4:development": "node scripts/phase4-development-frontend-acceptance.mjs",
|
"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs",
|
||||||
"preview": "vite preview --port 4173"
|
"preview": "vite preview --port 4173"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chongchong/shared": "link:./shared",
|
"@chongchong/shared": "link:./shared",
|
||||||
"@lucide/vue": "^1.27.0",
|
"@lucide/vue": "^1.27.0",
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
"dayjs": "^1.11.21",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"element-plus": "^2.14.3",
|
"element-plus": "^2.14.3",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"sortablejs": "^1.15.7",
|
"sortablejs": "^1.15.7",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
|
"vue-echarts": "^8.3.2",
|
||||||
"vue-router": "^5.2.0"
|
"vue-router": "^5.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.18.1
|
specifier: ^1.18.1
|
||||||
version: 1.18.1(debug@4.4.3)
|
version: 1.18.1(debug@4.4.3)
|
||||||
|
dayjs:
|
||||||
|
specifier: ^1.11.21
|
||||||
|
version: 1.11.21
|
||||||
|
echarts:
|
||||||
|
specifier: ^6.1.0
|
||||||
|
version: 6.1.0
|
||||||
element-plus:
|
element-plus:
|
||||||
specifier: ^2.14.3
|
specifier: ^2.14.3
|
||||||
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
|
version: 2.14.3(vue@3.5.40(typescript@5.9.3))
|
||||||
@@ -29,6 +35,9 @@ importers:
|
|||||||
vue:
|
vue:
|
||||||
specifier: ^3.5.40
|
specifier: ^3.5.40
|
||||||
version: 3.5.40(typescript@5.9.3)
|
version: 3.5.40(typescript@5.9.3)
|
||||||
|
vue-echarts:
|
||||||
|
specifier: ^8.3.2
|
||||||
|
version: 8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3))
|
||||||
vue-router:
|
vue-router:
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
version: 5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3))
|
||||||
@@ -638,6 +647,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
echarts@6.1.0:
|
||||||
|
resolution: {integrity: sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==}
|
||||||
|
|
||||||
editorconfig@1.0.7:
|
editorconfig@1.0.7:
|
||||||
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
|
resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1147,6 +1159,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
tslib@2.3.0:
|
||||||
|
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -1322,6 +1337,12 @@ packages:
|
|||||||
vue-component-type-helpers@3.3.8:
|
vue-component-type-helpers@3.3.8:
|
||||||
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
|
resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==}
|
||||||
|
|
||||||
|
vue-echarts@8.3.2:
|
||||||
|
resolution: {integrity: sha512-FbJD49OZZZv1I8aclHQ+gcQxU+Y9nNWQMuod1sewE7/YjGQUJAaeVcf6WrSfQDBRPke+WoVsUtdRFIqPOsRrnw==}
|
||||||
|
peerDependencies:
|
||||||
|
echarts: ^6.0.0
|
||||||
|
vue: ^3.3.0
|
||||||
|
|
||||||
vue-router@5.2.0:
|
vue-router@5.2.0:
|
||||||
resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
|
resolution: {integrity: sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1403,6 +1424,9 @@ packages:
|
|||||||
engines: {node: '>= 14.6'}
|
engines: {node: '>= 14.6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
zrender@6.1.0:
|
||||||
|
resolution: {integrity: sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@asamuzakjp/css-color@5.1.11':
|
'@asamuzakjp/css-color@5.1.11':
|
||||||
@@ -1957,6 +1981,11 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
echarts@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
zrender: 6.1.0
|
||||||
|
|
||||||
editorconfig@1.0.7:
|
editorconfig@1.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@one-ini/wasm': 0.1.1
|
'@one-ini/wasm': 0.1.1
|
||||||
@@ -2433,6 +2462,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
punycode: 2.3.1
|
||||||
|
|
||||||
|
tslib@2.3.0: {}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2560,6 +2591,11 @@ snapshots:
|
|||||||
|
|
||||||
vue-component-type-helpers@3.3.8: {}
|
vue-component-type-helpers@3.3.8: {}
|
||||||
|
|
||||||
|
vue-echarts@8.3.2(echarts@6.1.0)(vue@3.5.40(typescript@5.9.3)):
|
||||||
|
dependencies:
|
||||||
|
echarts: 6.1.0
|
||||||
|
vue: 3.5.40(typescript@5.9.3)
|
||||||
|
|
||||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)):
|
vue-router@5.2.0(@vue/compiler-sfc@3.5.40)(pinia@4.0.2(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.40(typescript@5.9.3)))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/generator': 8.0.0
|
'@babel/generator': 8.0.0
|
||||||
@@ -2655,3 +2691,7 @@ snapshots:
|
|||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
yaml@2.9.0: {}
|
yaml@2.9.0: {}
|
||||||
|
|
||||||
|
zrender@6.1.0:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.3.0
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
set +x
|
||||||
|
: "${PUBLIC_WEB_ORIGIN:?PUBLIC_WEB_ORIGIN is required}"
|
||||||
|
: "${PRODUCTION_IMAGE_REPOSITORY:?PRODUCTION_IMAGE_REPOSITORY is required}"
|
||||||
|
: "${TARGET_SSH_KEY:?}"
|
||||||
|
: "${TARGET_SSH_USER:?}"
|
||||||
|
: "${TARGET_SSH_HOST:?}"
|
||||||
|
: "${TARGET_SSH_KNOWN_HOSTS:?}"
|
||||||
|
: "${BUILD_REGISTRY_USER:?}"
|
||||||
|
: "${BUILD_REGISTRY_PASSWORD:?}"
|
||||||
|
: "${REGISTRY_USER:?}"
|
||||||
|
: "${REGISTRY_PASSWORD:?}"
|
||||||
|
case "$PUBLIC_WEB_ORIGIN" in https://*) ;; *) echo 'prod requires HTTPS web origin' >&2; exit 2 ;; esac
|
||||||
|
# Only an origin (optionally a trailing slash) is accepted; no shell/env-file metacharacters.
|
||||||
|
printf '%s' "$PUBLIC_WEB_ORIGIN" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?/?$'
|
||||||
|
printf '%s' "$PRODUCTION_IMAGE_REPOSITORY" | grep -Eq '^[A-Za-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$'
|
||||||
|
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||||
|
test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)"
|
||||||
|
IMAGE_TAG=$(git rev-parse --short=12 HEAD)
|
||||||
|
registry=${PRODUCTION_IMAGE_REPOSITORY%%/*}
|
||||||
|
image="$PRODUCTION_IMAGE_REPOSITORY:$IMAGE_TAG"
|
||||||
|
TARGET_SSH_TARGET="$TARGET_SSH_USER@$TARGET_SSH_HOST"
|
||||||
|
remote_dir=/data/firstleg/deploy/gray-admin-web
|
||||||
|
run_ssh() {
|
||||||
|
ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||||
|
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$TARGET_SSH_TARGET" "$@"
|
||||||
|
}
|
||||||
|
run_scp() {
|
||||||
|
scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||||
|
-o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=12 "$@"
|
||||||
|
}
|
||||||
|
export DOCKER_CONFIG
|
||||||
|
DOCKER_CONFIG=$(mktemp -d)
|
||||||
|
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
printf '%s' "$BUILD_REGISTRY_PASSWORD" | docker login -u "$BUILD_REGISTRY_USER" --password-stdin "$registry"
|
||||||
|
docker build --build-arg NODE_IMAGE="${NODE_BASE_IMAGE:-node:22-alpine}" \
|
||||||
|
--build-arg NGINX_IMAGE="${NGINX_BASE_IMAGE:-nginx:1.27-alpine}" \
|
||||||
|
--build-arg VITE_CLIENT_WEB_URL="$PUBLIC_WEB_ORIGIN" -t "$image" .
|
||||||
|
docker push "$image"
|
||||||
|
run_ssh install -d -m 750 "$remote_dir"
|
||||||
|
run_scp docker-compose.production.yml "$TARGET_SSH_TARGET:$remote_dir/compose.yml"
|
||||||
|
# The remote env file contains only image coordinates and public web origin, never credentials.
|
||||||
|
printf 'PRODUCTION_IMAGE_REPOSITORY=%s\nIMAGE_TAG=%s\nPUBLIC_WEB_ORIGIN=%s\n' \
|
||||||
|
"$PRODUCTION_IMAGE_REPOSITORY" "$IMAGE_TAG" "$PUBLIC_WEB_ORIGIN" | run_ssh sh -c "'umask 077; cat > $remote_dir/release.env'"
|
||||||
|
# Registry credentials exist only in an isolated temporary Docker config, removed after pull.
|
||||||
|
printf '%s' "$REGISTRY_USER" | grep -Eq '^[A-Za-z0-9_.@-]+$'
|
||||||
|
run_scp scripts/pull-production-image.sh "$TARGET_SSH_TARGET:$remote_dir/pull-image.sh"
|
||||||
|
printf '%s' "$REGISTRY_PASSWORD" | run_ssh sh "$remote_dir/pull-image.sh" "$REGISTRY_USER" "$registry" "$image"
|
||||||
|
run_ssh sh -s -- "$remote_dir" <<'REMOTE'
|
||||||
|
set -eu
|
||||||
|
cd "$1"
|
||||||
|
docker network inspect firstleg-production-network >/dev/null
|
||||||
|
docker compose --env-file release.env -f compose.yml config --quiet
|
||||||
|
docker compose --env-file release.env -f compose.yml up -d --wait --wait-timeout 120
|
||||||
|
docker compose --env-file release.env -f compose.yml exec -T admin-web wget -qO- http://127.0.0.1/healthz
|
||||||
|
REMOTE
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs'
|
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
function option(name) {
|
function option(name) {
|
||||||
@@ -15,22 +14,13 @@ function normalizeOrigin(value, name) {
|
|||||||
return url.origin
|
return url.origin
|
||||||
}
|
}
|
||||||
|
|
||||||
function acceptancePassword() {
|
|
||||||
if (process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD) return process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
|
|
||||||
const file = 'C:/Users/BY-OY/.codex/chongchong-global-dev-acceptance.env'
|
|
||||||
if (!existsSync(file)) return ''
|
|
||||||
const match = readFileSync(file, 'utf8').match(/^CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD=(.*)$/m)
|
|
||||||
return match?.[1]?.trim() || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const environment = option('--environment')
|
const environment = option('--environment')
|
||||||
if (environment !== 'development') throw new Error('本入口仅允许显式指定 --environment development')
|
if (environment !== 'test') throw new Error('本入口仅允许显式指定 --environment test')
|
||||||
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_DEVELOPMENT_GATEWAY, '--gateway')
|
const gateway = normalizeOrigin(option('--gateway') || process.env.ADMIN_TEST_GATEWAY, '--gateway')
|
||||||
const overseasGateway = normalizeOrigin(option('--overseas-gateway') || process.env.ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY, '--overseas-gateway')
|
const password = process.env.CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD
|
||||||
const password = acceptancePassword()
|
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD')
|
||||||
if (!password) throw new Error('缺少 CHONGCHONG_ADMIN_ACCEPTANCE_PASSWORD 或本机开发验收凭据文件')
|
|
||||||
|
|
||||||
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-development.config.ts'], {
|
const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.phase4-test.config.ts'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: process.platform === 'win32',
|
shell: process.platform === 'win32',
|
||||||
@@ -39,10 +29,8 @@ const result = spawnSync('pnpm', ['exec', 'vitest', 'run', '--config', 'vitest.p
|
|||||||
ADMIN_PHASE4_LIVE: '1',
|
ADMIN_PHASE4_LIVE: '1',
|
||||||
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
|
ADMIN_ACCEPTANCE_USERNAME: process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin',
|
||||||
ADMIN_ACCEPTANCE_PASSWORD: password,
|
ADMIN_ACCEPTANCE_PASSWORD: password,
|
||||||
ADMIN_DEVELOPMENT_GATEWAY: gateway,
|
ADMIN_TEST_GATEWAY: gateway,
|
||||||
ADMIN_DEVELOPMENT_OVERSEAS_GATEWAY: overseasGateway,
|
|
||||||
VITE_API_BASE_URL: `${gateway}/api`,
|
VITE_API_BASE_URL: `${gateway}/api`,
|
||||||
VITE_OVERSEAS_API_BASE_URL: `${overseasGateway}/api`,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (result.error) throw result.error
|
if (result.error) throw result.error
|
||||||
@@ -2,14 +2,14 @@ import assert from 'node:assert/strict'
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
|
|
||||||
const runner = readFileSync(new URL('./phase4-development-frontend-acceptance.mjs', import.meta.url), 'utf8')
|
const runner = readFileSync(new URL('./phase4-test-frontend-acceptance.mjs', import.meta.url), 'utf8')
|
||||||
const suite = readFileSync(new URL('./phase4-development-frontend.acceptance.ts', import.meta.url), 'utf8')
|
const suite = readFileSync(new URL('./phase4-test-frontend.acceptance.ts', import.meta.url), 'utf8')
|
||||||
|
|
||||||
test('development runner requires an explicit development environment and gateways', () => {
|
test('test runner requires an explicit test environment and unified gateway', () => {
|
||||||
assert.match(runner, /--environment/)
|
assert.match(runner, /--environment/)
|
||||||
assert.match(runner, /environment !== 'development'/)
|
assert.match(runner, /environment !== 'test'/)
|
||||||
assert.match(runner, /--gateway/)
|
assert.match(runner, /--gateway/)
|
||||||
assert.match(runner, /--overseas-gateway/)
|
assert.doesNotMatch(runner, /--overseas-gateway|VITE_OVERSEAS/)
|
||||||
assert.match(runner, /shell: process\.platform === 'win32'/)
|
assert.match(runner, /shell: process\.platform === 'win32'/)
|
||||||
assert.match(runner, /if \(result\.error\) throw result\.error/)
|
assert.match(runner, /if \(result\.error\) throw result\.error/)
|
||||||
assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/)
|
assert.doesNotMatch(runner, /ADMIN_ACCEPTANCE_PASSWORD\s*[:=]\s*['"][^'"]+['"]/)
|
||||||
@@ -17,7 +17,7 @@ test('development runner requires an explicit development environment and gatewa
|
|||||||
|
|
||||||
test('live suite uses production auth clients and domain adapters', () => {
|
test('live suite uses production auth clients and domain adapters', () => {
|
||||||
assert.match(suite, /useAuthStore/)
|
assert.match(suite, /useAuthStore/)
|
||||||
assert.match(suite, /clearOverseasAccessToken/)
|
assert.match(suite, /refreshAuthenticatedSession/)
|
||||||
assert.match(suite, /createQuote/)
|
assert.match(suite, /createQuote/)
|
||||||
assert.match(suite, /createUsTruckOrder/)
|
assert.match(suite, /createUsTruckOrder/)
|
||||||
assert.match(suite, /createLocalLooseboardOrder/)
|
assert.match(suite, /createLocalLooseboardOrder/)
|
||||||
@@ -8,7 +8,7 @@ import { defineComponent, nextTick } from 'vue'
|
|||||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import { http, ApiResponseError } from '@/shared/api/http'
|
import { http, ApiResponseError } from '@/shared/api/http'
|
||||||
import { clearOverseasAccessToken, overseasHttp } from '@/shared/api/overseasHttp'
|
import { refreshAuthenticatedSession } from '@/shared/api/http'
|
||||||
import { useAuthStore } from '@/core/auth/auth'
|
import { useAuthStore } from '@/core/auth/auth'
|
||||||
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
|
import { listPlatformTenants } from '@/modules/iam/api/platformIam'
|
||||||
import {
|
import {
|
||||||
@@ -37,8 +37,15 @@ import InboundOrdersView from '@/modules/overseas-warehouse/pages/InboundOrdersV
|
|||||||
import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue'
|
import InventoryView from '@/modules/overseas-warehouse/pages/InventoryView.vue'
|
||||||
import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue'
|
import DispatchesView from '@/modules/overseas-warehouse/pages/DispatchesView.vue'
|
||||||
import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue'
|
import RelabelOrdersView from '@/modules/overseas-warehouse/pages/RelabelOrdersView.vue'
|
||||||
|
import OutboundsView from '@/modules/overseas-warehouse/pages/OutboundsView.vue'
|
||||||
|
import DeliveryOrdersView from '@/modules/overseas-warehouse/pages/DeliveryOrdersView.vue'
|
||||||
|
import ExceptionOrdersView from '@/modules/overseas-warehouse/pages/ExceptionOrdersView.vue'
|
||||||
|
import CustomerVisitsView from '@/modules/overseas-warehouse/pages/CustomerVisitsView.vue'
|
||||||
|
import InventoryTransactionsView from '@/modules/overseas-warehouse/pages/InventoryTransactionsView.vue'
|
||||||
|
import PalletsView from '@/modules/overseas-warehouse/pages/PalletsView.vue'
|
||||||
|
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
|
||||||
|
|
||||||
type Region = 'domestic' | 'overseas'
|
type Region = 'unified'
|
||||||
interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean }
|
interface TraceStep { region: Region; method: string; path: string; params: unknown; body: unknown; httpStatus: number; code?: string; requestId?: string; traceId?: string; authorization: boolean }
|
||||||
interface CoverageGap { area: string; reason: string }
|
interface CoverageGap { area: string; reason: string }
|
||||||
|
|
||||||
@@ -143,7 +150,7 @@ async function masterDataLifecycle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupTransport(path: string, customerId: string, version: number) {
|
async function cleanupTransport(path: string, customerId: string, version: number) {
|
||||||
await overseasHttp.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
|
await http.patch(`${path}/status`, { version, status: 'CANCELLED', remark: `${marker} cleanup` }, { params: { customerId } })
|
||||||
}
|
}
|
||||||
async function overseasBusinessLifecycle() {
|
async function overseasBusinessLifecycle() {
|
||||||
const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE')
|
const customers = (await listPlatformTenants({ page: 1, size: 200, status: 'ACTIVE' })).records.filter(item => item.status === 'ACTIVE')
|
||||||
@@ -238,32 +245,135 @@ async function mountPage(component: Parameters<typeof mount>[0], method: string,
|
|||||||
await waitForTrace(method, path, start)
|
await waitForTrace(method, path, start)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.text()).not.toContain('服务暂时不可用')
|
expect(wrapper.text()).not.toContain('服务暂时不可用')
|
||||||
|
return wrapper
|
||||||
}
|
}
|
||||||
|
async function exerciseListSearch(
|
||||||
|
wrapper: VueWrapper,
|
||||||
|
path: string,
|
||||||
|
model: Record<string, unknown>,
|
||||||
|
expectedParams: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const toolbar = wrapper.findComponent(QueryToolbar)
|
||||||
|
expect(toolbar.exists()).toBe(true)
|
||||||
|
toolbar.vm.$emit('update:modelValue', { ...model })
|
||||||
|
await nextTick()
|
||||||
|
let start = traces.length
|
||||||
|
toolbar.vm.$emit('search', { ...model })
|
||||||
|
await waitForTrace('GET', path, start)
|
||||||
|
const searchRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
|
||||||
|
expect(searchRequests).toHaveLength(1)
|
||||||
|
expect(searchRequests[0]?.httpStatus).toBe(200)
|
||||||
|
expect(searchRequests[0]?.params).toMatchObject(expectedParams)
|
||||||
|
expect(wrapper.text()).not.toContain('加载失败')
|
||||||
|
|
||||||
|
start = traces.length
|
||||||
|
;(toolbar.vm as unknown as { reset(): void }).reset()
|
||||||
|
await waitForTrace('GET', path, start)
|
||||||
|
const resetRequests = traces.slice(start).filter(item => item.method === 'GET' && item.path === path)
|
||||||
|
expect(resetRequests).toHaveLength(1)
|
||||||
|
expect(resetRequests[0]?.httpStatus).toBe(200)
|
||||||
|
const resetParams = resetRequests[0]?.params as Record<string, unknown> | undefined
|
||||||
|
for (const key of Object.keys(expectedParams)) expect(resetParams?.[key] == null || resetParams[key] === '').toBe(true)
|
||||||
|
}
|
||||||
async function pageRequestCoverage() {
|
async function pageRequestCoverage() {
|
||||||
await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
|
const quotes = await mountPage(OverseasQuotesView, 'GET', '/platform/v1/last-leg/master-data/overseas-quotes')
|
||||||
await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
|
await exerciseListSearch(quotes, '/platform/v1/last-leg/master-data/overseas-quotes',
|
||||||
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
|
{ keyword: `${marker}-NO-MATCH`, warehouseId: '', providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' },
|
||||||
await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
|
{ keyword: `${marker}-NO-MATCH`, providerName: `${marker}-PROVIDER`, currency: 'USD', status: 'INACTIVE' })
|
||||||
await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
|
|
||||||
await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
|
const purchases = await mountPage(PurchaseOrdersView, 'GET', '/platform/v1/last-leg/overseas-procurements')
|
||||||
await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
|
await exerciseListSearch(purchases, '/platform/v1/last-leg/overseas-procurements',
|
||||||
await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
|
{ orderNo: `${marker}-NO-MATCH`, customerId: '', purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` },
|
||||||
await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
|
{ orderNo: `${marker}-NO-MATCH`, purchaseCategory: `${marker}-CATEGORY`, productName: `${marker}-PRODUCT` })
|
||||||
|
|
||||||
|
const trucks = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/us-truck-orders', { domain: 'truck' })
|
||||||
|
await exerciseListSearch(trucks, '/platform/v1/last-leg/us-truck-orders',
|
||||||
|
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
|
||||||
|
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
|
||||||
|
|
||||||
|
const looseboards = await mountPage(TransportOrdersView, 'GET', '/platform/v1/last-leg/local-looseboard-orders', { domain: 'looseboard' })
|
||||||
|
await exerciseListSearch(looseboards, '/platform/v1/last-leg/local-looseboard-orders',
|
||||||
|
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` },
|
||||||
|
{ status: 'CANCELLED', routeKeyword: `${marker}-NO-MATCH` })
|
||||||
|
|
||||||
|
const lastMile = await mountPage(LastMileOrdersView, 'GET', '/platform/v1/last-leg/orders')
|
||||||
|
await exerciseListSearch(lastMile, '/platform/v1/last-leg/orders',
|
||||||
|
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, status: 'DELIVERED', abnormal: 'true', detained: 'false', createdAt: ['2098-01-01', '2098-01-02'] },
|
||||||
|
{ orderNo: `${marker}-ORDER`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, orderType: 'FIRST_LEG', warehouse: `${marker}-WAREHOUSE`, sourceOrderNo: `${marker}-SOURCE`, containerNo: `${marker}-CONTAINER`, transferWarehouseCode: `${marker}-TRANSFER`, currentStatus: 'DELIVERED', abnormal: true, detained: false, createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
|
||||||
|
let start = traces.length
|
||||||
|
await lastMile.findAll('.el-radio-button').find(item => item.text() === '待分配')!.trigger('click')
|
||||||
|
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
|
||||||
|
start = traces.length
|
||||||
|
await lastMile.findAll('.el-radio-button').find(item => item.text().startsWith('柜子'))!.trigger('click')
|
||||||
|
await waitForTrace('GET', '/platform/v1/last-leg/orders/container-summaries', start)
|
||||||
|
start = traces.length
|
||||||
|
await lastMile.findAll('.el-radio-button').find(item => item.text() === '全部')!.trigger('click')
|
||||||
|
await waitForTrace('GET', '/platform/v1/last-leg/orders', start)
|
||||||
|
|
||||||
|
const inbounds = await mountPage(InboundOrdersView, 'GET', '/platform/v1/last-leg/inbound-plans')
|
||||||
|
await exerciseListSearch(inbounds, '/platform/v1/last-leg/inbound-plans',
|
||||||
|
{ orderNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouse: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', inboundDate: ['2098-01-01', '2098-01-02'] },
|
||||||
|
{ planNo: `${marker}-PLAN`, inboundCode: `${marker}-INBOUND`, lxCode: `${marker}-LX`, status: 'COMPLETED', warehouse: `${marker}-WAREHOUSE`, fbaWarehouseCode: `${marker}-FBA`, appointmentStatus: 'CANCELLED', dispatchStatus: 'SCHEDULED', createdFrom: '2098-01-01T00:00:00', createdTo: '2098-01-02T23:59:59' })
|
||||||
|
|
||||||
|
const inventory = await mountPage(InventoryView, 'GET', '/platform/v1/last-leg/inventory/balances')
|
||||||
|
await exerciseListSearch(inventory, '/platform/v1/last-leg/inventory/balances',
|
||||||
|
{ warehouseId: '', owner: `${marker}-OWNER`, sku: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, location: `${marker}-LOCATION`, status: 'AVAILABLE' },
|
||||||
|
{ customerName: `${marker}-OWNER`, skuCode: `${marker}-SKU`, productName: `${marker}-PRODUCT`, batchNo: `${marker}-BATCH`, warehouseLocationCode: `${marker}-LOCATION`, inventoryStatus: 'AVAILABLE' })
|
||||||
|
|
||||||
|
const dispatches = await mountPage(DispatchesView, 'GET', '/platform/v1/last-leg/dispatches')
|
||||||
|
await exerciseListSearch(dispatches, '/platform/v1/last-leg/dispatches',
|
||||||
|
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` },
|
||||||
|
{ dispatchNo: `${marker}-DISPATCH`, vehiclePlate: `${marker}-PLATE`, status: 'CANCELLED', fbaAppointmentNo: `${marker}-APPOINTMENT` })
|
||||||
|
|
||||||
|
const relabels = await mountPage(RelabelOrdersView, 'GET', '/platform/v1/last-leg/relabel-applications')
|
||||||
|
await exerciseListSearch(relabels, '/platform/v1/last-leg/relabel-applications',
|
||||||
|
{ relabelNo: `${marker}-RELABEL`, sku: `${marker}-SKU`, status: 'CANCELLED' },
|
||||||
|
{ relabelNo: `${marker}-RELABEL`, skuCode: `${marker}-SKU`, status: 'CANCELLED' })
|
||||||
|
|
||||||
|
const outbounds = await mountPage(OutboundsView, 'GET', '/platform/v1/last-leg/outbound-orders')
|
||||||
|
await exerciseListSearch(outbounds, '/platform/v1/last-leg/outbound-orders',
|
||||||
|
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' },
|
||||||
|
{ documentNo: `${marker}-OUTBOUND`, lxCode: `${marker}-LX`, warehouseId: `${marker}-WAREHOUSE`, status: 'OUTBOUNDED' })
|
||||||
|
|
||||||
|
const deliveries = await mountPage(DeliveryOrdersView, 'GET', '/platform/v1/last-leg/delivery-orders')
|
||||||
|
await exerciseListSearch(deliveries, '/platform/v1/last-leg/delivery-orders',
|
||||||
|
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, warehouseId: '', status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedAt: ['2098-01-01', '2098-01-02'] },
|
||||||
|
{ documentNo: `${marker}-DELIVERY`, dispatchNo: `${marker}-DISPATCH`, waybillNo: `${marker}-WAYBILL`, status: 'CANCELLED', vehiclePlate: `${marker}-PLATE`, driverName: `${marker}-DRIVER`, deliveryMethod: 'EXPRESS', plannedFrom: '2098-01-01T00:00:00', plannedTo: '2098-01-02T23:59:59' })
|
||||||
|
|
||||||
|
const exceptions = await mountPage(ExceptionOrdersView, 'GET', '/platform/v1/last-leg/exception-orders')
|
||||||
|
await exerciseListSearch(exceptions, '/platform/v1/last-leg/exception-orders',
|
||||||
|
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' },
|
||||||
|
{ keyword: `${marker}-EXCEPTION`, exceptionType: 'OTHER', status: 'COMPLETED' })
|
||||||
|
|
||||||
|
const visits = await mountPage(CustomerVisitsView, 'GET', '/platform/v1/last-leg/customer-visits')
|
||||||
|
await exerciseListSearch(visits, '/platform/v1/last-leg/customer-visits',
|
||||||
|
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedAt: ['2098-01-01', '2098-01-02'] },
|
||||||
|
{ keyword: `${marker}-VISIT`, status: 'COMPLETED', completedFrom: '2098-01-01T00:00:00', completedTo: '2098-01-02T23:59:59' })
|
||||||
|
|
||||||
|
const ledger = await mountPage(InventoryTransactionsView, 'GET', '/platform/v1/last-leg/inventory/ledger')
|
||||||
|
await exerciseListSearch(ledger, '/platform/v1/last-leg/inventory/ledger',
|
||||||
|
{ occurredDate: ['2098-01-01', '2098-01-02'], customerName: `${marker}-CUSTOMER`, warehouse: `${marker}-WAREHOUSE`, sku: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND` },
|
||||||
|
{ customerName: `${marker}-CUSTOMER`, destinationWarehouseCode: `${marker}-WAREHOUSE`, skuCode: `${marker}-SKU`, businessType: 'ADJUST', businessOrderNo: `${marker}-BUSINESS`, inboundCode: `${marker}-INBOUND`, inboundOrderNo: `${marker}-INBOUND-ORDER`, outboundOrderNo: `${marker}-OUTBOUND`, occurredFrom: '2098-01-01T00:00:00', occurredTo: '2098-01-02T23:59:59' })
|
||||||
|
|
||||||
|
const pallets = await mountPage(PalletsView, 'GET', '/platform/v1/overseas-warehouse-resources/pallets')
|
||||||
|
await exerciseListSearch(pallets, '/platform/v1/overseas-warehouse-resources/pallets',
|
||||||
|
{ warehouseId: '', code: `${marker}-PALLET`, type: 'WOOD', status: 'IDLE' },
|
||||||
|
{ keyword: `${marker}-PALLET`, palletType: 'WOOD', status: 'IDLE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', () => {
|
describe.skipIf(!enabled).sequential('phase4 test frontend acceptance', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) })
|
Object.defineProperty(window, 'matchMedia', { writable: true, value: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) })
|
||||||
globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} }
|
globalThis.ResizeObserver = class { observe() {}; unobserve() {}; disconnect() {} }
|
||||||
installTracingAdapter(http, 'domestic'); installTracingAdapter(overseasHttp, 'overseas')
|
installTracingAdapter(http, 'unified')
|
||||||
const pinia = createPinia(); setActivePinia(pinia)
|
const pinia = createPinia(); setActivePinia(pinia)
|
||||||
const auth = useAuthStore(pinia)
|
const auth = useAuthStore(pinia)
|
||||||
await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '')
|
await auth.login(process.env.ADMIN_ACCEPTANCE_USERNAME || 'platform_admin', process.env.ADMIN_ACCEPTANCE_PASSWORD || '')
|
||||||
expect(localStorage.getItem('cc-admin-token')).toBeTruthy()
|
expect(localStorage.getItem('cc-admin-token')).toBeTruthy()
|
||||||
clearOverseasAccessToken()
|
await refreshAuthenticatedSession()
|
||||||
await listVehicles({ page: 1, size: 1 })
|
await listVehicles({ page: 1, size: 1 })
|
||||||
expect(traces.some(item => item.region === 'domestic' && item.path === '/auth/refresh')).toBe(true)
|
expect(traces.some(item => item.region === 'unified' && item.path === '/auth/refresh')).toBe(true)
|
||||||
expect(traces.some(item => item.region === 'overseas' && item.authorization)).toBe(true)
|
expect(traces.some(item => item.region === 'unified' && item.authorization)).toBe(true)
|
||||||
})
|
})
|
||||||
it('runs marker-owned adapter lifecycles and real page requests', async () => {
|
it('runs marker-owned adapter lifecycles and real page requests', async () => {
|
||||||
await masterDataLifecycle()
|
await masterDataLifecycle()
|
||||||
@@ -278,7 +388,7 @@ describe.skipIf(!enabled).sequential('phase4 development frontend acceptance', (
|
|||||||
const reportDir = resolve(process.cwd(), 'reports')
|
const reportDir = resolve(process.cwd(), 'reports')
|
||||||
mkdirSync(reportDir, { recursive: true })
|
mkdirSync(reportDir, { recursive: true })
|
||||||
const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources }
|
const report = { generatedAt: new Date().toISOString(), marker, summary: { requests: traces.length, coverageGaps: coverageGaps.length }, traces, coverageGaps, createdResources }
|
||||||
const file = resolve(reportDir, `phase4-frontend-development-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
|
const file = resolve(reportDir, `phase4-frontend-test-${marker.replace(/[^A-Za-z0-9-]/g, '')}.json`)
|
||||||
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
writeFileSync(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
||||||
console.info(`PHASE4_FRONTEND_REPORT=${file}`)
|
console.info(`PHASE4_FRONTEND_REPORT=${file}`)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
set +x
|
||||||
|
export DOCKER_CONFIG
|
||||||
|
DOCKER_CONFIG=$(mktemp -d)
|
||||||
|
cleanup() { rm -f "$DOCKER_CONFIG/config.json"; rmdir "$DOCKER_CONFIG" 2>/dev/null || true; }
|
||||||
|
trap cleanup EXIT HUP INT TERM
|
||||||
|
docker login -u "$1" --password-stdin "$2"
|
||||||
|
docker pull "$3"
|
||||||
@@ -13,15 +13,15 @@
|
|||||||
- 服务端字段变化先核对 Controller、DTO 和 OpenAPI,再更新本应用的类型、API 适配层和测试;本包不反向定义服务端行为。
|
- 服务端字段变化先核对 Controller、DTO 和 OpenAPI,再更新本应用的类型、API 适配层和测试;本包不反向定义服务端行为。
|
||||||
- 避免建立宽泛的万能类型或引入重量级依赖。
|
- 避免建立宽泛的万能类型或引入重量级依赖。
|
||||||
|
|
||||||
## 一期/二期/三期共享模型索引
|
## 订单与客户/入库测量与计费/国内货柜履约共享模型索引
|
||||||
|
|
||||||
- 一期共享消费者:`customers`、`iam`、`warehouses`、`master-data`、`first-leg-orders`;稳定导出集中在 `src/index.ts`(用户、导航、响应、仓库摘要、订单状态)。
|
- 订单与客户共享消费者:`customers`、`iam`、`warehouses`、`master-data`、`first-leg-orders`;稳定导出集中在 `src/index.ts`(用户、导航、响应、仓库摘要、订单状态)。
|
||||||
- 二期共享消费者:`inbounds`、`warehouse-resources`、`fba-pricing`、`dimension-config`、`unified-orders`;单模块使用的测量、费用、托盘、库位类型留在模块内,只有真实跨模块复用才新增导出。
|
- 入库测量与计费共享消费者:`inbounds`、`warehouse-resources`、`fba-pricing`、`dimension-config`、`unified-orders`;单模块使用的测量、费用、托盘、库位类型留在模块内,只有真实跨模块复用才新增导出。
|
||||||
- 三期仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Admin 模块复用时才新增共享导出;先读 `../../../docs/三期/三期开工门禁.md`。订单、柜次、证据和报关的单模块类型仍留在所属模块,不在 shared 预留猜测字段。
|
- 国内货柜履约仅在服务端字段、权限和 OpenAPI 已稳定,且确有多个 Admin 模块复用时才新增共享导出;先读 `../../../docs/国内货柜履约/国内货柜履约开工门禁.md`。订单、柜次、证据和报关的单模块类型仍留在所属模块,不在 shared 预留猜测字段。
|
||||||
|
|
||||||
定位规则:先在 `src/index.ts` 确认公共导出,再搜索 `src/` 消费者;后端字段或状态有疑问时回到对应 Controller/DTO/OpenAPI,不在 shared 中猜测业务规则。
|
定位规则:先在 `src/index.ts` 确认公共导出,再搜索 `src/` 消费者;后端字段或状态有疑问时回到对应 Controller/DTO/OpenAPI,不在 shared 中猜测业务规则。
|
||||||
|
|
||||||
阶段依据(路径相对本文件所在目录):一期 `../../../docs/一期/`,二期 `../../../docs/二期/`,三期 `../../../docs/三期/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。
|
阶段依据(路径相对本文件所在目录):订单与客户 `../../../docs/订单与客户/`,入库测量与计费 `../../../docs/入库测量与计费/`,国内货柜履约 `../../../docs/国内货柜履约/`;共享包不保存阶段进度,也不承载跨应用(Client)源码复用。
|
||||||
|
|
||||||
## 契约与验证
|
## 契约与验证
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ describe('domestic outbound list responsive contracts', () => {
|
|||||||
const content = source(path)
|
const content = source(path)
|
||||||
const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)]
|
const columns = [...content.matchAll(/\{[^{}]*(?:key|prop): '[^']+'[^{}]*label: '[^']+'[^{}]*\}/g)]
|
||||||
.map((match) => match[0])
|
.map((match) => match[0])
|
||||||
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'"))
|
.filter((column) => !column.includes("type: 'input'") && !column.includes("type: 'select'") && !column.includes("type: 'daterange'") && !column.includes('priority:') && !column.includes("key: 'view'"))
|
||||||
expect(columns.length).toBeGreaterThan(1)
|
expect(columns.length).toBeGreaterThan(1)
|
||||||
for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/)
|
for (const column of columns) expect(column).toMatch(/\b(?:width|minWidth):\s*\d+/)
|
||||||
})
|
})
|
||||||
@@ -42,7 +42,8 @@ describe('domestic outbound list responsive contracts', () => {
|
|||||||
const listLayout = source('src/shared/components/ListPageLayout.vue')
|
const listLayout = source('src/shared/components/ListPageLayout.vue')
|
||||||
|
|
||||||
expect(queryToolbar).toContain('@media (max-width: 720px)')
|
expect(queryToolbar).toContain('@media (max-width: 720px)')
|
||||||
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; }')
|
expect(queryToolbar).toContain('.query-toolbar__field { width: 100%; flex: 0 0 auto; }')
|
||||||
|
expect(queryToolbar).toContain('.query-toolbar__control { width: 100%; flex: 0 0 auto; }')
|
||||||
expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))')
|
expect(queryToolbar).toContain('grid-template-columns: repeat(auto-fit, minmax(120px, 1fr))')
|
||||||
expect(dataTable).toContain('@media (max-width: 720px)')
|
expect(dataTable).toContain('@media (max-width: 720px)')
|
||||||
expect(dataTable).toContain('flex-direction: column')
|
expect(dataTable).toContain('flex-direction: column')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const pages = [
|
|||||||
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:list', '@retry="loadRows"'],
|
['src/modules/container-loading/pages/ContainersView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无货柜"', 'statusText(row.planStatus)', 'platform:firstleg:container:list', '@retry="loadRows"'],
|
||||||
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
|
['src/modules/loading-lists/components/LoadingListsView.vue', ':loading="loading"', 'LoadErrorState', ':empty-text="`暂无符合筛选条件的${title}`"', 'loadingListStatusLabel(row.status)', 'canView', '@retry="load"'],
|
||||||
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', "{{ row.status === 'DRAFT' ? '编辑' : '查看' }}", '@retry="load"'],
|
['src/modules/ocean-bookings/pages/OceanBookingsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的订舱"', 'oceanBookingStatusLabel(row.status)', "{{ row.status === 'DRAFT' ? '编辑' : '查看' }}", '@retry="load"'],
|
||||||
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '<Eye :size="15" />详情', '@retry="load"'],
|
['src/modules/trucking/pages/TruckAppointmentsView.vue', ':loading="loading"', 'LoadErrorState', 'empty-text="暂无符合筛选条件的拖车预约"', 'statusLabel(row.status)', '@click="openDetail(row as TruckAppointmentResource)">详情</el-button>', '@retry="load"'],
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
function source(path: string) {
|
function source(path: string) {
|
||||||
@@ -30,7 +30,7 @@ describe('domestic outbound list state contracts', () => {
|
|||||||
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
|
const truckingDetail = source('src/modules/trucking/pages/TruckAppointmentDetailView.vue')
|
||||||
|
|
||||||
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
|
expect(layout).toContain('<KeepAlive :max="MAX_CACHED_WORKSPACE_VIEWS">')
|
||||||
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute))"')
|
expect(layout).toContain(':key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + \':\' + JSON.stringify(renderedRoute.params)"')
|
||||||
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
|
expect(oceanDetail).toContain("router.push('/ocean-bookings')")
|
||||||
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
|
expect(truckingDetail).toContain("router.push({ name: 'trucking' })")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { defineComponent, markRaw } from 'vue'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
|
import { ElMenu, ElMenuItem, ElSubMenu } from 'element-plus'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||||
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
||||||
|
|
||||||
@@ -19,10 +24,12 @@ const visibleNavigation = filterAdminNavigation(adminNavigation, new Set([
|
|||||||
]))
|
]))
|
||||||
|
|
||||||
const stubs = {
|
const stubs = {
|
||||||
'el-menu': {
|
'el-menu': defineComponent({
|
||||||
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
|
props: ['defaultActive', 'defaultOpeneds', 'collapse'],
|
||||||
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)"><slot /></nav>',
|
emits: ['select'],
|
||||||
},
|
methods: { updateActiveIndex() {} },
|
||||||
|
template: '<nav :data-active="defaultActive" :data-openeds="JSON.stringify(defaultOpeneds)" :data-collapsed="String(collapse)" @click="$emit(\'select\', \'/warehouses\')"><slot /></nav>',
|
||||||
|
}),
|
||||||
'el-sub-menu': {
|
'el-sub-menu': {
|
||||||
props: ['index'],
|
props: ['index'],
|
||||||
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
||||||
@@ -34,10 +41,20 @@ const stubs = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('AdminSidebarMenu', () => {
|
describe('AdminSidebarMenu', () => {
|
||||||
|
function routerPlugin() {
|
||||||
|
return createRouter({
|
||||||
|
history: createMemoryHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: { template: '<p />' } },
|
||||||
|
{ path: '/warehouses', component: { template: '<p />' } },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
it('renders the visible phase-one modules and forwards the active route', () => {
|
it('renders the visible phase-one modules and forwards the active route', () => {
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
|
props: { items: visibleNavigation, activePath: '/system/users', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
||||||
@@ -46,12 +63,15 @@ describe('AdminSidebarMenu', () => {
|
|||||||
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
|
const paths = wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))
|
||||||
|
|
||||||
expect(groupTitles).toContain('客户管理')
|
expect(groupTitles).toContain('客户管理')
|
||||||
expect(groupTitles).toContain('供应商中心')
|
expect(groupTitles).not.toContain('供应商中心')
|
||||||
|
expect(groupTitles).toContain('通用')
|
||||||
expect(groupTitles).toContain('系统管理')
|
expect(groupTitles).toContain('系统管理')
|
||||||
|
expect(groupTitles).not.toContain('预约管理')
|
||||||
expect(groupTitles).not.toContain('财务管理')
|
expect(groupTitles).not.toContain('财务管理')
|
||||||
|
expect(paths).not.toContain('/finance/customer-accounts')
|
||||||
expect(paths).toContain('/dashboard')
|
expect(paths).toContain('/dashboard')
|
||||||
expect(paths).toContain('/orders')
|
expect(paths).toContain('/orders')
|
||||||
expect(paths).toContain('/master-data/express-companies')
|
expect(paths).not.toContain('/master-data/express-companies')
|
||||||
expect(paths).not.toContain('/orders/first-leg')
|
expect(paths).not.toContain('/orders/first-leg')
|
||||||
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
expect(paths).not.toContain('/first-leg-pricing/customer-rules')
|
||||||
expect(paths).toContain('/downloads')
|
expect(paths).toContain('/downloads')
|
||||||
@@ -62,17 +82,83 @@ describe('AdminSidebarMenu', () => {
|
|||||||
it('forwards desktop collapse state to Element Plus', () => {
|
it('forwards desktop collapse state to Element Plus', () => {
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items: visibleNavigation, activePath: '/', collapsed: true },
|
props: { items: visibleNavigation, activePath: '/', collapsed: true },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('fits collapsed icons inside the scrolling rail and keeps hover submenus outside it', async () => {
|
||||||
|
const host = document.createElement('div')
|
||||||
|
host.className = 'app-shell is-collapsed'
|
||||||
|
host.innerHTML = '<aside class="app-sidebar"><nav class="side-nav"></nav></aside>'
|
||||||
|
document.body.append(host)
|
||||||
|
const style = document.createElement('style')
|
||||||
|
style.textContent = readFileSync(resolve(process.cwd(), 'src/styles/shell.css'), 'utf8')
|
||||||
|
document.head.append(style)
|
||||||
|
const rail = host.querySelector<HTMLElement>('.side-nav')!
|
||||||
|
const router = routerPlugin()
|
||||||
|
await router.push('/')
|
||||||
|
const icon = markRaw(adminNavigation[0].icon)
|
||||||
|
const items: AdminNavigationNode[] = [
|
||||||
|
{ key: 'home', title: '首页', icon, path: '/' },
|
||||||
|
{
|
||||||
|
key: 'resources', title: '资源管理', icon,
|
||||||
|
children: [{ key: 'warehouses', title: '仓库管理', icon, path: '/warehouses' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
|
attachTo: rail,
|
||||||
|
props: { items, activePath: '/', collapsed: true },
|
||||||
|
global: { plugins: [router], components: { ElMenu, ElMenuItem, ElSubMenu }, stubs: { transition: false } },
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await flushPromises()
|
||||||
|
const menu = wrapper.get('.side-nav-menu.el-menu--collapse')
|
||||||
|
const group = wrapper.get('.side-nav-menu > .el-sub-menu')
|
||||||
|
const leafTrigger = wrapper.get('.el-menu-tooltip__trigger')
|
||||||
|
expect(getComputedStyle(host.querySelector('.app-sidebar')!).width).toBe('76px')
|
||||||
|
expect(getComputedStyle(rail).overflowX).toBe('hidden')
|
||||||
|
expect(getComputedStyle(rail).overflowY).toBe('auto')
|
||||||
|
expect(getComputedStyle(menu.element).width).toBe('100%')
|
||||||
|
for (const trigger of [leafTrigger, group.get('.el-sub-menu__title')]) {
|
||||||
|
expect(getComputedStyle(trigger.element).paddingLeft).toBe('0px')
|
||||||
|
expect(getComputedStyle(trigger.element).paddingRight).toBe('0px')
|
||||||
|
expect(getComputedStyle(trigger.element).justifyContent).toBe('center')
|
||||||
|
expect(getComputedStyle(trigger.get('.menu-icon').element).marginRight).toBe('0px')
|
||||||
|
}
|
||||||
|
|
||||||
|
await group.trigger('mouseenter')
|
||||||
|
await vi.waitFor(() => expect(group.attributes('aria-expanded')).toBe('true'))
|
||||||
|
const popup = document.body.querySelector('.shell-nav-popper .el-menu--popup')
|
||||||
|
expect(popup).not.toBeNull()
|
||||||
|
expect(popup?.closest('.side-nav')).toBeNull()
|
||||||
|
expect(popup?.textContent).toContain('仓库管理')
|
||||||
|
|
||||||
|
const destination = popup!.querySelector<HTMLElement>('.el-menu-item')!
|
||||||
|
destination.focus()
|
||||||
|
expect(document.activeElement).toBe(destination)
|
||||||
|
expect(destination.getAttribute('role')).toBe('menuitem')
|
||||||
|
destination.click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||||
|
|
||||||
|
await wrapper.setProps({ collapsed: false })
|
||||||
|
host.classList.remove('is-collapsed')
|
||||||
|
expect(getComputedStyle(host.querySelector('.app-sidebar')!).width).toBe('240px')
|
||||||
|
expect(wrapper.find('.el-menu--collapse').exists()).toBe(false)
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount()
|
||||||
|
style.remove()
|
||||||
|
host.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('renders only entries included in the filtered navigation input', () => {
|
it('renders only entries included in the filtered navigation input', () => {
|
||||||
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items, activePath: '/', collapsed: false },
|
props: { items, activePath: '/', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.text()).toContain('菜单权限')
|
expect(wrapper.text()).toContain('菜单权限')
|
||||||
@@ -100,7 +186,7 @@ describe('AdminSidebarMenu', () => {
|
|||||||
}]
|
}]
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items, activePath: '/tracking', collapsed: false },
|
props: { items, activePath: '/tracking', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
||||||
@@ -110,7 +196,7 @@ describe('AdminSidebarMenu', () => {
|
|||||||
it('recreates the menu when asynchronously loaded groups change', async () => {
|
it('recreates the menu when asynchronously loaded groups change', async () => {
|
||||||
const wrapper = mount(AdminSidebarMenu, {
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
|
props: { items: [adminNavigation[0]], activePath: '/dashboard', collapsed: false },
|
||||||
global: { stubs },
|
global: { stubs, plugins: [routerPlugin()] },
|
||||||
})
|
})
|
||||||
const initialMenu = wrapper.get('nav').element
|
const initialMenu = wrapper.get('nav').element
|
||||||
|
|
||||||
@@ -119,4 +205,17 @@ describe('AdminSidebarMenu', () => {
|
|||||||
expect(wrapper.get('nav').element).not.toBe(initialMenu)
|
expect(wrapper.get('nav').element).not.toBe(initialMenu)
|
||||||
expect(wrapper.text()).toContain('系统管理')
|
expect(wrapper.text()).toContain('系统管理')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('navigates explicitly when a leaf menu item is selected', async () => {
|
||||||
|
const router = routerPlugin()
|
||||||
|
await router.push('/')
|
||||||
|
const wrapper = mount(AdminSidebarMenu, {
|
||||||
|
props: { items: visibleNavigation, activePath: '/', collapsed: false },
|
||||||
|
global: { stubs, plugins: [router] },
|
||||||
|
})
|
||||||
|
|
||||||
|
await wrapper.get('nav').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { useRouter, type NavigationFailure } from 'vue-router'
|
||||||
|
import type { MenuInstance } from 'element-plus'
|
||||||
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
import type { AdminNavigationNode } from '@/core/navigation/adminNavigation'
|
||||||
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
||||||
|
|
||||||
@@ -8,6 +10,8 @@ const props = defineProps<{
|
|||||||
activePath: string
|
activePath: string
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const router = useRouter()
|
||||||
|
const menuRef = ref<MenuInstance>()
|
||||||
|
|
||||||
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
||||||
return nodes.flatMap((node) => node.children?.length
|
return nodes.flatMap((node) => node.children?.length
|
||||||
@@ -18,16 +22,36 @@ function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
|||||||
const groupKeys = computed(() => getGroupKeys(props.items))
|
const groupKeys = computed(() => getGroupKeys(props.items))
|
||||||
const defaultOpeneds: string[] = []
|
const defaultOpeneds: string[] = []
|
||||||
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
|
const menuInstanceKey = computed(() => `${props.collapsed ? 'collapsed' : 'expanded'}:${groupKeys.value.join('|')}`)
|
||||||
|
|
||||||
|
async function restoreActivePath() {
|
||||||
|
await nextTick()
|
||||||
|
menuRef.value?.updateActiveIndex(props.activePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(path: string) {
|
||||||
|
if (!path.startsWith('/') || path === props.activePath) return
|
||||||
|
let failure: NavigationFailure | void
|
||||||
|
try {
|
||||||
|
failure = await router.push(path)
|
||||||
|
} catch {
|
||||||
|
await restoreActivePath()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (failure) await restoreActivePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.activePath, restoreActivePath)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-menu
|
<el-menu
|
||||||
|
ref="menuRef"
|
||||||
:key="menuInstanceKey"
|
:key="menuInstanceKey"
|
||||||
class="side-nav-menu"
|
class="side-nav-menu"
|
||||||
:default-active="activePath"
|
:default-active="activePath"
|
||||||
:collapse="collapsed"
|
:collapse="collapsed"
|
||||||
:default-openeds="defaultOpeneds"
|
:default-openeds="defaultOpeneds"
|
||||||
router
|
@select="navigate"
|
||||||
>
|
>
|
||||||
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
|
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" :level="0" />
|
||||||
</el-menu>
|
</el-menu>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-sub-menu v-if="node.children?.length" :index="node.key">
|
<el-sub-menu v-if="node.children?.length" :index="node.key" popper-class="admin-workbench shell-nav-popper">
|
||||||
<template #title>
|
<template #title>
|
||||||
<component
|
<component
|
||||||
v-if="!level"
|
v-if="!level"
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ describe('WorkspaceTabs', () => {
|
|||||||
})
|
})
|
||||||
afterEach(() => { document.body.innerHTML = '' })
|
afterEach(() => { document.body.innerHTML = '' })
|
||||||
|
|
||||||
|
it('retains its own theme on the teleported management menu', async () => {
|
||||||
|
const { wrapper } = await mountTabs()
|
||||||
|
await wrapper.get('[aria-label="页签管理"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const popper = document.body.querySelector('.shell-header-popper')
|
||||||
|
expect(popper?.classList.contains('admin-workbench')).toBe(true)
|
||||||
|
expect(popper?.closest('.workspace-tabs')).toBeNull()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a dirty tab open when discard confirmation is cancelled', async () => {
|
it('keeps a dirty tab open when discard confirmation is cancelled', async () => {
|
||||||
const { wrapper, workspace, unsaved } = await mountTabs()
|
const { wrapper, workspace, unsaved } = await mountTabs()
|
||||||
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
unsaved.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null, workspaceTabId: 'customers' })
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminDensity.css'
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -32,7 +34,7 @@ async function confirmDiscard(tabIds: readonly string[]) {
|
|||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
|
`以下内容尚未保存:${labels.join('、')}。关闭页签后将放弃这些修改。`,
|
||||||
'放弃未保存的修改?',
|
'放弃未保存的修改?',
|
||||||
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning' },
|
{ confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
@@ -124,7 +126,7 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="workspace-tabs" aria-label="页面工作区">
|
<section class="workspace-tabs admin-workbench" aria-label="页面工作区">
|
||||||
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
|
<div ref="tabListRef" class="workspace-tab-list" role="tablist" aria-label="已打开页面" @wheel="handleTabWheel">
|
||||||
<div
|
<div
|
||||||
v-for="(tab, index) in workspace.tabs"
|
v-for="(tab, index) in workspace.tabs"
|
||||||
@@ -155,7 +157,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-dropdown trigger="click" placement="bottom-end" @command="handleCommand">
|
<el-dropdown trigger="click" placement="bottom-end" popper-class="admin-workbench shell-header-popper" @command="handleCommand">
|
||||||
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
|
<el-button class="workspace-tab-more" text aria-label="页签管理" title="页签管理">
|
||||||
<MoreHorizontal :size="18" aria-hidden="true" />
|
<MoreHorizontal :size="18" aria-hidden="true" />
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -180,9 +182,8 @@ watch(
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border-bottom: 1px solid var(--cc-border);
|
border-bottom: 1px solid var(--workbench-border);
|
||||||
background: #f7f9fc;
|
background: var(--workbench-surface);
|
||||||
box-shadow: 0 5px 14px rgba(23, 42, 70, .045);
|
|
||||||
}
|
}
|
||||||
.workspace-tab-list {
|
.workspace-tab-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -207,10 +208,10 @@ watch(
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-bottom: 0;
|
border-bottom: 0;
|
||||||
border-radius: 8px 8px 0 0;
|
border-radius: 8px 8px 0 0;
|
||||||
color: #526178;
|
color: var(--workbench-muted);
|
||||||
}
|
}
|
||||||
.workspace-tab:hover { background: #eef3fa; color: #24344d; }
|
.workspace-tab:hover { background: var(--workbench-surface-soft); color: var(--workbench-text); }
|
||||||
.workspace-tab.is-active { border-color: var(--cc-border); background: #fff; color: #172033; }
|
.workspace-tab.is-active { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
|
||||||
.workspace-tab-activate {
|
.workspace-tab-activate {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -238,19 +239,19 @@ watch(
|
|||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #7b8799;
|
color: var(--workbench-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.workspace-tab-close:hover { background: #dde6f2; color: #26364d; }
|
.workspace-tab-close:hover { background: var(--workbench-accent-border); color: var(--workbench-primary); }
|
||||||
.workspace-tab-activate:focus-visible,
|
.workspace-tab-activate:focus-visible,
|
||||||
.workspace-tab-close:focus-visible { outline: 2px solid var(--cc-primary); outline-offset: -2px; }
|
.workspace-tab-close:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: -2px; }
|
||||||
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--cc-border); color: #526178; }
|
.workspace-tab-more { width: 44px; height: 44px; flex: 0 0 44px; border-radius: 0; border-left: 1px solid var(--workbench-border); color: var(--workbench-muted); }
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.workspace-tabs { top: 64px; height: 48px; }
|
.workspace-tabs { top: 64px; height: 48px; }
|
||||||
.workspace-tab-list { align-items: center; padding: 4px 6px; }
|
.workspace-tab-list { align-items: center; padding: 4px 6px; }
|
||||||
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
|
.workspace-tab { min-width: 140px; height: 40px; border-bottom: 1px solid transparent; border-radius: 8px; }
|
||||||
.workspace-tab.is-active { border-color: #cbd7e7; }
|
.workspace-tab.is-active { border-color: var(--workbench-accent-border); }
|
||||||
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
|
.workspace-tab-close { width: 34px; height: 34px; flex-basis: 34px; }
|
||||||
.workspace-tab-more { height: 48px; }
|
.workspace-tab-more { height: 48px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ describe('AppLayout', () => {
|
|||||||
|
|
||||||
it('shows the authenticated identity and the localhost full-access control', async () => {
|
it('shows the authenticated identity and the localhost full-access control', async () => {
|
||||||
const { wrapper } = await mountLayout()
|
const { wrapper } = await mountLayout()
|
||||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
expect(wrapper.get('.user-copy').text()).toBe('平台管理员(admin)')
|
||||||
|
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
|
||||||
const accessToggle = wrapper.get('[role="switch"]')
|
const accessToggle = wrapper.get('[role="switch"]')
|
||||||
expect(accessToggle.text()).toContain('本地全权限')
|
expect(accessToggle.text()).toContain('本地全权限')
|
||||||
expect(accessToggle.text()).toContain('已关闭')
|
expect(accessToggle.text()).toContain('已关闭')
|
||||||
@@ -125,15 +126,34 @@ describe('AppLayout', () => {
|
|||||||
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
|
expect(wrapper.get('[aria-label="消息中心"]').attributes('disabled')).toBeUndefined()
|
||||||
expect(wrapper.text()).toContain('消息管理')
|
expect(wrapper.text()).toContain('消息管理')
|
||||||
expect(wrapper.text()).toContain('模板管理')
|
expect(wrapper.text()).toContain('模板管理')
|
||||||
expect(wrapper.text()).not.toContain('我的消息')
|
expect(wrapper.text()).toContain('我的消息')
|
||||||
const notificationTrigger = wrapper.get('.notification-trigger')
|
const notificationTrigger = wrapper.get('.notification-trigger')
|
||||||
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
expect(notificationTrigger.find('.notification-count').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('avoids repeating identical display names and accounts while retaining the full accessible identity', async () => {
|
||||||
|
const { wrapper } = await mountLayout()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.user = { ...auth.user!, name: 'admin', account: 'admin' }
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.get('.user-copy').text()).toBe('admin')
|
||||||
|
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:admin(admin)')
|
||||||
|
expect(wrapper.get('.user-copy').attributes('title')).toBe('登录身份:admin(admin)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits the shell theme to navigation and header instead of inheriting it into routed pages', async () => {
|
||||||
|
const { wrapper } = await mountLayout('/customers')
|
||||||
|
|
||||||
|
expect(wrapper.get('.app-sidebar').classes()).toContain('admin-workbench')
|
||||||
|
expect(wrapper.get('.app-header').classes()).toContain('admin-workbench')
|
||||||
|
expect(wrapper.get('.cached-count').element.closest('.admin-workbench')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('hides the shell heading when the route opts out', async () => {
|
it('hides the shell heading when the route opts out', async () => {
|
||||||
const { wrapper } = await mountLayout('/customers')
|
const { wrapper } = await mountLayout('/customers')
|
||||||
expect(wrapper.find('.page-heading').exists()).toBe(false)
|
expect(wrapper.find('.page-heading').exists()).toBe(false)
|
||||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
expect(wrapper.get('.user-copy').attributes('aria-label')).toBe('登录身份:平台管理员(admin)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('logs out after confirmation', async () => {
|
it('logs out after confirmation', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminDensity.css'
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -25,9 +27,12 @@ const collapsed = ref(false)
|
|||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
const isMobileViewport = ref(false)
|
const isMobileViewport = ref(false)
|
||||||
let mobileViewportMedia: MediaQueryList | undefined
|
let mobileViewportMedia: MediaQueryList | undefined
|
||||||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
|
||||||
const pageDescription = computed(() => route.meta.description || '')
|
|
||||||
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
|
||||||
|
const userIdentity = computed(() => {
|
||||||
|
const name = auth.user?.name, account = auth.user?.account
|
||||||
|
return name && account && name !== account ? `${name}(${account})` : name || account || '—'
|
||||||
|
})
|
||||||
|
const userIdentityLabel = computed(() => `登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`)
|
||||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||||
const menuButtonLabel = computed(() => {
|
const menuButtonLabel = computed(() => {
|
||||||
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
|
if (isMobileViewport.value) return mobileMenuOpen.value ? '关闭菜单' : '打开菜单'
|
||||||
@@ -37,7 +42,8 @@ const menuExpanded = computed(() => (
|
|||||||
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
|
isMobileViewport.value ? mobileMenuOpen.value : !collapsed.value
|
||||||
))
|
))
|
||||||
const canOpenNotificationManagement = computed(() => (
|
const canOpenNotificationManagement = computed(() => (
|
||||||
access.can('platform:notification:task:list')
|
access.can('platform:notification:view')
|
||||||
|
|| access.can('platform:notification:task:list')
|
||||||
|| access.can('platform:notification:template:list')
|
|| access.can('platform:notification:template:list')
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -56,7 +62,7 @@ async function pruneInaccessibleTabs() {
|
|||||||
await ElMessageBox.alert(
|
await ElMessageBox.alert(
|
||||||
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
|
`账号权限已变化,以下未保存内容无法继续编辑:${[...new Set(discardedDirtyWork.map((entry) => entry.label))].join('、')}。相关页签将关闭。`,
|
||||||
'权限已变化',
|
'权限已变化',
|
||||||
{ confirmButtonText: '知道了', type: 'warning' },
|
{ confirmButtonText: '知道了', type: 'warning', customClass: 'admin-density-confirm' },
|
||||||
).catch(() => undefined)
|
).catch(() => undefined)
|
||||||
}
|
}
|
||||||
const previousActiveId = workspace.activeId
|
const previousActiveId = workspace.activeId
|
||||||
@@ -75,6 +81,7 @@ async function handleLogout() {
|
|||||||
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
|
const dirtyNotice = dirtyLabels.length ? ` 当前还有未保存内容:${[...new Set(dirtyLabels)].join('、')},退出后将丢失。` : ''
|
||||||
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
|
await ElMessageBox.confirm(`确定退出管理后台吗?${dirtyNotice}`, '退出登录', {
|
||||||
confirmButtonText: '退出',
|
confirmButtonText: '退出',
|
||||||
|
customClass: 'admin-density-confirm',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
@@ -136,7 +143,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
|
<div class="app-shell" :class="{ 'is-collapsed': collapsed, 'is-mobile-open': mobileMenuOpen }">
|
||||||
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
|
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar admin-workbench">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
|
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
|
||||||
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
|
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
|
||||||
@@ -147,7 +154,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="app-main">
|
<div class="app-main">
|
||||||
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
|
<header class="app-header admin-workbench" :class="{ 'app-header--without-page-heading': !showPageHeading }">
|
||||||
<button
|
<button
|
||||||
class="shell-menu-button"
|
class="shell-menu-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -155,13 +162,12 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
:aria-expanded="menuExpanded"
|
:aria-expanded="menuExpanded"
|
||||||
@click="handleMenuToggle"
|
@click="handleMenuToggle"
|
||||||
><Menu :size="22" /></button>
|
><Menu :size="22" /></button>
|
||||||
<div v-if="showPageHeading" class="page-heading">
|
<div v-if="showPageHeading" class="page-heading" aria-hidden="true" />
|
||||||
<!-- <h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p> -->
|
|
||||||
</div>
|
|
||||||
<div class="header-user">
|
<div class="header-user">
|
||||||
<button
|
<button
|
||||||
v-if="access.localFullAccessAvailable"
|
v-if="access.localFullAccessAvailable"
|
||||||
class="local-full-access-toggle"
|
class="local-full-access-toggle workbench-tone"
|
||||||
|
:data-tone="access.localFullAccessEnabled ? 'green' : 'blue'"
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
:aria-checked="access.localFullAccessEnabled"
|
:aria-checked="access.localFullAccessEnabled"
|
||||||
@@ -172,17 +178,18 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
<span>本地全权限</span>
|
<span>本地全权限</span>
|
||||||
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
|
<strong>{{ access.localFullAccessEnabled ? '已开启' : '已关闭' }}</strong>
|
||||||
</button>
|
</button>
|
||||||
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end">
|
<el-dropdown v-if="canOpenNotificationManagement" trigger="click" placement="bottom-end" popper-class="admin-workbench shell-header-popper">
|
||||||
<button class="notification-trigger" type="button" aria-label="消息中心">
|
<button class="notification-trigger" type="button" aria-label="消息中心">
|
||||||
<Bell :size="18" /><span>消息中心</span>
|
<Bell :size="18" /><span>消息中心</span>
|
||||||
</button>
|
</button>
|
||||||
<template #dropdown><el-dropdown-menu>
|
<template #dropdown><el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-if="access.can('platform:notification:view')" @click="openNotificationPage('/notifications')"><Bell :size="15" />我的消息</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
|
<el-dropdown-item v-if="access.can('platform:notification:task:list')" @click="openNotificationPage('/notifications/messages')"><Mail :size="15" />消息管理</el-dropdown-item>
|
||||||
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
|
<el-dropdown-item v-if="access.can('platform:notification:template:list')" @click="openNotificationPage('/notifications/templates')"><FileText :size="15" />模板管理</el-dropdown-item>
|
||||||
</el-dropdown-menu></template>
|
</el-dropdown-menu></template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
||||||
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
|
<div class="user-copy" :aria-label="userIdentityLabel" :title="userIdentityLabel"><strong>{{ userIdentity }}</strong></div>
|
||||||
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -193,7 +200,7 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
<component
|
<component
|
||||||
:is="Component"
|
:is="Component"
|
||||||
v-if="renderedRoute.meta.keepAlive !== false"
|
v-if="renderedRoute.meta.keepAlive !== false"
|
||||||
:key="workspace.cacheKey(workspaceRouteId(renderedRoute))"
|
:key="workspace.cacheKey(workspaceRouteId(renderedRoute)) + ':' + JSON.stringify(renderedRoute.params)"
|
||||||
/>
|
/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
<component :is="Component" v-if="renderedRoute.meta.keepAlive === false" />
|
||||||
@@ -205,14 +212,14 @@ watch(() => auth.user?.id || auth.user?.account || '', (userId, previousUserId)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-header--without-page-heading .header-user { margin-left: auto; }
|
.app-header--without-page-heading .header-user { margin-left: auto; }
|
||||||
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid rgba(255, 255, 255, .3); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
|
.local-full-access-toggle { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 11px; border: 1px solid var(--workbench-border); border-radius: 10px; background: var(--workbench-surface); color: var(--workbench-muted); font: inherit; font-size: 13px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
|
||||||
.local-full-access-toggle:hover { border-color: rgba(255, 255, 255, .55); background: rgba(255, 255, 255, .22); }
|
.local-full-access-toggle:hover { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); }
|
||||||
.local-full-access-toggle:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
|
.local-full-access-toggle:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
|
||||||
.local-full-access-toggle[aria-checked="true"] { border-color: #86efac; background: rgba(22, 163, 74, .3); }
|
.local-full-access-toggle[aria-checked="true"] { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-accent); }
|
||||||
.local-full-access-toggle strong { color: #dcfce7; font-size: 12px; }
|
.local-full-access-toggle strong { color: inherit; font-size: 12px; }
|
||||||
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid rgba(255, 255, 255, .28); border-radius: 10px; background: rgba(255, 255, 255, .12); color: #fff; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease, transform .2s ease; }
|
.notification-trigger { display: inline-flex; height: 38px; align-items: center; gap: 7px; padding: 0 12px; border: 1px solid var(--workbench-border); border-radius: 10px; background: var(--workbench-surface); color: var(--workbench-text); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
|
||||||
.notification-trigger:hover { border-color: rgba(255, 255, 255, .48); background: rgba(255, 255, 255, .24); transform: translateY(-1px); }
|
.notification-trigger:hover { border-color: var(--workbench-accent-border); background: var(--workbench-accent-soft); color: var(--workbench-primary); }
|
||||||
.notification-trigger:focus-visible { outline: 2px solid rgba(255, 255, 255, .9); outline-offset: 2px; }
|
.notification-trigger:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
|
||||||
.notification-trigger > svg { flex: 0 0 auto; }
|
.notification-trigger > svg { flex: 0 0 auto; }
|
||||||
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
|
@media (max-width: 720px) { .local-full-access-toggle > span { display: none; } .notification-trigger { width: 44px; height: 44px; justify-content: center; padding: 0; } .notification-trigger > span { display: none; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { ShieldX } from '@lucide/vue'
|
import { ShieldX } from '@lucide/vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -16,7 +17,7 @@ async function returnToAccessiblePage() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="forbidden-page" aria-labelledby="forbidden-title">
|
<section class="forbidden-page admin-workbench" aria-labelledby="forbidden-title">
|
||||||
<div class="forbidden-icon" aria-hidden="true"><ShieldX :size="32" /></div>
|
<div class="forbidden-icon" aria-hidden="true"><ShieldX :size="32" /></div>
|
||||||
<p class="section-kicker">403 · 权限不足</p>
|
<p class="section-kicker">403 · 权限不足</p>
|
||||||
<h2 id="forbidden-title">当前登录账号无法访问此页面</h2>
|
<h2 id="forbidden-title">当前登录账号无法访问此页面</h2>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ describe('admin router access metadata', () => {
|
|||||||
it('keeps smart loading as an order dialog while retaining the single-container deep link', () => {
|
it('keeps smart loading as an order dialog while retaining the single-container deep link', () => {
|
||||||
expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false)
|
expect(router.getRoutes().some((item) => item.name === 'global-smart-loading-workbench')).toBe(false)
|
||||||
const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench')
|
const detail = router.getRoutes().find((item) => item.name === 'smart-loading-workbench')
|
||||||
expect(detail?.path).toBe('/container-loading/:containerId/smart-loading')
|
expect(detail?.path).toBe('/container-loading/domestic/:containerId/smart-loading')
|
||||||
expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list')
|
expect(detail?.meta.permission).toBe('platform:firstleg:smart-loading:list')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -110,6 +110,7 @@ describe('admin router access metadata', () => {
|
|||||||
|
|
||||||
it('registers message-center routes with the frozen IAM permissions', () => {
|
it('registers message-center routes with the frozen IAM permissions', () => {
|
||||||
const expected = [
|
const expected = [
|
||||||
|
['notification-inbox', '/notifications', 'platform:notification:view'],
|
||||||
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
|
['notification-messages', '/notifications/messages', 'platform:notification:task:list'],
|
||||||
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
|
['notification-templates', '/notifications/templates', 'platform:notification:template:list'],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '@/styles/adminDensity.css'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import AppLayout from '@/app/layouts/AppLayout.vue'
|
import AppLayout from '@/app/layouts/AppLayout.vue'
|
||||||
@@ -30,6 +31,10 @@ import { supplierRoutes } from '@/modules/suppliers/routes'
|
|||||||
import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
|
import { overseasWarehouseRoutes } from '@/modules/overseas-warehouse/routes'
|
||||||
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
|
import { overseasBusinessRoutes } from '@/modules/overseas-business/routes'
|
||||||
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
|
import { overseasMasterDataRoutes } from '@/modules/overseas-master-data/routes'
|
||||||
|
import { pdaReleaseRoutes } from '@/modules/pda-releases/routes'
|
||||||
|
import { sailingCalendarRoutes } from '@/modules/sailing-calendar/routes'
|
||||||
|
import { appointmentCalendarRoutes } from '@/modules/appointment-calendar/routes'
|
||||||
|
import { selfOperatedRoutes } from '@/modules/self-operated/routes'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -74,6 +79,10 @@ const router = createRouter({
|
|||||||
...masterDataRoutes,
|
...masterDataRoutes,
|
||||||
...downloadRoutes,
|
...downloadRoutes,
|
||||||
...iamRoutes,
|
...iamRoutes,
|
||||||
|
...pdaReleaseRoutes,
|
||||||
|
...sailingCalendarRoutes,
|
||||||
|
...appointmentCalendarRoutes,
|
||||||
|
...selfOperatedRoutes,
|
||||||
{
|
{
|
||||||
path: '403',
|
path: '403',
|
||||||
name: 'forbidden',
|
name: 'forbidden',
|
||||||
@@ -99,7 +108,7 @@ router.beforeEach(async (to, from) => {
|
|||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`当前页面还有未保存内容:${[...new Set(blocking.map((entry) => entry.label))].join('、')}。继续后将放弃这些修改。`,
|
`当前页面还有未保存内容:${[...new Set(blocking.map((entry) => entry.label))].join('、')}。继续后将放弃这些修改。`,
|
||||||
'放弃未保存的修改?',
|
'放弃未保存的修改?',
|
||||||
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning' },
|
{ confirmButtonText: '放弃并继续', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
||||||
)
|
)
|
||||||
unsaved.removeWorkspaces([fromId])
|
unsaved.removeWorkspaces([fromId])
|
||||||
useWorkspaceTabsStore().bumpCacheEpoch(fromId)
|
useWorkspaceTabsStore().bumpCacheEpoch(fromId)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createPinia, setActivePinia } from 'pinia'
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
post: vi.fn(),
|
post: vi.fn(),
|
||||||
@@ -60,13 +60,11 @@ function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
|
|||||||
expiresIn: 1800,
|
expiresIn: 1800,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
refreshExpiresIn: 604800,
|
refreshExpiresIn: 604800,
|
||||||
overseasAccessToken: 'overseas-token',
|
|
||||||
overseasExpiresIn: 900,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function domesticOnlyLoginResponse() {
|
function unifiedLoginResponse() {
|
||||||
return {
|
return {
|
||||||
code: '200',
|
code: '200',
|
||||||
message: 'success',
|
message: 'success',
|
||||||
@@ -89,13 +87,19 @@ describe('admin auth session', () => {
|
|||||||
mocks.refreshHandler = null
|
mocks.refreshHandler = null
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a domestic-only token response when the test environment has no overseas services', async () => {
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
mocks.post.mockResolvedValue(domesticOnlyLoginResponse())
|
|
||||||
|
it('accepts the unified access and refresh token response', async () => {
|
||||||
|
mocks.post.mockResolvedValue(unifiedLoginResponse())
|
||||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
await auth.login('platform_admin', 'password')
|
await auth.login('platform_admin', 'password')
|
||||||
|
|
||||||
|
expect(mocks.post).toHaveBeenCalledWith('/auth/login', {
|
||||||
|
loginId: 'platform_admin',
|
||||||
|
password: 'password',
|
||||||
|
})
|
||||||
expect(auth.isAuthenticated).toBe(true)
|
expect(auth.isAuthenticated).toBe(true)
|
||||||
expect(auth.refreshToken).toBe('refresh-domestic')
|
expect(auth.refreshToken).toBe('refresh-domestic')
|
||||||
})
|
})
|
||||||
@@ -110,8 +114,6 @@ describe('admin auth session', () => {
|
|||||||
expiresIn: '7200',
|
expiresIn: '7200',
|
||||||
refreshToken: 'refresh-serialized',
|
refreshToken: 'refresh-serialized',
|
||||||
refreshExpiresIn: '604800',
|
refreshExpiresIn: '604800',
|
||||||
overseasAccessToken: null,
|
|
||||||
overseasExpiresIn: '0',
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||||
@@ -178,29 +180,43 @@ describe('admin auth session', () => {
|
|||||||
expect(auth.isAuthenticated).toBe(true)
|
expect(auth.isAuthenticated).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshes a valid persisted session so newly granted permissions appear after reload', async () => {
|
it('reuses a valid persisted session without blocking reload on refresh', async () => {
|
||||||
localStorage.setItem('cc-admin-token', token())
|
localStorage.setItem('cc-admin-token', token())
|
||||||
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||||
const refreshedToken = token({
|
|
||||||
token_version: 2,
|
|
||||||
})
|
|
||||||
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
|
|
||||||
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
|
|
||||||
'platform:warehouse:list',
|
|
||||||
'platform:firstleg:inbound:list',
|
|
||||||
])
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
await expect(auth.restoreSession()).resolves.toBe(true)
|
await expect(auth.restoreSession()).resolves.toBe(true)
|
||||||
|
|
||||||
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
|
expect(mocks.post).not.toHaveBeenCalled()
|
||||||
expect([...auth.permissionCodes]).toEqual([
|
expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list'])
|
||||||
'platform:warehouse:list',
|
expect(localStorage.getItem('cc-admin-permission-codes')).toBe('["platform:warehouse:list"]')
|
||||||
'platform:firstleg:inbound:list',
|
})
|
||||||
])
|
|
||||||
expect(localStorage.getItem('cc-admin-permission-codes'))
|
it('reuses a session rotated by another tab without refreshing it again', async () => {
|
||||||
.toBe('["platform:warehouse:list","platform:firstleg:inbound:list"]')
|
const previousToken = token({ token_version: 1 })
|
||||||
|
const rotatedToken = token({ token_version: 2 })
|
||||||
|
localStorage.setItem('cc-admin-token', previousToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: {
|
||||||
|
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<void>) => {
|
||||||
|
localStorage.setItem('cc-admin-token', rotatedToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]')
|
||||||
|
return callback()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
await expect(auth.refreshSession()).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
expect(mocks.post).not.toHaveBeenCalled()
|
||||||
|
expect(auth.token).toBe(rotatedToken)
|
||||||
|
expect(auth.refreshToken).toBe('refresh-rotated')
|
||||||
|
expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not let an in-flight refresh restore a session after logout', async () => {
|
it('does not let an in-flight refresh restore a session after logout', async () => {
|
||||||
@@ -225,4 +241,157 @@ describe('admin auth session', () => {
|
|||||||
expect(auth.refreshToken).toBe('')
|
expect(auth.refreshToken).toBe('')
|
||||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects an old refresh after another tab has switched to a different account', async () => {
|
||||||
|
localStorage.setItem('cc-admin-token', token())
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'admin-refresh')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]')
|
||||||
|
let finishRefresh!: (value: ReturnType<typeof loginResponse>) => void
|
||||||
|
mocks.post.mockImplementation(() => new Promise(resolve => { finishRefresh = resolve }))
|
||||||
|
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||||
|
const listeners = vi.spyOn(window, 'addEventListener')
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
|
||||||
|
listeners.mockRestore()
|
||||||
|
const refreshing = auth.refreshSession()
|
||||||
|
await Promise.resolve()
|
||||||
|
const salesToken = token({ user_id: '2000002', username: 'sales' })
|
||||||
|
localStorage.setItem('cc-admin-token', salesToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
|
||||||
|
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
|
||||||
|
expect(auth.user?.account).toBe('sales')
|
||||||
|
finishRefresh(loginResponse(token(), 'admin-rotated'))
|
||||||
|
await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代')
|
||||||
|
expect(auth.user?.account).toBe('sales')
|
||||||
|
expect(auth.token).toBe(salesToken)
|
||||||
|
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
|
||||||
|
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('discards late permission results after another tab switches accounts without clearing the new session', async () => {
|
||||||
|
mocks.post.mockResolvedValue(loginResponse())
|
||||||
|
let finishPermissions!: (value: string[]) => void
|
||||||
|
let permissionsStarted!: () => void
|
||||||
|
const started = new Promise<void>(resolve => { permissionsStarted = resolve })
|
||||||
|
mocks.listCurrentPlatformPermissionCodes.mockImplementation(() => {
|
||||||
|
permissionsStarted()
|
||||||
|
return new Promise(resolve => { finishPermissions = resolve })
|
||||||
|
})
|
||||||
|
const listeners = vi.spyOn(window, 'addEventListener')
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const onStorage = listeners.mock.calls.find(([name]) => name === 'storage')![1] as (event: StorageEvent) => void
|
||||||
|
listeners.mockRestore()
|
||||||
|
const login = auth.login('platform_admin', 'password')
|
||||||
|
await started
|
||||||
|
const salesToken = token({ user_id: '2000002', username: 'sales' })
|
||||||
|
localStorage.setItem('cc-admin-token', salesToken)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'sales-refresh')
|
||||||
|
localStorage.setItem('cc-admin-permission-codes', '["platform:fleet:capacity-offer:list"]')
|
||||||
|
onStorage(new StorageEvent('storage', { key: 'cc-admin-token' }))
|
||||||
|
finishPermissions(['platform:warehouse:list'])
|
||||||
|
await expect(login).rejects.toThrow('登录会话已被后续操作替代')
|
||||||
|
expect(auth.user?.account).toBe('sales')
|
||||||
|
expect(auth.token).toBe(salesToken)
|
||||||
|
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('sales-refresh')
|
||||||
|
expect([...auth.permissionCodes]).toEqual(['platform:fleet:capacity-offer:list'])
|
||||||
|
expect(mocks.post).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => {
|
||||||
|
const previous = token()
|
||||||
|
const rotated = token({ token_version: 2 })
|
||||||
|
localStorage.setItem('cc-admin-token', previous)
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } })
|
||||||
|
.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated'))
|
||||||
|
.mockResolvedValueOnce({ code: '200', data: null })
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
await expect(auth.logout()).resolves.toBeUndefined()
|
||||||
|
|
||||||
|
expect(mocks.post).toHaveBeenCalledTimes(3)
|
||||||
|
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/refresh', { refreshToken: 'refresh-old' })
|
||||||
|
expect(mocks.post).toHaveBeenNthCalledWith(3, '/auth/logout', { refreshToken: 'refresh-rotated' }, {
|
||||||
|
headers: { Authorization: `Bearer ${rotated}` },
|
||||||
|
})
|
||||||
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||||
|
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
|
||||||
|
expect(mocks.listCurrentPlatformPermissionCodes).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revokes a persisted refresh session when startup already removed its expired access token', async () => {
|
||||||
|
localStorage.setItem('cc-admin-token', token({ exp: 1 }))
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
const rotated = token()
|
||||||
|
mocks.post.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated'))
|
||||||
|
.mockResolvedValueOnce({ code: '200', data: null })
|
||||||
|
const auth = useAuthStore()
|
||||||
|
expect(auth.token).toBe('')
|
||||||
|
|
||||||
|
await auth.logout()
|
||||||
|
|
||||||
|
expect(mocks.post).toHaveBeenCalledTimes(2)
|
||||||
|
expect(mocks.post).toHaveBeenNthCalledWith(1, '/auth/refresh', { refreshToken: 'refresh-old' })
|
||||||
|
expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-rotated' }, {
|
||||||
|
headers: { Authorization: `Bearer ${rotated}` },
|
||||||
|
})
|
||||||
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a subsequent login intact while finishing revocation of the previous session', async () => {
|
||||||
|
localStorage.setItem('cc-admin-token', token())
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
let finishRefresh: ((value: ReturnType<typeof loginResponse>) => void) | undefined
|
||||||
|
const started = new Promise<void>(resolve => {
|
||||||
|
mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } })
|
||||||
|
.mockImplementationOnce(() => { resolve(); return new Promise(done => { finishRefresh = done }) })
|
||||||
|
})
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loggingOut = auth.logout()
|
||||||
|
// Attach immediately so a regression rejecting early does not become an unhandled rejection.
|
||||||
|
const result = loggingOut.then(() => null, error => error)
|
||||||
|
await Promise.race([started, loggingOut.catch(() => undefined)])
|
||||||
|
expect(finishRefresh).toBeTypeOf('function')
|
||||||
|
const newAccess = token({ user_id: '1000002', username: 'second' })
|
||||||
|
mocks.post.mockResolvedValueOnce(loginResponse(newAccess, 'refresh-new-login'))
|
||||||
|
.mockResolvedValueOnce({ code: '200', data: null })
|
||||||
|
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list'])
|
||||||
|
await auth.login('second', 'password')
|
||||||
|
const revocationAccess = token({ token_version: 3 })
|
||||||
|
finishRefresh?.(loginResponse(revocationAccess, 'refresh-revocation-only'))
|
||||||
|
|
||||||
|
expect(await result).toBeNull()
|
||||||
|
expect(auth.token).toBe(newAccess)
|
||||||
|
expect(localStorage.getItem('cc-admin-refresh-token')).toBe('refresh-new-login')
|
||||||
|
expect(mocks.post).toHaveBeenLastCalledWith('/auth/logout', { refreshToken: 'refresh-revocation-only' }, {
|
||||||
|
headers: { Authorization: `Bearer ${revocationAccess}` },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not refresh or retry an ambiguous logout transport failure', async () => {
|
||||||
|
localStorage.setItem('cc-admin-token', token())
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
const failure = { isAxiosError: true, response: { status: 503 } }
|
||||||
|
mocks.post.mockRejectedValue(failure)
|
||||||
|
const auth = useAuthStore()
|
||||||
|
await expect(auth.logout()).rejects.toBe(failure)
|
||||||
|
expect(mocks.post).toHaveBeenCalledTimes(1)
|
||||||
|
expect(auth.isAuthenticated).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['refresh', 'second-logout'])('stops after a %s rejection and never restores the logged-out session', async stage => {
|
||||||
|
localStorage.setItem('cc-admin-token', token())
|
||||||
|
localStorage.setItem('cc-admin-refresh-token', 'refresh-old')
|
||||||
|
const unauthorized = { isAxiosError: true, response: { status: 401 } }
|
||||||
|
mocks.post.mockRejectedValueOnce(unauthorized)
|
||||||
|
if (stage === 'second-logout') mocks.post.mockResolvedValueOnce(loginResponse())
|
||||||
|
mocks.post.mockRejectedValueOnce(unauthorized)
|
||||||
|
const auth = useAuthStore()
|
||||||
|
await expect(auth.logout()).rejects.toBe(unauthorized)
|
||||||
|
expect(mocks.post).toHaveBeenCalledTimes(stage === 'refresh' ? 2 : 3)
|
||||||
|
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||||
|
expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
import type { CurrentUser } from '@chongchong/shared'
|
import type { CurrentUser } from '@chongchong/shared'
|
||||||
import {
|
import {
|
||||||
http,
|
http,
|
||||||
@@ -8,7 +9,6 @@ import {
|
|||||||
type ApiEnvelope,
|
type ApiEnvelope,
|
||||||
} from '@/shared/api/http'
|
} from '@/shared/api/http'
|
||||||
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
import type { PlatformPermissionResource } from '@/shared/types/platformPermission'
|
||||||
import { clearOverseasAccessToken, replaceOverseasAccessToken } from '@/shared/api/overseasHttp'
|
|
||||||
import { decodeJwtClaims } from './jwt'
|
import { decodeJwtClaims } from './jwt'
|
||||||
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
import { listCurrentPlatformPermissionCodes } from './platformAuthorization'
|
||||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
||||||
@@ -24,8 +24,6 @@ interface LoginResponse {
|
|||||||
expiresIn: number | string
|
expiresIn: number | string
|
||||||
refreshToken: string
|
refreshToken: string
|
||||||
refreshExpiresIn: number | string
|
refreshExpiresIn: number | string
|
||||||
overseasAccessToken?: string | null
|
|
||||||
overseasExpiresIn?: number | string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExpiredAccessTokenError extends Error {}
|
class ExpiredAccessTokenError extends Error {}
|
||||||
@@ -100,10 +98,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
async function applySession(result: LoginResponse, expectedGeneration: number) {
|
async function applySession(result: LoginResponse, expectedGeneration: number) {
|
||||||
const session = sessionFromToken(result.accessToken)
|
const session = sessionFromToken(result.accessToken)
|
||||||
ensureCurrentGeneration(expectedGeneration)
|
ensureCurrentGeneration(expectedGeneration)
|
||||||
replaceOverseasAccessToken(
|
|
||||||
result.overseasAccessToken ?? null,
|
|
||||||
parseLifetimeSeconds(result.overseasExpiresIn ?? 0, 'overseasExpiresIn'),
|
|
||||||
)
|
|
||||||
token.value = result.accessToken
|
token.value = result.accessToken
|
||||||
refreshToken.value = result.refreshToken
|
refreshToken.value = result.refreshToken
|
||||||
user.value = session.user
|
user.value = session.user
|
||||||
@@ -123,7 +117,6 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
|
|
||||||
function clearSession() {
|
function clearSession() {
|
||||||
sessionGeneration += 1
|
sessionGeneration += 1
|
||||||
clearOverseasAccessToken()
|
|
||||||
token.value = ''
|
token.value = ''
|
||||||
refreshToken.value = ''
|
refreshToken.value = ''
|
||||||
user.value = null
|
user.value = null
|
||||||
@@ -151,7 +144,10 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
try {
|
try {
|
||||||
const session = sessionFromToken(storedToken)
|
const session = sessionFromToken(storedToken)
|
||||||
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
|
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
|
||||||
if (token.value !== storedToken) clearOverseasAccessToken()
|
// A different account in another tab supersedes this tab's pending login/refresh responses.
|
||||||
|
if (user.value?.id !== session.user.id || user.value?.account !== session.user.account) {
|
||||||
|
sessionGeneration += 1
|
||||||
|
}
|
||||||
token.value = storedToken
|
token.value = storedToken
|
||||||
refreshToken.value = storedRefreshToken
|
refreshToken.value = storedRefreshToken
|
||||||
user.value = session.user
|
user.value = session.user
|
||||||
@@ -166,9 +162,29 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
|
async function revokeTokenPair(accessToken: string, refreshTokenToRevoke: string) {
|
||||||
await http.post('/auth/logout', { refreshToken: refreshTokenToRevoke }, {
|
async function revoke(access: string, refresh: string) {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
const response = await http.post<ApiEnvelope<null>, ApiEnvelope<null>>(
|
||||||
})
|
'/auth/logout', { refreshToken: refresh }, { headers: { Authorization: `Bearer ${access}` } },
|
||||||
|
)
|
||||||
|
unwrapApiResponse(response)
|
||||||
|
}
|
||||||
|
if (accessToken) {
|
||||||
|
try {
|
||||||
|
await revoke(accessToken, refreshTokenToRevoke)
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
if (!isAxiosError(error) || error.response?.status !== 401) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Logout has already cleared local state. Refresh only this captured pair
|
||||||
|
// for revocation; never apply it to this tab or overwrite a subsequent login.
|
||||||
|
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||||
|
'/auth/refresh', { refreshToken: refreshTokenToRevoke },
|
||||||
|
)
|
||||||
|
const rotated = unwrapApiResponse<LoginResponse>(response)
|
||||||
|
sessionFromToken(rotated.accessToken)
|
||||||
|
if (!rotated.refreshToken) throw new Error('退出时刷新响应缺少会话凭据')
|
||||||
|
await revoke(rotated.accessToken, rotated.refreshToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(account: string, password: string) {
|
async function login(account: string, password: string) {
|
||||||
@@ -176,7 +192,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
try {
|
try {
|
||||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||||
'/auth/login',
|
'/auth/login',
|
||||||
{ username: account, password },
|
{ loginId: account, password },
|
||||||
)
|
)
|
||||||
const result = unwrapApiResponse<LoginResponse>(response)
|
const result = unwrapApiResponse<LoginResponse>(response)
|
||||||
await applySession(result, expectedGeneration)
|
await applySession(result, expectedGeneration)
|
||||||
@@ -203,14 +219,9 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
observedRefreshToken,
|
observedRefreshToken,
|
||||||
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
currentRefreshToken: () => localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
||||||
reuseCurrentSession: async () => {
|
reuseCurrentSession: async () => {
|
||||||
const storedAccessToken = localStorage.getItem(TOKEN_KEY) || ''
|
applyStoredSession()
|
||||||
const storedRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY) || ''
|
ensureCurrentGeneration(expectedGeneration)
|
||||||
if (!storedAccessToken || !storedRefreshToken) throw new Error('登录状态已过期')
|
if (!token.value || !refreshToken.value) throw new Error('登录状态已过期')
|
||||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
|
||||||
'/auth/refresh',
|
|
||||||
{ refreshToken: storedRefreshToken },
|
|
||||||
)
|
|
||||||
await applySession(unwrapApiResponse<LoginResponse>(response), expectedGeneration)
|
|
||||||
},
|
},
|
||||||
refresh: async (currentRefreshToken) => {
|
refresh: async (currentRefreshToken) => {
|
||||||
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
|
||||||
@@ -229,15 +240,11 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function restoreSession() {
|
async function restoreSession() {
|
||||||
if (isAuthenticated.value) {
|
// The constructor has already validated a persisted access token. Reusing
|
||||||
if (!refreshToken.value) return true
|
// a still-valid token lets the app mount immediately; refresh only remains
|
||||||
try {
|
// necessary after the access token has expired and the constructor has
|
||||||
await refreshSession()
|
// retained the persisted refresh token for recovery.
|
||||||
return true
|
if (isAuthenticated.value) return true
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!refreshToken.value) return false
|
if (!refreshToken.value) return false
|
||||||
try {
|
try {
|
||||||
await refreshSession()
|
await refreshSession()
|
||||||
@@ -263,7 +270,7 @@ export const useAuthStore = defineStore('admin-auth', () => {
|
|||||||
const accessToken = token.value
|
const accessToken = token.value
|
||||||
const tokenToRevoke = refreshToken.value
|
const tokenToRevoke = refreshToken.value
|
||||||
clearSession()
|
clearSession()
|
||||||
if (accessToken && tokenToRevoke) {
|
if (tokenToRevoke) {
|
||||||
await revokeTokenPair(accessToken, tokenToRevoke)
|
await revokeTokenPair(accessToken, tokenToRevoke)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { coordinateCrossTabRefresh } from './crossTabRefresh'
|
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh'
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
const reuseCurrentSession = vi.fn(async () => 'access-new')
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
locks: {
|
locks: {
|
||||||
request: vi.fn(async (_name: string, callback: () => Promise<string>) => {
|
request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => {
|
||||||
currentRefreshToken = 'refresh-new'
|
currentRefreshToken = 'refresh-new'
|
||||||
return callback()
|
return callback()
|
||||||
}),
|
}),
|
||||||
@@ -32,7 +32,7 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
it('performs the refresh when the token has not changed', async () => {
|
it('performs the refresh when the token has not changed', async () => {
|
||||||
const refresh = vi.fn(async () => 'access-new')
|
const refresh = vi.fn(async () => 'access-new')
|
||||||
vi.stubGlobal('navigator', {
|
vi.stubGlobal('navigator', {
|
||||||
locks: { request: vi.fn(async (_name: string, callback: () => Promise<string>) => callback()) },
|
locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise<string>) => callback()) },
|
||||||
})
|
})
|
||||||
|
|
||||||
await expect(coordinateCrossTabRefresh({
|
await expect(coordinateCrossTabRefresh({
|
||||||
@@ -61,4 +61,23 @@ describe('cross-tab refresh coordination', () => {
|
|||||||
|
|
||||||
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
expect(refresh).toHaveBeenCalledWith('refresh-old')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('aborts a refresh lock that cannot be acquired', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
locks: {
|
||||||
|
request: vi.fn((_name: string, options: LockOptions) => new Promise((_resolve, reject) => {
|
||||||
|
options.signal?.addEventListener('abort', () => reject(options.signal?.reason), { once: true })
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinateCrossTabRefresh({
|
||||||
|
lockName: 'admin-refresh',
|
||||||
|
lockTimeoutMs: 5,
|
||||||
|
observedRefreshToken: 'refresh-old',
|
||||||
|
currentRefreshToken: () => 'refresh-old',
|
||||||
|
reuseCurrentSession: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
})).rejects.toBeInstanceOf(CrossTabRefreshTimeoutError)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
export interface CrossTabRefreshOptions<T> {
|
export interface CrossTabRefreshOptions<T> {
|
||||||
lockName: string
|
lockName: string
|
||||||
|
lockTimeoutMs?: number
|
||||||
observedRefreshToken: string
|
observedRefreshToken: string
|
||||||
currentRefreshToken: () => string
|
currentRefreshToken: () => string
|
||||||
reuseCurrentSession: () => Promise<T>
|
reuseCurrentSession: () => Promise<T>
|
||||||
refresh: (refreshToken: string) => Promise<T>
|
refresh: (refreshToken: string) => Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CrossTabRefreshTimeoutError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('登录状态刷新等待超时,请重新登录')
|
||||||
|
this.name = 'CrossTabRefreshTimeoutError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
|
||||||
|
|
||||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const currentRefreshToken = options.currentRefreshToken()
|
const currentRefreshToken = options.currentRefreshToken()
|
||||||
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return navigator.locks.request(options.lockName, run)
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
||||||
|
try {
|
||||||
|
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { decodeJwtClaims } from './jwt'
|
||||||
|
|
||||||
|
function token(payload: string) {
|
||||||
|
return `header.${Buffer.from(payload).toString('base64url')}.signature`
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('JWT claim decoding', () => {
|
||||||
|
it.each(['2110664514823360515', '"2110664514823360515"'])('preserves the exact account ID encoded as %s', userId => {
|
||||||
|
const claims = decodeJwtClaims(token(`{"user_id":${userId},"app_code":"PLATFORM","exp":1792060000,"iat":1792058200}`))
|
||||||
|
expect(claims.user_id).toBe('2110664514823360515')
|
||||||
|
expect(claims.exp).toBe(1792060000)
|
||||||
|
expect(claims).toHaveProperty('iat', 1792058200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains safe numeric IDs and ordinary claims', () => {
|
||||||
|
expect(decodeJwtClaims(token('{"user_id":123,"username":"operator","roles":["ADMIN"],"exp":1}')))
|
||||||
|
.toEqual({ user_id: 123, username: 'operator', roles: ['ADMIN'], exp: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['', 'header..signature'])('rejects a missing payload: %s', value => {
|
||||||
|
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 格式无效')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['header.%.signature', token('{"user_id":2110664514823360515,')])('rejects malformed encoding or JSON: %s', value => {
|
||||||
|
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 无法解析')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseJsonWithSafeIntegers } from '@chongchong/shared'
|
||||||
|
|
||||||
export interface PlatformJwtClaims {
|
export interface PlatformJwtClaims {
|
||||||
user_id?: string | number
|
user_id?: string | number
|
||||||
username?: string
|
username?: string
|
||||||
@@ -19,7 +21,7 @@ export function decodeJwtClaims(token: string): PlatformJwtClaims {
|
|||||||
const payload = token.split('.')[1]
|
const payload = token.split('.')[1]
|
||||||
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
|
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
|
||||||
try {
|
try {
|
||||||
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
|
return parseJsonWithSafeIntegers(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('登录响应中的 accessToken 无法解析')
|
throw new Error('登录响应中的 accessToken 无法解析')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,71 +8,56 @@ import {
|
|||||||
} from './adminNavigation'
|
} from './adminNavigation'
|
||||||
|
|
||||||
describe('adminNavigation', () => {
|
describe('adminNavigation', () => {
|
||||||
it('keeps the latest prototype menu skeleton in source order and groups additions under general', () => {
|
it('keeps outsourced read access separate from owned access and write-only permissions', () => {
|
||||||
expect(adminNavigation.map((item) => item.title)).toEqual([
|
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:list']))
|
||||||
'首页数据看板',
|
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||||
'客户管理',
|
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/outsourced-business'])
|
||||||
'国内仓',
|
const write = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:outsourced-business:edit', 'platform:fleet:outsourced-business:upload']))
|
||||||
'海外派送',
|
expect(getNavigationLeaves(write).some(node => node.path === '/overseas-master-data/outsourced-business')).toBe(false)
|
||||||
'供应商中心',
|
})
|
||||||
'货柜管理',
|
it('places owned-business under vehicle management and keeps edit/upload from granting page access', () => {
|
||||||
'海外业务',
|
const read = filterAdminNavigation(adminNavigation, new Set(['platform:fleet:owned-business:list']))
|
||||||
'海外仓',
|
const vehicle = read.find(node => node.key === 'vehicle-management')
|
||||||
'财务管理',
|
expect(vehicle?.children?.map(node => node.path)).toEqual(['/overseas-master-data/owned-business'])
|
||||||
'销售管理',
|
const writeOnly = filterAdminNavigation(adminNavigation, new Set([
|
||||||
'通用',
|
'platform:fleet:owned-business:edit', 'platform:fleet:owned-business:upload',
|
||||||
'系统管理',
|
]))
|
||||||
|
expect(getNavigationLeaves(writeOnly).some(node => node.path === '/overseas-master-data/owned-business')).toBe(false)
|
||||||
|
})
|
||||||
|
it('opens all self-operated read pages with list permission and keeps sync alone insufficient', () => {
|
||||||
|
const read = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:list']))
|
||||||
|
expect(getNavigationLeaves(read).map(item => item.path)).toEqual([
|
||||||
|
'/dashboard', '/self-operated/overview', '/self-operated/sales', '/self-operated/inventory', '/self-operated/collection',
|
||||||
])
|
])
|
||||||
expect(adminNavigation.find((item) => item.key === 'customers')?.children?.map((item) => item.title)).toEqual([
|
const sync = filterAdminNavigation(adminNavigation, new Set(['platform:self-operated-analytics:sync']))
|
||||||
'客户列表',
|
expect(getNavigationLeaves(sync).map(item => item.path)).toEqual(['/dashboard'])
|
||||||
'海外仓阶梯费用',
|
})
|
||||||
])
|
it('offers the common container menu to overseas-only operators', () => {
|
||||||
expect(adminNavigation.find((item) => item.key === 'domestic-warehouse')?.children?.map((item) => item.title)).toEqual([
|
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:firstleg:terminal-drayage:list']))
|
||||||
'订单列表',
|
expect(getNavigationLeaves(visible).map(item => item.path)).toEqual(['/dashboard', '/container-loading/domestic'])
|
||||||
'订单测量',
|
})
|
||||||
'测量数据审核',
|
it('matches the latest prototype sidebar using only implemented menu entries', () => {
|
||||||
'库位管理',
|
// 新版.html sidebar (1404–1539), ordered by its data-menu CSS rules (45–56).
|
||||||
'整柜业务',
|
const allPermissions = new Set(getNavigationLeaves(adminNavigation).flatMap(node => node.permission ? [node.permission] : []))
|
||||||
])
|
const visible = filterAdminNavigation(adminNavigation, allPermissions)
|
||||||
expect(adminNavigation.find((item) => item.key === 'service-center')).toBeUndefined()
|
expect(visible.map(node => ({ title: node.title, children: node.children?.map(child => child.title) ?? [] }))).toEqual([
|
||||||
expect(adminNavigation.find((item) => item.key === 'supplier-center')?.children?.map((item) => item.title)).toEqual([
|
{ title: '首页数据看板', children: [] },
|
||||||
'供应商列表',
|
{ title: '客户管理', children: ['客户列表', '海外仓阶梯费用'] },
|
||||||
'快递公司',
|
{ title: '国内头程', children: ['订单列表', '订单测量', '测量数据审核', '库位管理', '托盘管理', '整柜业务', '船期管理'] },
|
||||||
'往来单位',
|
{ title: '货柜管理', children: ['订舱列表', '拖车列表', '货柜列表', '装柜清单', '预装清单'] },
|
||||||
])
|
{ title: '海外尾程', children: ['尾程订单', '入库单', '废弃单', '出库单', '换标单', '派送单', '托盘管理', '库位管理', '库存管理', '退货库存管理', '退货箱号管理'] },
|
||||||
expect(adminNavigation.find((item) => item.key === 'container-management')?.children?.map((item) => item.title)).toEqual([
|
{ title: '预约管理', children: ['预约列表'] },
|
||||||
'订舱列表',
|
{ title: '车辆管理', children: ['排车列表', '车辆列表', '司机管理', '自有业务', '外配任务', '出车记录'] },
|
||||||
'拖车列表',
|
{ title: '海外业务', children: ['采购订单', '本土卡车', '本土散板', '散板需求'] },
|
||||||
'货柜列表',
|
{ title: '客户服务', children: ['异常订单', '客户回访'] },
|
||||||
'预装清单',
|
{ title: '财务管理', children: ['海外费用明细', '客户账户', '客户应收应付', '应收款统计表', '客户对账单', '结算单应收', '结算单应付'] },
|
||||||
'装柜清单',
|
{ title: '海外仓', children: ['经营总览', '销量分析', '库存分析', '数据采集'] },
|
||||||
])
|
{ title: '通用', children: ['费用项目', '汇率管理', '仓库管理', '海外仓报价', '车队报价', '船期价格表', '下载中心', '供应商列表'] },
|
||||||
expect(adminNavigation.find((item) => item.key === 'overseas-inventory')?.children?.map((item) => item.title)).toEqual([
|
{ title: '系统管理', children: ['用户管理', '角色权限', '菜单权限'] },
|
||||||
'尾程订单',
|
|
||||||
'入库单',
|
|
||||||
'排车列表',
|
|
||||||
'换标单',
|
|
||||||
'托盘管理',
|
|
||||||
'库存管理',
|
|
||||||
])
|
|
||||||
expect(adminNavigation.find((item) => item.key === 'general')?.children?.map((item) => item.title)).toEqual([
|
|
||||||
'产品信息',
|
|
||||||
'费用项目',
|
|
||||||
'汇率管理',
|
|
||||||
'仓库管理',
|
|
||||||
'海外仓报价',
|
|
||||||
'船期价格表',
|
|
||||||
'车辆列表',
|
|
||||||
'下载中心',
|
|
||||||
])
|
])
|
||||||
|
expect(visible).toHaveLength(13)
|
||||||
|
expect(visible.flatMap(node => node.children ?? [])).toHaveLength(60)
|
||||||
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
expect(adminNavigation.find((item) => item.key === 'notifications')).toBeUndefined()
|
||||||
expect(adminNavigation.find((item) => item.key === 'system')?.children?.map((item) => item.title)).toEqual([
|
|
||||||
'用户管理',
|
|
||||||
'角色权限',
|
|
||||||
'租户管理',
|
|
||||||
'品牌设置',
|
|
||||||
'菜单权限',
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('assigns every page a unique path and only shares permissions backed by the API contract', () => {
|
it('assigns every page a unique path and only shares permissions backed by the API contract', () => {
|
||||||
@@ -88,7 +73,8 @@ describe('adminNavigation', () => {
|
|||||||
expect(paths).toContain('/downloads')
|
expect(paths).toContain('/downloads')
|
||||||
expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
|
expect(paths).not.toEqual(expect.arrayContaining(['/notifications/messages', '/notifications/templates', '/notifications/mine']))
|
||||||
expect(paths).toContain('/master-data/fee-items')
|
expect(paths).toContain('/master-data/fee-items')
|
||||||
expect(paths).toContain('/container-loading')
|
expect(paths).toContain('/container-loading/domestic')
|
||||||
|
expect(paths).toContain('/container-loading/overseas')
|
||||||
expect(paths).not.toContain('/container-loading/smart-loading')
|
expect(paths).not.toContain('/container-loading/smart-loading')
|
||||||
expect(paths).toContain('/loading-plans')
|
expect(paths).toContain('/loading-plans')
|
||||||
expect(paths).toContain('/loading-lists')
|
expect(paths).toContain('/loading-lists')
|
||||||
@@ -112,7 +98,7 @@ describe('adminNavigation', () => {
|
|||||||
expect(paths).toContain('/system/permissions')
|
expect(paths).toContain('/system/permissions')
|
||||||
expect(new Set(paths).size).toBe(paths.length)
|
expect(new Set(paths).size).toBe(paths.length)
|
||||||
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
|
const duplicatedPermissions = permissions.filter((permission, index) => permissions.indexOf(permission) !== index)
|
||||||
expect(new Set(duplicatedPermissions)).toEqual(new Set())
|
expect(new Set(duplicatedPermissions)).toEqual(new Set(['platform:self-operated-analytics:list']))
|
||||||
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
|
expect(leaves.every((item) => Boolean(item.icon))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -141,6 +127,10 @@ describe('adminNavigation', () => {
|
|||||||
'platform:overseas-warehouse:last-mile-order:list',
|
'platform:overseas-warehouse:last-mile-order:list',
|
||||||
'platform:overseas-warehouse:inbound-order:list',
|
'platform:overseas-warehouse:inbound-order:list',
|
||||||
'platform:overseas-warehouse:dispatch:list',
|
'platform:overseas-warehouse:dispatch:list',
|
||||||
|
'platform:overseas-warehouse:outbound:list',
|
||||||
|
'platform:overseas-warehouse:delivery:list',
|
||||||
|
'platform:overseas-warehouse:exception-order:list',
|
||||||
|
'platform:overseas-warehouse:customer-visit:list',
|
||||||
'platform:overseas-warehouse:relabel-order:list',
|
'platform:overseas-warehouse:relabel-order:list',
|
||||||
'platform:overseas-warehouse:pallet:list',
|
'platform:overseas-warehouse:pallet:list',
|
||||||
'platform:overseas-warehouse:inventory:list',
|
'platform:overseas-warehouse:inventory:list',
|
||||||
@@ -157,6 +147,9 @@ describe('adminNavigation', () => {
|
|||||||
'/overseas-warehouse/last-mile-orders',
|
'/overseas-warehouse/last-mile-orders',
|
||||||
'/overseas-warehouse/inbound-orders',
|
'/overseas-warehouse/inbound-orders',
|
||||||
'/overseas-warehouse/dispatches',
|
'/overseas-warehouse/dispatches',
|
||||||
|
'/overseas-warehouse/outbounds',
|
||||||
|
'/overseas-warehouse/delivery-orders',
|
||||||
|
'/overseas-warehouse/exception-orders',
|
||||||
'/overseas-warehouse/relabel-orders',
|
'/overseas-warehouse/relabel-orders',
|
||||||
'/overseas-warehouse/pallets',
|
'/overseas-warehouse/pallets',
|
||||||
'/overseas-warehouse/inventory',
|
'/overseas-warehouse/inventory',
|
||||||
@@ -169,7 +162,7 @@ describe('adminNavigation', () => {
|
|||||||
]))
|
]))
|
||||||
expect(paths).not.toContain('/overseas-warehouse/cartons')
|
expect(paths).not.toContain('/overseas-warehouse/cartons')
|
||||||
expect(paths).not.toContain('/overseas-warehouse/products')
|
expect(paths).not.toContain('/overseas-warehouse/products')
|
||||||
expect(paths.some((path) => path.includes('outbound') || path.includes('delivery'))).toBe(false)
|
expect(paths).toEqual(expect.arrayContaining(['/overseas-warehouse/outbounds', '/overseas-warehouse/delivery-orders']))
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters protected pages without mutating the source tree', () => {
|
it('filters protected pages without mutating the source tree', () => {
|
||||||
@@ -196,12 +189,10 @@ describe('adminNavigation', () => {
|
|||||||
]))
|
]))
|
||||||
const paths = getNavigationLeaves(filtered).map((item) => item.path)
|
const paths = getNavigationLeaves(filtered).map((item) => item.path)
|
||||||
|
|
||||||
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内仓', '供应商中心', '通用'])
|
expect(filtered.map((item) => item.title)).toEqual(['首页数据看板', '国内头程', '通用'])
|
||||||
expect(paths).toEqual([
|
expect(paths).toEqual([
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
'/orders',
|
'/orders',
|
||||||
'/master-data/express-companies',
|
|
||||||
'/master-data/counterparties',
|
|
||||||
'/warehouses',
|
'/warehouses',
|
||||||
])
|
])
|
||||||
expect(paths).toContain('/dashboard')
|
expect(paths).toContain('/dashboard')
|
||||||
@@ -221,26 +212,30 @@ describe('adminNavigation', () => {
|
|||||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
|
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual(['/dashboard', '/master-data/fee-items'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the public supplier list only with the list permission', () => {
|
it('shows the restored supplier entry under general only with its list permission', () => {
|
||||||
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
|
const visible = filterAdminNavigation(adminNavigation, new Set(['platform:supplier:list']))
|
||||||
const missing = filterAdminNavigation(adminNavigation, new Set())
|
const missing = filterAdminNavigation(adminNavigation, new Set())
|
||||||
|
|
||||||
|
expect(getNavigationLeaves(adminNavigation).find(item => item.path === '/suppliers')?.permission).toBe('platform:supplier:list')
|
||||||
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
expect(getNavigationLeaves(visible).map((item) => item.path)).toEqual(['/dashboard', '/suppliers'])
|
||||||
|
expect(visible.find(item => item.title === '通用')?.children?.map(item => item.path)).toEqual(['/suppliers'])
|
||||||
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
|
expect(getNavigationLeaves(missing).map((item) => item.path)).toEqual(['/dashboard'])
|
||||||
|
expect(getNavigationLeaves(filterAdminNavigation(adminNavigation, new Set(['platform:supplier:create', 'platform:supplier:edit']))).map(item => item.path)).toEqual(['/dashboard'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows container list and downstream lists without a standalone smart-loading menu', () => {
|
it('shows split container workbenches without a standalone smart-loading menu', () => {
|
||||||
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
const filtered = filterAdminNavigation(adminNavigation, new Set([
|
||||||
'platform:firstleg:container:list',
|
'platform:firstleg:container:list',
|
||||||
'platform:firstleg:preload:list',
|
'platform:firstleg:preload:list',
|
||||||
'platform:firstleg:loading-list:list',
|
'platform:firstleg:loading-list:list',
|
||||||
|
'platform:firstleg:terminal-drayage:list',
|
||||||
]))
|
]))
|
||||||
|
|
||||||
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
expect(getNavigationLeaves(filtered).map((item) => item.path)).toEqual([
|
||||||
'/dashboard',
|
'/dashboard',
|
||||||
'/container-loading',
|
'/container-loading/domestic',
|
||||||
'/loading-plans',
|
|
||||||
'/loading-lists',
|
'/loading-lists',
|
||||||
|
'/loading-plans',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
|
CalendarDays,
|
||||||
ContactRound,
|
ContactRound,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
CircleDollarSign,
|
CircleDollarSign,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import {
|
import {
|
||||||
PrototypeContainerIcon,
|
PrototypeContainerIcon,
|
||||||
|
PrototypeCustomerServiceIcon,
|
||||||
PrototypeCustomerIcon,
|
PrototypeCustomerIcon,
|
||||||
PrototypeDashboardIcon,
|
PrototypeDashboardIcon,
|
||||||
PrototypeDeliveryIcon,
|
PrototypeDeliveryIcon,
|
||||||
@@ -25,8 +27,8 @@ import {
|
|||||||
PrototypeOverseasBusinessIcon,
|
PrototypeOverseasBusinessIcon,
|
||||||
PrototypeOverseasWarehouseIcon,
|
PrototypeOverseasWarehouseIcon,
|
||||||
PrototypeSalesIcon,
|
PrototypeSalesIcon,
|
||||||
PrototypeSupplierIcon,
|
|
||||||
PrototypeSystemIcon,
|
PrototypeSystemIcon,
|
||||||
|
PrototypeVehicleIcon,
|
||||||
} from './prototypeMenuIcons'
|
} from './prototypeMenuIcons'
|
||||||
|
|
||||||
export interface AdminNavigationNode {
|
export interface AdminNavigationNode {
|
||||||
@@ -61,14 +63,16 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'domestic-warehouse',
|
key: 'domestic-warehouse',
|
||||||
title: '国内仓',
|
title: '国内头程',
|
||||||
icon: PrototypeDomesticWarehouseIcon,
|
icon: PrototypeDomesticWarehouseIcon,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
|
{ key: 'order-list', title: '订单列表', icon: ClipboardList, path: '/orders', permission: 'platform:order:list', permissionAnyOf: ['platform:firstleg:inbound:list'] },
|
||||||
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
|
{ key: 'inbound-dedup', title: '订单测量', icon: ClipboardList, path: '/inbounds/measurements', permission: 'platform:firstleg:inbound:measure:view' },
|
||||||
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
|
{ key: 'inbound-measure', title: '测量数据审核', icon: FileCheck2, path: '/inbounds/measurement-reviews', permission: 'firstleg:inbound:measurement-review' },
|
||||||
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
|
{ key: 'locations', title: '库位管理', icon: WarehouseIcon, path: '/master-data/locations', permission: 'platform:location:list' },
|
||||||
|
{ key: 'pallets', title: '托盘管理', icon: WarehouseIcon, path: '/master-data/pallets', permission: 'platform:pallet:list' },
|
||||||
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
|
{ key: 'domestic-containers', title: '整柜业务', icon: WarehouseIcon, path: '/domestic-containers', permission: 'platform:domestic-container:list' },
|
||||||
|
{ key: 'sailing-calendar', title: '船期管理', icon: CalendarDays, path: '/sailing-calendar', permission: 'platform:firstleg:sailing-calendar:list' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,16 +87,6 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
|
{ key: 'outbound-selfpick', title: '自提单', icon: ClipboardList, path: '/prototype/overseas-delivery/self-pickup' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'supplier-center',
|
|
||||||
title: '供应商中心',
|
|
||||||
icon: PrototypeSupplierIcon,
|
|
||||||
children: [
|
|
||||||
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
|
||||||
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list' },
|
|
||||||
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'container-management',
|
key: 'container-management',
|
||||||
title: '货柜管理',
|
title: '货柜管理',
|
||||||
@@ -100,9 +94,50 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
{ key: 'booking-list', title: '订舱列表', icon: ClipboardList, path: '/ocean-bookings', permission: 'platform:firstleg:ocean-booking:list' },
|
||||||
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
|
{ key: 'truck-list', title: '拖车列表', icon: Truck, path: '/trucking', permission: 'platform:firstleg:truck-appointment:list' },
|
||||||
{ key: 'container-list', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading', permission: 'platform:firstleg:container:list' },
|
{ key: 'domestic-container-workbench', title: '货柜列表', icon: WarehouseIcon, path: '/container-loading/domestic', permission: 'platform:firstleg:container:list', permissionAnyOf: ['platform:firstleg:terminal-drayage:list'] },
|
||||||
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
|
||||||
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
|
{ key: 'container-manifest', title: '装柜清单', icon: ClipboardList, path: '/loading-lists', permission: 'platform:firstleg:loading-list:list' },
|
||||||
|
{ key: 'container-preload', title: '预装清单', icon: ClipboardList, path: '/loading-plans', permission: 'platform:firstleg:preload:list' },
|
||||||
|
{ key: 'overseas-container-workbench', title: '清关提柜', icon: ClipboardList, path: '/container-loading/overseas', permission: 'platform:firstleg:terminal-drayage:list', hidden: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'overseas-inventory',
|
||||||
|
title: '海外尾程',
|
||||||
|
icon: PrototypeOverseasWarehouseIcon,
|
||||||
|
children: [
|
||||||
|
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
|
||||||
|
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
|
||||||
|
{ key: 'fba-cargo-discard', title: '废弃单', icon: ClipboardList, path: '/overseas-warehouse/cargo-discards', permission: 'platform:overseas-warehouse:discard:list' },
|
||||||
|
{ key: 'fba-outbound', title: '出库单', icon: ClipboardList, path: '/overseas-warehouse/outbounds', permission: 'platform:overseas-warehouse:outbound:list' },
|
||||||
|
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
|
||||||
|
{ key: 'fba-delivery', title: '派送单', icon: Truck, path: '/overseas-warehouse/delivery-orders', permission: 'platform:overseas-warehouse:delivery:list' },
|
||||||
|
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
|
||||||
|
{ key: 'overseas-locations', title: '库位管理', icon: WarehouseIcon, path: '/overseas-warehouse/locations', permission: 'platform:overseas-warehouse:location:list' },
|
||||||
|
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
|
||||||
|
{ key: 'return-inventory-list', title: '退货库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-inventory', permission: 'platform:overseas-warehouse:return-inventory:list' },
|
||||||
|
{ key: 'return-cartons-list', title: '退货箱号管理', icon: WarehouseIcon, path: '/overseas-warehouse/return-cartons', permission: 'platform:overseas-warehouse:return-cartons:list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'appointment-management',
|
||||||
|
title: '预约管理',
|
||||||
|
icon: CalendarDays,
|
||||||
|
children: [
|
||||||
|
{ key: 'appointment-calendar', title: '预约列表', icon: CalendarDays, path: '/appointment-calendar', permission: 'platform:appointment:list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'vehicle-management',
|
||||||
|
title: '车辆管理',
|
||||||
|
icon: PrototypeVehicleIcon,
|
||||||
|
children: [
|
||||||
|
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
||||||
|
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
||||||
|
{ key: 'driver-management', title: '司机管理', icon: ContactRound, path: '/overseas-master-data/drivers', permission: 'platform:overseas-master-data:driver:list' },
|
||||||
|
{ key: 'fleet-owned-business', title: '自有业务', icon: Truck, path: '/overseas-master-data/owned-business', permission: 'platform:fleet:owned-business:list' },
|
||||||
|
{ key: 'fleet-outsourced-business', title: '外配任务', icon: Truck, path: '/overseas-master-data/outsourced-business', permission: 'platform:fleet:outsourced-business:list' },
|
||||||
|
{ key: 'fleet-trips', title: '车次管理', icon: Truck, path: '/overseas-master-data/trips', permission: 'platform:fleet:trip:list', hidden: true },
|
||||||
|
{ key: 'fleet-operations', title: '出车记录', icon: Truck, path: '/overseas-master-data/trip-operations', permission: 'platform:fleet:operations:list' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -113,19 +148,16 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
{ key: 'overseas-purchase', title: '采购订单', icon: ClipboardList, path: '/overseas-business/purchases', permission: 'platform:overseas-business:purchase:list' },
|
||||||
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
{ key: 'overseas-truck', title: '本土卡车', icon: Truck, path: '/overseas-business/trucks', permission: 'platform:overseas-business:truck:list' },
|
||||||
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
{ key: 'overseas-looseboard', title: '本土散板', icon: ClipboardList, path: '/overseas-business/looseboards', permission: 'platform:overseas-business:looseboard:list' },
|
||||||
|
{ key: 'fleet-capacity-offers', title: '散板需求', icon: Truck, path: '/overseas-master-data/capacity-offers', permission: 'platform:fleet:capacity-offer:list', permissionAnyOf: ['platform:fleet:capacity-offer:manage'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'overseas-inventory',
|
key: 'customer-service',
|
||||||
title: '海外仓',
|
title: '客户服务',
|
||||||
icon: PrototypeOverseasWarehouseIcon,
|
icon: PrototypeCustomerServiceIcon,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'last-mile-orders', title: '尾程订单', icon: ClipboardList, path: '/overseas-warehouse/last-mile-orders', permission: 'platform:overseas-warehouse:last-mile-order:list' },
|
{ key: 'exception-orders', title: '异常订单', icon: ClipboardList, path: '/overseas-warehouse/exception-orders', permission: 'platform:overseas-warehouse:exception-order:list' },
|
||||||
{ key: 'fba-inbound-order', title: '入库单', icon: ClipboardList, path: '/overseas-warehouse/inbound-orders', permission: 'platform:overseas-warehouse:inbound-order:list' },
|
{ key: 'customer-visits', title: '客户回访', icon: ContactRound, path: '/overseas-warehouse/customer-visits', permission: 'platform:overseas-warehouse:customer-visit:list' },
|
||||||
{ key: 'fba-dispatch', title: '排车列表', icon: Truck, path: '/overseas-warehouse/dispatches', permission: 'platform:overseas-warehouse:dispatch:list' },
|
|
||||||
{ key: 'fba-relabel-order', title: '换标单', icon: ClipboardList, path: '/overseas-warehouse/relabel-orders', permission: 'platform:overseas-warehouse:relabel-order:list' },
|
|
||||||
{ key: 'fba-pallet', title: '托盘管理', icon: WarehouseIcon, path: '/overseas-warehouse/pallets', permission: 'platform:overseas-warehouse:pallet:list' },
|
|
||||||
{ key: 'overseas-inventory-list', title: '库存管理', icon: WarehouseIcon, path: '/overseas-warehouse/inventory', permission: 'platform:overseas-warehouse:inventory:list' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -133,6 +165,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
title: '财务管理',
|
title: '财务管理',
|
||||||
icon: PrototypeFinanceIcon,
|
icon: PrototypeFinanceIcon,
|
||||||
children: [
|
children: [
|
||||||
|
{ key: 'overseas-fees', title: '海外费用明细', icon: CircleDollarSign, path: '/overseas-business/fees', permission: 'platform:overseas-fee:list' },
|
||||||
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
|
{ key: 'finance-customer-accounts', title: '客户账户', icon: CircleDollarSign, path: '/finance/customer-accounts', permission: 'platform:finance:customer-account:list' },
|
||||||
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
|
{ key: 'finance-customer-arap', title: '客户应收应付', icon: Database, path: '/finance/customer-arap', permission: 'platform:finance:customer-arap:list' },
|
||||||
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
|
{ key: 'finance-receivable-statistics', title: '应收款统计表', icon: CircleDollarSign, path: '/finance/receivable-statistics', permission: 'platform:finance:receivable-statistics:list' },
|
||||||
@@ -151,19 +184,33 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
|
{ key: 'sales-commission', title: '销售提成', icon: Database, path: '/prototype/sales/commission' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'self-operated',
|
||||||
|
title: '海外仓',
|
||||||
|
icon: PrototypeSalesIcon,
|
||||||
|
children: [
|
||||||
|
{ key: 'self-operated-overview', title: '经营总览', icon: PrototypeDashboardIcon, path: '/self-operated/overview', permission: 'platform:self-operated-analytics:list' },
|
||||||
|
{ key: 'self-operated-sales', title: '销量分析', icon: ClipboardList, path: '/self-operated/sales', permission: 'platform:self-operated-analytics:list' },
|
||||||
|
{ key: 'self-operated-inventory', title: '库存分析', icon: WarehouseIcon, path: '/self-operated/inventory', permission: 'platform:self-operated-analytics:list' },
|
||||||
|
{ key: 'self-operated-collection', title: '数据采集', icon: Database, path: '/self-operated/collection', permission: 'platform:self-operated-analytics:list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'general',
|
key: 'general',
|
||||||
title: '通用',
|
title: '通用',
|
||||||
icon: PrototypeGeneralIcon,
|
icon: PrototypeGeneralIcon,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'general-product', title: '产品信息', icon: Database, path: '/prototype/general/product-information', hidden: true },
|
|
||||||
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
|
{ key: 'general-fee', title: '费用项目', icon: Database, path: '/master-data/fee-items', permission: 'firstleg:fee-item:view' },
|
||||||
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
|
{ key: 'exchange-rates', title: '汇率管理', icon: CircleDollarSign, path: '/overseas-master-data/exchange-rates', permission: 'platform:overseas-master-data:exchange-rate:list' },
|
||||||
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
{ key: 'warehouses', title: '仓库管理', icon: WarehouseIcon, path: '/warehouses', permission: 'platform:warehouse:list' },
|
||||||
{ key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' },
|
{ key: 'overseas-quotes', title: '海外仓报价', icon: CircleDollarSign, path: '/overseas-master-data/quotes', permission: 'platform:overseas-master-data:quote:list' },
|
||||||
|
{ key: 'fleet-quotes', title: '车队报价', icon: Truck, path: '/overseas-master-data/fleet-quotes', permission: 'platform:fleet:quote:list' },
|
||||||
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
{ key: 'fba-carrier-rates', title: '船期价格表', icon: CircleDollarSign, path: '/first-leg-pricing/carrier-rates', permission: 'firstleg:fba-pricing:rate:view' },
|
||||||
{ key: 'vehicle-list', title: '车辆列表', icon: Truck, path: '/overseas-master-data/vehicles', permission: 'platform:overseas-master-data:vehicle:list' },
|
|
||||||
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
{ key: 'download-center', title: '下载中心', icon: Download, path: '/downloads', permission: 'platform:download:list' },
|
||||||
|
{ key: 'supplier-list', title: '供应商列表', icon: ContactRound, path: '/suppliers', permission: 'platform:supplier:list' },
|
||||||
|
{ key: 'express-companies', title: '快递公司', icon: Truck, path: '/master-data/express-companies', permission: 'platform:express:list', hidden: true },
|
||||||
|
{ key: 'counterparties', title: '往来单位', icon: ContactRound, path: '/master-data/counterparties', permission: 'platform:counterparty:list', hidden: true },
|
||||||
|
{ key: 'mojia-sku-sync', title: '称重机器', icon: Database, path: '/general/mojia-sku-sync', permission: 'platform:mojia-sku-sync:list', hidden: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -176,6 +223,7 @@ export const adminNavigation: AdminNavigationNode[] = [
|
|||||||
{ key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true },
|
{ key: 'tenant-management', title: '租户管理', icon: Building2, path: '/prototype/system-management/tenant-management', hidden: true },
|
||||||
{ key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true },
|
{ key: 'brand-settings', title: '品牌设置', icon: Settings, path: '/prototype/system-management/brand-settings', hidden: true },
|
||||||
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
|
{ key: 'permissions', title: '菜单权限', icon: KeyRound, path: '/system/permissions', permission: 'platform:permission:list' },
|
||||||
|
{ key: 'pda-releases', title: 'PDA版本', icon: Download, path: '/system/pda-releases', permission: 'platform:pda-release:list', hidden: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ export const PrototypeOverseasBusinessIcon = createPrototypeMenuIcon('PrototypeO
|
|||||||
h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
|
h('path', { d: 'M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18', stroke: '#8c8c8c', 'stroke-width': 1.5, 'stroke-linecap': 'round' }),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export const PrototypeVehicleIcon = createPrototypeMenuIcon('PrototypeVehicleIcon', [
|
||||||
|
h('path', { d: 'M3 6h12v11H3zM15 10h3l3 3v4h-6z', stroke: '#149b8f', 'stroke-width': 1.8, 'stroke-linejoin': 'round' }),
|
||||||
|
h('circle', { cx: 7, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
|
||||||
|
h('circle', { cx: 18, cy: 18, r: 2, stroke: '#149b8f', 'stroke-width': 1.8 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
export const PrototypeCustomerServiceIcon = createPrototypeMenuIcon('PrototypeCustomerServiceIcon', [
|
||||||
|
h('path', { d: 'M4 13v-2a8 8 0 0116 0v2', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||||
|
h('path', { d: 'M4 13a2 2 0 012-2h1v6H6a2 2 0 01-2-2v-2zM20 13a2 2 0 00-2-2h-1v6h1a2 2 0 002-2v-2z', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linejoin': 'round' }),
|
||||||
|
h('path', { d: 'M17 17c0 2-2 3-5 3', stroke: '#13c2c2', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||||
|
])
|
||||||
|
|
||||||
export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [
|
export const PrototypeFinanceIcon = createPrototypeMenuIcon('PrototypeFinanceIcon', [
|
||||||
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }),
|
h('circle', { cx: 12, cy: 12, r: 10, stroke: '#f5a623', 'stroke-width': 2 }),
|
||||||
h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
h('path', { d: 'M12 6v6l4 2', stroke: '#f5a623', 'stroke-width': 2, 'stroke-linecap': 'round' }),
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
createNavigationRecoveryHandler,
|
||||||
|
installAssetRecoveryListeners,
|
||||||
|
isStaleRouteAssetError,
|
||||||
|
recoverNavigationError,
|
||||||
|
reloadNavigationTarget,
|
||||||
|
type NavigationRecoveryDependencies,
|
||||||
|
} from './navigationRecovery'
|
||||||
|
|
||||||
|
function memoryStorage() {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
|
||||||
|
return {
|
||||||
|
storage: memoryStorage(),
|
||||||
|
now: () => 1_000,
|
||||||
|
hasUnsavedWork: () => false,
|
||||||
|
confirmDiscard: vi.fn(async () => true),
|
||||||
|
discardUnsavedWork: vi.fn(),
|
||||||
|
navigate: vi.fn(),
|
||||||
|
showError: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
} satisfies NavigationRecoveryDependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('navigationRecovery', () => {
|
||||||
|
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
|
||||||
|
'forces a full reload for an async component failure on the current document: %s', (target) => {
|
||||||
|
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
|
||||||
|
const history = { state: { position: 2 }, replaceState: vi.fn() }
|
||||||
|
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
|
||||||
|
expect(location.reload).toHaveBeenCalledOnce()
|
||||||
|
expect(location.assign).not.toHaveBeenCalled()
|
||||||
|
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('loads the intended new route through a document navigation', () => {
|
||||||
|
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
|
||||||
|
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
|
||||||
|
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
|
||||||
|
expect(location.reload).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'Failed to fetch dynamically imported module: /assets/page.js',
|
||||||
|
'Importing a module script failed',
|
||||||
|
'ChunkLoadError: Loading chunk 42 failed',
|
||||||
|
'Loading CSS chunk page failed',
|
||||||
|
'Unable to preload CSS for /assets/page-123.css',
|
||||||
|
])('recognizes stale deployed route assets: %s', (message) => {
|
||||||
|
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
await expect(recoverNavigationError(
|
||||||
|
new TypeError('Failed to fetch dynamically imported module'),
|
||||||
|
'/overseas-warehouse/inventory',
|
||||||
|
deps,
|
||||||
|
)).resolves.toBe(true)
|
||||||
|
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
|
||||||
|
expect(deps.showError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks before discarding dirty work and stays put when the user cancels', async () => {
|
||||||
|
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
|
||||||
|
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
|
||||||
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
const deps = dependencies({ storage })
|
||||||
|
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
||||||
|
await recoverNavigationError(error, '/system/users', deps)
|
||||||
|
await recoverNavigationError(error, '/system/users', deps)
|
||||||
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
||||||
|
const reloaded = dependencies({ storage })
|
||||||
|
await recoverNavigationError(error, '/system/redirect', reloaded)
|
||||||
|
expect(reloaded.navigate).not.toHaveBeenCalled()
|
||||||
|
reloaded.now = () => 31_000
|
||||||
|
await recoverNavigationError(error, '/system/users', reloaded)
|
||||||
|
expect(reloaded.navigate).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports ordinary router failures without reloading the application', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
|
||||||
|
const storage = memoryStorage()
|
||||||
|
storage[method] = () => { throw new Error('Storage unavailable') }
|
||||||
|
const deps = dependencies({ storage })
|
||||||
|
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
||||||
|
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves the target query and hash after the user confirms discarding work', async () => {
|
||||||
|
const deps = dependencies({ hasUnsavedWork: () => true })
|
||||||
|
const target = '/orders?status=NEW&page=3#details'
|
||||||
|
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
|
||||||
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.navigate).toHaveBeenCalledWith(target)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
|
||||||
|
let finishConfirmation!: (value: boolean) => void
|
||||||
|
const deps = dependencies({
|
||||||
|
hasUnsavedWork: () => true,
|
||||||
|
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
|
||||||
|
})
|
||||||
|
const recover = createNavigationRecoveryHandler(deps)
|
||||||
|
const cachedError = new Error('Importing a module script failed')
|
||||||
|
const first = recover(cachedError, '/orders')
|
||||||
|
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
||||||
|
finishConfirmation(false)
|
||||||
|
await Promise.all([first, duplicate])
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
deps.confirmDiscard = vi.fn(async () => true)
|
||||||
|
await recover(cachedError, '/orders')
|
||||||
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deduplicates the same error reported by Vite and the router', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
const recover = createNavigationRecoveryHandler(deps)
|
||||||
|
const error = new Error('Importing a module script failed')
|
||||||
|
await recover(error, '/orders')
|
||||||
|
await recover(error, '/orders')
|
||||||
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
||||||
|
expect(deps.showError).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
|
||||||
|
const deps = dependencies()
|
||||||
|
const recover = createNavigationRecoveryHandler(deps)
|
||||||
|
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
|
||||||
|
try {
|
||||||
|
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
|
||||||
|
payload: new Error('Cannot read properties of undefined'),
|
||||||
|
})
|
||||||
|
window.dispatchEvent(ordinary)
|
||||||
|
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
|
||||||
|
reason: new Error('Request failed with status code 500'),
|
||||||
|
})
|
||||||
|
window.dispatchEvent(apiError)
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(deps.navigate).not.toHaveBeenCalled()
|
||||||
|
expect(apiError.defaultPrevented).toBe(false)
|
||||||
|
|
||||||
|
const error = new Error('Unable to preload CSS for /assets/page.css')
|
||||||
|
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
|
||||||
|
window.dispatchEvent(preload)
|
||||||
|
expect(preload.defaultPrevented).toBe(false)
|
||||||
|
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
|
||||||
|
window.dispatchEvent(rejection)
|
||||||
|
expect(rejection.defaultPrevented).toBe(true)
|
||||||
|
await recover(error, '/orders?page=2')
|
||||||
|
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
|
||||||
|
} finally {
|
||||||
|
removeListeners()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
const RECOVERY_STORAGE_KEY = 'cc-admin-route-asset-recovery:v1'
|
||||||
|
const RECOVERY_WINDOW_MS = 30_000
|
||||||
|
|
||||||
|
const staleAssetErrorPatterns = [
|
||||||
|
/failed to fetch dynamically imported module/i,
|
||||||
|
/error loading dynamically imported module/i,
|
||||||
|
/importing a module script failed/i,
|
||||||
|
/chunkloaderror/i,
|
||||||
|
/loading (css )?chunk [\w-]+ failed/i,
|
||||||
|
/unable to preload css for/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
export interface NavigationRecoveryDependencies {
|
||||||
|
storage: Pick<Storage, 'getItem' | 'setItem'>
|
||||||
|
now: () => number
|
||||||
|
hasUnsavedWork: () => boolean
|
||||||
|
confirmDiscard: () => Promise<boolean>
|
||||||
|
discardUnsavedWork: () => void
|
||||||
|
navigate: (target: string) => void
|
||||||
|
showError: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecoveryAttempt {
|
||||||
|
target: string
|
||||||
|
attemptedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return typeof error === 'string' ? error : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStaleRouteAssetError(error: unknown) {
|
||||||
|
const message = errorMessage(error)
|
||||||
|
return staleAssetErrorPatterns.some((pattern) => pattern.test(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recoverNavigationError(
|
||||||
|
error: unknown,
|
||||||
|
target: string,
|
||||||
|
dependencies: NavigationRecoveryDependencies,
|
||||||
|
) {
|
||||||
|
if (!isStaleRouteAssetError(error)) {
|
||||||
|
dependencies.showError('页面切换失败,请重试')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Keep this marker across successful routing and full reloads. A redirect to
|
||||||
|
// another route must not bypass the cooldown either.
|
||||||
|
try {
|
||||||
|
const stored = dependencies.storage.getItem(RECOVERY_STORAGE_KEY)
|
||||||
|
let previous: RecoveryAttempt | null = null
|
||||||
|
try { previous = JSON.parse(stored || 'null') } catch { /* Replace malformed records. */ }
|
||||||
|
if (previous && Number.isFinite(previous.attemptedAt)
|
||||||
|
&& dependencies.now() - previous.attemptedAt < RECOVERY_WINDOW_MS) {
|
||||||
|
dependencies.showError('页面资源加载失败,请稍后重试或手动刷新')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true
|
||||||
|
|
||||||
|
try {
|
||||||
|
dependencies.storage.setItem(RECOVERY_STORAGE_KEY, JSON.stringify({
|
||||||
|
target,
|
||||||
|
attemptedAt: dependencies.now(),
|
||||||
|
} satisfies RecoveryAttempt))
|
||||||
|
} catch {
|
||||||
|
dependencies.showError('无法记录自动刷新次数,请手动刷新页面')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
dependencies.discardUnsavedWork()
|
||||||
|
dependencies.navigate(target)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNavigationRecoveryHandler(dependencies: NavigationRecoveryDependencies) {
|
||||||
|
let handledErrors = new WeakSet<object>()
|
||||||
|
let pending: Promise<boolean> | undefined
|
||||||
|
const guardedDependencies = {
|
||||||
|
...dependencies,
|
||||||
|
confirmDiscard: async () => {
|
||||||
|
const confirmed = await dependencies.confirmDiscard()
|
||||||
|
// Some importers cache the rejected promise/error. Cancelling must not
|
||||||
|
// prevent a later explicit retry from asking again.
|
||||||
|
if (!confirmed) handledErrors = new WeakSet<object>()
|
||||||
|
return confirmed
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return (error: unknown, target: string): Promise<boolean> => {
|
||||||
|
if (typeof error === 'object' && error !== null) {
|
||||||
|
if (handledErrors.has(error)) return pending ?? Promise.resolve(true)
|
||||||
|
handledErrors.add(error)
|
||||||
|
}
|
||||||
|
if (pending) return pending
|
||||||
|
// Defer execution so simultaneous router, Vite and Vue errors share one
|
||||||
|
// confirmation/reload, even when there is no unsaved form.
|
||||||
|
pending = Promise.resolve().then(() => recoverNavigationError(error, target, guardedDependencies))
|
||||||
|
.finally(() => { pending = undefined })
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadNavigationTarget(browser: Pick<Window, 'location' | 'history'>, target: string) {
|
||||||
|
const current = new URL(browser.location.href)
|
||||||
|
const destination = new URL(target, current)
|
||||||
|
if (destination.pathname === current.pathname && destination.search === current.search) {
|
||||||
|
// Assigning a URL that only changes the fragment does not reload assets.
|
||||||
|
browser.history.replaceState(browser.history.state, '', destination.href)
|
||||||
|
browser.location.reload()
|
||||||
|
} else {
|
||||||
|
browser.location.assign(destination.href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function installAssetRecoveryListeners(
|
||||||
|
browser: Window,
|
||||||
|
target: () => string,
|
||||||
|
recover: (error: unknown, target: string) => Promise<boolean>,
|
||||||
|
) {
|
||||||
|
const onPreloadError = (event: Event) => {
|
||||||
|
const error = (event as Event & { payload?: unknown }).payload
|
||||||
|
if (isStaleRouteAssetError(error)) void recover(error, target())
|
||||||
|
// Let Vite reject the import: swallowing it would make Vue Router treat
|
||||||
|
// an undefined module as a successful navigation, including on cancel.
|
||||||
|
}
|
||||||
|
const onUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||||
|
if (!isStaleRouteAssetError(event.reason)) return
|
||||||
|
event.preventDefault()
|
||||||
|
void recover(event.reason, target())
|
||||||
|
}
|
||||||
|
browser.addEventListener('vite:preloadError', onPreloadError)
|
||||||
|
browser.addEventListener('unhandledrejection', onUnhandledRejection)
|
||||||
|
return () => {
|
||||||
|
browser.removeEventListener('vite:preloadError', onPreloadError)
|
||||||
|
browser.removeEventListener('unhandledrejection', onUnhandledRejection)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,76 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync, existsSync } from 'node:fs'
|
||||||
import { resolve } from 'node:path'
|
import { resolve } from 'node:path'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { test } from 'vitest'
|
||||||
|
|
||||||
const config = readFileSync(resolve(process.cwd(), 'nginx/default.conf.template'), 'utf8')
|
const root = process.cwd()
|
||||||
const testCompose = readFileSync(resolve(process.cwd(), 'docker-compose.test.yml'), 'utf8')
|
const config = readFileSync(resolve(root, 'nginx/default.conf.template'), 'utf8')
|
||||||
const developmentCompose = readFileSync(resolve(process.cwd(), 'docker-compose.development.yml'), 'utf8')
|
const compose = readFileSync(resolve(root, 'docker-compose.test.yml'), 'utf8')
|
||||||
const jenkinsfile = readFileSync(resolve(process.cwd(), 'Jenkinsfile'), 'utf8')
|
const jenkins = readFileSync(resolve(root, 'Jenkinsfile'), 'utf8')
|
||||||
const dockerfile = readFileSync(resolve(process.cwd(), 'Dockerfile'), 'utf8')
|
const vite = readFileSync(resolve(root, 'vite.config.ts'), 'utf8')
|
||||||
|
|
||||||
describe('admin nginx deployment config', () => {
|
test('all business traffic uses one Gateway without rewriting API paths', () => {
|
||||||
it('allows qualification uploads with multipart overhead through the API proxy', () => {
|
assert.match(config, /location \/api\//)
|
||||||
expect(config).toContain('client_max_body_size 12m;')
|
assert.match(config, /proxy_pass \$\{API_UPSTREAM\};/)
|
||||||
})
|
assert.doesNotMatch(config, /overseas-api|OVERSEAS_API_UPSTREAM|rewrite/)
|
||||||
|
assert.match(config, /client_max_body_size 55m;/)
|
||||||
|
assert.match(config, /proxy_set_header Host \$proxy_host;/)
|
||||||
|
assert.doesNotMatch(vite, /OVERSEAS_API|AUTH_API_PROXY_TARGET|IAM_API_PROXY_TARGET|WAREHOUSE_API_PROXY_TARGET/)
|
||||||
|
})
|
||||||
|
|
||||||
it('prevents the application shell from retaining stale chunk references', () => {
|
test('test deployment requires an explicit shared network, Gateway and version', () => {
|
||||||
expect(config).toContain('location = /index.html')
|
for (const key of ['IMAGE_TAG']) {
|
||||||
expect(config).toContain('Cache-Control "no-cache, no-store, must-revalidate"')
|
assert.ok(compose.includes('${' + key + ':?'))
|
||||||
})
|
}
|
||||||
|
assert.ok(compose.includes('${API_UPSTREAM:-http://gateway:9000}'))
|
||||||
|
assert.ok(compose.includes('${TEST_NETWORK:-firstleg-test-network}'))
|
||||||
|
assert.match(compose, /external: true/)
|
||||||
|
assert.match(compose, /127\.0\.0\.1:/)
|
||||||
|
assert.doesNotMatch(compose, /overseas|host\.docker\.internal|production|development/)
|
||||||
|
assert.match(jenkins, /'java-builder' : 'java-test'/)
|
||||||
|
assert.match(jenkins, /docker compose -f docker-compose\.test\.yml config --quiet/)
|
||||||
|
assert.match(jenkins, /docker network inspect/)
|
||||||
|
assert.match(jenkins, /scripts\/deploy-production\.sh/)
|
||||||
|
assert.match(jenkins, /firstleg-production-ssh/)
|
||||||
|
|
||||||
it('serves hashed assets immutably and never falls back missing chunks to HTML', () => {
|
})
|
||||||
expect(config).toContain('location /assets/')
|
|
||||||
expect(config).toContain('try_files $uri =404;')
|
|
||||||
expect(config).toContain('Cache-Control "public, max-age=31536000, immutable"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps Vue history routes on the application shell without caching them', () => {
|
test('application shell is revalidated and missing chunks remain 404', () => {
|
||||||
expect(config).toContain('try_files $uri $uri/ /index.html;')
|
assert.match(config, /location = \/index\.html/)
|
||||||
expect(config).toContain('Cache-Control "no-cache"')
|
assert.match(config, /no-store/)
|
||||||
})
|
assert.match(config, /max-age=31536000, immutable/)
|
||||||
|
assert.match(config, /try_files \$uri =404;/)
|
||||||
|
assert.match(config, /try_files \$uri \$uri\/ \/index\.html;/)
|
||||||
|
})
|
||||||
|
|
||||||
it('proxies overseas API requests to the gateway API prefix', () => {
|
test('PDA static apps keep independent paths and share the authenticated Gateway', () => {
|
||||||
expect(config).toContain('location /overseas-api/')
|
assert.match(config, /resolver 127\.0\.0\.11 valid=10s ipv6=off;/)
|
||||||
expect(config).toContain('rewrite ^/overseas-api/(.*)$ /api/$1 break;')
|
for (const app of ['domestic', 'overseas']) {
|
||||||
expect(config).toContain('proxy_pass ${OVERSEAS_API_UPSTREAM};')
|
assert.ok(config.includes(`location = /pda-${app} { return 308 /pda-${app}/; }`))
|
||||||
})
|
assert.ok(config.includes(`location ^~ /pda-${app}/ {`))
|
||||||
|
assert.ok(config.includes(`set $pda_${app}_origin http://pda-${app}-h5-test:80;`))
|
||||||
|
assert.ok(config.includes(`proxy_pass $pda_${app}_origin$request_uri;`))
|
||||||
|
}
|
||||||
|
assert.match(config, /location \/gateway-api\/\s*\{[^}]*client_max_body_size 55m;[^}]*proxy_pass \$\{API_UPSTREAM\}\/api\//)
|
||||||
|
assert.doesNotMatch(config, /proxy_pass.*(?:auth:|lastleg-service:|firstleg-service:)/)
|
||||||
|
})
|
||||||
|
|
||||||
it('declares an explicit overseas gateway upstream in container deployments', () => {
|
|
||||||
expect(testCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
|
||||||
expect(developmentCompose).toContain('OVERSEAS_API_UPSTREAM: "http://host.docker.internal:9300"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs each deployment on its target node without controller-local SSH files', () => {
|
test('container build excludes local environment values and waits for Nginx health', () => {
|
||||||
expect(jenkinsfile).toContain("env.JOB_BASE_NAME?.endsWith('-dev') ? 'java-dev' : 'java-test'")
|
const ignore = readFileSync(resolve(root, '.dockerignore'), 'utf8')
|
||||||
expect(jenkinsfile).toContain("DEVELOPMENT_COMPOSE_PROJECT = 'admin-web'")
|
assert.match(ignore, /^\.env$/m)
|
||||||
expect(jenkinsfile).toContain('docker compose -p "$DEVELOPMENT_COMPOSE_PROJECT" -f "$DEVELOPMENT_COMPOSE_FILE" up -d')
|
assert.match(ignore, /^\.env\.\*$/m)
|
||||||
expect(jenkinsfile).not.toContain('/var/jenkins_home/.ssh')
|
assert.match(compose, /healthcheck:/)
|
||||||
expect(jenkinsfile).not.toContain('docker save')
|
assert.match(compose, /http:\/\/127\.0\.0\.1\/healthz/)
|
||||||
expect(jenkinsfile).not.toMatch(/\bssh\b/)
|
assert.match(jenkins, /up -d --wait --wait-timeout 120/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses authenticated internal base images in CI instead of pulling from Docker Hub', () => {
|
test('gray production is isolated from TEST and routes to the single Gateway', () => {
|
||||||
expect(dockerfile).toContain('ARG NODE_IMAGE=node:22-alpine')
|
const production = readFileSync(resolve(root, 'docker-compose.production.yml'), 'utf8')
|
||||||
expect(dockerfile).toContain('ARG NGINX_IMAGE=nginx:1.27-alpine')
|
assert.match(production, /name: firstleg-production-network/)
|
||||||
expect(dockerfile).toContain('FROM ${NODE_IMAGE} AS build')
|
assert.match(production, /API_UPSTREAM: "http:\/\/gateway:9000"/)
|
||||||
expect(dockerfile).toContain('FROM ${NGINX_IMAGE}')
|
assert.match(production, /127\.0\.0\.1:/)
|
||||||
expect(jenkinsfile).toContain("NODE_BASE_IMAGE = '172.31.198.7:10082/java-project/node:22-alpine'")
|
assert.match(production, /healthcheck:/)
|
||||||
expect(jenkinsfile).toContain("NGINX_BASE_IMAGE = '172.31.198.7:10082/java-project/nginx:1.27-alpine'")
|
assert.doesNotMatch(production, /test-network|gateway-overseas|gateway-active|routing-network/)
|
||||||
expect(jenkinsfile).toContain('withCredentials([usernamePassword(')
|
assert.doesNotMatch(jenkins, /wecom|sendProductionWeComNotification/)
|
||||||
expect(jenkinsfile).toContain('export DOCKER_CONFIG="${WORKSPACE}@tmp/docker-auth"')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import '@/styles/adminDensity.css'
|
||||||
import { computed, createApp } from 'vue'
|
import { computed, createApp } from 'vue'
|
||||||
|
import 'dayjs/locale/zh-cn'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import 'element-plus/es/components/message/style/css'
|
import 'element-plus/es/components/message/style/css'
|
||||||
import 'element-plus/es/components/message-box/style/css'
|
import 'element-plus/es/components/message-box/style/css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from '@/app/router'
|
import router from '@/app/router'
|
||||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||||
|
import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, reloadNavigationTarget } from '@/core/routing/navigationRecovery'
|
||||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
import { useUnsavedWorkStore } from '@/core/unsaved-work/unsavedWork'
|
||||||
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
import { useWorkspaceTabsStore } from '@/core/workspace-tabs/workspaceTabs'
|
||||||
@@ -19,8 +22,6 @@ const auth = useAuthStore(pinia)
|
|||||||
const unsaved = useUnsavedWorkStore(pinia)
|
const unsaved = useUnsavedWorkStore(pinia)
|
||||||
const workspace = useWorkspaceTabsStore(pinia)
|
const workspace = useWorkspaceTabsStore(pinia)
|
||||||
|
|
||||||
await auth.restoreSession()
|
|
||||||
|
|
||||||
registerAuthSessionExpiredHandler(async () => {
|
registerAuthSessionExpiredHandler(async () => {
|
||||||
const currentRoute = router.currentRoute.value
|
const currentRoute = router.currentRoute.value
|
||||||
if (currentRoute.name === 'login') return
|
if (currentRoute.name === 'login') return
|
||||||
@@ -36,6 +37,42 @@ registerAuthSessionExpiredHandler(async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const recoverAssetError = createNavigationRecoveryHandler({
|
||||||
|
storage: {
|
||||||
|
getItem: (key) => window.sessionStorage.getItem(key),
|
||||||
|
setItem: (key, value) => window.sessionStorage.setItem(key, value),
|
||||||
|
},
|
||||||
|
now: Date.now,
|
||||||
|
hasUnsavedWork: () => unsaved.entries.some((entry) => entry.dirty),
|
||||||
|
confirmDiscard: async () => {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'页面资源加载失败,可能已发布新版本,需要重新加载页面。继续后将放弃当前未保存内容。',
|
||||||
|
'重新加载页面?',
|
||||||
|
{ confirmButtonText: '重新加载', cancelButtonText: '继续编辑', type: 'warning', customClass: 'admin-density-confirm' },
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
discardUnsavedWork: () => unsaved.clear(),
|
||||||
|
navigate: (target) => reloadNavigationTarget(window, target),
|
||||||
|
showError: (message) => ElMessage.error(message),
|
||||||
|
})
|
||||||
|
let pendingRoute: string | undefined
|
||||||
|
const recoveryTarget = () => pendingRoute
|
||||||
|
?? `${window.location.pathname}${window.location.search}${window.location.hash}`
|
||||||
|
router.beforeEach((to) => { pendingRoute = to.fullPath })
|
||||||
|
router.onError((error, to) => {
|
||||||
|
void recoverAssetError(error, to.fullPath)
|
||||||
|
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||||
|
})
|
||||||
|
router.afterEach((to) => {
|
||||||
|
if (pendingRoute === to.fullPath) pendingRoute = undefined
|
||||||
|
})
|
||||||
|
installAssetRecoveryListeners(window, recoveryTarget, recoverAssetError)
|
||||||
|
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
if (!unsaved.entries.some((entry) => entry.dirty)) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -43,5 +80,10 @@ window.addEventListener('beforeunload', (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
app.config.errorHandler = (error) => {
|
||||||
|
if (isStaleRouteAssetError(error)) void recoverAssetError(error, recoveryTarget())
|
||||||
|
else console.error(error)
|
||||||
|
}
|
||||||
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
|
app.provide(UI_PREFERENCE_SCOPE, computed(() => auth.user?.id || 'anonymous'))
|
||||||
|
await auth.restoreSession()
|
||||||
app.use(pinia).use(router).mount('#app')
|
app.use(pinia).use(router).mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Admin 预约日历模块指引
|
||||||
|
|
||||||
|
## 路径基准与读取顺序
|
||||||
|
|
||||||
|
- 本文件适用于 `src/modules/appointment-calendar/`,代码路径相对本目录;应用公共规则见 `../../../AGENTS.md`,预约规则见 `../../../docs/business-index.md`;工作日历正式规则在**工作区** `docs/海外仓业务/预约工作日历V13变更说明.md`。
|
||||||
|
- 本模块扁平存放页面、面板与 API,无 `pages/`、`components/` 分层:`*.vue` 多为面板,`*Api.ts` 为对应接口,`calendar.ts`/`schedule.ts`/`tripCalendar.ts` 为日历与航段计算,`useWorkDraft.ts`/`useWorkSyncRead.ts` 为草稿与只读同步。
|
||||||
|
- 后端只查 `lastleg-service/`(`inboundplan/`、`appointmentplanning/`);到港事实只读 `firstleg/` 的船节点,纯页面与文案不读后端。
|
||||||
|
|
||||||
|
## 页面与入口
|
||||||
|
|
||||||
|
| 目标 | 入口 | API 与后端 |
|
||||||
|
|---|---|---|
|
||||||
|
| 预约列表与日历主页面 | `AppointmentCalendarView.vue`;`appointment-calendar` | `api.ts`、`calendar.ts`;`lastleg-service/inboundplan/` |
|
||||||
|
| 工作日历、车次与送仓待办 | `WorkCalendarPanel.vue`、`TripAppointmentPanel.vue` | `workApi.ts`、`tripCalendar.ts`;`lastleg-service/appointmentplanning/` |
|
||||||
|
| 新建/批量预约、多客户共车 | `AppointmentCreatePanel.vue`、`AppointmentBatchForm.vue`、`WorkSharedTripForm.vue` | `workApi.ts`、`batchApi.ts` |
|
||||||
|
| 货物验证与入仓码关系 | `AppointmentCargoPanel.vue`、`AppointmentCargoTabs.vue`、`AppointmentInboundCodes.vue`、`CargoVerificationForm.vue` | `cargoVerificationApi.ts` |
|
||||||
|
| 改期、取消与取消记录 | `AppointmentImpactDialog.vue`、`AppointmentCancellationDialog.vue`、`AppointmentCancellationHistory.vue`、`AppointmentCancellationRecords.vue` | `impactApi.ts` |
|
||||||
|
| 派送安排与分批后送 | `WorkDispatchForm.vue`、`WorkExistingDispatchForm.vue` | `workApi.ts`;`lastleg-service/delivery/` |
|
||||||
|
| 收货范围与收货计划 | `ReceivingScope.vue`、`WorkReceivingPanel.vue`、`WorkReceiptPlans.vue` | `receivingApi.ts` |
|
||||||
|
| 外部候选取货与跟进 | `AppointmentExternalCandidates.vue`、`AppointmentFollowupDialog.vue` | `planningApi.ts`、`workApi.ts` |
|
||||||
|
|
||||||
|
## 关键边界
|
||||||
|
|
||||||
|
- 同仓跨客户共用一张预约,客户归属在货物明细,不为每个客户拆预约;同车多客户按 V13 规则处理。
|
||||||
|
- 到港事实只读 Firstleg 船节点,送仓与排车归 Lastleg;不得用预计到港替代计划到仓,也不得反向写船节点。
|
||||||
|
- 改期、取消、重新预约必须按服务端影响范围与版本提交,取消记录只追加,不改写历史。
|
||||||
|
- 未定日期事项进入待办跟进,不伪造日期;分批后送的批次关系以服务端返回为准。
|
||||||
|
- 面板之间共享草稿状态,`useWorkDraft.ts` 是唯一草稿入口;刷新时用 `useWorkSyncRead.ts` 合并服务端事实并保留本机未提交输入。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 定向:`pnpm test src/modules/appointment-calendar/<目标测试>`;日历与批次规则变更同时核对 V13 规则文档与 `lastleg-service` 合同。
|
||||||
|
- 本文件或索引变更后,从后端根运行 `./scripts/agents-index-contract.ps1`。
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import AppointmentBatchForm from './AppointmentBatchForm.vue'
|
||||||
|
import { getAppointmentBatch, repartitionAppointments } from './batchApi'
|
||||||
|
import { getWorkItems } from './workApi'
|
||||||
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
|
import { batchContext } from './batch.test-support'
|
||||||
|
import { workItem } from './work.test-support'
|
||||||
|
import type { LeaveGuard } from './useWorkDraft'
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn().mockResolvedValue(true) } }))
|
||||||
|
vi.mock('./batchApi', async original => ({ ...await original<typeof import('./batchApi')>(), getAppointmentBatch: vi.fn(), repartitionAppointments: vi.fn() }))
|
||||||
|
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkItems: vi.fn() }))
|
||||||
|
let guard: LeaveGuard
|
||||||
|
function render() { return mount(AppointmentBatchForm, { props: { batchKey: 'FBA_APPOINTMENT:1' }, global: { provide: { 'appointment-work-leave-guard': (value: LeaveGuard) => { guard = value; return () => undefined } }, stubs: {
|
||||||
|
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
||||||
|
ElButton: { props: ['nativeType', 'disabled'], template: '<button :type="nativeType || \'button\'" :disabled="disabled"><slot /></button>' },
|
||||||
|
LoadErrorState: { props: ['message'], template: '<section role="alert">{{ message }}<button @click="$emit(\'retry\')">重试读取</button></section>' }, ApiErrorMeta: true, CargoVerificationForm: true,
|
||||||
|
} } }) }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks(); vi.mocked(getAppointmentBatch).mockResolvedValue(batchContext())
|
||||||
|
vi.mocked(repartitionAppointments).mockResolvedValue({ replacedBatchKeys: ['FBA_APPOINTMENT:1'], replacedAppointmentNos: ['AP-001'], appointments: [batchContext('AP-NEW-1', 'FBA_APPOINTMENT:11').appointment, batchContext('AP-NEW-2', 'FBA_APPOINTMENT:12').appointment] })
|
||||||
|
vi.mocked(getWorkItems).mockResolvedValue({ records: [workItem({ batchKey: 'FBA_APPOINTMENT:2' })], page: 1, size: 8, total: 1, pages: 1 })
|
||||||
|
})
|
||||||
|
it('submits two conservative allocations without fake confirmation times and renders replacement business numbers', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
|
||||||
|
await wrapper.findAll('textarea')[0]!.setValue('分两次送仓'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||||
|
expect(request.originals).toEqual([{ appointmentNo: 'AP-001', version: 3 }])
|
||||||
|
expect(request.batches.map(row => row.plannedDate)).toEqual([null, null])
|
||||||
|
expect(request.batches[0]!.allocations.map(row => row.packageCount)).toEqual([50, 30])
|
||||||
|
expect(request.batches[1]!.allocations.map(row => row.packageCount)).toEqual([20])
|
||||||
|
expect(request).not.toHaveProperty('appointmentAt')
|
||||||
|
expect(wrapper.text()).toContain('AP-NEW-1'); expect(wrapper.text()).not.toContain('FBA_APPOINTMENT:')
|
||||||
|
expect(wrapper.emitted('updated')).toHaveLength(1); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('splits only the entered 60-box later batch into 40/20, never reallocating its original 160-box source', async () => {
|
||||||
|
const context = batchContext('AP-LATER-60', 'FBA_APPOINTMENT:1')
|
||||||
|
context.appointment.items = [{ ...context.appointment.items[0]!, packageCount: 60 }]
|
||||||
|
context.sources = [{ ...context.sources[0]!, packageCount: 60, originalPackages: 160 }]
|
||||||
|
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20)
|
||||||
|
await wrapper.findAll('textarea')[0]!.setValue('本批40先安排,20待排')
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
const request = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||||
|
expect(request.originals).toEqual([{ appointmentNo: 'AP-LATER-60', version: 3 }])
|
||||||
|
expect(request.batches.map(batch => batch.allocations.map(row => row.packageCount))).toEqual([[40], [20]])
|
||||||
|
expect(request.batches.flatMap(batch => batch.allocations).every(row => row.customerId === 'customer-a' && row.sourceKey === 'ORDER:1')).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('locks an unknown outcome and retries the same immutable repartition command', async () => {
|
||||||
|
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new Error('unknown outcome'))
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('分批')
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
const first = vi.mocked(repartitionAppointments).mock.calls[0]![0]
|
||||||
|
expect(await guard()).toBe(false); expect(wrapper.get('fieldset').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(repartitionAppointments).toHaveBeenNthCalledWith(2, first); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps edited allocations after a version conflict and requires a fresh explicit submit', async () => {
|
||||||
|
vi.mocked(repartitionAppointments).mockRejectedValueOnce(new ApiResponseError('版本变化', 'A0409', 409))
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.get('[aria-label="来源1分出箱数"]').setValue(20); await wrapper.findAll('textarea')[0]!.setValue('保留分批原因')
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(getAppointmentBatch).toHaveBeenCalledTimes(2); expect(repartitionAppointments).toHaveBeenCalledTimes(1)
|
||||||
|
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('20')
|
||||||
|
expect((wrapper.findAll('textarea')[0]!.element as HTMLTextAreaElement).value).toBe('保留分批原因'); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('shows full appointment cargo when confirmed or replaced batches have no repartition sources', async () => {
|
||||||
|
const context = batchContext(); context.sources = []; context.allowedActions = []; context.blockReasons = ['原批次已有正式确认']; context.appointment.appointmentStatus = 'SUCCESS'
|
||||||
|
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.get('[aria-label="原批次货物快照"]').text()).toContain('IN-A')
|
||||||
|
expect(wrapper.text()).toContain('FBA-B'); expect(wrapper.text()).toContain('当前批次无分批资格')
|
||||||
|
await wrapper.get('form').trigger('submit'); expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('loads a candidate context before merging and preserves both original versions', async () => {
|
||||||
|
const other = batchContext('AP-002', 'FBA_APPOINTMENT:2'); other.appointment.version = 6
|
||||||
|
vi.mocked(getAppointmentBatch).mockImplementation(async key => key === 'FBA_APPOINTMENT:2' ? other : batchContext())
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '同仓合并安排')!.trigger('click'); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '核对并选择')!.trigger('click'); await flushPromises()
|
||||||
|
await wrapper.findAll('textarea')[0]!.setValue('同仓合并'); await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(repartitionAppointments).toHaveBeenCalledWith(expect.objectContaining({ originals: [{ appointmentNo: 'AP-001', version: 3 }, { appointmentNo: 'AP-002', version: 6 }], batches: [expect.objectContaining({ allocations: [expect.objectContaining({ customerId: 'customer-a', packageCount: 140 }), expect.objectContaining({ customerId: 'customer-b', packageCount: 60 })] })] }))
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('shows every terminal branch with only the original customer/source/destination quantities and opens the real batch', async () => {
|
||||||
|
const context = batchContext()
|
||||||
|
const source = context.appointment.items[0]!
|
||||||
|
context.appointment.items = [source]
|
||||||
|
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2', 'FBA_APPOINTMENT:3']
|
||||||
|
context.appointment.replacementAppointmentNos = ['AP-INTERMEDIATE', 'AP-20']
|
||||||
|
context.sources = []; context.allowedActions = []
|
||||||
|
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:4').appointment
|
||||||
|
forty.appointmentStatus = 'SUCCESS'
|
||||||
|
forty.items = [
|
||||||
|
{ ...source, packageCount: 40 },
|
||||||
|
{ ...source, id: 'foreign-customer', customerId: 'other-customer', inboundCode: 'FOREIGN-CUSTOMER', packageCount: 900 },
|
||||||
|
{ ...source, id: 'foreign-source', sourceKey: 'ORDER:OTHER', inboundCode: 'FOREIGN-SOURCE', packageCount: 800 },
|
||||||
|
{ ...source, id: 'foreign-destination', destinationKey: 'FBA:OTHER', inboundCode: 'FOREIGN-DESTINATION', packageCount: 700 },
|
||||||
|
]
|
||||||
|
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:3').appointment
|
||||||
|
twenty.appointmentStatus = 'CANCELLED'; twenty.items = [{ ...source, packageCount: 20 }]
|
||||||
|
context.currentBatches = [forty, twenty]
|
||||||
|
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||||
|
expect(destinations.findAll('article')).toHaveLength(2)
|
||||||
|
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
|
||||||
|
expect(destinations.text()).toContain('已取消,分配保留')
|
||||||
|
expect(destinations.text()).not.toContain('FOREIGN-'); expect(destinations.text()).not.toContain('AP-INTERMEDIATE')
|
||||||
|
expect(destinations.text()).not.toContain('FBA_APPOINTMENT:')
|
||||||
|
await destinations.findAll('button').find(button => button.text() === '查看本批预约')!.trigger('click')
|
||||||
|
expect(wrapper.emitted('appointment')).toEqual([['AP-40']])
|
||||||
|
await destinations.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
|
||||||
|
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:4')
|
||||||
|
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not create zero-quantity destinations or convert unknown quantities to zero', async () => {
|
||||||
|
const context = batchContext()
|
||||||
|
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
|
||||||
|
context.sources = []; context.allowedActions = []
|
||||||
|
const unknown = batchContext('AP-UNKNOWN', 'FBA_APPOINTMENT:2').appointment
|
||||||
|
unknown.items = [{ ...context.appointment.items[0]!, packageCount: null }]
|
||||||
|
const empty = batchContext('AP-EMPTY', 'FBA_APPOINTMENT:3').appointment
|
||||||
|
empty.items = [{ ...context.appointment.items[0]!, packageCount: 0 }]
|
||||||
|
context.currentBatches = [unknown, empty]
|
||||||
|
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||||
|
expect(destinations.findAll('article')).toHaveLength(1)
|
||||||
|
expect(destinations.text()).toContain('箱数待核实'); expect(destinations.text()).not.toContain('0 箱')
|
||||||
|
expect(destinations.text()).not.toContain('AP-EMPTY'); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not fall back to historical replacement links when current destinations are empty', async () => {
|
||||||
|
const context = batchContext()
|
||||||
|
context.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']
|
||||||
|
context.appointment.replacementAppointmentNos = ['AP-OLD-REPLACEMENT']
|
||||||
|
context.sources = []; context.allowedActions = []; context.currentBatches = []
|
||||||
|
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||||
|
expect(destinations.findAll('article')).toHaveLength(0)
|
||||||
|
expect(destinations.text()).toContain('原快照不代表当前待办')
|
||||||
|
expect(destinations.text()).not.toContain('AP-OLD-REPLACEMENT'); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps every 40/20 destination after the entire customer has left the original current items', async () => {
|
||||||
|
const context = batchContext(), originalItem = context.appointment.items[0]!
|
||||||
|
context.appointment.items = [context.appointment.items[1]!]
|
||||||
|
context.sources = [context.sources[1]!]
|
||||||
|
const forty = batchContext('AP-40', 'FBA_APPOINTMENT:40').appointment
|
||||||
|
forty.items = [{ ...originalItem, packageCount: 40 }, { ...originalItem, id: 'unrelated', sourceKey: 'ORDER:OTHER', inboundCode: 'IN-UNRELATED', packageCount: 999 }]
|
||||||
|
const twenty = batchContext('AP-20', 'FBA_APPOINTMENT:20').appointment
|
||||||
|
twenty.items = [{ ...originalItem, packageCount: 20 }]
|
||||||
|
context.currentBatches = [context.appointment, forty, twenty]
|
||||||
|
vi.mocked(getAppointmentBatch).mockResolvedValue(context)
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
const destinations = wrapper.get('[aria-label="当前货物去向"]')
|
||||||
|
expect(destinations.text()).toContain('40 箱'); expect(destinations.text()).toContain('20 箱')
|
||||||
|
expect(destinations.text()).not.toContain('IN-UNRELATED')
|
||||||
|
expect(wrapper.findAll('[aria-label="来源1分出箱数"]')).toHaveLength(1)
|
||||||
|
expect((wrapper.get('[aria-label="来源1分出箱数"]').attributes('max'))).toBe('30')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('retries a new destination without exposing the prior batch or retaining its allocation inputs', async () => {
|
||||||
|
const original = batchContext()
|
||||||
|
original.appointment.replacementBatchKeys = ['FBA_APPOINTMENT:2']; original.sources = []; original.allowedActions = []
|
||||||
|
const current = batchContext('AP-CURRENT', 'FBA_APPOINTMENT:2')
|
||||||
|
current.appointment.plannedDate = '2026-10-22'
|
||||||
|
original.currentBatches = [current.appointment]
|
||||||
|
vi.mocked(getAppointmentBatch).mockResolvedValueOnce(original).mockRejectedValueOnce(new Error('新批次暂不可用')).mockResolvedValue(current)
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '查看完整批次')!.trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.text()).not.toContain('AP-001'); expect(wrapper.find('form').exists()).toBe(false)
|
||||||
|
await wrapper.get('[role="alert"] button').trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('AP-CURRENT')
|
||||||
|
expect((wrapper.findAll('input[data-picker-kind="date"]')[0]!.element as HTMLInputElement).value).toBe('2026-10-22')
|
||||||
|
expect((wrapper.get('[aria-label="来源1分出箱数"]').element as HTMLInputElement).value).toBe('0')
|
||||||
|
expect(getAppointmentBatch).toHaveBeenLastCalledWith('FBA_APPOINTMENT:2')
|
||||||
|
expect(repartitionAppointments).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import './appointmentNativeForms.css'
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import { getAppointmentBatch, repartitionAppointments, allocation, allocationKey, mergeBatchSources, splitBatch, type FbaAppointmentBatchContext, type RepartitionFbaAppointmentsRequest, type RepartitionFbaAppointmentsResponse } from './batchApi'
|
||||||
|
import { getWorkItems, type WorkPage } from './workApi'
|
||||||
|
import { rejectedCommand, useWorkDraft } from './useWorkDraft'
|
||||||
|
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||||
|
import type { CargoVerificationSource } from './cargoVerificationApi'
|
||||||
|
const props = defineProps<{ batchKey: string }>()
|
||||||
|
const emit = defineEmits<{ updated: []; appointment: [appointmentNo: string] }>()
|
||||||
|
const access = useAccessPreviewStore(), activeKey = ref(props.batchKey), context = ref<FbaAppointmentBatchContext | null>(null), other = ref<FbaAppointmentBatchContext | null>(null)
|
||||||
|
const mode = ref<'split' | 'merge'>('split'), loading = ref(false), saving = ref(false), ready = ref(false)
|
||||||
|
const error = ref<ApiErrorDetails | null>(null), saveError = ref<ApiErrorDetails | null>(null), pending = ref<RepartitionFbaAppointmentsRequest | null>(null), result = ref<RepartitionFbaAppointmentsResponse | null>(null)
|
||||||
|
const form = reactive({ firstDate: '', nextDate: '', reason: '', remark: '', moved: {} as Record<string, number | string> })
|
||||||
|
const baseline = ref(JSON.stringify(form)), verification = ref<CargoVerificationSource | null>(null)
|
||||||
|
const canLeave = useWorkDraft(computed(() => baseline.value !== JSON.stringify(form)), computed(() => saving.value || !!pending.value))
|
||||||
|
const candidates = ref<WorkPage | null>(null), candidatePage = ref(1), keyword = ref(''), candidateLoading = ref(false), candidateError = ref<ApiErrorDetails | null>(null)
|
||||||
|
let candidateRequest = 0
|
||||||
|
let initializedKey: string | null = null
|
||||||
|
const canWrite = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment'))
|
||||||
|
const replaced = computed(() => !!context.value?.appointment.replacementBatchKeys?.length || !!context.value?.appointment.replacementAppointmentNos?.length)
|
||||||
|
const hasCurrentDestinations = computed(() => replaced.value || context.value?.currentBatches.some(batch => batch.batchKey !== context.value?.appointment.batchKey))
|
||||||
|
const currentDestinations = computed(() => {
|
||||||
|
if (!context.value) return []
|
||||||
|
const sourceIdentity = (item: FbaAppointmentBatchContext['appointment']['items'][number]) => JSON.stringify([item.customerId, item.sourceKey, item.destinationKey])
|
||||||
|
const originalSources = new Set(context.value.originalSourceRefs.map(allocationKey))
|
||||||
|
return context.value.currentBatches.map(appointment => ({ appointment, items: appointment.items.filter(item => item.destinationKey && originalSources.has(sourceIdentity(item)) && (item.packageCount == null || item.packageCount > 0)) })).filter(row => row.items.length)
|
||||||
|
})
|
||||||
|
function bookingStatus(status: string) { return ({ PENDING: '待预约', SUCCESS: '已确认预约', FAILED: '未约成', CANCELLED: '已取消,分配保留' } as Record<string, string>)[status] || '预约状态待核实' }
|
||||||
|
async function openAppointment(number: string) { if (await canLeave()) emit('appointment', number) }
|
||||||
|
const canSubmit = computed(() => canWrite.value && ready.value && !loading.value && !error.value && !!context.value?.allowedActions.includes(mode.value === 'split' ? 'SPLIT' : 'MERGE') && (mode.value !== 'merge' || !!other.value?.allowedActions.includes('MERGE')))
|
||||||
|
async function load(preserve = false) {
|
||||||
|
loading.value = true; ready.value = false; error.value = null
|
||||||
|
try {
|
||||||
|
context.value = await getAppointmentBatch(activeKey.value)
|
||||||
|
if (other.value?.appointment.batchKey) other.value = await getAppointmentBatch(other.value.appointment.batchKey)
|
||||||
|
if (!preserve || initializedKey !== activeKey.value) {
|
||||||
|
Object.assign(form, { firstDate: context.value.appointment.plannedDate || '', nextDate: '', reason: '', remark: '', moved: Object.fromEntries(context.value.sources.map(source => [allocationKey(source), 0])) })
|
||||||
|
baseline.value = JSON.stringify(form)
|
||||||
|
initializedKey = activeKey.value
|
||||||
|
}
|
||||||
|
ready.value = true
|
||||||
|
} catch (cause) { error.value = getApiErrorDetails(cause, '批次资料读取失败') }
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function searchCandidates(page = 1) {
|
||||||
|
const request = ++candidateRequest; candidatePage.value = page; candidates.value = null; candidateError.value = null; candidateLoading.value = true
|
||||||
|
try { const rows = await getWorkItems({ view: 'ALL', types: ['DELIVERY'], keyword: keyword.value.trim() || undefined, page, size: 8 }); if (request === candidateRequest) candidates.value = rows }
|
||||||
|
catch (cause) { if (request === candidateRequest) candidateError.value = getApiErrorDetails(cause, '可合并工作事项读取失败') }
|
||||||
|
finally { if (request === candidateRequest) candidateLoading.value = false }
|
||||||
|
}
|
||||||
|
async function chooseOther(key: string) {
|
||||||
|
if (!context.value || pending.value || saving.value) return
|
||||||
|
candidateLoading.value = true; candidateError.value = null
|
||||||
|
try {
|
||||||
|
const selected = await getAppointmentBatch(key)
|
||||||
|
if (!selected.allowedActions.includes('MERGE')) throw new Error(selected.blockReasons.join(';') || '该批次当前不允许合并')
|
||||||
|
mergeBatchSources([context.value, selected]); other.value = selected
|
||||||
|
} catch (cause) { candidateError.value = getApiErrorDetails(cause, '批次不能合并') }
|
||||||
|
finally { candidateLoading.value = false }
|
||||||
|
}
|
||||||
|
async function changeMode(next: 'split' | 'merge') {
|
||||||
|
if (!await canLeave()) return
|
||||||
|
mode.value = next; other.value = null; result.value = null; await load()
|
||||||
|
if (next === 'merge') await searchCandidates()
|
||||||
|
}
|
||||||
|
function sourceLabel(source: CargoVerificationSource) {
|
||||||
|
const item = context.value?.appointment.items.find(item => item.customerId === source.customerId && item.sourceKey === source.sourceKey)
|
||||||
|
return item?.inboundCode || '原票业务号未提供'
|
||||||
|
}
|
||||||
|
async function verify(source: CargoVerificationSource) { if (await canLeave()) { baseline.value = JSON.stringify(form); verification.value = source } }
|
||||||
|
async function verified() { verification.value = null; await load(); emit('updated') }
|
||||||
|
async function openBatch(key: string) { if (!await canLeave()) return; activeKey.value = key; context.value = null; result.value = null; other.value = null; mode.value = 'split'; await load() }
|
||||||
|
async function save() {
|
||||||
|
if (!context.value || !canSubmit.value || saving.value) return
|
||||||
|
if (!pending.value) {
|
||||||
|
try {
|
||||||
|
if (!form.reason.trim()) { ElMessage.warning('请填写分批或合并原因'); return }
|
||||||
|
const contexts = mode.value === 'merge' && other.value ? [context.value, other.value] : [context.value]
|
||||||
|
const allocations = mode.value === 'split' ? splitBatch(context.value, form.moved) : [mergeBatchSources(contexts).map(source => allocation(source, Number(source.packageCount)))]
|
||||||
|
pending.value = { originals: contexts.map(row => ({ appointmentNo: row.appointment.appointmentNo, version: row.appointment.version })), batches: allocations.map((rows, index) => ({ plannedDate: (index === 0 ? form.firstDate : form.nextDate) || null, remark: form.remark.trim() || null, allocations: rows })), reason: form.reason.trim(), idempotencyKey: crypto.randomUUID() }
|
||||||
|
} catch (cause) { saveError.value = getApiErrorDetails(cause, '请核对逐票分配数量'); return }
|
||||||
|
}
|
||||||
|
saving.value = true; saveError.value = null
|
||||||
|
try {
|
||||||
|
result.value = await repartitionAppointments(pending.value); pending.value = null; baseline.value = JSON.stringify(form)
|
||||||
|
ElMessage.success(mode.value === 'split' ? '已分为两个待安排批次' : '已合并为一个待安排批次'); emit('updated')
|
||||||
|
} catch (cause) { saveError.value = getApiErrorDetails(cause, '调整结果待确认,请重试本次提交'); if (rejectedCommand(cause)) { pending.value = null; await load(true) } }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
onMounted(() => load())
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="work-form" aria-label="预约分批合并">
|
||||||
|
<CargoVerificationForm v-if="verification" :key="`${verification.customerId}:${verification.sourceKey}`" :source="verification" @saved="verified" />
|
||||||
|
<template v-else>
|
||||||
|
<h3>{{ context?.appointment.appointmentNo || '预约批次' }} · 分批与合并</h3>
|
||||||
|
<LoadErrorState v-if="error" title="批次资料读取失败" :message="error.message" :loading="loading" @retry="load(true)"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
|
<p v-if="loading">正在核对批次、来源和核实版本…</p>
|
||||||
|
<template v-if="result"><p>原批次 {{ result.replacedAppointmentNos.join('、') }} 已保留历史并被替代。</p><div v-for="appointment in result.appointments" :key="appointment.appointmentNo" class="batch-result"><strong>{{ appointment.appointmentNo }}</strong><span>{{ appointment.plannedDate || '日期未定' }} · 待安排</span><el-button @click="emit('appointment', appointment.appointmentNo)">登记安排</el-button><el-button v-if="appointment.batchKey" @click="openBatch(appointment.batchKey)">继续分批</el-button></div></template>
|
||||||
|
<template v-else-if="context">
|
||||||
|
<p>{{ context.appointment.fbaWarehouseCode }} · {{ context.appointment.items.length }} 条货物来源;客户与原来源逐票保留。</p>
|
||||||
|
<template v-if="hasCurrentDestinations">
|
||||||
|
<p v-if="replaced">该批次已被替代,原货物快照仅供追溯。{{ context.appointment.repartitionReason }}</p>
|
||||||
|
<p v-else>本批仍有保留货物,以下同时列出服务端返回的当前后送去向。</p>
|
||||||
|
<section aria-label="当前货物去向">
|
||||||
|
<h4>当前货物去向</h4><p>以下仅展示原批次客户、来源及目的仓对应的当前分配;合并批次可能还含其他货物,进入完整批次后可查看。</p>
|
||||||
|
<article v-for="destination in currentDestinations" :key="destination.appointment.batchKey" class="allocation-row">
|
||||||
|
<strong>{{ destination.appointment.appointmentNo }}</strong><p>{{ bookingStatus(destination.appointment.appointmentStatus) }} · {{ destination.appointment.plannedDate || '日期未定' }}</p>
|
||||||
|
<div v-for="item in destination.items" :key="item.id"><p>{{ item.inboundCode || '原票业务号未提供' }} · {{ item.fbaWarehouseCode }} · {{ item.packageCount == null ? '箱数待核实' : `${item.packageCount} 箱` }}</p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div>
|
||||||
|
<el-button :disabled="loading || !!error || saving || !!pending" @click="openAppointment(destination.appointment.appointmentNo)">查看本批预约</el-button>
|
||||||
|
<el-button v-if="destination.appointment.batchKey" :disabled="loading || !!error || saving || !!pending" @click="openBatch(destination.appointment.batchKey)">查看完整批次</el-button>
|
||||||
|
</article>
|
||||||
|
<p v-if="!currentDestinations.length && !loading && !error">当前没有与原货物范围匹配的分配;原快照不代表当前待办。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<p v-for="reason in context.blockReasons" :key="reason" role="status">{{ reason }}</p>
|
||||||
|
<div v-if="!context.sources.length" aria-label="原批次货物快照"><p>当前批次无分批资格,以下为原预约保留的货物资料。</p><div v-for="(item, index) in context.appointment.items" :key="item.id" class="allocation-row"><b>来源 {{ index + 1 }} · {{ item.inboundCode || '原票业务号未提供' }}</b><p>{{ item.fbaWarehouseCode }} · {{ item.packageCount ?? '箱数待核实' }}<span v-if="item.packageCount != null"> 箱</span></p><p>货件:{{ item.shipmentReferences?.join('、') || '待核实' }}</p></div></div>
|
||||||
|
<div class="batch-modes"><el-button :disabled="saving || !!pending || !context.allowedActions.includes('SPLIT')" @click="changeMode('split')">分成两次送仓</el-button><el-button :disabled="saving || !!pending || !context.allowedActions.includes('MERGE')" @click="changeMode('merge')">同仓合并安排</el-button></div>
|
||||||
|
<form class="density-form appointment-native-form" @submit.prevent="save"><fieldset :disabled="saving || !!pending || loading || !canWrite">
|
||||||
|
<div v-for="(source, index) in context.sources" :key="allocationKey(source)" class="allocation-row"><b>来源 {{ index + 1 }} · {{ sourceLabel(source) }}</b><p>本批 {{ source.packageCount ?? '待核实' }} 箱 · 原箱数 {{ source.originalPackages ?? '未知' }}</p><p>货件:{{ source.shipmentReferences.join('、') || '待核实' }}</p><label v-if="mode === 'split'">分到第二批的箱数<input v-model="form.moved[allocationKey(source)]" type="number" min="0" :max="source.packageCount ?? undefined" step="1" :aria-label="`来源${index + 1}分出箱数`" class="workbench-input"></label><el-button v-if="context.allowedActions.includes('VERIFY_CARGO')" @click="verify(source)">核实本票资料</el-button></div>
|
||||||
|
<template v-if="mode === 'merge'"><label>查找另一批工作事项<input v-model="keyword" placeholder="预约号、批次号、柜号" maxlength="80" @keydown.enter.prevent="searchCandidates()" class="workbench-input"></label><el-button :loading="candidateLoading" @click="searchCandidates()">查询批次</el-button><LoadErrorState v-if="candidateError" title="合并候选不可用" :message="candidateError.message" @retry="searchCandidates(candidatePage)" /><div v-for="candidate in candidates?.records.filter(item => item.batchKey && item.batchKey !== activeKey)" :key="candidate.workItemKey" class="batch-candidate"><span>{{ candidate.title }} · {{ candidate.destinationName || '目的仓待核实' }}</span><el-button :disabled="candidateLoading" @click="chooseOther(candidate.batchKey!)">核对并选择</el-button></div><p v-if="candidates && !candidates.records.length">本页未查询到工作事项。</p><div v-if="candidates && candidates.total > 8" class="batch-modes"><el-button :disabled="candidatePage === 1 || candidateLoading" @click="searchCandidates(candidatePage - 1)">上一页</el-button><span>第 {{ candidatePage }} 页</span><el-button :disabled="candidatePage * 8 >= candidates.total || candidateLoading" @click="searchCandidates(candidatePage + 1)">下一页</el-button></div><p v-if="other">已核对:{{ other.appointment.appointmentNo }},{{ other.sources.length }} 条来源;提交时再次逐来源校验。</p></template>
|
||||||
|
<label>{{ mode === 'split' ? '第一批目标日期(可不填)' : '合并后目标日期(可不填)' }}<el-date-picker v-model="form.firstDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label v-if="mode === 'split'">第二批目标日期(可不填)<el-date-picker v-model="form.nextDate" type="date" :disabled="saving || !!pending || loading || !canWrite" format="YYYY-MM-DD" value-format="YYYY-MM-DD" clearable /></label><label>调整原因<textarea v-model="form.reason" required maxlength="1000" class="workbench-input" /></label><label>批次备注<textarea v-model="form.remark" maxlength="1000" class="workbench-input" /></label>
|
||||||
|
</fieldset><LoadErrorState v-if="saveError" title="批次调整未完成" :message="saveError.message" :show-retry="false"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState><div class="density-actions density-actions--footer appointment-form-actions"><el-button type="primary" native-type="submit" :loading="saving" :disabled="!canSubmit || !!result">{{ pending ? '重试本次调整' : mode === 'split' ? '拆分为两批预约安排' : '合并为一批继续预约' }}</el-button></div></form>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>.allocation-row, .batch-result, .batch-candidate { padding: var(--admin-space-3) 0; border-bottom: 1px solid var(--workbench-border); overflow-wrap: anywhere; }.batch-modes, .batch-result, .batch-candidate { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }.batch-candidate > span { flex: 1; }</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { DOMWrapper, enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import LineClampText from '@/shared/components/LineClampText.vue'
|
||||||
|
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||||
|
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||||
|
import { listTripCalendar } from './tripCalendar'
|
||||||
|
import { createSourceAppointment, listPlanningCargo, previewPlanningBatch, resolvePlanningSource, type PlanningCargo, type PlanningBatchPreview } from './planningApi'
|
||||||
|
import { batchContext } from './batch.test-support'
|
||||||
|
import type { ScheduleEvent } from './schedule'
|
||||||
|
import { getWorkDetail, type WorkAction } from './workApi'
|
||||||
|
import { workDetail, workItem } from './work.test-support'
|
||||||
|
import { getCargoVerification } from './cargoVerificationApi'
|
||||||
|
|
||||||
|
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||||
|
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||||
|
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||||
|
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||||
|
vi.mock('./planningApi', async original => ({ ...await original<typeof import('./planningApi')>(), listPlanningCargo: vi.fn(), previewPlanningBatch: vi.fn(), resolvePlanningSource: vi.fn(), createSourceAppointment: vi.fn() }))
|
||||||
|
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), getWorkDetail: vi.fn() }))
|
||||||
|
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
afterEach(() => { vi.unstubAllGlobals() })
|
||||||
|
const warehouse: WarehouseArrival = { sourceContainerBookingId: '9007199254740999', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, actualAt: null, version: 3, planCount: 0, canEdit: true }
|
||||||
|
const event: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||||
|
const cargo: PlanningCargo = { sourceKey: 'ORDER:9007199254740998', orderId: '9007199254740998', customerId: '9007199254740997', customerName: '预约长客户名称'.repeat(12), inboundCode: 'IN-TEST', containerBookingId: 'container-1', containerNo: 'TEST-CONT', originWarehouseId: 'origin-1', originWarehouseCode: 'US-W', originWarehouseName: '美国长仓库名称'.repeat(12), destinationWarehouseCode: 'ONT8', packages: 20, receivedPackages: 0, shippedPackages: 0, sourcePalletCount: null, volumeCbm: null, estimatedArrivalAt: null, estimatedWarehouseAt: null, orderVersion: 1, standardNode: 'DEPARTED', shippingVersion: null, shippingObservedAt: null, shippingProvider: null, hasInboundPlan: false, hasActiveAppointment: false, detained: false, mixedDestinations: false }
|
||||||
|
const source = { sourceKey: cargo.sourceKey, customerId: cargo.customerId, orderId: cargo.orderId, inboundCode: cargo.inboundCode, bookingNo: null, containerNo: cargo.containerNo, originWarehouseId: cargo.originWarehouseId, originWarehouseName: cargo.originWarehouseName, destinationKey: 'FBA:LAX9', destinationWarehouseCode: 'LAX9', packages: 20, sourceVersion: 'v1', canFollowup: true, blockReasons: [] }
|
||||||
|
const preview: PlanningBatchPreview = { group: { originWarehouseId: cargo.originWarehouseId, destinationKey: source.destinationKey, packages: 20, sourceCount: 1, customerCount: 1, readinessIncomplete: true, readyFrom: null, readyTo: null, unknownQuantitySources: 0 }, originWarehouseName: cargo.originWarehouseName, destinationWarehouseCode: 'LAX9', destinationTimezone: 'America/Los_Angeles', sources: [source], missingFields: [] }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks(); routing.denied.clear(); routing.query = { date: '2026-10-19' }
|
||||||
|
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} })
|
||||||
|
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [], emptyReturns: [], emptyReturnsReadable: true })
|
||||||
|
vi.mocked(getWarehouseArrivals).mockResolvedValue([])
|
||||||
|
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||||
|
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||||
|
vi.mocked(listPlanningCargo).mockResolvedValue({ records: [cargo], total: 1, page: 1, size: 20, pages: 1 })
|
||||||
|
vi.mocked(resolvePlanningSource).mockResolvedValue({ source, coverage: { status: 'NONE', missingFields: [], changeReviewRequired: false, appointments: [] } })
|
||||||
|
vi.mocked(getCargoVerification).mockResolvedValue({ source, verification: null, allocatedPackages: 0, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: ['FBA-TEST'], availablePackages: 20, sourceChanged: false, allowedActions: [], blockReasons: [] })
|
||||||
|
vi.mocked(previewPlanningBatch).mockResolvedValue(preview)
|
||||||
|
vi.mocked(createSourceAppointment).mockResolvedValue(batchContext().appointment)
|
||||||
|
vi.mocked(getWorkDetail).mockResolvedValue(workDetail())
|
||||||
|
})
|
||||||
|
async function open() {
|
||||||
|
const wrapper = mount(AppointmentCalendarView, { global: { plugins: [ElementPlus], stubs: {
|
||||||
|
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', template: '<section data-calendar />' },
|
||||||
|
} } })
|
||||||
|
await flushPromises()
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
async function create(wrapper: Awaited<ReturnType<typeof open>>) {
|
||||||
|
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||||
|
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '新增FBA送仓预约')!
|
||||||
|
const content = creationBody()
|
||||||
|
await content.get('input[type="checkbox"]').setValue(true)
|
||||||
|
await content.findAll('button').find(button => button.text().startsWith('下一步'))!.trigger('click'); await flushPromises()
|
||||||
|
await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
function creationBody() { return new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('新增FBA送仓预约'))!) }
|
||||||
|
it('keeps the arrival context and typed local time across native fullscreen and submits the original version', async () => {
|
||||||
|
const wrapper = await open()
|
||||||
|
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||||
|
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||||
|
expect(dialog.props('width')).toBe('520px')
|
||||||
|
expect(dialog.get('[aria-label="到仓业务信息"]').text()).toContain('TEST-CONT 计划到仓')
|
||||||
|
expect(dialog.text()).not.toContain(warehouse.sourceContainerBookingId)
|
||||||
|
const picker = dialog.getComponent({ name: 'ElDatePicker' })
|
||||||
|
expect(picker.props()).toMatchObject({ type: 'datetime', valueFormat: 'YYYY-MM-DDTHH:mm', format: 'YYYY-MM-DD HH:mm' })
|
||||||
|
expect(dialog.getComponent({ name: 'ElForm' }).props('labelWidth')).toBe('150px')
|
||||||
|
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
|
||||||
|
await dialog.get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
|
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||||
|
expect(picker.props('modelValue')).toBe('2026-10-22T09:30')
|
||||||
|
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||||
|
expect(saveWarehouseArrival).toHaveBeenCalledExactlyOnceWith(warehouse.sourceContainerBookingId, { estimatedWarehouseArrivalAt: '2026-10-22T09:30:00', expectedVersion: 3 }, expect.any(String))
|
||||||
|
})
|
||||||
|
it('keeps the real arrival input editable after a failed attempt and submits the edited time with a fresh key', async () => {
|
||||||
|
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('connection lost'))
|
||||||
|
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||||
|
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||||
|
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
|
||||||
|
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||||
|
expect(dialog.get('input').attributes('disabled')).toBeUndefined()
|
||||||
|
expect(dialog.get('.el-dialog__footer .el-button--primary').text()).toBe('保存')
|
||||||
|
dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-23T09:30'); await flushPromises()
|
||||||
|
await dialog.get('.el-dialog__footer .el-button--primary').trigger('click'); await flushPromises()
|
||||||
|
const calls = vi.mocked(saveWarehouseArrival).mock.calls
|
||||||
|
expect(calls[1]![2]).not.toBe(calls[0]![2])
|
||||||
|
expect(calls[1]![1]).toEqual({ estimatedWarehouseArrivalAt: '2026-10-23T09:30:00', expectedVersion: 3 })
|
||||||
|
})
|
||||||
|
it('clears the Element arrival picker without saving an empty required time', async () => {
|
||||||
|
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', event); await flushPromises()
|
||||||
|
const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title') === '调整计划到仓')!
|
||||||
|
const picker = dialog.getComponent({ name: 'ElDatePicker' })
|
||||||
|
expect(dialog.find('input[type="datetime-local"]').exists()).toBe(false)
|
||||||
|
picker.vm.$emit('update:modelValue', '2026-10-22T09:30'); await flushPromises()
|
||||||
|
expect(dialog.props('confirmDisabled')).toBe(false)
|
||||||
|
picker.vm.$emit('update:modelValue', null); await flushPromises()
|
||||||
|
expect(dialog.props('confirmDisabled')).toBe(true)
|
||||||
|
expect(saveWarehouseArrival).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('keeps complete long cargo names accessible in the selectable table', async () => {
|
||||||
|
const wrapper = await open()
|
||||||
|
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await vi.dynamicImportSettled(); await flushPromises()
|
||||||
|
const names = wrapper.findAllComponents(LineClampText).map(item => item.props('content'))
|
||||||
|
expect(names).toContain(cargo.customerName); expect(names).toContain(cargo.originWarehouseName)
|
||||||
|
expect(wrapper.getComponent({ name: 'DataTable' }).props('maxHeight')).toBe('')
|
||||||
|
expect(wrapper.text()).not.toMatch(/900719925474099[7-9]/)
|
||||||
|
expect(resolvePlanningSource).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
it('preserves real FBA fields and entered values when expanding the create dialog', async () => {
|
||||||
|
const wrapper = await open(); const dialog = await create(wrapper)
|
||||||
|
const fields = dialog.getComponent({ name: 'FbaAppointmentFields' })
|
||||||
|
expect(fields.props('continuous')).toBe(true)
|
||||||
|
const date = fields.findAllComponents({ name: 'ElDatePicker' })[0]!
|
||||||
|
date.vm.$emit('update:modelValue', '2026-10-25'); await flushPromises()
|
||||||
|
await creationBody().get('button[aria-label="展开全屏"]').trigger('click'); await flushPromises()
|
||||||
|
expect(dialog.findComponent({ name: 'ElDialog' }).props('fullscreen')).toBe(true)
|
||||||
|
expect(date.props('modelValue')).toBe('2026-10-25')
|
||||||
|
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||||
|
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: 'FBA:LAX9' }], details: expect.objectContaining({ plannedDate: '2026-10-25', plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }), idempotencyKey: expect.any(String) }))
|
||||||
|
expect(creationBody().get('[aria-label="预约保存回执"]').findAll('input, textarea, select')).toHaveLength(0)
|
||||||
|
expect(creationBody().text()).toContain('AP-001')
|
||||||
|
})
|
||||||
|
it('shows every saved server field as readonly text including confirmed offsets and long remarks', async () => {
|
||||||
|
const saved = { ...batchContext().appointment, plannedDate: '2026-10-26', plannedAt: '2026-10-26T08:00:00', plannedEndAt: '2026-10-26T18:00:00', timezone: 'America/Los_Angeles', appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-001', appointmentAt: '2026-11-01T01:30:00', appointmentEndAt: '2026-11-01T01:45:00', appointmentStartUtc: '2026-11-01T08:30:00Z', appointmentEndUtc: '2026-11-01T09:45:00Z', appointmentRemark: '完整预约备注'.repeat(30) }
|
||||||
|
vi.mocked(createSourceAppointment).mockResolvedValue(saved)
|
||||||
|
const wrapper = await open(); await create(wrapper)
|
||||||
|
await creationBody().findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||||
|
const receipt = creationBody().get('[aria-label="预约保存回执"]')
|
||||||
|
for (const text of ['AP-001', saved.plannedDate, saved.plannedAt, saved.plannedEndAt, saved.timezone, '已取得对方正式确认', saved.confirmationNo, saved.appointmentAt, saved.appointmentEndAt, '-07:00', '-08:00', saved.appointmentRemark]) expect(receipt.text()).toContain(text)
|
||||||
|
expect(receipt.findAll('input, textarea, select')).toHaveLength(0)
|
||||||
|
expect(receipt.text()).not.toMatch(/item-1|customer-a|FBA_APPOINTMENT:1/)
|
||||||
|
})
|
||||||
|
it('does not open or request creation data without its original write permission', async () => {
|
||||||
|
routing.denied.add('platform:overseas-warehouse:inbound-order:fba-appointment')
|
||||||
|
const wrapper = await open(); wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('create'); await flushPromises()
|
||||||
|
expect(listPlanningCargo).not.toHaveBeenCalled(); expect(createSourceAppointment).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.findAllComponents(AppDialog).some(dialog => dialog.props('title') === '新增FBA送仓预约')).toBe(false)
|
||||||
|
})
|
||||||
|
it('uses the same real continuous form through the original work detail deep link', async () => {
|
||||||
|
routing.query.workItem = 'work-delivery-1'
|
||||||
|
const action: WorkAction = { code: 'SAVE_PLAN', label: '安排预约', targetWorkItemKey: null, target: { kind: 'FBA_APPOINTMENT', appointmentId: null, batchKey: null, sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }] } }
|
||||||
|
vi.mocked(getWorkDetail).mockResolvedValue(workDetail({ item: workItem({ allowedActions: [action], nextAction: action }) }))
|
||||||
|
const wrapper = await open(); await vi.dynamicImportSettled(); await flushPromises()
|
||||||
|
const content = new DOMWrapper(Array.from(document.querySelectorAll<HTMLElement>('.el-dialog')).find(item => item.textContent?.includes('原业务预约办理'))!)
|
||||||
|
await content.get('.primary-next-action').trigger('click'); await flushPromises()
|
||||||
|
await content.get('input[placeholder="填写收货方提供的确认号"]').setValue('CONFIRM-NEW'); await flushPromises()
|
||||||
|
expect(wrapper.getComponent({ name: 'FbaAppointmentFields' }).props('continuous')).toBe(true)
|
||||||
|
expect(previewPlanningBatch).toHaveBeenCalledExactlyOnceWith(action.target.kind === 'FBA_APPOINTMENT' ? action.target.sources : [])
|
||||||
|
await content.findAll('button').find(button => button.text() === '保存预约安排')!.trigger('click'); await flushPromises()
|
||||||
|
expect(createSourceAppointment).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ sources: [{ sourceKey: source.sourceKey, customerId: source.customerId, destinationKey: source.destinationKey }], details: expect.objectContaining({ plannedAt: null, appointmentStatus: 'SUCCESS', confirmationNo: 'CONFIRM-NEW', appointmentAt: null }) }))
|
||||||
|
expect(content.text()).toContain('本次保存已完成')
|
||||||
|
})
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||||
|
import { scheduleTypes, type ScheduleEvent } from './schedule'
|
||||||
|
import { projectTripCalendar, type TripCalendarEntry } from './tripCalendar'
|
||||||
|
|
||||||
|
const event = (type: ScheduleEvent['type'], index = 0): ScheduleEvent => ({ key: `${type}-${index}`, type, date: '2026-10-19', time: '', title: `TEST-CONT ${type}`, fields: [{ label: '仓库', value: 'ONT8' }] })
|
||||||
|
const render = (events: ScheduleEvent[] = [], initialDate = '2026-10-19') => mount(AppointmentCalendarPanel, { props: { initialDate, events },
|
||||||
|
global: { stubs: { ElButton: { template: '<button><slot /></button>' } } } })
|
||||||
|
function reportedDispatch() {
|
||||||
|
const row: TripCalendarEntry = { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: ['D1821', 'D1822'], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [] }
|
||||||
|
return projectTripCalendar({ records: [row, row], unresolvedRecords: [] }, { from: '2026-10-01', to: '2026-11-01' }).events
|
||||||
|
}
|
||||||
|
describe('four-type appointment calendar', () => {
|
||||||
|
it.each(['TSTU1234567', 'tstu1234567', 'TSTU7654321', ' tstu7654321 ', 'D1821', ' d1821 ', 'D1822', '1821'])('finds the one shared dispatch by associated container %s on its origin-local date, not the Beijing date', async keyword => {
|
||||||
|
const wrapper = render(reportedDispatch(), '2026-10-23')
|
||||||
|
const search = wrapper.get('[aria-label="搜索当前日历"]')
|
||||||
|
expect(search.attributes('placeholder')).toBe('柜号、排车单号、仓库')
|
||||||
|
await search.setValue(keyword)
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
|
||||||
|
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('当天暂无安排')
|
||||||
|
expect(wrapper.get('[data-date="2026-10-23"]').findAll('.event-chip')).toHaveLength(0)
|
||||||
|
expect(wrapper.get('[data-date="2026-10-22"]').findAll('.event-chip')).toHaveLength(1)
|
||||||
|
await wrapper.get('[data-date="2026-10-22"]').trigger('click')
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||||
|
expect(wrapper.get('.detail-event').text()).toContain('PC-CALENDAR-001')
|
||||||
|
expect(wrapper.get('.detail-event').text()).toContain('09:00')
|
||||||
|
expect(wrapper.get('.detail-event').text()).toContain('TSTU1234567、TSTU7654321')
|
||||||
|
expect(wrapper.get('.detail-event').text()).toContain('D1821、D1822')
|
||||||
|
expect(wrapper.get('.detail-event').text()).toContain('2026-10-22 09:00')
|
||||||
|
expect(wrapper.emitted('range')).toBeUndefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not match an unrelated container and can find the same event by the dispatch number', async () => {
|
||||||
|
const wrapper = render(reportedDispatch(), '2026-10-22')
|
||||||
|
await wrapper.get('[aria-label="搜索当前日历"]').setValue('UNRELATED9999')
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(0)
|
||||||
|
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||||
|
await wrapper.get('[aria-label="搜索当前日历"]').setValue('PC-CALENDAR-001')
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||||
|
expect(wrapper.findAll('.event-chip')).toHaveLength(1)
|
||||||
|
expect(wrapper.emitted('range')).toBeUndefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('shows all four types by default with a calendar and persistent daily detail', () => {
|
||||||
|
const wrapper = render(scheduleTypes.map(type => event(type)))
|
||||||
|
expect(wrapper.findAll('[aria-label="安排类型"] input')).toHaveLength(4)
|
||||||
|
expect(wrapper.get('[aria-label="安排类型"]').text()).toBe('到港排车还柜计划到仓')
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(4)
|
||||||
|
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('4 项安排')
|
||||||
|
expect(wrapper.findAll('.calendar-day')).toHaveLength(35)
|
||||||
|
expect(wrapper.find('[data-date="2026-11-01"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-date="2026-11-02"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.text()).not.toMatch(/新增预约|全局待办|需复核|中转|直送/)
|
||||||
|
})
|
||||||
|
it('filters locally without requesting another range', async () => {
|
||||||
|
const wrapper = render([event('arrival'), event('dispatch')])
|
||||||
|
await wrapper.get('[aria-label="安排类型"] input').setValue(false)
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||||
|
await wrapper.get('[aria-label="搜索当前日历"]').setValue('missing')
|
||||||
|
expect(wrapper.text()).toContain('当天暂无安排')
|
||||||
|
expect(wrapper.emitted('range')).toBeUndefined()
|
||||||
|
})
|
||||||
|
it('offers creation in the header only when authorized', async () => {
|
||||||
|
const wrapper = render()
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '新增预约')).toBe(false)
|
||||||
|
await wrapper.setProps({ canCreate: true })
|
||||||
|
await wrapper.findAll('.calendar-controls button').find(button => button.text() === '新增预约')!.trigger('click')
|
||||||
|
expect(wrapper.emitted('create')).toEqual([[]])
|
||||||
|
expect(wrapper.emitted('range')).toBeUndefined()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('changes the selected date locally and requests one range per mode or period change', async () => {
|
||||||
|
const wrapper = render()
|
||||||
|
await wrapper.get('[data-date="2026-10-20"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('range')).toBeUndefined()
|
||||||
|
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('2026-10-20')
|
||||||
|
await wrapper.get('[aria-label="预约视图"]').findAll('button')[1]!.trigger('click')
|
||||||
|
expect(wrapper.emitted('range')).toEqual([[{ from: '2026-10-19', to: '2026-10-26' }]])
|
||||||
|
await wrapper.get('[aria-label="下一周"]').trigger('click')
|
||||||
|
expect(wrapper.emitted('range')?.at(-1)).toEqual([{ from: '2026-10-26', to: '2026-11-02' }])
|
||||||
|
})
|
||||||
|
it('limits previews to three while retaining every event in the daily details', () => {
|
||||||
|
const wrapper = render(Array.from({ length: 23 }, (_, index) => event('arrival', index)))
|
||||||
|
expect(wrapper.get('[data-date="2026-10-19"]').findAll('.event-chip')).toHaveLength(3)
|
||||||
|
expect(wrapper.get('.event-more').text()).toBe('还有 20 条')
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(23)
|
||||||
|
})
|
||||||
|
it('keeps undated records outside date cells and exposes them explicitly', async () => {
|
||||||
|
const wrapper = render([{ ...event('warehouse'), date: null }])
|
||||||
|
expect(wrapper.findAll('.event-chip')).toHaveLength(0)
|
||||||
|
await wrapper.get('.undated-button').trigger('click')
|
||||||
|
expect(wrapper.findAll('.detail-event')).toHaveLength(1)
|
||||||
|
expect(wrapper.get('[aria-label="当天安排"]').text()).toContain('日期未定')
|
||||||
|
})
|
||||||
|
it('retains business links and exposes editing only with authoritative eligibility', async () => {
|
||||||
|
const row = { ...event('warehouse'), sourcePath: '/container-loading/overseas/1', canEdit: true }
|
||||||
|
const wrapper = render([row])
|
||||||
|
await wrapper.get('.detail-event footer').findAll('button')[0]!.trigger('click')
|
||||||
|
await wrapper.get('.detail-event footer').findAll('button')[1]!.trigger('click')
|
||||||
|
expect(wrapper.emitted('open')).toEqual([[row]])
|
||||||
|
expect(wrapper.emitted('edit')).toEqual([[row]])
|
||||||
|
await wrapper.setProps({ events: [{ ...row, canEdit: false, sourcePath: undefined }] })
|
||||||
|
expect(wrapper.find('.detail-event footer').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
it('distinguishes loading and partial failure from a complete empty day', async () => {
|
||||||
|
const wrapper = render()
|
||||||
|
await wrapper.setProps({ loading: true })
|
||||||
|
expect(wrapper.text()).toContain('正在读取安排')
|
||||||
|
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||||
|
await wrapper.setProps({ loading: false, incomplete: true })
|
||||||
|
expect(wrapper.get('[aria-label="当天安排"] header').text()).toContain('当前已加载 0 项安排')
|
||||||
|
expect(wrapper.text()).not.toContain('部分数据不可用')
|
||||||
|
expect(wrapper.text()).not.toContain('当天暂无安排')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
|
import '@/styles/adminDensity.css'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Plus } from '@lucide/vue'
|
||||||
|
import { calendarDates, calendarRange, dateKey, moveCalendar, type CalendarMode } from './calendar'
|
||||||
|
import { scheduleLabels, scheduleTypes, type ScheduleEvent, type ScheduleType } from './schedule'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ events: ScheduleEvent[]; loading?: boolean; incomplete?: boolean; initialDate?: string; initialMode?: CalendarMode; canCreate?: boolean }>(),
|
||||||
|
{ loading: false, incomplete: false, initialDate: '', initialMode: 'month', canCreate: false })
|
||||||
|
const emit = defineEmits<{ range: [range: { from: string; to: string }]; select: [date: string, mode: CalendarMode]; open: [event: ScheduleEvent]; edit: [event: ScheduleEvent]; create: [] }>()
|
||||||
|
const selectedDate = ref(props.initialDate || dateKey(new Date())), mode = ref<CalendarMode>(props.initialMode)
|
||||||
|
const types = ref<ScheduleType[]>([...scheduleTypes]), keyword = ref(''), undated = ref(false)
|
||||||
|
const dates = computed(() => calendarDates(selectedDate.value, mode.value))
|
||||||
|
const visibleEvents = computed(() => props.events.filter(event => types.value.includes(event.type) &&
|
||||||
|
`${event.title} ${event.fields.map(field => field.value || '').join(' ')}`.toLowerCase().includes(keyword.value.trim().toLowerCase())))
|
||||||
|
const grouped = computed(() => {
|
||||||
|
const result = new Map<string, ScheduleEvent[]>()
|
||||||
|
for (const event of visibleEvents.value) {
|
||||||
|
const date = event.date || 'undated'
|
||||||
|
const rows = result.get(date) || []; rows.push(event); result.set(date, rows)
|
||||||
|
}
|
||||||
|
for (const rows of result.values()) rows.sort((a, b) => a.time.localeCompare(b.time) || a.key.localeCompare(b.key))
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
const selectedEvents = computed(() => grouped.value.get(undated.value ? 'undated' : selectedDate.value) || [])
|
||||||
|
const heading = computed(() => mode.value === 'month' ? `${selectedDate.value.slice(0, 4)}年${Number(selectedDate.value.slice(5, 7))}月` : `${dates.value[0]} — ${dates.value.at(-1)}`)
|
||||||
|
function select(date: string) {
|
||||||
|
const before = calendarRange(selectedDate.value, mode.value)
|
||||||
|
selectedDate.value = date; undated.value = false
|
||||||
|
const after = calendarRange(date, mode.value)
|
||||||
|
emit('select', date, mode.value)
|
||||||
|
if (before.from !== after.from || before.to !== after.to) emit('range', after)
|
||||||
|
}
|
||||||
|
function changeMode(next: CalendarMode) {
|
||||||
|
if (mode.value === next) return
|
||||||
|
mode.value = next; emit('select', selectedDate.value, next); emit('range', calendarRange(selectedDate.value, next))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="appointment-calendar admin-density admin-workbench" :aria-busy="loading">
|
||||||
|
<div class="calendar-main workbench-panel is-flush">
|
||||||
|
<header class="calendar-toolbar">
|
||||||
|
<div class="calendar-label">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"><rect x="3" y="5" width="18" height="16" rx="2" stroke="currentColor" stroke-width="1.8"/><path d="M8 3v4M16 3v4M3 10h18M8 15h3M14 15h3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
|
||||||
|
<strong>预约日历</strong>
|
||||||
|
</div>
|
||||||
|
<div class="calendar-controls">
|
||||||
|
<el-button v-if="canCreate" type="primary" @click="emit('create')"><Plus :size="16" aria-hidden="true" />新增预约</el-button>
|
||||||
|
<div class="view-toggle workbench-tabs" role="group" aria-label="预约视图"><button type="button" :aria-pressed="mode === 'month'" @click="changeMode('month')">月</button><button type="button" :aria-pressed="mode === 'week'" @click="changeMode('week')">周</button></div>
|
||||||
|
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '上个月' : '上一周'" @click="select(moveCalendar(selectedDate, mode, -1))">‹</button>
|
||||||
|
<strong class="calendar-period">{{ heading }}</strong>
|
||||||
|
<button class="nav-button" type="button" :aria-label="mode === 'month' ? '下个月' : '下一周'" @click="select(moveCalendar(selectedDate, mode, 1))">›</button>
|
||||||
|
<el-button @click="select(dateKey(new Date()))">今天</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="calendar-options">
|
||||||
|
<div class="calendar-layers" role="group" aria-label="安排类型"><label v-for="type in scheduleTypes" :key="type" class="legend" :class="type"><input v-model="types" type="checkbox" :value="type">{{ scheduleLabels[type] }}</label></div>
|
||||||
|
<label class="calendar-search"><span class="sr-only">搜索当前日历</span><input class="workbench-input" v-model="keyword" type="search" maxlength="128" placeholder="柜号、排车单号、仓库" aria-label="搜索当前日历"></label>
|
||||||
|
<button class="undated-button" type="button" :aria-pressed="undated" @click="undated = !undated">未定日期 {{ grouped.get('undated')?.length || 0 }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="weekdays"><span v-for="day in ['周一', '周二', '周三', '周四', '周五', '周六', '周日']" :key="day">{{ day }}</span></div>
|
||||||
|
<div class="calendar-grid" :class="{ 'week-grid': mode === 'week' }">
|
||||||
|
<button v-for="date in dates" :key="date" type="button" class="calendar-day" :data-date="date"
|
||||||
|
:class="{ selected: !undated && selectedDate === date, outside: mode === 'month' && date.slice(0, 7) !== selectedDate.slice(0, 7) }"
|
||||||
|
:aria-label="`${date},${loading ? '加载中' : `${(grouped.get(date) || []).length} 项安排`}`" :aria-pressed="!undated && selectedDate === date" @click="select(date)">
|
||||||
|
<span class="day-number">{{ Number(date.slice(8)) }}</span>
|
||||||
|
<span v-if="!loading" class="day-events"><span v-for="event in (grouped.get(date) || []).slice(0, 3)" :key="event.key" class="event-chip" :class="event.type" :title="event.title">{{ event.title }}</span><span v-if="(grouped.get(date) || []).length > 3" class="event-more">还有 {{ (grouped.get(date) || []).length - 3 }} 条</span></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<aside class="calendar-detail workbench-panel is-flush" aria-label="当天安排">
|
||||||
|
<header><strong>{{ undated ? '未定日期' : selectedDate }}</strong><p v-if="loading">正在读取安排…</p><p v-else>{{ incomplete ? '当前已加载' : undated ? '共有' : '当天共有' }} <b>{{ selectedEvents.length }}</b> 项安排</p></header>
|
||||||
|
<div class="detail-events" aria-live="polite">
|
||||||
|
<p v-if="!selectedEvents.length && !loading" class="empty-day">{{ incomplete ? '已加载数据中没有安排。' : undated ? '没有未定日期的安排' : '当天暂无安排' }}</p>
|
||||||
|
<article v-for="event in selectedEvents" :key="event.key" class="detail-event">
|
||||||
|
<div class="event-heading"><span class="event-type" :class="event.type">{{ scheduleLabels[event.type] }}</span><span>{{ event.time || (event.date ? '日期计划' : '日期未定') }}</span></div>
|
||||||
|
<h3>{{ event.title }}</h3><p v-if="event.subtitle" class="event-subtitle">{{ event.subtitle }}</p>
|
||||||
|
<dl><div v-for="field in event.fields" :key="field.label"><dt>{{ field.label }}</dt><dd>{{ field.value || '—' }}</dd></div></dl>
|
||||||
|
<footer v-if="event.sourcePath || event.canEdit"><el-button v-if="event.sourcePath" link type="primary" :disabled="loading" @click="emit('open', event)">{{ event.type === 'dispatch' ? '查看排车' : '查看货柜' }}</el-button><el-button v-if="event.canEdit" link type="primary" :disabled="loading" @click="emit('edit', event)">调整计划到仓</el-button></footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.appointment-calendar { display: grid; align-items: start; grid-template-columns: minmax(0, 1fr) 360px; gap: var(--admin-space-5); color: var(--el-text-color-regular); }
|
||||||
|
.calendar-main, .calendar-detail { overflow: hidden; }
|
||||||
|
.calendar-toolbar { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: var(--admin-space-3); padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }
|
||||||
|
.calendar-label, .calendar-controls { display: flex; align-items: center; gap: var(--admin-space-3); }
|
||||||
|
.calendar-label > svg { color: var(--workbench-primary); }.calendar-label > strong { font-size: var(--admin-font-section); }
|
||||||
|
.calendar-options, .calendar-layers { display: flex; align-items: center; flex-wrap: wrap; gap: var(--admin-space-3); }
|
||||||
|
.calendar-options { padding: var(--admin-space-3) var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }
|
||||||
|
.legend { display: inline-flex; align-items: center; gap: var(--admin-space-1); font-size: var(--admin-font-caption); cursor: pointer; color: var(--event-fg); }.legend input { accent-color: var(--event-fg); }
|
||||||
|
.calendar-search { margin-left: auto; }.calendar-search input { width: 190px; }
|
||||||
|
.view-toggle { display: flex; }
|
||||||
|
.nav-button, .undated-button { border: 0; border-radius: var(--admin-radius); background: transparent; cursor: pointer; color: var(--el-text-color-regular); min-width: var(--admin-control-height); min-height: var(--admin-control-height); font: inherit; }
|
||||||
|
.undated-button[aria-pressed='true'] { background: var(--workbench-surface); color: var(--workbench-primary); font-weight: 600; }.nav-button { border: 1px solid var(--workbench-border); font-size: 22px; }
|
||||||
|
.calendar-period { font-size: var(--admin-font-section); white-space: nowrap; }
|
||||||
|
.weekdays, .calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }.weekdays { text-align: center; color: var(--workbench-muted); background: var(--workbench-surface-soft); padding: var(--admin-space-3); font-size: var(--admin-font-caption); }
|
||||||
|
.calendar-grid { gap: var(--admin-space-2); padding: var(--admin-space-3); background: var(--workbench-surface-soft); }
|
||||||
|
.calendar-day { display: flex; flex-direction: column; align-items: stretch; height: 146px; min-width: 0; padding: var(--admin-space-3); border: 1px solid var(--workbench-border); border-radius: 8px; background: var(--workbench-surface); text-align: left; cursor: pointer; color: inherit; font: inherit; }
|
||||||
|
.calendar-day.outside { background: var(--workbench-surface-soft); color: var(--workbench-muted); }.calendar-day:hover { border-color: var(--workbench-accent-border); }.calendar-day.selected { border-color: var(--workbench-primary); background: var(--workbench-accent-soft); }
|
||||||
|
button:focus-visible, input:focus-visible { outline: 2px solid var(--workbench-primary); outline-offset: 2px; }
|
||||||
|
.day-number { font-size: var(--admin-font-caption); font-weight: 600; }.day-events { display: flex; flex-direction: column; gap: var(--admin-space-1); margin-top: var(--admin-space-2); }
|
||||||
|
.event-chip { display: block; padding: 2px 6px; line-height: 18px; border-radius: var(--admin-radius); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--admin-font-caption); }
|
||||||
|
.arrival { --event-bg: #edf3ff; --event-fg: #3765d4; }.warehouse { --event-bg: #e8f8f1; --event-fg: #18845f; }.return { --event-bg: #fff4e3; --event-fg: #995a10; }.dispatch { --event-bg: #f2edfa; --event-fg: #725199; }
|
||||||
|
.event-chip, .event-type { background: var(--event-bg); color: var(--event-fg); }.event-more, .empty-day { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||||
|
.week-grid .calendar-day { height: 270px; }.calendar-detail > header { padding: var(--admin-space-5); border-bottom: 1px solid var(--workbench-border); }.calendar-detail > header > strong { font-size: var(--admin-font-title); }
|
||||||
|
.calendar-detail header p { margin: var(--admin-space-2) 0 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.calendar-detail header b { color: var(--workbench-primary); }
|
||||||
|
.detail-events { padding: var(--admin-space-5); max-height: calc(100dvh - 260px); overflow-y: auto; }
|
||||||
|
.detail-event + .detail-event { border-top: 1px solid var(--workbench-border); margin-top: var(--admin-space-5); padding-top: var(--admin-space-5); }
|
||||||
|
.event-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--admin-space-3); font-size: var(--admin-font-caption); }.event-type { padding: var(--admin-space-2) var(--admin-space-3); border-radius: var(--admin-radius); }
|
||||||
|
.detail-event h3 { font-size: var(--admin-font-section); margin: var(--admin-space-3) 0; overflow-wrap: anywhere; }.event-subtitle { color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); overflow-wrap: anywhere; }
|
||||||
|
.detail-event dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--admin-space-3) var(--admin-space-5); margin: var(--admin-space-3) 0; }.detail-event dt { font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }.detail-event dd { margin: var(--admin-space-1) 0 0; font-size: var(--admin-font-body); overflow-wrap: anywhere; }
|
||||||
|
.detail-event footer { display: flex; justify-content: flex-end; gap: var(--admin-space-3); border-top: 1px solid var(--workbench-border); padding-top: var(--admin-space-3); }.detail-event footer :deep(.el-button + .el-button) { margin-left: 0; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||||||
|
@media (max-width: 1100px) { .appointment-calendar { grid-template-columns: minmax(0, 1fr); } }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { DatePickerStub, TimePickerStub } from './temporal-picker.test-support'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import { ApiResponseError } from '@/shared/api/http'
|
||||||
|
import AppointmentCalendarView from './AppointmentCalendarView.vue'
|
||||||
|
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type WarehouseArrival } from './scheduleApi'
|
||||||
|
import { listTripCalendar, type TripCalendarEntry } from './tripCalendar'
|
||||||
|
import type { ScheduleEvent } from './schedule'
|
||||||
|
const routing = vi.hoisted(() => ({ query: { date: '2026-10-19' } as Record<string, string>, push: vi.fn(), replace: vi.fn(), denied: new Set<string>() }))
|
||||||
|
vi.mock('vue-router', () => ({ useRoute: () => routing, useRouter: () => routing, onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn() }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: (permission: string) => !routing.denied.has(permission) }) }))
|
||||||
|
vi.mock('./scheduleApi', () => ({ getCalendarSources: vi.fn(), getWarehouseArrivals: vi.fn(), saveWarehouseArrival: vi.fn() }))
|
||||||
|
vi.mock('./tripCalendar', async original => ({ ...await original<typeof import('./tripCalendar')>(), listTripCalendar: vi.fn() }))
|
||||||
|
vi.mock('element-plus', () => ({ ElMessage: { success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||||
|
const warehouse: WarehouseArrival = { sourceContainerBookingId: '1', containerNo: 'TEST-CONT', warehouseName: '美国仓', warehouseCode: 'US-W', plannedAt: null, plannedDate: '2026-10-21', isDefault: true, actualAt: null, version: 0, planCount: 0, canEdit: true }
|
||||||
|
const row: ScheduleEvent = { key: 'warehouse-1', type: 'warehouse', date: '2026-10-21', time: '', title: 'TEST-CONT 计划到仓', fields: [], warehouse, canEdit: true }
|
||||||
|
function dispatchEntry(overrides: Partial<TripCalendarEntry> = {}): TripCalendarEntry {
|
||||||
|
return { sourceType: 'DISPATCH', dispatchNo: 'PC-CALENDAR-001', containerNumbers: ['TSTU1234567', 'TSTU7654321'], cabinetNumbers: [], status: 'PLANNED', originWarehouseId: '1', originWarehouseCode: 'LAX', originWarehouseName: '洛杉矶仓', originCountryCode: 'US', originTimezone: 'America/Los_Angeles', originWarehouseStatus: 'ACTIVE', plannedDepartureAt: '2026-10-22T16:00:00Z', expectedDriverEndAt: null, departedAt: null, completedAt: null, stops: [{ name: '洛杉矶仓' }, { name: 'ONT8' }], incompleteReasons: [], ...overrides }
|
||||||
|
}
|
||||||
|
export function renderSchedule() {
|
||||||
|
return mount(AppointmentCalendarView, { global: { stubs: {
|
||||||
|
ElDatePicker: DatePickerStub, ElTimePicker: TimePickerStub,
|
||||||
|
AdminPage: { template: '<main><slot /></main>' },
|
||||||
|
AppointmentCalendarPanel: { name: 'AppointmentCalendarPanel', props: ['events', 'loading', 'incomplete', 'initialDate', 'initialMode', 'canCreate'], template: '<section data-calendar />' },
|
||||||
|
AppDialog: { name: 'AppDialog', props: ['modelValue', 'beforeClose', 'confirmDisabled', 'confirmLoading'], emits: ['confirm'], template: '<section v-if="modelValue" data-dialog><slot /><button data-save :disabled="confirmDisabled" @click="$emit(\'confirm\')">保存</button><button data-close @click="beforeClose(() => {})">关闭</button></section>' },
|
||||||
|
WorkDetailPanel: true, SourceAppointmentPanel: true, ApiErrorMeta: true,
|
||||||
|
ElForm: { template: '<form><slot /></form>' },
|
||||||
|
ElFormItem: { props: ['label'], template: '<label>{{ label }}<slot /></label>' },
|
||||||
|
ElInput: { props: ['modelValue', 'disabled'], emits: ['update:modelValue'], template: '<input :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target.value)">' },
|
||||||
|
AppointmentCreatePanel: { name: 'AppointmentCreatePanel', props: ['canWrite', 'canLeave'], emits: ['created'], template: '<section data-create-panel />' },
|
||||||
|
LoadErrorState: { props: ['title', 'message'], emits: ['retry'], template: '<section role="alert">{{ title }} {{ message }}<slot name="details" /><button data-retry @click="$emit(\'retry\')">重试</button></section>' },
|
||||||
|
} } })
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks(); routing.query = { date: '2026-10-19' }; routing.denied.clear()
|
||||||
|
vi.mocked(getCalendarSources).mockResolvedValue({ containers: [{ containerBookingId: '1', cabinetNo: 'D0001', containerNo: 'TEST-CONT', vesselName: null, voyageNo: null, destinationPortName: null, destinationWarehouseCode: null, arrivalAt: '2026-10-19T10:00:00', arrivalType: 'ACTUAL' }], emptyReturns: [], emptyReturnsReadable: true })
|
||||||
|
vi.mocked(getWarehouseArrivals).mockResolvedValue([warehouse])
|
||||||
|
vi.mocked(listTripCalendar).mockResolvedValue({ records: [], unresolvedRecords: [] })
|
||||||
|
vi.mocked(saveWarehouseArrival).mockResolvedValue({})
|
||||||
|
})
|
||||||
|
describe('simplified appointment entry', () => {
|
||||||
|
it('opens creation lazily with the original read and write permissions and refreshes after creation', async () => {
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||||
|
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||||
|
expect(calendar.props('canCreate')).toBe(true)
|
||||||
|
calendar.vm.$emit('create'); await flushPromises()
|
||||||
|
const panel = wrapper.getComponent({ name: 'AppointmentCreatePanel' })
|
||||||
|
expect(panel.props('canWrite')).toBe(true)
|
||||||
|
vi.mocked(getCalendarSources).mockClear()
|
||||||
|
panel.vm.$emit('created'); await flushPromises()
|
||||||
|
expect(getCalendarSources).toHaveBeenCalledTimes(1)
|
||||||
|
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it.each(['platform:overseas-warehouse:inbound-order:fba-appointment', 'platform:overseas-warehouse:last-mile-order:list'])('does not open creation without %s', async permission => {
|
||||||
|
routing.denied.add(permission)
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
const calendar = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||||
|
expect(calendar.props('canCreate')).toBe(false)
|
||||||
|
calendar.vm.$emit('create'); await flushPromises()
|
||||||
|
expect(wrapper.find('[data-create-panel]').exists()).toBe(false)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps unsaved input when the operator declines to leave', async () => {
|
||||||
|
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||||
|
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||||
|
await wrapper.get('[data-close]').trigger('click'); await flushPromises()
|
||||||
|
expect(ElMessageBox.confirm).toHaveBeenCalled()
|
||||||
|
expect(wrapper.find('[data-dialog]').exists()).toBe(true)
|
||||||
|
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('loads original sources and real trips without loading work summaries or generated work items', async () => {
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
expect(getWarehouseArrivals).toHaveBeenCalledWith({ from: '2026-09-28', to: '2026-11-02' }, ['1'], expect.any(AbortSignal))
|
||||||
|
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||||
|
expect(panel.props('events').map((event: ScheduleEvent) => [event.type, event.date])).toEqual([['arrival', '2026-10-19'], ['warehouse', '2026-10-21']])
|
||||||
|
expect(wrapper.text()).not.toMatch(/全局待办|跟进|复核/)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps available arrivals while reporting a failed warehouse source instead of inventing defaults', async () => {
|
||||||
|
vi.mocked(getWarehouseArrivals).mockRejectedValue(new Error('offline'))
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events').map((event: ScheduleEvent) => event.type)).toEqual(['arrival'])
|
||||||
|
expect(wrapper.text()).toContain('计划到仓读取失败')
|
||||||
|
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('incomplete')).toBe(true)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not request unreadable domains and describes the incomplete scope', async () => {
|
||||||
|
routing.denied.add('platform:overseas-warehouse:dispatch:list'); routing.denied.add('platform:overseas-warehouse:last-mile-order:list')
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
expect(listTripCalendar).not.toHaveBeenCalled(); expect(getWarehouseArrivals).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.text()).toContain('缺少排车读取权限')
|
||||||
|
expect(wrapper.text()).toContain('缺少尾程订单读取权限')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('ignores stale calendar responses when the date range changes', async () => {
|
||||||
|
let resolveOld!: (value: Awaited<ReturnType<typeof getCalendarSources>>) => void
|
||||||
|
vi.mocked(getCalendarSources).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('range', { from: '2026-11-01', to: '2026-11-08' }); await flushPromises()
|
||||||
|
resolveOld({ containers: [], emptyReturns: [], emptyReturnsReadable: false }); await flushPromises()
|
||||||
|
expect(wrapper.text()).not.toContain('缺少还柜读取权限')
|
||||||
|
expect(wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).props('events')).toHaveLength(0)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('releases an unknown save and sends the edited date with a fresh key', async () => {
|
||||||
|
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new Error('network interrupted')).mockResolvedValueOnce({})
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||||
|
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-22T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.get('input').attributes('disabled')).toBeUndefined()
|
||||||
|
await wrapper.get('input[data-temporal-picker]').setValue('2026-10-23T09:30'); await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||||
|
const calls = vi.mocked(saveWarehouseArrival).mock.calls
|
||||||
|
expect(calls[1]![2]).not.toBe(calls[0]![2]); expect(calls[1]![1].estimatedWarehouseArrivalAt).toBe('2026-10-23T09:30:00')
|
||||||
|
expect(wrapper.find('[data-dialog]').exists()).toBe(false); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('allows editing after a conflict and preserves input across an optional manual refresh', async () => {
|
||||||
|
vi.mocked(saveWarehouseArrival).mockRejectedValueOnce(new ApiResponseError('版本已变化', 'A0409', 409))
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
wrapper.getComponent({ name: 'AppointmentCalendarPanel' }).vm.$emit('edit', row); await flushPromises()
|
||||||
|
await wrapper.get('input').setValue('2026-10-22T09:30')
|
||||||
|
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||||
|
expect(wrapper.get('[data-save]').attributes('disabled')).toBeUndefined()
|
||||||
|
vi.mocked(getWarehouseArrivals).mockResolvedValueOnce([{ ...warehouse, version: 2 }])
|
||||||
|
await wrapper.get('[data-retry]').trigger('click'); await flushPromises()
|
||||||
|
expect((wrapper.get('input').element as HTMLInputElement).value).toBe('2026-10-22T09:30')
|
||||||
|
await wrapper.get('[data-save]').trigger('click'); await flushPromises()
|
||||||
|
expect(saveWarehouseArrival).toHaveBeenLastCalledWith('1', expect.objectContaining({ expectedVersion: 2 }), expect.any(String))
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps the selected date and view in the source return link', async () => {
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||||
|
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||||
|
panel.vm.$emit('open', { ...row, sourcePath: '/container-loading/overseas/1' }); await flushPromises()
|
||||||
|
expect(routing.push).toHaveBeenCalledWith({ path: '/container-loading/overseas/1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('opens the real dispatch detail with calendar context without requiring retired trip-list access', async () => {
|
||||||
|
routing.denied.add('platform:fleet:trip:list')
|
||||||
|
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [dispatchEntry({ dispatchNo: 'DS/1', containerNumbers: [], plannedDepartureAt: '2026-10-21T16:00:00Z' })], unresolvedRecords: [] })
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||||
|
const event = panel.props('events').find((value: ScheduleEvent) => value.type === 'dispatch')
|
||||||
|
expect(listTripCalendar).toHaveBeenCalledOnce()
|
||||||
|
expect(event.sourcePath).toBe('/overseas-warehouse/dispatches/DS%2F1')
|
||||||
|
panel.vm.$emit('select', '2026-10-21', 'week')
|
||||||
|
panel.vm.$emit('open', event); await flushPromises()
|
||||||
|
expect(routing.push).toHaveBeenCalledWith({ path: '/overseas-warehouse/dispatches/DS%2F1', query: { appointmentReturn: '/appointment-calendar?date=2026-10-21&mode=week' } })
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('retains a complete dispatch with its container search fields without showing an unresolved dispatch notice', async () => {
|
||||||
|
const good = dispatchEntry()
|
||||||
|
const unknown = dispatchEntry({ dispatchNo: 'PC-TIMEZONE-MISSING', containerNumbers: ['OTHER7654321'], originTimezone: null, incompleteReasons: ['ORIGIN_TIMEZONE_UNKNOWN'] })
|
||||||
|
vi.mocked(listTripCalendar).mockResolvedValueOnce({ records: [good, good, unknown], unresolvedRecords: [] })
|
||||||
|
const wrapper = renderSchedule(); await flushPromises()
|
||||||
|
const panel = wrapper.getComponent({ name: 'AppointmentCalendarPanel' })
|
||||||
|
const dispatches = (panel.props('events') as ScheduleEvent[]).filter(event => event.type === 'dispatch')
|
||||||
|
expect(dispatches).toHaveLength(1)
|
||||||
|
expect(dispatches[0]).toMatchObject({ key: 'dispatch-PC-CALENDAR-001', date: '2026-10-22', time: '09:00' })
|
||||||
|
expect(dispatches[0]!.fields).toContainEqual({ label: '关联柜号', value: 'TSTU1234567、TSTU7654321' })
|
||||||
|
expect(dispatches[0]!.fields).toContainEqual({ label: '计划出发(起运仓当地)', value: '2026-10-22 09:00' })
|
||||||
|
expect(panel.props('incomplete')).toBe(true)
|
||||||
|
expect(wrapper.find('.data-notice').exists()).toBe(false)
|
||||||
|
expect(dispatches.some(event => event.key === 'dispatch-PC-TIMEZONE-MISSING')).toBe(false)
|
||||||
|
expect(listTripCalendar).toHaveBeenCalledTimes(1)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import '@/styles/adminWorkbench.css'
|
||||||
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
||||||
|
import '@/styles/adminDensity.css'
|
||||||
|
import { computed, defineAsyncComponent, onActivated, onDeactivated, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||||
|
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import AppointmentCalendarPanel from './AppointmentCalendarPanel.vue'
|
||||||
|
import { calendarRange, dateKey, type CalendarMode } from './calendar'
|
||||||
|
import { getCalendarSources, getWarehouseArrivals, saveWarehouseArrival, type CalendarSources, type ScheduleRange, type WarehouseArrival, type WarehouseArrivalChange } from './scheduleApi'
|
||||||
|
import { projectContainerSchedule, type ScheduleEvent } from './schedule'
|
||||||
|
import { listTripCalendar, projectTripCalendar } from './tripCalendar'
|
||||||
|
import { rejectedCommand, type LeaveGuard } from './useWorkDraft'
|
||||||
|
import type { PlanningSourceLookup } from './planningApi'
|
||||||
|
import type { WorkAction } from './workApi'
|
||||||
|
import { appointmentExternalTarget } from './workNavigation'
|
||||||
|
|
||||||
|
// 原业务页面仍能直接进入其预约表单;日历不再提供工作清单或流程编排入口。
|
||||||
|
const WorkDetailPanel = defineAsyncComponent(() => import('./WorkDetailPanel.vue'))
|
||||||
|
const SourceAppointmentPanel = defineAsyncComponent(() => import('./SourceAppointmentPanel.vue'))
|
||||||
|
const AppointmentCreatePanel = defineAsyncComponent(() => import('./AppointmentCreatePanel.vue'))
|
||||||
|
const route = useRoute(), router = useRouter(), access = useAccessPreviewStore()
|
||||||
|
const creating = ref(false)
|
||||||
|
const canCreate = computed(() => access.can('platform:overseas-warehouse:inbound-order:fba-appointment') && access.can('platform:overseas-warehouse:last-mile-order:list'))
|
||||||
|
function initialDate() {
|
||||||
|
const value = typeof route.query.date === 'string' ? route.query.date : ''
|
||||||
|
try { calendarRange(value, 'month'); return value } catch { return dateKey(new Date()) }
|
||||||
|
}
|
||||||
|
const selectedDate = ref(initialDate()), mode = ref<CalendarMode>(route.query.mode === 'week' ? 'week' : 'month')
|
||||||
|
const range = ref<ScheduleRange>(calendarRange(selectedDate.value, mode.value))
|
||||||
|
const sources = ref<CalendarSources | null>(null), warehouses = ref<WarehouseArrival[] | null>(null), trips = ref<ScheduleEvent[]>([])
|
||||||
|
const errors = ref<Array<{ title: string; details: ApiErrorDetails }>>([]), notices = ref<string[]>([]), loading = ref(false), active = ref(true)
|
||||||
|
const hasUnresolvedTrips = ref(false)
|
||||||
|
const incomplete = computed(() => !!errors.value.length || !!notices.value.length || hasUnresolvedTrips.value)
|
||||||
|
const events = computed(() => [...projectContainerSchedule(sources.value || { containers: [], emptyReturns: [], emptyReturnsReadable: true },
|
||||||
|
warehouses.value, range.value, access.can('platform:firstleg:terminal-drayage:list')), ...trips.value])
|
||||||
|
let generation = 0, controller: AbortController | null = null
|
||||||
|
async function load(next = range.value) {
|
||||||
|
range.value = next
|
||||||
|
const request = ++generation; controller?.abort(); controller = new AbortController()
|
||||||
|
const signal = controller.signal
|
||||||
|
loading.value = true; sources.value = null; warehouses.value = null; trips.value = []; errors.value = []; notices.value = []
|
||||||
|
hasUnresolvedTrips.value = false
|
||||||
|
const fail = (title: string, cause: unknown) => { if (request === generation) errors.value.push({ title, details: getApiErrorDetails(cause, title) }) }
|
||||||
|
await Promise.allSettled([
|
||||||
|
(async () => {
|
||||||
|
let result: CalendarSources | null = null
|
||||||
|
try {
|
||||||
|
result = await getCalendarSources(next, signal)
|
||||||
|
if (request !== generation) return
|
||||||
|
sources.value = result
|
||||||
|
if (!result.emptyReturnsReadable) notices.value.push('缺少还柜读取权限,当前未显示还柜安排。')
|
||||||
|
} catch (cause) { fail('到港与还柜读取失败', cause) }
|
||||||
|
if (request !== generation) return
|
||||||
|
if (!access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||||
|
notices.value.push('缺少尾程订单读取权限,当前未显示计划到仓。'); return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rows = await getWarehouseArrivals(next, [...new Set(result?.containers.map(row => row.containerBookingId) || [])], signal)
|
||||||
|
if (request === generation) warehouses.value = rows
|
||||||
|
} catch (cause) { fail('计划到仓读取失败', cause) }
|
||||||
|
})(),
|
||||||
|
(async () => {
|
||||||
|
if (!access.can('platform:overseas-warehouse:dispatch:list')) { notices.value.push('缺少排车读取权限,当前未显示排车安排。'); return }
|
||||||
|
try {
|
||||||
|
const result = projectTripCalendar(await listTripCalendar(next, signal), next)
|
||||||
|
if (request !== generation) return
|
||||||
|
trips.value = result.events
|
||||||
|
hasUnresolvedTrips.value = result.unresolved.length > 0
|
||||||
|
} catch (cause) { fail('排车读取失败', cause) }
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
if (request === generation) loading.value = false
|
||||||
|
}
|
||||||
|
function select(date: string, nextMode: CalendarMode) { selectedDate.value = date; mode.value = nextMode }
|
||||||
|
async function openSource(event: ScheduleEvent) {
|
||||||
|
if (!event.sourcePath || !await canLeave()) return
|
||||||
|
const context = { date: selectedDate.value, mode: mode.value }
|
||||||
|
await router.replace({ query: context })
|
||||||
|
await router.push(appointmentExternalTarget(event.sourcePath, context))
|
||||||
|
}
|
||||||
|
const editor = ref<ScheduleEvent | null>(null), plannedAt = ref(''), originalAt = ref(''), saving = ref(false)
|
||||||
|
const saveError = ref<ApiErrorDetails | null>(null), conflict = ref(false)
|
||||||
|
function edit(event: ScheduleEvent) {
|
||||||
|
if (!event.canEdit || !event.warehouse) return
|
||||||
|
editor.value = event; plannedAt.value = event.warehouse.plannedAt?.slice(0, 16) || ''; originalAt.value = plannedAt.value
|
||||||
|
saveError.value = null; conflict.value = false
|
||||||
|
}
|
||||||
|
async function save() {
|
||||||
|
const row = editor.value?.warehouse
|
||||||
|
if (!row || saving.value || !plannedAt.value) return
|
||||||
|
const request = { id: row.sourceContainerBookingId, body: { estimatedWarehouseArrivalAt: `${plannedAt.value}:00`, expectedVersion: row.version },
|
||||||
|
key: `calendar-arrival-${crypto.randomUUID()}` }
|
||||||
|
saving.value = true; saveError.value = null
|
||||||
|
try {
|
||||||
|
await saveWarehouseArrival(request.id, request.body, request.key)
|
||||||
|
editor.value = null
|
||||||
|
ElMessage.success('计划到仓已保存')
|
||||||
|
await load()
|
||||||
|
} catch (cause) {
|
||||||
|
saveError.value = getApiErrorDetails(cause, '计划到仓保存失败')
|
||||||
|
const status = cause instanceof ApiResponseError ? cause.status : isAxiosError(cause) ? cause.response?.status : undefined
|
||||||
|
conflict.value = status === 409 || /409$/.test(saveError.value.code || '')
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
async function rereadEditor() {
|
||||||
|
const row = editor.value?.warehouse
|
||||||
|
if (!row) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const rows = await getWarehouseArrivals(range.value, [row.sourceContainerBookingId])
|
||||||
|
const latest = rows.find(value => value.sourceContainerBookingId === row.sourceContainerBookingId)
|
||||||
|
if (!latest?.canEdit) { saveError.value = { message: '该柜已不能调整计划到仓,请关闭并查看最新货柜记录。' }; return }
|
||||||
|
editor.value = { ...editor.value!, warehouse: latest }; conflict.value = false; saveError.value = null
|
||||||
|
} catch (cause) { saveError.value = getApiErrorDetails(cause, '最新计划读取失败') }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
const guards = new Set<LeaveGuard>()
|
||||||
|
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||||
|
async function canLeave() {
|
||||||
|
if (saving.value) return false
|
||||||
|
for (const guard of guards) if (!await guard()) return false
|
||||||
|
if (editor.value && plannedAt.value !== originalAt.value) {
|
||||||
|
try { await ElMessageBox.confirm('计划到仓尚未保存,确定放弃本次修改?', '未保存修改', { type: 'warning', customClass: 'admin-density-confirm admin-workbench is-dialog' }) }
|
||||||
|
catch { return false }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
async function closeEditor(done: () => void) { if (await canLeave()) { done(); editor.value = null } }
|
||||||
|
async function openCreate() { if (canCreate.value && await canLeave()) creating.value = true }
|
||||||
|
async function closeCreate(done: () => void) { if (await canLeave()) { done(); creating.value = false } }
|
||||||
|
onBeforeRouteLeave(canLeave)
|
||||||
|
onBeforeRouteUpdate(canLeave)
|
||||||
|
|
||||||
|
// 仅承接原业务仍在使用的精确来源/事项深链,不恢复旧全局待办、分页和筛选协议。
|
||||||
|
const workKey = ref(''), origins = ref<string[]>([]), sourceLink = ref<PlanningSourceLookup | null>(null), initialAction = ref<WorkAction | null>(null)
|
||||||
|
watch(() => [route.query.workItem, route.query.sourceKey, route.query.orderId, route.query.customerId, route.query.destinationKey], () => {
|
||||||
|
workKey.value = typeof route.query.workItem === 'string' ? route.query.workItem : ''
|
||||||
|
const customerId = typeof route.query.customerId === 'string' ? route.query.customerId : ''
|
||||||
|
const destinationKey = typeof route.query.destinationKey === 'string' ? route.query.destinationKey : undefined
|
||||||
|
sourceLink.value = customerId && typeof route.query.sourceKey === 'string' ? { customerId, sourceKey: route.query.sourceKey, destinationKey }
|
||||||
|
: customerId && typeof route.query.orderId === 'string' ? { customerId, orderId: route.query.orderId, destinationKey } : null
|
||||||
|
origins.value = []
|
||||||
|
initialAction.value = null
|
||||||
|
}, { immediate: true })
|
||||||
|
async function openWork(key: string, action?: WorkAction) {
|
||||||
|
if (await canLeave()) { if (workKey.value) origins.value.push(workKey.value); workKey.value = key; initialAction.value = action || null }
|
||||||
|
}
|
||||||
|
async function backWork() { if (await canLeave()) { workKey.value = origins.value.pop() || ''; initialAction.value = null } }
|
||||||
|
async function closeBusiness(done: () => void) {
|
||||||
|
if (!await canLeave()) return
|
||||||
|
done(); workKey.value = ''; sourceLink.value = null; origins.value = []
|
||||||
|
await router.replace({ query: { date: selectedDate.value, mode: mode.value } })
|
||||||
|
}
|
||||||
|
async function external(path: string) {
|
||||||
|
if (!await canLeave()) return
|
||||||
|
await router.push(appointmentExternalTarget(path, { date: selectedDate.value, mode: mode.value, workItem: workKey.value || undefined }))
|
||||||
|
}
|
||||||
|
onMounted(() => { void load() })
|
||||||
|
onActivated(() => { const returning = !active.value; active.value = true; if (returning) void load() })
|
||||||
|
onDeactivated(() => { active.value = false; ++generation; controller?.abort() })
|
||||||
|
onBeforeUnmount(() => { ++generation; controller?.abort() })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AdminPage class="admin-density appointment-schedule admin-workbench" spacing="compact">
|
||||||
|
<LoadErrorState v-for="error in errors" :key="error.title" :title="error.title" :message="error.details.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error.details" /></template></LoadErrorState>
|
||||||
|
<p v-for="notice in notices" :key="notice" role="status" class="data-notice">{{ notice }}</p>
|
||||||
|
<AppointmentCalendarPanel :events="events" :loading="loading" :incomplete="incomplete" :initial-date="selectedDate" :initial-mode="mode" :can-create="canCreate" @create="openCreate" @range="load" @select="select" @open="openSource" @edit="edit" />
|
||||||
|
<AppDialog v-if="active && creating" :model-value="true" title="新增FBA送仓预约" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="1080px" :show-footer="false" :before-close="closeCreate" :close-on-click-modal="true" append-to-body fullscreenable>
|
||||||
|
<AppointmentCreatePanel :can-write="canCreate" :can-leave="canLeave" @created="load()" />
|
||||||
|
</AppDialog>
|
||||||
|
<AppDialog v-if="active" :model-value="!!editor" title="调整计划到仓" class="admin-density-dialog admin-workbench is-dialog" width="520px" fullscreenable :confirm-loading="saving" :confirm-disabled="!plannedAt" :before-close="closeEditor" confirm-text="保存" @confirm="save">
|
||||||
|
<div v-if="editor" class="density-dialog-sections">
|
||||||
|
<section class="density-dialog-section" data-tone="source" aria-label="到仓业务信息">
|
||||||
|
<table class="density-table"><tbody><tr><th scope="row">业务</th><td>{{ editor.title }}</td></tr></tbody></table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="density-dialog-section" data-tone="work" aria-label="调整到仓时间">
|
||||||
|
<el-form class="density-form" label-position="left" label-width="150px" :disabled="saving">
|
||||||
|
<table class="density-table density-table--editable"><tbody><tr><td><el-form-item label="计划到仓时间" required><template #label>计划到仓时间<InlineHelp title="到仓时间说明"><p>自动计划为到港日期加2个自然日;人工保存后优先使用人工时间。填写接仓地点当地时间。</p></InlineHelp></template><el-date-picker v-model="plannedAt" type="datetime" :disabled="saving" required aria-label="计划到仓时间" placeholder="接仓地点当地时间" format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm" clearable /></el-form-item></td></tr></tbody></table>
|
||||||
|
</el-form>
|
||||||
|
</section>
|
||||||
|
<LoadErrorState v-if="saveError" title="计划到仓未保存" :message="saveError.message" :loading="saving" @retry="conflict ? rereadEditor() : save()"><template #details><ApiErrorMeta :details="saveError" /></template></LoadErrorState>
|
||||||
|
<p v-if="conflict" role="status">重试将重新读取当前资格和版本并保留输入,核对后再次点击保存。</p>
|
||||||
|
</div>
|
||||||
|
</AppDialog>
|
||||||
|
<AppDialog v-if="active && (workKey || sourceLink)" :model-value="true" title="原业务预约办理" class="admin-density-dialog admin-density business-dialog admin-workbench is-dialog" width="min(1120px, calc(100vw - 48px))" fullscreenable append-to-body :show-footer="false" :before-close="closeBusiness" :close-on-click-modal="true">
|
||||||
|
<el-button v-if="origins.length" link @click="backWork">返回原事项</el-button>
|
||||||
|
<WorkDetailPanel v-if="workKey" :key="workKey" :item-key="workKey" :initial-action="initialAction" :can-leave="canLeave" @open="openWork" @external="external" @saved="load()" @updated="load()" @container="id => external(`/container-loading/overseas/${encodeURIComponent(id)}`)" />
|
||||||
|
<SourceAppointmentPanel v-else-if="sourceLink" :key="JSON.stringify(sourceLink)" :source="sourceLink" :can-leave="canLeave" @updated="load()" />
|
||||||
|
</AppDialog>
|
||||||
|
</AdminPage>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.data-notice { margin: 0; font-size: var(--admin-font-caption); color: var(--el-text-color-secondary); }
|
||||||
|
:global(.business-dialog.el-dialog:not(.is-fullscreen)) { max-height: 88dvh; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import AppointmentCancellationDialog from './AppointmentCancellationDialog.vue'
|
||||||
|
import AppointmentCancellationHistory from './AppointmentCancellationHistory.vue'
|
||||||
|
import AppointmentCancellationRecords from './AppointmentCancellationRecords.vue'
|
||||||
|
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
|
||||||
|
const access = vi.hoisted(() => ({ canCancel: true }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({useAccessPreviewStore: () => ({can: () => access.canCancel})}))
|
||||||
|
vi.mock('vue-router', () => ({onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn()}))
|
||||||
|
vi.mock('./api', () => ({deleteAppointment: vi.fn(),listAppointmentCancellations: vi.fn(),listAppointmentCancellationRecords: vi.fn()}))
|
||||||
|
const record = {appointmentNo:'AP-CANCEL',version:1,cancelReason:null,cancelledAt:'2026-10-11T01:00:00',snapshot:{fbaWarehouseCode:'ABE3',appointmentStatus:'CANCELLED',appointmentRemark:'原备注保留',plannedDate:null,plannedAt:null,plannedEndAt:null,confirmationNo:'EXT-1',appointmentAt:'2026-10-15T09:00:00',appointmentEndAt:null,timezone:'America/New_York',selectedCount:1}}
|
||||||
|
function dialog() {
|
||||||
|
return mount(AppointmentCancellationDialog,{props:{appointmentNo:'AP-CANCEL',version:0,selectedCount:1},global:{plugins:[ElementPlus],stubs:{AppDialog:{name:'AppDialog',props:['beforeClose','showConfirm'],template:'<section><slot/><button v-if="showConfirm" data-save @click="$emit(\'confirm\')">取消</button></section>'}}}})
|
||||||
|
}
|
||||||
|
describe('appointment cancellation',()=>{
|
||||||
|
beforeEach(()=>{vi.resetAllMocks();access.canCancel=true})
|
||||||
|
it('requires a reason and retries the same reason and version after an unknown result',async()=>{
|
||||||
|
vi.mocked(deleteAppointment).mockRejectedValueOnce(new Error('lost')).mockResolvedValueOnce(undefined)
|
||||||
|
const wrapper=dialog();await flushPromises()
|
||||||
|
await wrapper.get('[data-save]').trigger('click');expect(deleteAppointment).not.toHaveBeenCalled()
|
||||||
|
await wrapper.get('textarea').setValue(' 仓库取消时段 ')
|
||||||
|
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||||
|
expect(deleteAppointment).toHaveBeenCalledWith('AP-CANCEL',0,'仓库取消时段')
|
||||||
|
const done=vi.fn();wrapper.getComponent({name:'AppDialog'}).props('beforeClose')(done);expect(done).not.toHaveBeenCalled()
|
||||||
|
expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
|
||||||
|
await wrapper.get('[data-save]').trigger('click');await flushPromises()
|
||||||
|
expect(deleteAppointment).toHaveBeenNthCalledWith(2,'AP-CANCEL',0,'仓库取消时段')
|
||||||
|
expect(wrapper.emitted('cancelled')).toHaveLength(1);wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not expose or invoke cancellation without its independent permission',async()=>{
|
||||||
|
access.canCancel=false;const wrapper=dialog();await flushPromises()
|
||||||
|
expect(wrapper.find('[data-save]').exists()).toBe(false)
|
||||||
|
wrapper.getComponent({name:'AppDialog'}).vm.$emit('confirm');await flushPromises()
|
||||||
|
expect(deleteAppointment).not.toHaveBeenCalled();wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('reads history once per page and keeps missing historical reason explicit',async()=>{
|
||||||
|
vi.mocked(listAppointmentCancellations).mockResolvedValue({records:[record],total:11})
|
||||||
|
const wrapper=mount(AppointmentCancellationHistory,{props:{appointmentNo:'AP-CANCEL'},global:{plugins:[ElementPlus]}});await flushPromises()
|
||||||
|
expect(listAppointmentCancellations).toHaveBeenCalledExactlyOnceWith('AP-CANCEL',1,10)
|
||||||
|
expect(wrapper.text()).toContain('历史原因未记录');expect(wrapper.text()).toContain('原备注保留')
|
||||||
|
wrapper.getComponent({name:'DataTable'}).vm.$emit('update:page',2);await flushPromises()
|
||||||
|
expect(listAppointmentCancellations).toHaveBeenLastCalledWith('AP-CANCEL',2,10);wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('discovers persisted cancellations with one server page and opens by business number',async()=>{
|
||||||
|
vi.mocked(listAppointmentCancellationRecords).mockResolvedValue({records:[{appointmentNo:'AP-CANCEL',version:1,cancelReason:'实际原因',cancelledAt:record.cancelledAt,fbaWarehouseCode:'ABE3',selectedCount:1}],total:1})
|
||||||
|
const wrapper=mount(AppointmentCancellationRecords,{global:{plugins:[ElementPlus]}});await flushPromises()
|
||||||
|
expect(listAppointmentCancellationRecords).toHaveBeenCalledExactlyOnceWith(1,10)
|
||||||
|
expect(listAppointmentCancellations).not.toHaveBeenCalled()
|
||||||
|
await wrapper.get('.el-table__body-wrapper .el-table__row').findAll('button').find(b=>b.text()==='查看')!.trigger('click')
|
||||||
|
expect(wrapper.emitted('view')).toEqual([['AP-CANCEL']]);wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import { http } from '@/shared/api/http'
|
||||||
|
import { deleteAppointment, listAppointmentCancellations, listAppointmentCancellationRecords } from './api'
|
||||||
|
vi.mock('@/shared/api/http',()=>({http:{get:vi.fn(),delete:vi.fn()},unwrapApiResponse:(result:{data:unknown})=>result.data}))
|
||||||
|
beforeEach(()=>vi.resetAllMocks())
|
||||||
|
it('sends the required cancellation body without the old reasonless query shape',async()=>{
|
||||||
|
vi.mocked(http.delete).mockResolvedValue({data:undefined})
|
||||||
|
await deleteAppointment('AP-TEST',3,'仓库取消时段')
|
||||||
|
expect(http.delete).toHaveBeenCalledExactlyOnceWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP-TEST',{data:{version:3,cancelReason:'仓库取消时段'}})
|
||||||
|
})
|
||||||
|
it('uses paged cancellation reads and the original business number for detail history',async()=>{
|
||||||
|
vi.mocked(http.get).mockResolvedValue({data:{records:[],total:'12'}})
|
||||||
|
expect((await listAppointmentCancellationRecords(2,10)).total).toBe(12)
|
||||||
|
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/cancellations',{params:{page:2,size:10}})
|
||||||
|
await listAppointmentCancellations('AP/TEST',1,10)
|
||||||
|
expect(http.get).toHaveBeenLastCalledWith('/platform/v1/last-leg/inbound-plans/fba-appointments/AP%2FTEST/cancellations',{params:{page:1,size:10}})
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { deleteAppointment } from './api'
|
||||||
|
const props = defineProps<{ appointmentNo: string; version: number; selectedCount: number }>()
|
||||||
|
const emit = defineEmits<{ close: []; cancelled: [] }>()
|
||||||
|
const access = useAccessPreviewStore()
|
||||||
|
const reason = ref(''), saving = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
|
const pending = ref<{number: string; version: number; reason: string} | null>(null)
|
||||||
|
function canLeave() {
|
||||||
|
if (saving.value || pending.value) { ElMessage.warning('请先核实本次取消结果,再关闭或切换'); return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||||
|
function close() { if (canLeave()) emit('close') }
|
||||||
|
const register = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||||
|
const unregister = register?.(canLeave)
|
||||||
|
onBeforeUnmount(() => unregister?.())
|
||||||
|
onBeforeRouteLeave(canLeave)
|
||||||
|
onBeforeRouteUpdate(canLeave)
|
||||||
|
async function save() {
|
||||||
|
if (!access.can('platform:appointment:delete') || saving.value) return
|
||||||
|
if (!pending.value && !reason.value.trim()) { ElMessage.warning('请填写实际取消原因'); return }
|
||||||
|
pending.value ||= {number: props.appointmentNo, version: props.version, reason: reason.value.trim()}
|
||||||
|
saving.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
await deleteAppointment(pending.value.number, pending.value.version, pending.value.reason)
|
||||||
|
pending.value = null; emit('cancelled')
|
||||||
|
} catch (cause) {
|
||||||
|
error.value = getApiErrorDetails(cause, '取消结果待核实,请重试本次取消')
|
||||||
|
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(error.value.code || '')) pending.value = null
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AppDialog :model-value="true" title="取消预约" width="min(600px, calc(100vw - 48px))" class="admin-density-dialog" confirm-type="danger" :confirm-text="pending ? '重试本次取消' : '确认取消预约'" :show-confirm="access.can('platform:appointment:delete')" :confirm-loading="saving" :before-close="beforeClose" @update:model-value="close" @confirm="save">
|
||||||
|
<p>{{ appointmentNo }} · {{ selectedCount }} 票货物</p>
|
||||||
|
<p>取消后释放货物占用,保留原备注、时间及取消历史;不会回退库存或取消车次。</p>
|
||||||
|
<LoadErrorState v-if="error" title="取消未完成" :message="error.message" :show-retry="false" />
|
||||||
|
<el-form label-position="top" :disabled="saving || !!pending">
|
||||||
|
<el-form-item label="取消原因" required><el-input v-model="reason" type="textarea" maxlength="1000" show-word-limit /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, onBeforeUnmount } from 'vue'
|
||||||
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import { listAppointmentCancellations, type AppointmentCancellation } from './api'
|
||||||
|
const props = defineProps<{ appointmentNo: string }>()
|
||||||
|
const page = ref(1), size = ref(10), total = ref(0), rows = ref<AppointmentCancellation[]>([])
|
||||||
|
const loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
|
let request = 0
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:170,singleLine:false},
|
||||||
|
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:220,singleLine:false},
|
||||||
|
{key:'snapshot',label:'取消时记录',slotName:'snapshot',minWidth:260,singleLine:false},
|
||||||
|
]
|
||||||
|
async function load() {
|
||||||
|
const current = ++request; loading.value = true; error.value = null
|
||||||
|
try { const result = await listAppointmentCancellations(props.appointmentNo,page.value,size.value); if(current===request) {rows.value=result.records;total.value=result.total} }
|
||||||
|
catch(cause) { if(current===request) error.value=getApiErrorDetails(cause,'取消历史加载失败') }
|
||||||
|
finally { if(current===request) loading.value=false }
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
onBeforeUnmount(()=>++request)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<LoadErrorState v-if="error" title="取消历史加载失败" :message="error.message" @retry="load" />
|
||||||
|
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" row-key="version" @update:page="load" @update:page-size="page=1;load()">
|
||||||
|
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
|
||||||
|
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
|
||||||
|
<template #snapshot="{row}"><details><summary>{{ row.snapshot.fbaWarehouseCode }} · {{ row.snapshot.selectedCount }} 票 · 查看取消时记录</summary><p>原业务备注:{{ row.snapshot.appointmentRemark || '—' }}</p><p>原计划:{{ row.snapshot.plannedAt || row.snapshot.plannedDate || '待定' }}<span v-if="row.snapshot.plannedEndAt"> — {{ row.snapshot.plannedEndAt }}</span></p><p>原确认:{{ row.snapshot.appointmentAt || '未确认' }}<span v-if="row.snapshot.appointmentEndAt"> — {{ row.snapshot.appointmentEndAt }}</span> · {{ row.snapshot.timezone || '时区待核实' }}</p><p>外部确认号:{{ row.snapshot.confirmationNo || '—' }}</p></details></template>
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, onBeforeUnmount } from 'vue'
|
||||||
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import { listAppointmentCancellationRecords, type AppointmentCancellationSummary } from './api'
|
||||||
|
const emit = defineEmits<{ view: [appointmentNo: string] }>()
|
||||||
|
const page=ref(1),size=ref(10),total=ref(0),rows=ref<AppointmentCancellationSummary[]>([])
|
||||||
|
const loading=ref(false),error=ref<ApiErrorDetails|null>(null)
|
||||||
|
let request=0
|
||||||
|
const columns: DataTableColumn[]=[
|
||||||
|
{key:'actions',label:'操作',slotName:'actions',width:90,fixed:'left'},
|
||||||
|
{prop:'appointmentNo',label:'预约单号',minWidth:220,singleLine:false},
|
||||||
|
{prop:'fbaWarehouseCode',label:'目的仓',width:100},
|
||||||
|
{prop:'selectedCount',label:'票数',width:80},
|
||||||
|
{prop:'cancelledAt',label:'取消时间(UTC)',slotName:'time',minWidth:180,singleLine:false},
|
||||||
|
{prop:'cancelReason',label:'取消原因',slotName:'reason',minWidth:240,singleLine:false},
|
||||||
|
]
|
||||||
|
async function load() {
|
||||||
|
const current=++request;loading.value=true;error.value=null
|
||||||
|
try {const result=await listAppointmentCancellationRecords(page.value,size.value);if(current===request){rows.value=result.records;total.value=result.total}}
|
||||||
|
catch(cause){if(current===request)error.value=getApiErrorDetails(cause,'取消记录加载失败')}
|
||||||
|
finally{if(current===request)loading.value=false}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
onBeforeUnmount(()=>++request)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<LoadErrorState v-if="error" title="取消记录加载失败" :message="error.message" @retry="load" />
|
||||||
|
<DataTable v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :total="total" :loading="loading" :results-visible="!error" :row-key="(row: object) => `${(row as AppointmentCancellationSummary).appointmentNo}:${(row as AppointmentCancellationSummary).version}`" @update:page="load" @update:page-size="page=1;load()">
|
||||||
|
<template #actions="{row}"><el-button @click="emit('view',row.appointmentNo)">查看</el-button></template>
|
||||||
|
<template #time="{row}">{{ row.cancelledAt?.replace('T',' ') || '—' }}</template>
|
||||||
|
<template #reason="{row}">{{ row.cancelReason || '历史原因未记录' }}</template>
|
||||||
|
</DataTable>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import AppointmentCargoPanel from './AppointmentCargoPanel.vue'
|
||||||
|
import { listPlanningCargo } from './planningApi'
|
||||||
|
|
||||||
|
vi.mock('./planningApi', () => ({ listPlanningCargo: vi.fn(), resolvePlanningSource: vi.fn() }))
|
||||||
|
vi.mock('vue-router', () => ({ onBeforeRouteLeave: vi.fn(), onBeforeRouteUpdate: vi.fn(), useRouter: () => ({ push: vi.fn() }) }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||||
|
const empty = { records: [], page: 1, size: 20, total: 0, pages: 0 }
|
||||||
|
function render(attach = false) {
|
||||||
|
return mount(AppointmentCargoPanel, { attachTo: attach ? document.body : undefined, global: { stubs: {
|
||||||
|
DataTable: { name: 'DataTable', props: ['data', 'total', 'resultsVisible'], emits: ['update:page'], template: '<div><slot name="filters"/><slot name="notices"/><button data-next @click="$emit(\'update:page\',2)">下一页</button></div>' },
|
||||||
|
QueryToolbar: { name: 'QueryToolbar', emits: ['search'], template: '<button data-search @click="$emit(\'search\')">查询</button>' },
|
||||||
|
LoadErrorState: true, ApiErrorMeta: true, RowActions: true, AppointmentExternalCandidates: { name: 'AppointmentExternalCandidates', template: '<section class="integrated-query-group"><slot name="query-tabs"/></section>' },
|
||||||
|
} } })
|
||||||
|
}
|
||||||
|
describe('appointment planning cargo query', () => {
|
||||||
|
beforeEach(() => { vi.resetAllMocks(); vi.mocked(listPlanningCargo).mockResolvedValue(empty) })
|
||||||
|
it('switches cargo tabs by click and keyboard without reloading first-leg cargo', async () => {
|
||||||
|
const wrapper = render(true); await flushPromises()
|
||||||
|
const tabs = () => wrapper.findAll('[role="tab"]')
|
||||||
|
expect(tabs().map(tab => tab.text())).toEqual(['头程货物', '外接货物'])
|
||||||
|
expect(wrapper.find('.integrated-query-group [role="tablist"]').exists()).toBe(true)
|
||||||
|
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||||
|
await tabs()[1]!.trigger('click')
|
||||||
|
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||||
|
expect(wrapper.findComponent({ name: 'AppointmentExternalCandidates' }).exists()).toBe(true)
|
||||||
|
await tabs()[1]!.trigger('keydown', { key: 'ArrowRight' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||||
|
expect(document.activeElement).toBe(tabs()[0]!.element)
|
||||||
|
expect(tabs()[1]!.attributes('tabindex')).toBe('-1')
|
||||||
|
await tabs()[0]!.trigger('keydown', { key: 'End' })
|
||||||
|
await flushPromises()
|
||||||
|
expect(tabs()[1]!.attributes('aria-selected')).toBe('true')
|
||||||
|
expect(document.activeElement).toBe(tabs()[1]!.element)
|
||||||
|
await tabs()[1]!.trigger('keydown', { key: 'Home' })
|
||||||
|
expect(tabs()[0]!.attributes('aria-selected')).toBe('true')
|
||||||
|
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('uses one server-page request per entry, page change and reset', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(listPlanningCargo).toHaveBeenCalledTimes(1)
|
||||||
|
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
|
||||||
|
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 2, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||||
|
await wrapper.get('[data-search]').trigger('click'); await flushPromises()
|
||||||
|
expect(listPlanningCargo).toHaveBeenCalledTimes(3)
|
||||||
|
expect(listPlanningCargo).toHaveBeenLastCalledWith({ page: 1, size: 20, keyword: undefined }, expect.any(AbortSignal))
|
||||||
|
})
|
||||||
|
it('shows a source failure instead of an authoritative empty list', async () => {
|
||||||
|
vi.mocked(listPlanningCargo).mockRejectedValueOnce(new Error('目录不可用'))
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.findComponent({ name: 'LoadErrorState' }).exists()).toBe(true)
|
||||||
|
expect(wrapper.findComponent({ name: 'DataTable' }).props('resultsVisible')).toBe(false)
|
||||||
|
})
|
||||||
|
it('discards a late previous page and uses the new server total', async () => {
|
||||||
|
let resolveOld!: (value: typeof empty) => void
|
||||||
|
vi.mocked(listPlanningCargo).mockImplementationOnce(() => new Promise(resolve => { resolveOld = resolve }))
|
||||||
|
const wrapper = render()
|
||||||
|
vi.mocked(listPlanningCargo).mockResolvedValueOnce({ ...empty, page: 2, total: 21, pages: 2 })
|
||||||
|
await wrapper.get('[data-next]').trigger('click'); await flushPromises()
|
||||||
|
resolveOld({ ...empty, total: 100, pages: 5 }); await flushPromises()
|
||||||
|
expect(wrapper.findComponent({ name: 'DataTable' }).props('total')).toBe(21)
|
||||||
|
expect(vi.mocked(listPlanningCargo).mock.calls[0]![1]!.aborted).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, computed, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { FbaAppointmentFields, createAppointmentForm, appointmentPayload, appointmentFormError, overseasWarehousePermissions } from '@/modules/overseas-warehouse'
|
||||||
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||||
|
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRouter } from 'vue-router'
|
||||||
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||||
|
import QueryToolbar, { type QueryModel, type QueryField } from '@/shared/components/QueryToolbar.vue'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import RowActions from '@/shared/components/RowActions.vue'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import { listPlanningCargo, resolvePlanningSource, previewPlanningBatch, createSourceAppointment, type SourceAppointmentPayload, type PlanningBatchPreview, type ResolvedPlanningSource, type PlanningCargo, type PlanningSource } from './planningApi'
|
||||||
|
import AppointmentFollowupDialog from './AppointmentFollowupDialog.vue'
|
||||||
|
import AppointmentExternalCandidates from './AppointmentExternalCandidates.vue'
|
||||||
|
import AppointmentCargoTabs from './AppointmentCargoTabs.vue'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const access = useAccessPreviewStore()
|
||||||
|
const props = defineProps<{ externalSource?: PlanningSource | null }>()
|
||||||
|
const emit = defineEmits<{ created: []; sourceAccepted: [] }>()
|
||||||
|
const arrangement = reactive(createAppointmentForm())
|
||||||
|
const creating = ref(false), createError = ref<ApiErrorDetails | null>(null)
|
||||||
|
const pendingCreate = ref<SourceAppointmentPayload | null>(null)
|
||||||
|
const following = ref<ResolvedPlanningSource | null>(null)
|
||||||
|
const candidateMode = ref<'firstleg' | 'external'>('firstleg')
|
||||||
|
const externalRevision = ref(0)
|
||||||
|
const followupError = ref<ApiErrorDetails | null>(null)
|
||||||
|
type Selection = Pick<PlanningSource, 'sourceKey' | 'customerId' | 'originWarehouseId' | 'destinationWarehouseCode' | 'inboundCode'> & { bookingNo?: string | null }
|
||||||
|
const selected = ref<Record<string, Selection>>({})
|
||||||
|
const selectedRows = computed(() => Object.values(selected.value))
|
||||||
|
const externalRows = computed(() => selectedRows.value.filter(row => row.sourceKey.startsWith('BOOKING:')))
|
||||||
|
const batchPreview = ref<PlanningBatchPreview | null>(null)
|
||||||
|
const batchLoading = ref(false), batchError = ref<ApiErrorDetails | null>(null)
|
||||||
|
let batchRequest = 0
|
||||||
|
function addSource(source: PlanningSource) {
|
||||||
|
if (!access.can(overseasWarehousePermissions.inboundOrderAppointment) || batchLoading.value || creating.value) return false
|
||||||
|
if (!source.canFollowup) { ElMessage.warning('当前来源尚不能安排预约'); return false }
|
||||||
|
const anchor = selectedRows.value[0]
|
||||||
|
if (anchor && (anchor.originWarehouseId !== source.originWarehouseId || anchor.destinationWarehouseCode !== source.destinationWarehouseCode)) {
|
||||||
|
ElMessage.warning('请先完成或清空当前合批选择;不同起运仓或目的仓不能混入'); return false
|
||||||
|
}
|
||||||
|
if (!selected.value[source.sourceKey] && selectedRows.value.length >= 100) { ElMessage.warning('每批最多选择100票'); return false }
|
||||||
|
selected.value[source.sourceKey] = source; batchError.value = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function acceptExternalSource() {
|
||||||
|
if (props.externalSource && addSource(props.externalSource)) emit('sourceAccepted')
|
||||||
|
}
|
||||||
|
function arrangeFollowing() {
|
||||||
|
if (following.value && !following.value.coverage.appointments.length && addSource(following.value.source)) following.value = null
|
||||||
|
}
|
||||||
|
function selectable(row: PlanningCargo) {
|
||||||
|
const anchor = selectedRows.value[0]
|
||||||
|
return !row.detained && !row.mixedDestinations && !row.hasActiveAppointment && (!anchor || (anchor.originWarehouseId === row.originWarehouseId && anchor.destinationWarehouseCode === row.destinationWarehouseCode))
|
||||||
|
}
|
||||||
|
function toggle(row: PlanningCargo, checked: unknown) {
|
||||||
|
batchError.value = null
|
||||||
|
if (checked && selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row
|
||||||
|
else delete selected.value[row.sourceKey]
|
||||||
|
}
|
||||||
|
function selectPage() { for (const row of rows.value) if (selectable(row) && selectedRows.value.length < 100) selected.value[row.sourceKey] = row }
|
||||||
|
async function previewBatch() {
|
||||||
|
if (!selectedRows.value.length || batchLoading.value || creating.value || pendingCreate.value) return
|
||||||
|
const current = ++batchRequest
|
||||||
|
batchLoading.value = true; batchError.value = null
|
||||||
|
try {
|
||||||
|
const result = await previewPlanningBatch(selectedRows.value.map(row => ({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })))
|
||||||
|
if (current === batchRequest) { batchPreview.value = result; Object.assign(arrangement, createAppointmentForm()); pendingCreate.value = null; createError.value = null }
|
||||||
|
} catch (cause) { if (current === batchRequest) batchError.value = getApiErrorDetails(cause, '所选货物暂不能合批') }
|
||||||
|
finally { if (current === batchRequest) batchLoading.value = false }
|
||||||
|
}
|
||||||
|
async function saveArrangement() {
|
||||||
|
if (!batchPreview.value || creating.value || !access.can(overseasWarehousePermissions.inboundOrderAppointment)) return
|
||||||
|
const error = appointmentFormError(arrangement)
|
||||||
|
if (!pendingCreate.value && error) { ElMessage.warning(error); return }
|
||||||
|
pendingCreate.value ||= { originWarehouseId: batchPreview.value.group.originWarehouseId, fbaWarehouseCode: batchPreview.value.destinationWarehouseCode, sources: batchPreview.value.sources.map(row => ({sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: row.destinationKey})), details: appointmentPayload(arrangement), idempotencyKey: crypto.randomUUID() }
|
||||||
|
creating.value = true; createError.value = null
|
||||||
|
try {
|
||||||
|
const result = await createSourceAppointment(pendingCreate.value)
|
||||||
|
pendingCreate.value = null; batchPreview.value = null; selected.value = {}
|
||||||
|
ElMessage.success(`预约安排 ${result.appointmentNo} 已保存`); ++externalRevision.value; emit('created'); await load()
|
||||||
|
} catch (cause) {
|
||||||
|
createError.value = getApiErrorDetails(cause, '保存失败,请重新操作')
|
||||||
|
if (['400','403','404','409','A0400','A0403','A0404','A0409'].includes(createError.value.code || '')) pendingCreate.value = null
|
||||||
|
} finally { pendingCreate.value = null; creating.value = false }
|
||||||
|
}
|
||||||
|
let followupRequest = 0
|
||||||
|
const query = ref<QueryModel>({ keyword: '' })
|
||||||
|
const fields: QueryField[] = [{ key: 'keyword', type: 'input', label: '货物', placeholder: '入仓码、柜号、客户名称', width: 300 }]
|
||||||
|
const page = ref(1), size = ref(20), total = ref(0)
|
||||||
|
const rows = ref<PlanningCargo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<ApiErrorDetails | null>(null)
|
||||||
|
let request = 0
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: 'actions', label: '操作', slotName: 'actions', width: 150, fixed: 'left' },
|
||||||
|
{ key: 'select', label: '选择', slotName: 'select', width: 65 },
|
||||||
|
{ prop: 'inboundCode', label: '入仓码', minWidth: 180 },
|
||||||
|
{ prop: 'customerName', label: '客户', minWidth: 150 },
|
||||||
|
{ prop: 'containerNo', label: '柜号', minWidth: 150 },
|
||||||
|
{ prop: 'originWarehouseName', label: '接仓点', minWidth: 140 },
|
||||||
|
{ prop: 'destinationWarehouseCode', label: '目的FBA仓', width: 110 },
|
||||||
|
{ prop: 'packages', label: '预报箱数', width: 100 },
|
||||||
|
{ prop: 'receivedPackages', label: '实收箱数', width: 100 },
|
||||||
|
{ prop: 'estimatedArrivalAt', label: '来源预计到港', minWidth: 170 },
|
||||||
|
{ prop: 'estimatedWarehouseAt', label: '已保存预计到仓', minWidth: 180, slotName: 'warehouseEta', singleLine: false },
|
||||||
|
{ key: 'progress', label: '当前事项', slotName: 'progress', minWidth: 170 },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function load(reset = false) {
|
||||||
|
if (reset) page.value = 1
|
||||||
|
const current = ++request
|
||||||
|
controller?.abort(); controller = new AbortController()
|
||||||
|
loading.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
const result = await listPlanningCargo({ page: page.value, size: size.value, keyword: String(query.value.keyword || '').trim() || undefined }, controller.signal)
|
||||||
|
if (request !== current) return
|
||||||
|
rows.value = result.records; total.value = result.total
|
||||||
|
} catch (cause) {
|
||||||
|
if (request === current) { error.value = getApiErrorDetails(cause, '已开船货物加载失败'); rows.value = []; total.value = 0 }
|
||||||
|
} finally { if (request === current) loading.value = false }
|
||||||
|
}
|
||||||
|
function source(row: PlanningCargo) {
|
||||||
|
return router.push({ name: 'overseas-last-mile-order-detail', params: { orderId: row.orderId }, query: { customerId: row.customerId } })
|
||||||
|
}
|
||||||
|
async function openFollowup(row: Pick<PlanningCargo, 'sourceKey' | 'customerId' | 'destinationWarehouseCode'>) {
|
||||||
|
const request = ++followupRequest
|
||||||
|
followupError.value = null
|
||||||
|
try {
|
||||||
|
const result = await resolvePlanningSource({ sourceKey: row.sourceKey, customerId: row.customerId, destinationKey: `FBA:${row.destinationWarehouseCode}` })
|
||||||
|
if (request === followupRequest) following.value = result
|
||||||
|
} catch (cause) { if (request === followupRequest) followupError.value = getApiErrorDetails(cause, '来源核对失败') }
|
||||||
|
}
|
||||||
|
function canLeave() {
|
||||||
|
if (creating.value || pendingCreate.value) { ElMessage.warning('请先核实本次保存结果,再关闭或切换'); return false }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
function closeBatch() { if (canLeave()) batchPreview.value = null }
|
||||||
|
function beforeClose(done: () => void) { if (canLeave()) done() }
|
||||||
|
const registerLeaveGuard = inject<((guard: () => boolean) => () => void) | undefined>('appointment-register-leave-guard', undefined)
|
||||||
|
const unregisterLeaveGuard = registerLeaveGuard?.(canLeave)
|
||||||
|
onBeforeUnmount(() => unregisterLeaveGuard?.())
|
||||||
|
onBeforeRouteLeave(canLeave)
|
||||||
|
onBeforeRouteUpdate(canLeave)
|
||||||
|
onMounted(() => load())
|
||||||
|
onBeforeUnmount(() => { ++request; ++followupRequest; ++batchRequest; controller?.abort() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppointmentExternalCandidates v-if="candidateMode === 'external'" :key="externalRevision" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :selected-keys="Object.keys(selected)" :busy="batchLoading || creating" @select="addSource" @followup="openFollowup($event.source)">
|
||||||
|
<template #query-tabs><AppointmentCargoTabs v-model="candidateMode" /></template>
|
||||||
|
<template #toolbar-actions>
|
||||||
|
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||||
|
</template>
|
||||||
|
<template #notices>
|
||||||
|
<details v-if="selectedRows.length" class="external-selection"><summary>已选 {{ selectedRows.length }} 票(切换货源保留选择)</summary>
|
||||||
|
<div class="external-selection-list"><div v-for="row in selectedRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
|
||||||
|
</details>
|
||||||
|
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false" />
|
||||||
|
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
|
||||||
|
</template>
|
||||||
|
</AppointmentExternalCandidates>
|
||||||
|
<DataTable v-show="candidateMode === 'firstleg'" v-model:page="page" v-model:page-size="size" appearance="integrated" :columns="columns" :data="rows" :total="total" :loading="loading" :results-visible="!error" row-key="sourceKey" @update:page="load()" @update:page-size="load(true)">
|
||||||
|
<template #toolbar-start>已选 {{ selectedRows.length }} 票,可跨客户选择同一起运仓、同一目的仓货物</template>
|
||||||
|
<template #toolbar-actions>
|
||||||
|
<el-button :disabled="loading || batchLoading" @click="selectPage">选择本页可合批货物</el-button>
|
||||||
|
<el-button :disabled="!selectedRows.length || batchLoading" @click="selected = {}; batchError = null">清空选择</el-button>
|
||||||
|
<el-button type="primary" :disabled="!selectedRows.length" :loading="batchLoading" @click="previewBatch">合批核对({{ selectedRows.length }}/100)</el-button>
|
||||||
|
</template>
|
||||||
|
<template #filters>
|
||||||
|
<section class="integrated-query-group" aria-label="头程货物类型与筛选">
|
||||||
|
<AppointmentCargoTabs v-if="candidateMode === 'firstleg'" v-model="candidateMode" />
|
||||||
|
<QueryToolbar v-model="query" ariaLabel="在途货物查询" :fields="fields" :loading="loading" @search="load(true)" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<template #notices>
|
||||||
|
<div v-if="externalSource" class="cargo-note">
|
||||||
|
待加入来源:{{ externalSource.bookingNo || externalSource.inboundCode || '尚未关联业务编号' }} · {{ externalSource.originWarehouseName }} → {{ externalSource.destinationWarehouseCode }}
|
||||||
|
<el-button v-if="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :disabled="batchLoading || creating" @click="acceptExternalSource">加入本次选择</el-button>
|
||||||
|
</div>
|
||||||
|
<details v-if="externalRows.length" class="external-selection">
|
||||||
|
<summary>已选外接货 {{ externalRows.length }} 票(可与同仓头程货合批)</summary>
|
||||||
|
<div class="external-selection-list"><div v-for="row in externalRows" :key="row.sourceKey">{{ row.bookingNo || row.inboundCode || '尚未关联业务编号' }}<el-button :disabled="batchLoading || creating" @click="delete selected[row.sourceKey]">移除选择</el-button></div></div>
|
||||||
|
</details>
|
||||||
|
<LoadErrorState v-if="batchError" title="所选货物暂不能合批" :message="batchError.message" :show-retry="false"><template #details><ApiErrorMeta :details="batchError" /></template></LoadErrorState>
|
||||||
|
<LoadErrorState v-if="followupError" title="来源核对失败" :message="followupError.message" :show-retry="false" />
|
||||||
|
<LoadErrorState v-if="error" title="已开船货物加载失败" :message="error.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
|
<p v-else class="cargo-note">来源预计时间供筹划核对,确认预约前请核对最新船期与货物备妥情况。</p>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }"><RowActions :actions="[{ key: 'followup', label: '跟进' }, { key: 'source', label: '查看来源' }]" text-actions @action="key => key === 'followup' ? openFollowup(row as PlanningCargo) : source(row as PlanningCargo)" /></template>
|
||||||
|
<template #select="{ row }"><el-checkbox :model-value="!!selected[row.sourceKey]" :aria-label="`选择 ${row.inboundCode || '未关联入仓码'}`" :disabled="batchLoading || (!selected[row.sourceKey] && (!selectable(row as PlanningCargo) || selectedRows.length >= 100))" @change="(value: boolean | string | number) => toggle(row as PlanningCargo, value)" /></template>
|
||||||
|
<template #warehouseEta="{ row }"><span>{{ row.estimatedWarehouseAt?.replace('T', ' ') || '—' }}</span><div v-if="row.estimatedWarehouseAt" class="cargo-note">来源时区未明确</div></template>
|
||||||
|
<template #progress="{ row }">
|
||||||
|
<span v-if="row.detained">暂扣,待处理</span>
|
||||||
|
<span v-else-if="row.mixedDestinations">含多个目的地,待核对</span>
|
||||||
|
<span v-else-if="row.hasActiveAppointment">已有预约</span>
|
||||||
|
<span v-else>{{ row.hasInboundPlan ? '已建入仓计划,待安排' : '尚未建入仓计划' }}</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<AppointmentFollowupDialog v-if="following" :source="following.source" :coverage="following.coverage" :can-write="access.can('platform:appointment:followup')" :can-arrange="access.can(overseasWarehousePermissions.inboundOrderAppointment)" @arrange="arrangeFollowing" @close="following = null" />
|
||||||
|
<AppDialog v-if="batchPreview" :model-value="true" class="admin-density-dialog" title="合批预约安排" width="min(1080px, calc(100vw - 48px))" :show-confirm="access.can(overseasWarehousePermissions.inboundOrderAppointment)" :confirm-text="'保存预约安排'" :confirm-loading="creating" cancel-text="关闭" @confirm="saveArrangement" @update:model-value="closeBatch" :before-close="beforeClose">
|
||||||
|
<p>{{ batchPreview.originWarehouseName }} → {{ batchPreview.destinationWarehouseCode }}</p>
|
||||||
|
<p>{{ batchPreview.group.sourceCount }} 票 / {{ batchPreview.group.customerCount }} 个客户 / 预报 {{ batchPreview.group.packages ?? '待核实' }} 箱</p>
|
||||||
|
<p v-if="batchPreview.group.readinessIncomplete">货物备妥窗口待核实;预计到港或预计到仓不能替代备妥时间。</p>
|
||||||
|
<p>保存前尚未占用货物;先保存安排不代表已取得对方确认。</p>
|
||||||
|
<LoadErrorState v-if="createError" title="预约安排保存未完成" :message="createError.message" :show-retry="false"><template #details><ApiErrorMeta :details="createError" /></template></LoadErrorState>
|
||||||
|
<FbaAppointmentFields :timezone="batchPreview.destinationTimezone" :model="arrangement" :disabled="creating || !!pendingCreate" />
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cargo-note { margin: var(--admin-space-3) 0; color: var(--el-text-color-secondary); font-size: var(--admin-font-caption); }
|
||||||
|
.external-selection { margin: var(--admin-space-3) 0; }
|
||||||
|
.external-selection-list { max-height: 216px; overflow-y: auto; }
|
||||||
|
.external-selection-list > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; overflow-wrap: anywhere; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick } from 'vue'
|
||||||
|
|
||||||
|
const mode = defineModel<'firstleg' | 'external'>({ required: true })
|
||||||
|
const tabs = [{ key: 'firstleg', label: '头程货物' }, { key: 'external', label: '外接货物' }] as const
|
||||||
|
async function handleKeydown(event: KeyboardEvent, index: number) {
|
||||||
|
let next: number
|
||||||
|
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length
|
||||||
|
else if (event.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length
|
||||||
|
else if (event.key === 'Home') next = 0
|
||||||
|
else if (event.key === 'End') next = tabs.length - 1
|
||||||
|
else return
|
||||||
|
event.preventDefault()
|
||||||
|
const key = tabs[next]!.key
|
||||||
|
mode.value = key
|
||||||
|
await nextTick()
|
||||||
|
document.getElementById(`appointment-cargo-tab-${key}`)?.focus()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="density-status-tabs" role="tablist" aria-label="预约货源类型">
|
||||||
|
<button v-for="(tab, index) in tabs" :id="`appointment-cargo-tab-${tab.key}`" :key="tab.key" type="button" role="tab"
|
||||||
|
:aria-selected="mode === tab.key" :tabindex="mode === tab.key ? 0 : -1"
|
||||||
|
:class="{ 'is-active': mode === tab.key }"
|
||||||
|
@click="mode = tab.key" @keydown="handleKeydown($event, index)">{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest'
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import AppointmentConfirmationCargo from './AppointmentConfirmationCargo.vue'
|
||||||
|
import { resolvePlanningOrder, resolvePlanningSource, type ResolvedPlanningSource } from './planningApi'
|
||||||
|
import { getCargoVerification, saveCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||||
|
|
||||||
|
const access = vi.hoisted(() => ({ read: true }))
|
||||||
|
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => access.read }) }))
|
||||||
|
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||||
|
vi.mock('./planningApi', () => ({ resolvePlanningOrder: vi.fn(), resolvePlanningSource: vi.fn() }))
|
||||||
|
vi.mock('./cargoVerificationApi', () => ({ getCargoVerification: vi.fn(), saveCargoVerification: vi.fn() }))
|
||||||
|
const source: ResolvedPlanningSource['source'] = { sourceKey: 'BOOKING:BK-TEST', customerId: 'customer-1', orderId: '9007199254740993', inboundCode: 'IN-TEST', bookingNo: 'BK-TEST', containerNo: null, originWarehouseId: 'warehouse-1', originWarehouseName: '测试仓', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', packages: 20, sourceVersion: 'BOOKING:1/ORDER:1', canFollowup: true, blockReasons: [] }
|
||||||
|
const resolved: ResolvedPlanningSource = { source, coverage: { status: 'PENDING', appointments: [], missingFields: ['FBA_SHIPMENT_REFERENCES'], changeReviewRequired: false } }
|
||||||
|
const context: CargoVerificationContext = { source, verification: null, allocatedPackages: 20, unknownAllocationCount: 0, effectivePackages: 20, effectiveShipmentReferences: [], availablePackages: 0, sourceChanged: false, allowedActions: ['VERIFY_CARGO'], blockReasons: [] }
|
||||||
|
const lookup = { orderId: source.orderId!, customerId: source.customerId, destinationKey: source.destinationKey }
|
||||||
|
function render(active = true, disabled = false) {
|
||||||
|
return mount(AppointmentConfirmationCargo, { props: { sources: [lookup], active, disabled }, global: { stubs: {
|
||||||
|
ElButton: { props: ['disabled', 'nativeType'], template: '<button :disabled="disabled" :type="nativeType || \'button\'"><slot /></button>' },
|
||||||
|
InlineHelp: { template: '<span />' }, ApiErrorMeta: true,
|
||||||
|
LoadErrorState: { props: ['message'], template: '<div role="alert">{{ message }}<button @click="$emit(\'retry\')">重试核对</button></div>' },
|
||||||
|
} } })
|
||||||
|
}
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks(); access.read = true
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue(resolved)
|
||||||
|
vi.mocked(resolvePlanningSource).mockResolvedValue(resolved)
|
||||||
|
vi.mocked(getCargoVerification).mockResolvedValue(context)
|
||||||
|
vi.mocked(saveCargoVerification).mockResolvedValue({ version: 1, sourceVersion: source.sourceVersion, destinationKey: source.destinationKey, sourcePackages: 20, packageCount: 20, shipmentReferences: ['FBA-TEST'], remark: null, operatorName: '测试员', verifiedAt: '2026-10-21T03:00:00Z' })
|
||||||
|
})
|
||||||
|
it('names the missing material and supplements the canonical booking inside the appointment', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).toHaveBeenCalledWith(lookup)
|
||||||
|
expect(wrapper.text()).toContain('IN-TEST'); expect(wrapper.text()).toContain('缺少本票 FBA 货件号或追踪号')
|
||||||
|
expect(wrapper.text()).toContain('不能代替货件号'); expect(wrapper.text()).not.toContain(source.orderId)
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||||
|
expect((wrapper.get('input[type=number]').element as HTMLInputElement).value).toBe('20')
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(saveCargoVerification).not.toHaveBeenCalled()
|
||||||
|
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-TEST')
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(saveCargoVerification).toHaveBeenCalledWith(source, expect.objectContaining({ packageCount: 20, shipmentReferences: ['FBA-TEST'], expectedSourceVersion: source.sourceVersion }))
|
||||||
|
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('leaves pending arrangements lazy and reads only when formal confirmation is selected', async () => {
|
||||||
|
const wrapper = render(false); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).not.toHaveBeenCalled()
|
||||||
|
await wrapper.setProps({ active: true }); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).toHaveBeenCalledTimes(1); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('does not ask for known materials to be entered again', async () => {
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, coverage: { ...resolved.coverage, missingFields: [] } })
|
||||||
|
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectiveShipmentReferences: ['FBA-ORIGINAL', 'TRACK-ORIGINAL'] })
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('资料齐全,无需重复填写')
|
||||||
|
expect(wrapper.text()).toContain('FBA-ORIGINAL、TRACK-ORIGINAL')
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([true]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('retains unsaved inputs when the appointment mode changes and blocks leaving an unknown save outcome', async () => {
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
await wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.trigger('click'); await flushPromises()
|
||||||
|
await wrapper.get('textarea[aria-label="本票货件号"]').setValue('FBA-KEEP')
|
||||||
|
await wrapper.setProps({ active: false })
|
||||||
|
expect(wrapper.get('textarea[aria-label="本票货件号"]').isVisible()).toBe(true)
|
||||||
|
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce(new Error('keep editing'))
|
||||||
|
expect(await wrapper.vm.canLeave()).toBe(false)
|
||||||
|
vi.mocked(saveCargoVerification).mockRejectedValueOnce(new Error('connection lost'))
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(await wrapper.vm.canLeave()).toBe(false)
|
||||||
|
const payload = vi.mocked(saveCargoVerification).mock.calls[0]![1]
|
||||||
|
await wrapper.get('form').trigger('submit'); await flushPromises()
|
||||||
|
expect(saveCargoVerification).toHaveBeenNthCalledWith(2, source, payload); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('reports unknown quantity and unmaterialized cargo without inventing values', async () => {
|
||||||
|
vi.mocked(resolvePlanningOrder).mockResolvedValue({ ...resolved, source: { ...source, orderId: null } })
|
||||||
|
vi.mocked(getCargoVerification).mockResolvedValue({ ...context, effectivePackages: null })
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('缺少本票箱数'); expect(wrapper.text()).toContain('原来货登记完成实收关联')
|
||||||
|
expect(wrapper.findAll('button').some(button => button.text() === '在此补齐资料')).toBe(false)
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false]); wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('keeps failed reads blocked, supports retry, and denies reads without permission', async () => {
|
||||||
|
vi.mocked(resolvePlanningOrder).mockRejectedValueOnce(new Error('network lost'))
|
||||||
|
const wrapper = render(); await flushPromises()
|
||||||
|
expect(wrapper.find('[role=alert]').exists()).toBe(true)
|
||||||
|
expect(wrapper.emitted('ready')?.at(-1)).toEqual([false])
|
||||||
|
await wrapper.get('[role=alert] button').trigger('click'); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).toHaveBeenCalledTimes(2); wrapper.unmount()
|
||||||
|
vi.clearAllMocks(); access.read = false
|
||||||
|
const denied = render(); await flushPromises()
|
||||||
|
expect(resolvePlanningOrder).not.toHaveBeenCalled(); expect(denied.text()).toContain('查看权限'); denied.unmount()
|
||||||
|
})
|
||||||
|
it('keeps the supplement action disabled for read-only appointment views', async () => {
|
||||||
|
const wrapper = render(true, true); await flushPromises()
|
||||||
|
expect(wrapper.findAll('button').find(button => button.text() === '在此补齐资料')!.attributes('disabled')).toBeDefined()
|
||||||
|
expect(saveCargoVerification).not.toHaveBeenCalled(); wrapper.unmount()
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject, onBeforeUnmount, provide, ref, watch } from 'vue'
|
||||||
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||||
|
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||||
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||||
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||||
|
import CargoVerificationForm from './CargoVerificationForm.vue'
|
||||||
|
import { getCargoVerification, type CargoVerificationContext } from './cargoVerificationApi'
|
||||||
|
import { resolvePlanningOrder, resolvePlanningSource, type PlanningSourceLookup, type ResolvedPlanningSource } from './planningApi'
|
||||||
|
import type { LeaveGuard } from './useWorkDraft'
|
||||||
|
|
||||||
|
const props = defineProps<{ sources: PlanningSourceLookup[]; active: boolean; disabled?: boolean }>()
|
||||||
|
const emit = defineEmits<{ ready: [value: boolean] }>()
|
||||||
|
const access = useAccessPreviewStore()
|
||||||
|
type Row = { resolved: ResolvedPlanningSource; cargo: CargoVerificationContext }
|
||||||
|
const rows = ref<Row[]>([]), loading = ref(false), error = ref<ApiErrorDetails | null>(null)
|
||||||
|
const editing = ref<string | null>(null), editor = ref<InstanceType<typeof CargoVerificationForm> | null>(null)
|
||||||
|
const editingRow = computed(() => rows.value.find(row => key(row) === editing.value))
|
||||||
|
let generation = 0
|
||||||
|
const guards = new Set<LeaveGuard>()
|
||||||
|
const register = inject<((guard: LeaveGuard) => () => void) | undefined>('appointment-work-leave-guard', undefined)
|
||||||
|
provide('appointment-work-leave-guard', (guard: LeaveGuard) => { guards.add(guard); return () => guards.delete(guard) })
|
||||||
|
async function canLeave() { for (const guard of guards) if (!await guard()) return false; return true }
|
||||||
|
const unregister = register?.(canLeave)
|
||||||
|
defineExpose({ canLeave })
|
||||||
|
function key(row: Row) { return `${row.resolved.source.customerId}:${row.resolved.source.sourceKey}` }
|
||||||
|
function issues(row: Row) {
|
||||||
|
const missing: string[] = []
|
||||||
|
if (!row.resolved.source.orderId) missing.push('尚未来货建单,请先在原来货登记完成实收关联')
|
||||||
|
if (row.cargo.effectivePackages == null || row.cargo.effectivePackages <= 0) missing.push('缺少本票箱数')
|
||||||
|
if (row.resolved.coverage.missingFields.includes('FBA_SHIPMENT_REFERENCES')) missing.push('缺少本票 FBA 货件号或追踪号(至少一种)')
|
||||||
|
if (row.cargo.sourceChanged) missing.push('原货物资料已变化,请重新核对箱数和货件号')
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
const ready = computed(() => rows.value.length === props.sources.length && rows.value.length > 0 && !loading.value && !error.value && !editing.value && rows.value.every(row => !issues(row).length))
|
||||||
|
watch(ready, value => emit('ready', value), { immediate: true })
|
||||||
|
async function load() {
|
||||||
|
const current = ++generation
|
||||||
|
loading.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
if (!access.can('platform:appointment:list') || !access.can('platform:overseas-warehouse:last-mile-order:list')) {
|
||||||
|
error.value = { message: '无法读取本票核实资料,请联系有预约列表和尾程订单查看权限的同事补齐。' }; return
|
||||||
|
}
|
||||||
|
const result: Row[] = []
|
||||||
|
// 只核对本次明确选择的货物,并由服务端解析原 ORDER / BOOKING 身份。
|
||||||
|
for (const lookup of props.sources) {
|
||||||
|
const resolved = 'sourceKey' in lookup ? await resolvePlanningSource(lookup) : await resolvePlanningOrder(lookup)
|
||||||
|
if (current !== generation) return
|
||||||
|
const cargo = await getCargoVerification(resolved.source)
|
||||||
|
if (current !== generation) return
|
||||||
|
result.push({ resolved, cargo })
|
||||||
|
}
|
||||||
|
if (current === generation) rows.value = result
|
||||||
|
} catch (cause) { if (current === generation) error.value = getApiErrorDetails(cause, '货物缺项读取失败,请重试核对') }
|
||||||
|
finally { if (current === generation) loading.value = false }
|
||||||
|
}
|
||||||
|
async function closeEditor() {
|
||||||
|
if (editor.value && !await editor.value.canLeave()) return
|
||||||
|
editing.value = null
|
||||||
|
}
|
||||||
|
async function saved() { editing.value = null; await load() }
|
||||||
|
watch(() => [props.active, JSON.stringify(props.sources)] as const, ([active]) => { if (active && !editing.value) void load() }, { immediate: true })
|
||||||
|
onBeforeUnmount(() => { ++generation; unregister?.() })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section v-show="active || editing" class="density-dialog-section" aria-label="正式确认前的货物资料" :aria-busy="loading">
|
||||||
|
<h3 class="density-dialog-section__title">正式确认前:核对本次送仓货物</h3>
|
||||||
|
<el-button :disabled="loading || !!editing || disabled" :loading="loading" @click="load">重新核对资料</el-button>
|
||||||
|
<p>本票总箱数用于校验预约货量;FBA 货件号或追踪号用于识别送的是哪批货。外部约号只代表仓库的预约确认号,不能代替货件号。</p>
|
||||||
|
<LoadErrorState v-if="error" title="货物资料未核对" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||||
|
<table class="density-table"><thead><tr><th>操作</th><th>入仓码 / 外接登记号</th><th>本票总箱数</th><th>FBA 货件号 / 追踪号</th><th>需要补充的资料</th></tr></thead><tbody>
|
||||||
|
<tr v-for="row in rows" :key="key(row)">
|
||||||
|
<td><el-button v-if="issues(row).length && row.resolved.source.orderId" :disabled="disabled || loading || !!editing || !row.cargo.allowedActions.includes('VERIFY_CARGO')" @click="editing = key(row)">在此补齐资料</el-button><span v-else>—</span></td>
|
||||||
|
<td>{{ row.resolved.source.inboundCode || row.resolved.source.bookingNo || '未提供业务编号' }}</td>
|
||||||
|
<td>{{ row.cargo.effectivePackages ?? '待补充' }}</td>
|
||||||
|
<td>{{ row.cargo.effectiveShipmentReferences.join('、') || '—' }}</td>
|
||||||
|
<td>{{ issues(row).join(';') || '资料齐全,无需重复填写' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody></table>
|
||||||
|
<div v-if="editingRow">
|
||||||
|
<CargoVerificationForm ref="editor" :source="editingRow.resolved.source" :disabled="disabled" confirmation @saved="saved" />
|
||||||
|
<el-button :disabled="disabled" @click="closeEditor">收起补充资料</el-button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!loading && !error && !ready" role="status">请先在上表补齐并保存资料,再点击“保存预约安排”。</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||