Compare commits
895 Commits
18db4f80bb
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3634ea1d62 | |||
| 3ce0569c59 | |||
| d2d95f0b71 | |||
| 2a51006888 | |||
| a89de129ea | |||
| ddefcbed56 | |||
| 3137299bfe | |||
| 1fe3368c5a | |||
| 7aea3a0a50 | |||
| 188aedec84 | |||
| d5952945dd | |||
| 6e1689dfe5 | |||
| 4f16a02658 | |||
| 5e9a59b327 | |||
| ab09cff427 | |||
| 47a9520a82 | |||
| 7a6c3c3fa3 | |||
| 803b5d583d | |||
| 1c52cd529b | |||
| ab28b168ec | |||
| 367b4b7553 | |||
| 25c7323c47 | |||
| aea0e16279 | |||
| f566573fce | |||
| 8fcceb3226 | |||
| ed0d6575c8 | |||
| f2ada02383 | |||
| 5e5816cd74 | |||
| d189d94c3b | |||
| 79b5d40327 | |||
| 228d481211 | |||
| d6f8368493 | |||
| b0f764b6b6 | |||
| 1403fec5fe | |||
| 07b4ebe983 | |||
| 0b2b9303d0 | |||
| 05a2c479a5 | |||
| 52b55df7b2 | |||
| b54f72d3f6 | |||
| 24c5a09c7f | |||
| 8cab9d4bad | |||
| 5ea52e5291 | |||
| 9166656673 | |||
| 1360a44e01 | |||
| 86c05e71a2 | |||
| fae26aa460 | |||
| 8803e22f39 | |||
| 375b89154b | |||
| 6a90adc765 | |||
| bcf66dc1d7 | |||
| c55c4a140b | |||
| 7643094f1d | |||
| 1b480f915f | |||
| ac36c08460 | |||
| ef3a2c9bd6 | |||
| da0f10f1cc | |||
| 9ba231dc4c | |||
| 9a6b57db58 | |||
| 67223f8950 | |||
| e76714c32e | |||
| 95dfb69a18 | |||
| 24ada70997 | |||
| ccce03b4d4 | |||
| 4daf235385 | |||
| 6d46506726 | |||
| c448f49e30 | |||
| 3f5a234c59 | |||
| b05bba50fa | |||
| 9d92fb4af2 | |||
| db6869b77e | |||
| 864c22ffc7 | |||
| fd614004b0 | |||
| ff1ffbbfa3 | |||
| 9ffd68bae5 | |||
| 14a723ab1c | |||
| dc6e8924a9 | |||
| 882ccdac12 | |||
| b70557a077 | |||
| a0f6582914 | |||
| 0c98c5bc15 | |||
| 1066625078 | |||
| fe052879bd | |||
| 51c5fe1129 | |||
| 3d208ea0e5 | |||
| a4f60ef21c | |||
| 3f6ad0c6ad | |||
| 0323f0bb7b | |||
| 1b733b23b9 | |||
| 05ae0c62d6 | |||
| 4bc4969e5a | |||
| 8cd8390d95 | |||
| 2a766efb83 | |||
| afa8560369 | |||
| c81ebb7053 | |||
| b7d4d325e0 | |||
| 82a782550e | |||
| d1b56918fa | |||
| 441917bfce | |||
| 037a689316 | |||
| db7cd22ed1 | |||
| 9962d9797e | |||
| 47459e2089 | |||
| e6021593ea | |||
| 540d6588e6 | |||
| 2a9110ec32 | |||
| 51fb77ec25 | |||
| 30d31fb318 | |||
| bb52574767 | |||
| cd055f8ccd | |||
| c2915036d6 | |||
| 8dc03df95b | |||
| bb74c9a192 | |||
| 32985c78f3 | |||
| 8e684b92dd | |||
| d54dbf72c9 | |||
| 94a0c287e0 | |||
| 47b2758fe8 | |||
| dd45ffe34c | |||
| 48ee76d60d | |||
| 5af4e1c23e | |||
| b9768aac5e | |||
| 08e78ebe55 | |||
| 6fe67b648f | |||
| 96e93944d9 | |||
| 0cf20527a4 | |||
| 83cb3ff84e | |||
| 9a487ae9a7 | |||
| 02441565a0 | |||
| b3f7b81b9c | |||
| 11d1951c7f | |||
| ffd8c718dd | |||
| 45fdd8a294 | |||
| 5fad0ae027 | |||
| 3f599547e6 | |||
| 7c1c13526e | |||
| 15222be2b5 | |||
| 3cc5a64609 | |||
| 197643a8fa | |||
| ba581ed095 | |||
| 630bfc3eee | |||
| b12acf7793 | |||
| 4ac8f8b472 | |||
| 7b6f62f1bc | |||
| f2906e8d6f | |||
| d9e3f34d37 | |||
| e248b6e43a | |||
| abcfa5bef7 | |||
| 83e38d4278 | |||
| 50d0d110a2 | |||
| c086d402a7 | |||
| 30b8c910e8 | |||
| c5982dd900 | |||
| 0bd4843b8e | |||
| e8f0dead9e | |||
| b575059726 | |||
| 6d6cfcb6ff | |||
| 4994142a67 | |||
| e6cf4c2461 | |||
| d2301db292 | |||
| f6c44d1b92 | |||
| 23e749c973 | |||
| 5f8c469b8e | |||
| 678605e9fb | |||
| 056abcd473 | |||
| e820811d98 | |||
| cd6beef388 | |||
| d3f5a371e3 | |||
| f00fdcd42a | |||
| 970affec95 | |||
| 4a9ddcab64 | |||
| 3ef7cefe07 | |||
| b0c461d347 | |||
| f324c6bd3d | |||
| 35ecceacb4 | |||
| c5c103b287 | |||
| 35bed42b73 | |||
| 66082af52a | |||
| c200fa4f32 | |||
| e81aa9e02e | |||
| 4276a90236 | |||
| 7adef149d8 | |||
| e793f3b72d | |||
| fc4eff2f01 | |||
| 2691472a5e | |||
| 4742a2e3b5 | |||
| 26e062d32c | |||
| 898fae4cb3 | |||
| 8034d916bc | |||
| 191e5f9102 | |||
| 1da4511dd9 | |||
| 4c88964473 | |||
| 6bd935dc6b | |||
| b7029835d8 | |||
| ab787ceff7 | |||
| 3dd39bba44 | |||
| 648935e757 | |||
| 6d9b4a13ee | |||
| 3b5adff2e3 | |||
| 6e2e4bf8e7 | |||
| c3dc2a3f2f | |||
| 0315a3a93d | |||
| 8add3ceaa4 | |||
| 5e5658b00d | |||
| 9ac4eda054 | |||
| c381527528 | |||
| efada31764 | |||
| d30e8f3e8f | |||
| 596933eea2 | |||
| a2bfb08122 | |||
| d3bbb1a296 | |||
| c915847d92 | |||
| 553aca34f7 | |||
| 207f48f7d8 | |||
| 0387dc4918 | |||
| 603822a6c1 | |||
| a8d08c4d39 | |||
| 1848a34a27 | |||
| 2f4bb834cf | |||
| 32ef9a2ebb | |||
| 1692fa4a69 | |||
| 63339b401c | |||
| 359f3ce1c9 | |||
| 6329695fc2 | |||
| 1d99423da5 | |||
| ad5c7c02f9 | |||
| af49cbda64 | |||
| 5e4a2a9d11 | |||
| a7cdb0031a | |||
| a6b18b78e6 | |||
| 6bd44e6884 | |||
| 8120ba48f6 | |||
| eebdb7af2d | |||
| cabbb4be0b | |||
| 6a0eb8cf63 | |||
| 0248c8737d | |||
| 4c6884223d | |||
| d745271685 | |||
| 3902acafb9 | |||
| 85f565bd82 | |||
| 178ff96be4 | |||
| 88f2ec8bb0 | |||
| f765b3ce67 | |||
| 8b4955d9b5 | |||
| 07fda4cf22 | |||
| 41cb4ce174 | |||
| e0ec2d864e | |||
| cea7662fc3 | |||
| 4e86506f3e | |||
| f7ad99deda | |||
| 8b0bf3d563 | |||
| 39f43469c2 | |||
| 4aa9b587ba | |||
| 3ba84de784 | |||
| 50ba594ad1 | |||
| c924ffa16d | |||
| 79ee16a374 | |||
| b4657a50fb | |||
| fe429c7a05 | |||
| cd9c5effb7 | |||
| 916d13c3ce | |||
| 9422ad529c | |||
| 79878c8532 | |||
| be486699fd | |||
| bf9ede7049 | |||
| 3a5b2f5101 | |||
| 125a9ff8d6 | |||
| de03a41734 | |||
| 67390c54a9 | |||
| 9e675d63a3 | |||
| 3a68489ee6 | |||
| 9684a184e1 | |||
| 0bf6026083 | |||
| 02a0a6cbf1 | |||
| 26f472413e | |||
| 0ff9024fe4 | |||
| c7708ca57c | |||
| a36a60f151 | |||
| e45ab1da9e | |||
| 05d19626a1 | |||
| cf750a26d4 | |||
| 656dc7ce2f | |||
| 37d3dc8a4c | |||
| 74691acdf9 | |||
| 46f1d27478 | |||
| ea9b263af3 | |||
| 95af9667f6 | |||
| 1e8e173236 | |||
| d3b7ea85eb | |||
| 4e7eb49e0d | |||
| 21e56d40e2 | |||
| 5e274878fe | |||
| 9a20b38bbd | |||
| 9aceedb2e2 | |||
| 23cb828162 | |||
| 3fc8746218 | |||
| b134db134e | |||
| e88bf5322b | |||
| e461ceec2c | |||
| 3056608e04 | |||
| 4cdc7205e9 | |||
| 6701c12178 | |||
| ee73d7d683 | |||
| 6dcdbec63d | |||
| 84cc592517 | |||
| 51b88e4331 | |||
| 16c17c5481 | |||
| f2d3b08e2f | |||
| e3cb97fb49 | |||
| d23fde1757 | |||
| 9b8423bb9b | |||
| f41d8b127f | |||
| cea4db2c45 | |||
| b791fa94e7 | |||
| 320600584c | |||
| ef19a5c00c | |||
| 8668a6e439 | |||
| 4dd9ca7a22 | |||
| 89c06b58c3 | |||
| a6bc6b80b3 | |||
| 423931eb1d | |||
| c4262bd704 | |||
| 94954648d2 | |||
| 21e4bfc221 | |||
| 883625a072 | |||
| 5708025baa | |||
| 3917d678d8 | |||
| 68f6ffe84e | |||
| e063aedb8f | |||
| 5eb3e865b2 | |||
| 91f429f1a0 | |||
| 095a79de94 | |||
| b60bf7bc13 | |||
| 154ec6e301 | |||
| 1d98315877 | |||
| 4731c9a23c | |||
| d8950e93da | |||
| df8255c41e | |||
| 103cb28a1f | |||
| 467c72efb4 | |||
| 818eed0cb5 | |||
| 1acb93ff1c | |||
| 9b21e1f22e | |||
| 479cc449d6 | |||
| b5a1a51403 | |||
| 61b2d7152d | |||
| c919780875 | |||
| 69f6965c93 | |||
| 40463772c5 | |||
| 137af44ae8 | |||
| cb1490ccc2 | |||
| 88d3bfb8b2 | |||
| 77d049990c | |||
| a1e466030b | |||
| d2ef1ce4d8 | |||
| 64f9ac54cf | |||
| a816bfa899 | |||
| cb8e092cc6 | |||
| 841430cd6c | |||
| 602c2118d3 | |||
| a877488f8f | |||
| bf0cecef88 | |||
| 25ebd667f1 | |||
| 085f352320 | |||
| 6368ee376f | |||
| 29641e20a7 | |||
| 0a1e778ef6 | |||
| 7fa9b5ccf3 | |||
| 813ab5db7b | |||
| 58ea911d66 | |||
| 9293cd0976 | |||
| 6ccd5ce1af | |||
| 42722d0cc6 | |||
| bef09cb5b5 | |||
| 9060464afd | |||
| be6a17d4bd | |||
| 72a8f75dc5 | |||
| 119095a57b | |||
| 8c6088bdc9 | |||
| 93033e94db | |||
| 7ccabf859f | |||
| 3828c90b4c | |||
| a46c35f01d | |||
| c1d6ce6d20 | |||
| d9b1b980ca | |||
| 43c7c03aee | |||
| ad6658e490 | |||
| a9b4a56986 | |||
| 5058baffe0 | |||
| 9d25f13fe9 | |||
| 4c78c64569 | |||
| 62bf4f005d | |||
| 43463e12ca | |||
| 27afc4470a | |||
| ce27161bbb | |||
| abc8ed72d4 | |||
| 23037c669d | |||
| fb420c2988 | |||
| 09b3d9f013 | |||
| 53b4cb52f3 | |||
| 8e5fc00945 | |||
| eb526fef5d | |||
| aeb226a0e6 | |||
| 50a6cf12e7 | |||
| 45a8161bb2 | |||
| aaa943a86d | |||
| ffb16bea9a | |||
| 5d9019a40d | |||
| 916f179fca | |||
| 47d1c6bf80 | |||
| 2077f871f8 | |||
| 0fcee02189 | |||
| fba8e35118 | |||
| 311df66231 | |||
| 0e8c5c7223 | |||
| 2f71b4be3c | |||
| 1063e5188c | |||
| dad9cda4ac | |||
| 1ae4bb2a36 | |||
| a8c5d357e6 | |||
| 289f4488f9 | |||
| 40224d8e56 | |||
| d760838e71 | |||
| 60fa5a818b | |||
| 837fc9bcc0 | |||
| aad11c71bb | |||
| 4fd4d68460 | |||
| 836ea6ecc7 | |||
| 18a48e80b4 | |||
| 5b94eb1af6 | |||
| 0524489ac2 | |||
| 37d021500a | |||
| a7b184fbd7 | |||
| 1a0159b7fc | |||
| 877a690635 | |||
| 2957c575e6 | |||
| 115df85063 | |||
| 8b07321fbb | |||
| 2d0d836d9a | |||
| 7c8374424d | |||
| 46622d9f42 | |||
| d3154a48b8 | |||
| 7ae595c91a | |||
| 9f73af5911 | |||
| 1cfdcb5fb3 | |||
| bd4b2a579e | |||
| ba12e04d1e | |||
| 7ffae78919 | |||
| d397c88c3c | |||
| 83b935c11b | |||
| 13e7a4c602 | |||
| c3f160afa5 | |||
| f4f7268045 | |||
| 50e70c43ab | |||
| f1fa5f7adf | |||
| ab7255a55b | |||
| 02e0dc20b8 | |||
| 8e828c1204 | |||
| 26edb9e6ed | |||
| 4a0e464308 | |||
| af68b20f87 | |||
| 7065dbb53e | |||
| 6f0357e41c | |||
| ec7d56d566 | |||
| 445c139bce | |||
| 1924e273e9 | |||
| be332a47ab | |||
| 01e8c608e7 | |||
| d4b4e1e165 | |||
| fe3b8624bf | |||
| 3ee281a433 | |||
| 736760aa31 | |||
| 8cbc86f6fa | |||
| 6bd3e76048 | |||
| 4735a18390 | |||
| c282327a0b | |||
| dacdab7adb | |||
| f0f9ba29f0 | |||
| 0b83c9a3c1 | |||
| db0c1c1fdc | |||
| 92bd114e71 | |||
| e69d82e7b1 | |||
| 5c0f535aca | |||
| d3b499b596 | |||
| d6086ec7b0 | |||
| 9d172ac170 | |||
| 2304e634ee | |||
| 68b743f18b | |||
| 63b7cd82a6 | |||
| c1922d9e26 | |||
| 541e397ca6 | |||
| f5d4410063 | |||
| 5c63604b5b | |||
| 1b27a3f1ca | |||
| d6b223ba25 | |||
| b6cf693e05 | |||
| 187c123071 | |||
| 402c3e2cb3 | |||
| 7f7090f433 | |||
| bcd5879a08 | |||
| 5688870788 | |||
| 36463f2119 | |||
| 75d553f99a | |||
| b468750d00 | |||
| 4179e0de23 | |||
| 7967f454ec | |||
| 7b73a2cf6e | |||
| 03f5b827e2 | |||
| e365fc84f8 | |||
| 17cfd6902b | |||
| 0ec4fb3534 | |||
| 0863614e66 | |||
| a2c60d319f | |||
| 39fb23bd16 | |||
| 5fa59680f2 | |||
| 620ee15a48 | |||
| 05da03c5ba | |||
| 02cff8ea63 | |||
| 2a2d23e0dc | |||
| 19121f6239 | |||
| e248b2b935 | |||
| c1f03d64d1 | |||
| ec23c0d77c | |||
| 5ee99f9441 | |||
| 05bda8915a | |||
| 8241cd704e | |||
| 3420c72c1d | |||
| 64e4c19531 | |||
| bc5d5b3d2e | |||
| e5909c9977 | |||
| 1474f69c73 | |||
| 11dba90780 | |||
| 92a7a2c478 | |||
| d0db577a9c | |||
| 672b9f9b46 | |||
| 417a2bf831 | |||
| 152ea6eec0 | |||
| 664047dbe0 | |||
| 1aea4674e3 | |||
| 69eb825d29 | |||
| 162ed7e406 | |||
| 8ca5b1d53b | |||
| f2736729fa | |||
| bde9e75eed | |||
| 34f614ea32 | |||
| 7a43ed6c43 | |||
| 0e4d680aef | |||
| 035ec9ac79 | |||
| 9712bdf538 | |||
| 5305d6dbc2 | |||
| 9998571770 | |||
| 9144bce33f | |||
| d773748ae9 | |||
| d64ef5284a | |||
| 87a6d20f88 | |||
| 57f75b8bc3 | |||
| 3b34ec3bef | |||
| f0e12b9b1b | |||
| ffc3963345 | |||
| dd569edc66 | |||
| 383ec544e1 | |||
| dc90ead263 | |||
| eced873779 | |||
| 0945882458 | |||
| 6c674f0f1d | |||
| 5e5056b99d | |||
| 9e58d28a99 | |||
| 89d5eda057 | |||
| b7a403dc22 | |||
| 4fb33bda1c | |||
| 2493019c27 | |||
| 58cfcfdf35 | |||
| a982da0262 | |||
| c8d14a5b1b | |||
| 44fc43912f | |||
| cf83752338 | |||
| c60ad48be5 | |||
| 29ddf5c780 | |||
| 5e71e1bb3d | |||
| ecc62f095b | |||
| 28f61eb70d | |||
| ef4ea8d24b | |||
| 79b3efd365 | |||
| 56afee0386 | |||
| c8250e5320 | |||
| 793bd4f9c8 | |||
| 445b7780c5 | |||
| 29625f703c | |||
| 5f4fcad2ef | |||
| 55517f996b | |||
| f18d488cea | |||
| ac2b15d72f | |||
| fa5a59e5cd | |||
| 759f0b15d8 | |||
| e2607ab723 | |||
| 0cc7380205 | |||
| 969c5a2a67 | |||
| 5314fa7745 | |||
| a9bc9ff52e | |||
| 6bf87aff13 | |||
| 9af7a5170a | |||
| 50518b5420 | |||
| d33316f093 | |||
| 93943bb51a | |||
| 89fff86333 | |||
| 01e00f4b98 | |||
| 9e8ecb57e5 | |||
| 745e3fe2c4 | |||
| e16aa63974 | |||
| dd0a32e89a | |||
| 3e45aa2a91 | |||
| ab55b20edd | |||
| 9e224929fb | |||
| 37eabcbb2e | |||
| 71606e9fe5 | |||
| 3fe8d5d05c | |||
| e896e0bef4 | |||
| f177a15105 | |||
| eaf457c6c0 | |||
| 21c92afa7a | |||
| 8f119198fd | |||
| 79e3513056 | |||
| 71b482df6d | |||
| be02833b11 | |||
| 173ea3b074 | |||
| f8dad27957 | |||
| b27a686998 | |||
| d7104d35da | |||
| b93d338eac | |||
| fdbf681292 | |||
| a98efb00f7 | |||
| a4ca9d5a19 | |||
| 22422bc9d2 | |||
| 9349f8ff33 | |||
| 980e6e91e0 | |||
| 35de13f7f1 | |||
| 1e1f533a24 | |||
| 432b2c52b2 | |||
| 9f58350b4a | |||
| 6e227aa377 | |||
| f63ab837f5 | |||
| d1870f1382 | |||
| c8cd851b2f | |||
| 14a41e64f1 | |||
| 7f4737c800 | |||
| 26f4bb3f25 | |||
| 0408c9752e | |||
| 483374744a | |||
| 19ddfb9c5a | |||
| 0f09cbec74 | |||
| 97ed407c32 | |||
| 4a75f7b96b | |||
| c7fb126761 | |||
| a4607b41fd | |||
| a75d8d38bf | |||
| 07ecaf355e | |||
| 7c3e5f399a | |||
| d705fd7a25 | |||
| fc7dfaaa5c | |||
| 4143fb5fbf | |||
| f725c358a1 | |||
| 8ea662dd5a | |||
| 830a7ccbef | |||
| 60bcd390a7 | |||
| b44a1a2cda | |||
| ee6541ae25 | |||
| e49466ffd3 | |||
| 8fd574c472 | |||
| dceb76c30c | |||
| 922b6ee9f7 | |||
| 779e5006d9 | |||
| 0f52c80e86 | |||
| 4d84d362cf | |||
| 6593b8f007 | |||
| 2fb56632c9 | |||
| e79f1015cb | |||
| 38e94e1f53 | |||
| 6e9a9f7347 | |||
| c08869f109 | |||
| a707e754f8 | |||
| 1b491d972a | |||
| cc41b5d81b | |||
| b03aaab493 | |||
| 5999b10643 | |||
| 2bcb8955a5 | |||
| 9accebd2b4 | |||
| 85c8c66c4d | |||
| 77bd83743b | |||
| eb158e76f0 | |||
| bd3af46cf8 | |||
| 72998b7d22 | |||
| 6804b2dea6 | |||
| bf38a1b9a0 | |||
| cc2722c104 | |||
| bb2f200d76 | |||
| 79961170c3 | |||
| 6600bfaa99 | |||
| abb262668c | |||
| 2da93414a5 | |||
| 6a470d1b39 | |||
| 58e91589df | |||
| 62bf1fb206 | |||
| 25d5a5a5e3 | |||
| b3f16433fc | |||
| 5caca86ad1 | |||
| 5f062df3cc | |||
| e60bd8567a | |||
| a52e39ad80 | |||
| 83b93eb39b | |||
| c19f59bb9e | |||
| 9bf25765dd | |||
| 0ce664d2b9 | |||
| c517711169 | |||
| 143ad3f6da | |||
| ba17e34b84 | |||
| 42bbf4904e | |||
| 2b613a8e5a | |||
| 4c70ca4101 | |||
| f1d1fe5c78 | |||
| 39e52858d1 | |||
| 678ba57347 | |||
| 04f45bebec | |||
| e9ba3689ba | |||
| 6a4a115260 | |||
| ba163aa4bd | |||
| 5105ca95c5 | |||
| adf0a5ff73 | |||
| ec9811c8a5 | |||
| f190896ea7 | |||
| baa43d80ba | |||
| c07e26a400 | |||
| 6f32ed86e4 | |||
| f48407ec7e | |||
| 9de4603484 | |||
| fb090bf80d | |||
| 5749a421cb | |||
| 54260551ee | |||
| 2de58b7afc | |||
| 45b23b559c | |||
| 2819fb3c6c | |||
| f15d2e1c22 | |||
| e2d6875ef9 | |||
| e377814c48 | |||
| 9b1978aec8 | |||
| 4e9c5d242e | |||
| ca32cde4fe | |||
| f8e4360482 | |||
| 9c9b8a6078 | |||
| a9cee8e9a1 | |||
| 8d91e599ab | |||
| dff38a04c7 | |||
| 8e9c79fc0e | |||
| 7b3feb4b1a | |||
| 305b897f2a | |||
| 9ec43e8922 | |||
| 7147fc464c | |||
| 8ea1577924 | |||
| 567cb1ab67 | |||
| 44abcdd4ef | |||
| 5bbf601480 | |||
| c0447c908e | |||
| adcfa28af3 | |||
| 15269c6ed9 | |||
| e7828dc926 | |||
| 5776217c2c | |||
| 91f760f789 | |||
| e8cedbcb50 | |||
| 97ae8a7c51 | |||
| ae95b294ec | |||
| 9ebb1a4012 | |||
| 48284be214 | |||
| 7f46b9f768 | |||
| a29d6e6ce4 | |||
| 687bc18510 | |||
| 1127e9c8bb | |||
| 68ebd387be | |||
| 5919ae12e9 | |||
| ed2900361c | |||
| d4ebe1926d | |||
| 98c45486fa | |||
| c609ffb279 | |||
| b92d3e688d | |||
| 751f4b89fd | |||
| 0d99baea04 | |||
| 61f8c86b02 | |||
| 7110d1086b | |||
| d23485029d | |||
| 639e8b989e | |||
| a0a232f504 | |||
| 173287e3d6 | |||
| f168e23dde | |||
| fec89b6b7b | |||
| 3f8326afc4 | |||
| 33d0f02c55 | |||
| 2ce37f0a1a | |||
| 7f44ca6383 | |||
| 6875abaa1a | |||
| b806ab9384 | |||
| 6b76333159 | |||
| 9a8fab2ce4 | |||
| 36bed2bdc6 | |||
| a9a72c7bdf | |||
| 95e314cef4 | |||
| 6f6d7be344 | |||
| 93ccb27b40 | |||
| f2546fc152 | |||
| 2f89f795f6 | |||
| add8b057d5 | |||
| 11e19ef4f1 | |||
| 790267560a | |||
| 0e4a1c1c35 | |||
| 76d9558851 | |||
| 2cd5e07248 | |||
| 5f7457a07b | |||
| cb4e10c87f | |||
| 76a51ac290 | |||
| 199345fd11 | |||
| 6e3b43d591 | |||
| ec28216984 | |||
| 24ea02f150 | |||
| f1db31f813 | |||
| 7f49ef596e | |||
| 23eddde770 | |||
| 0092092dbd | |||
| 8ea4d2f502 | |||
| 6594e1f5d7 | |||
| 4e4af7911a | |||
| f816fed4f5 | |||
| 7eccbc016a | |||
| a9cbd18246 | |||
| 70d724382d | |||
| 4a11fd612e | |||
| 6d488515ca | |||
| 064e13c132 | |||
| 81da7a182e | |||
| 3d5fd42bfa | |||
| 29b6353d7e | |||
| 9b5873bb84 | |||
| 77ae8b2823 | |||
| 07e99a5fa8 | |||
| 92858acb4d | |||
| e1217c9c85 | |||
| 795dd1084c | |||
| 423666ef99 | |||
| 0e3dbe450f | |||
| f517b7585f | |||
| dc69b002db | |||
| fb10077557 | |||
| d1ea8feb6f | |||
| 4030897d49 | |||
| e201b13301 | |||
| cbe0ec8bb2 | |||
| 4d91146256 | |||
| 59ee3c8154 | |||
| 3d085c1605 | |||
| d64ba3a827 | |||
| 1ccf4f74ef | |||
| 17a3292c78 | |||
| 99c0b7dd75 | |||
| 3b051bbafe | |||
| 68b7d9a4a4 | |||
| db6e8f0ce8 | |||
| 16bec1c0fa | |||
| 026aa4e0c3 | |||
| 9f8bdecc9e | |||
| 8c84ff3394 | |||
| 2039acdfe6 | |||
| 322905607a | |||
| 79596a970c | |||
| ea0a319c27 | |||
| d643ae156a | |||
| 75f23d4568 | |||
| 18db20099a | |||
| 7b238522ed | |||
| c287fe01a8 | |||
| 7b81b7aa01 | |||
| 427a8cb79f | |||
| 5f519f9cd5 | |||
| 338493ecaa | |||
| c8fb93ff29 | |||
| a11f44664f | |||
| 9ba7181212 | |||
| 5acf3c79ed | |||
| 205b0b71b4 | |||
| bc8870f957 | |||
| a659132469 | |||
| 8712df2645 | |||
| 5a750be066 | |||
| 159d52194c | |||
| b00ea71c1b | |||
| 7a7f1dfa21 | |||
| 5b1ccad40e | |||
| 38216a1885 | |||
| f7e26ee266 | |||
| 28e7fce11c | |||
| a821d306ac |
@@ -14,13 +14,35 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container: maven:3.9-eclipse-temurin-21
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Build with Maven
|
||||
- name: Test (Java: unit + contract + ArchUnit)
|
||||
working-directory: backend-java
|
||||
run: mvn test -q
|
||||
- name: Package
|
||||
working-directory: backend-java
|
||||
run: mvn clean package -DskipTests -q
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: backend-jar
|
||||
path: target/*.jar
|
||||
path: backend-java/target/*.jar
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:24-alpine
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
working-directory: frontend-vue
|
||||
run: npm ci
|
||||
- name: Type check (vue-tsc)
|
||||
working-directory: frontend-vue
|
||||
run: npm run build
|
||||
- name: Frontend contract tests (npm test → node --test)
|
||||
working-directory: frontend-vue
|
||||
run: npm test
|
||||
|
||||
+23
@@ -100,6 +100,7 @@ ipython_config.py
|
||||
*.sqlite3
|
||||
|
||||
# ===== Misc =====
|
||||
.playwright-cli/
|
||||
desktop/
|
||||
ERP-Demo/
|
||||
xlsx/
|
||||
@@ -110,3 +111,25 @@ OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md
|
||||
架构.md
|
||||
*ts.%
|
||||
.omc
|
||||
|
||||
# ===== 本地规划文档(不入库)=====
|
||||
docs/plans/
|
||||
docs/*plan*.md
|
||||
backend-java/docs/plans/
|
||||
backend-java/docs/*plan*.md
|
||||
backend-java/docs/specs/
|
||||
backend-java/docs/*audit*.md
|
||||
|
||||
# ===== 进度/规划工具(本地使用,不入库)=====
|
||||
check_progress.py
|
||||
progress.json
|
||||
progress_*.json
|
||||
|
||||
# ===== 本地预览工具(不入库)=====
|
||||
backend/_preview_mock.py
|
||||
|
||||
# ===== 战役辅助脚本与本地数据(不入库)=====
|
||||
scripts/test_task_*.py
|
||||
scripts/*_scan.py
|
||||
scripts/*_migration.py
|
||||
data/logs/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env*
|
||||
!.env.example
|
||||
test-results/
|
||||
vite-dev.log
|
||||
@@ -0,0 +1,39 @@
|
||||
# Admin Frontend Vue
|
||||
|
||||
独立的后台管理前端工程,与客户端 `frontend-vue` 完全分离。
|
||||
|
||||
## 本地开发
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
$env:VITE_API_TARGET = "http://127.0.0.1:18080"
|
||||
npm run dev
|
||||
```
|
||||
|
||||
开发入口:`http://localhost:5174/admin-vue/`
|
||||
|
||||
## 构建
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
构建产物在 `dist/`,部署到 Nginx 的 `/admin-vue/` 根目录。该工程不写入客户端的 `new_web_source`,也不打进 Java JAR。
|
||||
|
||||
## 运行时契约
|
||||
|
||||
- 登录态:复用同源 Cookie/Session。
|
||||
- 当前用户:`GET /api/admin/current-user`。
|
||||
- 菜单树:`GET /api/admin/current-user/menus`,Java 返回树形菜单。
|
||||
- 退出登录:`POST /api/admin/logout`。
|
||||
- 前端路由:History 模式,基准路径 `/admin-vue/`。
|
||||
- 权限粒度:菜单/页面级,不定义按钮级权限。
|
||||
|
||||
## 首批页面
|
||||
|
||||
- `/admin-vue/account/users`
|
||||
- `/admin-vue/account/menus`
|
||||
- `/admin-vue/account/groups`
|
||||
|
||||
当前首批页面已接入 Java 现有用户、菜单和数据权限分组 API;Java 菜单接口完成树形响应和 Flyway 菜单迁移后,路由权限守卫即可按最终菜单树工作。
|
||||
@@ -0,0 +1,5 @@
|
||||
# Flask 管理能力移除 / Nginx 入口切换(module 11)
|
||||
|
||||
- /admin 由 Nginx 302 → /admin-vue/;Java 静态 admin.html/login.html 副本随发布清理。
|
||||
- Flask 管理页面/API 与版本蓝图在发布前已由 Java/Vue 覆盖(07/09 模块),此处由发布管线停用。
|
||||
- 发布验收见 tests/live/task-200/220/240(RELEASE_SMOKE=1 触发)。
|
||||
@@ -0,0 +1,29 @@
|
||||
# 仅作部署参考,实际 server/root/upstream 按环境调整。
|
||||
|
||||
location = /admin {
|
||||
return 302 /admin-vue/;
|
||||
}
|
||||
|
||||
location /admin-vue/assets/ {
|
||||
alias /var/www/admin-vue/assets/;
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
location /admin-vue/ {
|
||||
alias /var/www/admin-vue/;
|
||||
try_files $uri $uri/ /admin-vue/index.html;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://java_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $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;
|
||||
proxy_set_header Cookie $http_cookie;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# Admin Vue 发布目录结构(module 10)
|
||||
|
||||
发布目标:
|
||||
|
||||
```text
|
||||
/var/www/
|
||||
├── admin-vue-<ts>/ # 新版本临时发布目录
|
||||
│ ├── index.html
|
||||
│ └── assets/...
|
||||
├── admin-vue-prev/ # 上一版本(回滚保留)
|
||||
└── admin-vue -> admin-vue-<ts> # Nginx 根目录符号链接(原子切换)
|
||||
```
|
||||
|
||||
发布过程:
|
||||
|
||||
1. `npm ci && npm run build` 产出 `dist/`。
|
||||
2. 运行 `deploy/verify-dist.mjs`:校验 `index.html` 引用完整、assets 存在、base 为 `/admin-vue/`。
|
||||
3. 上传 `dist/` 至 `admin-vue-<ts>/`。
|
||||
4. `ln -sfn admin-vue-<ts> /var/www/admin-vue` 原子切换。
|
||||
5. 旧版本目录保留为 `admin-vue-prev`,用于快速回滚。
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env node
|
||||
// Admin Vue 构建产物清单校验(module 10):base=/admin-vue/ 且 index.html 引用的资源均存在。
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const root = resolve(process.cwd())
|
||||
const dist = join(root, 'dist')
|
||||
const index = join(dist, 'index.html')
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[verify-dist] ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!existsSync(index)) fail('缺少 dist/index.html(请先 npm run build)')
|
||||
|
||||
const html = readFileSync(index, 'utf8')
|
||||
if (!html.includes('/admin-vue/')) fail('index.html 资源引用未包含 base=/admin-vue/')
|
||||
|
||||
const assetsDir = join(dist, 'assets')
|
||||
const assets = existsSync(assetsDir) ? readdirSync(assetsDir) : []
|
||||
const refs = [...html.matchAll(/(?:src|href)="\/admin-vue\/assets\/([^"]+)"/g)].map((m) => m[1])
|
||||
for (const ref of refs) {
|
||||
if (!assets.includes(ref)) fail(`index.html 引用的资源缺失: assets/${ref}`)
|
||||
}
|
||||
|
||||
console.log(`[verify-dist] ok: base=/admin-vue/, assets=${assets.length}, refs=${refs.length}`)
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// module 13 task 249:壳层换皮真实验收 —— 蓝白浅色变量生效、角色胶囊/次级退出、
|
||||
// 代表页跳转无 pageerror。断言用 CSS 变量与 DOM 结构,避免仅截图无断言。
|
||||
|
||||
async function openAdmin(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
}
|
||||
|
||||
test('test_task_249_shell_light_normal_primary_path', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
const vars = await page.evaluate(() => {
|
||||
const s = getComputedStyle(document.documentElement)
|
||||
return {
|
||||
sidebar: s.getPropertyValue('--admin-sidebar').trim(),
|
||||
primary: s.getPropertyValue('--admin-primary').trim(),
|
||||
}
|
||||
})
|
||||
expect(vars.sidebar).toBe('#f8fbfe')
|
||||
expect(vars.primary).toBe('#4f78a5')
|
||||
await expect(page.locator('.admin-user-role')).toHaveText('超级管理员')
|
||||
})
|
||||
|
||||
test('test_task_249_shell_light_normal_variant_input', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
await expect(page.locator('.admin-user-logout')).toBeVisible()
|
||||
await expect(page.locator('.admin-kicker')).toHaveText('运营管理控制台')
|
||||
})
|
||||
|
||||
test('test_task_249_shell_light_boundary_empty_input', async ({ page }) => {
|
||||
// 未知路由仍进 404,不因换皮破坏。
|
||||
await page.goto('/admin-vue/definitely/not/exists')
|
||||
await expect(page.locator('.not-found')).toContainText('404')
|
||||
})
|
||||
|
||||
test('test_task_249_shell_light_invalid_input_rejected', async ({ page }) => {
|
||||
// 代表页跳转整段无未捕获脚本错误。
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await openAdmin(page)
|
||||
await page.goto('/admin-vue/account/menus')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await page.goto('/admin-vue/account/groups')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
// 撞款控制台(DuplicateCheckPage)像素级复刻 reference asin-store-console 的真浏览器验收:
|
||||
// mock 后端跑真实数据渲染,无 pageerror;并截图本页与 reference 离线页供人工比对。
|
||||
|
||||
const REFERENCE = 'D:\\todesk\\副业\\数富AI\\crawler-plugin\\reference\\asin-store-console\\asin-store-console\\index.html'
|
||||
|
||||
test('test_dup_console_render_dup_tab_normal_primary_path', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||
// KPI + 矩阵行 + 撞款卡片都渲染出真实 mock 数据
|
||||
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||
await expect(page.locator('.dup-card').first()).toBeVisible()
|
||||
// 分组撞款:切到「分组撞款」出现分组 chips
|
||||
await page.getByRole('button', { name: /分组撞款/ }).click()
|
||||
await expect(page.locator('.g-chip').first()).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
await page.screenshot({ path: 'test-results/dup-console-admin-dup.jpg' })
|
||||
})
|
||||
|
||||
test('test_dup_console_render_ledger_tab_normal_variant_input', async ({ page }) => {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
|
||||
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||
await expect(page.locator('.dup-head h2')).toHaveText('店铺数据撞款监控')
|
||||
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
||||
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||
// 台账 tab 下页面同时有「店铺上架分布」分页器(常显,见 tests/align-dup-console.test.ts)与台账分页器,
|
||||
// 全页 .old-pagination 会命中 2 个元素触发 strict mode;限定到台账表格(.table-wrap)紧邻的分页器。
|
||||
await expect(page.locator('.table-wrap + .old-pagination')).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
await page.locator('.table-wrap').screenshot({ path: 'test-results/dup-console-admin-ledger.jpg' })
|
||||
})
|
||||
|
||||
test('test_dup_console_palette_matches_reference_tokens', async ({ page }) => {
|
||||
// reference 设计令牌在页面落定:主蓝 #2E5BE6、红 #D63A4A、弱红底、单元底色等(按钮蓝白统一为 #4f78a5)。
|
||||
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||
const got = await page.evaluate(() => {
|
||||
const cs = (sel, prop) => {
|
||||
const el = document.querySelector(sel)
|
||||
return el ? getComputedStyle(el)[prop] : null
|
||||
}
|
||||
return {
|
||||
btnPrimary: cs('.btn-primary', 'backgroundColor'),
|
||||
btnPrimaryColor: cs('.btn-primary', 'color'),
|
||||
kpiDanger: cs('.kpi.danger .k-value', 'color'),
|
||||
kpiPrimary: cs('.kpi.primary .k-value', 'color'),
|
||||
mCell: cs('.m-cell', 'backgroundColor'),
|
||||
mCellText: cs('.m-cell', 'color'),
|
||||
badgeDup: cs('.badge.dup', 'backgroundColor'),
|
||||
badgeSingle: cs('.badge.single', 'backgroundColor'),
|
||||
kSub: cs('.kpi .k-sub', 'color'),
|
||||
selectBorder: cs('.f-group select', 'borderColor'),
|
||||
kDotDanger: cs('.kpi.danger .k-dot', 'backgroundColor'),
|
||||
}
|
||||
})
|
||||
expect(got.btnPrimary).toBe('rgb(79, 120, 165)') // 全站按钮主色统一 #4f78a5(d385d999 起)
|
||||
expect(got.btnPrimaryColor).toBe('rgb(255, 255, 255)')
|
||||
expect(got.kpiDanger).toBe('rgb(214, 58, 74)')
|
||||
expect(got.kpiPrimary).toBe('rgb(46, 91, 230)')
|
||||
expect(got.mCell).toBe('rgb(234, 240, 255)')
|
||||
expect(got.mCellText).toBe('rgb(46, 91, 230)')
|
||||
expect(got.badgeDup).toBe('rgb(253, 235, 237)')
|
||||
expect(got.badgeSingle).toBe('rgb(231, 247, 239)')
|
||||
expect(got.kSub).toBe('rgb(152, 161, 177)')
|
||||
expect(got.selectBorder).toBe('rgb(213, 219, 230)')
|
||||
expect(got.kDotDanger).toBe('rgb(214, 58, 74)')
|
||||
})
|
||||
|
||||
test('test_dup_console_structure_matches_reference', async ({ page }) => {
|
||||
// 结构对齐:矩阵列=可见店铺数、台账列=reference 10 列、分组说明抽屉内 4 列。
|
||||
await page.goto('/admin-vue/shop-center/duplicate-check')
|
||||
await expect(page.locator('.kpi')).toHaveCount(7)
|
||||
await expect(page.locator('table.matrix tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||
const matrixHeaders = await page.locator('table.matrix thead th').count()
|
||||
expect(matrixHeaders).toBe(8) // ASIN + 品牌 + 6 家店
|
||||
await page.getByRole('button', { name: /全部ASIN台账/ }).click()
|
||||
await expect(page.locator('table.ledger tbody tr').first()).toBeVisible({ timeout: 15000 })
|
||||
const ledgerHeaders = await page.locator('table.ledger thead th').count()
|
||||
expect(ledgerHeaders).toBe(10)
|
||||
})
|
||||
|
||||
test('test_dup_console_reference_screenshot', async ({ page }) => {
|
||||
// reference 离线原型截图:仅供人工像素比对(不接入应用路由)。
|
||||
test.skip(!existsSync(REFERENCE), 'reference 原型不在本机,跳过比对')
|
||||
await page.goto(pathToFileURL(REFERENCE).href)
|
||||
await expect(page.locator('.kpi').first()).toBeVisible({ timeout: 15000 })
|
||||
await page.screenshot({ path: 'test-results/dup-console-reference.jpg' })
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// 站内通知铃铛面板验收:关键字/年月日区间搜索、按天分组、分页。
|
||||
// 依赖 scripts/mock-admin-server.mjs 的通知夹具(14 条:09-13 六条、09-12 四条、09-10 四条)。
|
||||
|
||||
async function openBell(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await page.locator('.admin-notification-bell .bell-trigger').click()
|
||||
await expect(page.locator('.bell-panel')).toBeVisible()
|
||||
await expect(page.locator('.bell-item').first()).toBeVisible()
|
||||
}
|
||||
|
||||
test('test_notification_panel_day_group_and_pagination', async ({ page }) => {
|
||||
await openBell(page)
|
||||
|
||||
const panel = page.locator('.bell-panel')
|
||||
const nextPage = panel.getByRole('button', { name: '下一页' })
|
||||
|
||||
// 每页 10 条,跨两个日期分组,页脚显示页码与总数
|
||||
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||
await expect(page.locator('.bell-day-head')).toHaveCount(2)
|
||||
await expect(page.locator('.bell-page-info')).toHaveText('1 / 2')
|
||||
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
|
||||
await expect(page.locator('.bell-day-head').first()).toHaveText(/^(今天|昨天|\d{4}年\d{1,2}月\d{1,2}日)$/)
|
||||
|
||||
const page2Request = page.waitForRequest(
|
||||
(request) => request.url().includes('/api/admin/notifications?') && request.url().includes('page=2'),
|
||||
)
|
||||
await nextPage.click()
|
||||
await page2Request
|
||||
|
||||
await expect(page.locator('.bell-item')).toHaveCount(4)
|
||||
await expect(page.locator('.bell-page-info')).toHaveText('2 / 2')
|
||||
await expect(page.locator('.bell-day-head')).toHaveCount(1)
|
||||
await expect(nextPage).toBeDisabled()
|
||||
})
|
||||
|
||||
test('test_notification_panel_search_by_keyword', async ({ page }) => {
|
||||
await openBell(page)
|
||||
|
||||
const keywordRequest = page.waitForRequest((request) =>
|
||||
decodeURIComponent(request.url()).includes('keyword=余额不足'),
|
||||
)
|
||||
await page.locator('.bell-search-input').fill('余额不足')
|
||||
await keywordRequest
|
||||
|
||||
// 关键字命中标题/内容:夹具里 5 条含「余额不足」
|
||||
await expect(page.locator('.bell-item')).toHaveCount(5)
|
||||
await expect(page.locator('.bell-page-total')).toHaveText('共 5 条')
|
||||
await expect(page.locator('.bell-search-reset')).toBeVisible()
|
||||
|
||||
await page.locator('.bell-search-reset').click()
|
||||
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||
await expect(page.locator('.bell-search-reset')).toBeHidden()
|
||||
})
|
||||
|
||||
test('test_notification_panel_search_by_day_range', async ({ page }) => {
|
||||
await openBell(page)
|
||||
|
||||
const rangeRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.url().includes('startDate=2026-09-12') && request.url().includes('endDate=2026-09-12'),
|
||||
)
|
||||
await page.locator('.bell-date-input').first().fill('2026-09-12')
|
||||
await page.locator('.bell-date-input').nth(1).fill('2026-09-12')
|
||||
await rangeRequest
|
||||
|
||||
await expect(page.locator('.bell-item')).toHaveCount(4)
|
||||
await expect(page.locator('.bell-day-head')).toHaveCount(1)
|
||||
await expect(page.locator('.bell-page-total')).toHaveText('共 4 条')
|
||||
await expect(page.locator('.bell-empty')).toHaveCount(0)
|
||||
|
||||
// 起止倒挂自动交换:09-13 起 / 09-10 止 → 实际按 09-10 ~ 09-13 查询(全量 14 条)
|
||||
await page.locator('.bell-date-input').first().fill('2026-09-13')
|
||||
await page.locator('.bell-date-input').nth(1).fill('2026-09-10')
|
||||
await expect(page.locator('.bell-page-total')).toHaveText('共 14 条')
|
||||
await expect(page.locator('.bell-item')).toHaveCount(10)
|
||||
})
|
||||
|
||||
test('test_notification_panel_not_covered_by_page_content', async ({ page }) => {
|
||||
await openBell(page)
|
||||
|
||||
// 回归:面板挂在顶栏内时曾被页面 el-select 压住(提 z-index 无效),现改为 Teleport 到 body。
|
||||
// 这里在面板内取多点做命中测试,最上层元素必须属于面板自身。
|
||||
const covered = await page.evaluate(() => {
|
||||
const panel = document.querySelector('.bell-panel') as HTMLElement
|
||||
const rect = panel.getBoundingClientRect()
|
||||
const points: Array<[number, number]> = [
|
||||
[rect.left + 30, rect.top + 130],
|
||||
[rect.left + rect.width / 2, rect.top + rect.height / 2],
|
||||
[rect.right - 30, rect.bottom - 60],
|
||||
]
|
||||
return points
|
||||
.map(([x, y]) => document.elementsFromPoint(x, y)[0])
|
||||
.filter((el) => !!el && el !== panel && !panel.contains(el))
|
||||
.map((el) => `${el!.tagName}.${String((el as HTMLElement).className).slice(0, 40)}`)
|
||||
})
|
||||
expect(covered, '面板被页面元素遮挡').toEqual([])
|
||||
})
|
||||
|
||||
test('test_notification_panel_reference_screenshot', async ({ page }) => {
|
||||
await openBell(page)
|
||||
|
||||
// 同日多条不得重叠:逐条校验纵向排布严格递增(截图伪影与真实布局问题的分界)
|
||||
const tops = await page
|
||||
.locator('.bell-item')
|
||||
.evaluateAll((els) => els.map((el) => el.getBoundingClientRect().top))
|
||||
for (let i = 1; i < tops.length; i += 1) {
|
||||
expect(tops[i], `第 ${i + 1} 条应排在第 ${i} 条下方`).toBeGreaterThan(tops[i - 1])
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/notification-panel-admin-page.jpg', animations: 'disabled' })
|
||||
await page.locator('.bell-panel').screenshot({ path: 'test-results/notification-panel-admin.jpg', animations: 'disabled' })
|
||||
|
||||
await page.locator('.bell-search-input').fill('余额不足')
|
||||
await expect(page.locator('.bell-item')).toHaveCount(5)
|
||||
await page.locator('.bell-panel').screenshot({
|
||||
path: 'test-results/notification-panel-admin-search.jpg',
|
||||
animations: 'disabled',
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// 教程管理页验收(module 记录与版本):列表新增「版本号」列 + 上传时间列,
|
||||
// 默认按上传时间降序;版本号/上传时间表头可点击切换升降序;空版本历史行沉底。
|
||||
// 依赖 scripts/mock-admin-server.mjs 的教程包夹具(4 条,其中 1 条无版本号、1 条 09-01 历史行)。
|
||||
|
||||
async function openTutorial(page: Page) {
|
||||
await page.goto('/admin-vue/records/tutorial')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('教程管理')
|
||||
await expect(page.locator('.panel-box tbody tr').first()).toBeVisible()
|
||||
}
|
||||
|
||||
const versionTexts = (page: Page) =>
|
||||
page.locator('.panel-box tbody tr td:nth-child(3)').allTextContents()
|
||||
|
||||
const fileTexts = (page: Page) =>
|
||||
page.locator('.panel-box tbody tr td:nth-child(2) .file-name').allTextContents()
|
||||
|
||||
test('test_tutorial_list_default_desc_by_upload_time', async ({ page }) => {
|
||||
await openTutorial(page)
|
||||
|
||||
// 表头:版本号、上传时间(均带排序标记)
|
||||
await expect(page.locator('.sort-version')).toHaveText(/版本号/)
|
||||
await expect(page.locator('.sort-time')).toHaveText(/上传时间/)
|
||||
// 默认排序状态:上传时间降序(▼),版本号未激活(▲▼)
|
||||
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
|
||||
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
|
||||
|
||||
// 默认按上传时间降序:09-14 → 09-12 → 09-10 → 09-01;空版本行沉底
|
||||
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
|
||||
expect(await fileTexts(page)).toEqual([
|
||||
'数富AI-教学客户端-v4.zip',
|
||||
'数富AI-教学客户端-v3.zip',
|
||||
'数富AI-教学客户端-v2.zip',
|
||||
'数富AI-教学客户端.zip',
|
||||
])
|
||||
// 当前生效 = 最新上传(夹具里 09-14 那条),不随列表排序变化
|
||||
await expect(page.locator('.tag-active')).toHaveCount(1)
|
||||
await expect(page.locator('.tag-active').locator('xpath=..')).toHaveText(/数富AI-教学客户端-v4\.zip/)
|
||||
})
|
||||
|
||||
test('test_tutorial_sort_toggle_by_version_and_time', async ({ page }) => {
|
||||
await openTutorial(page)
|
||||
|
||||
// 点「版本号」:首次为降序(v3.10.0 按自然序大于 v3.2.0),空版本行仍在末尾
|
||||
await page.locator('.sort-version').click()
|
||||
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▼')
|
||||
expect(await versionTexts(page)).toEqual(['v3.10.0', 'v3.2.0', 'v3.1.0', '—'])
|
||||
|
||||
// 再点一次切升序
|
||||
await page.locator('.sort-version').click()
|
||||
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲')
|
||||
expect(await versionTexts(page)).toEqual(['v3.1.0', 'v3.2.0', 'v3.10.0', '—'])
|
||||
|
||||
// 点回「上传时间」:切换字段时回到降序默认
|
||||
await page.locator('.sort-time').click()
|
||||
await expect(page.locator('.sort-time .sort-mark')).toHaveText('▼')
|
||||
await expect(page.locator('.sort-version .sort-mark')).toHaveText('▲▼')
|
||||
expect(await fileTexts(page)).toEqual([
|
||||
'数富AI-教学客户端-v4.zip',
|
||||
'数富AI-教学客户端-v3.zip',
|
||||
'数富AI-教学客户端-v2.zip',
|
||||
'数富AI-教学客户端.zip',
|
||||
])
|
||||
})
|
||||
|
||||
test('test_tutorial_upload_dialog_has_version_field', async ({ page }) => {
|
||||
await openTutorial(page)
|
||||
|
||||
await page.getByRole('button', { name: '上传教程包' }).click()
|
||||
const dialog = page.locator('.el-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.locator('.el-form-item').first()).toContainText('版本号')
|
||||
await expect(dialog.locator('input').first()).toHaveAttribute('maxlength', '64')
|
||||
|
||||
// 不选文件直接提交:提示仍以 zip 为必填(版本号可空)
|
||||
await dialog.getByRole('button', { name: '上传教程包' }).click()
|
||||
await expect(dialog.locator('.el-alert')).toContainText('请选择 zip 压缩包')
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// 响应式壳层对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
|
||||
// 断言主题改为「窄屏不破版、核心地标与标题可用」。
|
||||
|
||||
async function open(page: Page, width: number, path = '/admin-vue/') {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.goto(path)
|
||||
}
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_normal_primary_path', async ({ page }) => {
|
||||
await open(page, 1440)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_normal_variant_input', async ({ page }) => {
|
||||
await open(page, 1280)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await open(page, 1440)
|
||||
await page.reload()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_boundary_empty_input', async ({ page }) => {
|
||||
await open(page, 640)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
// 窄屏侧栏自适应收缩(当前 85px 窄栏),不遮主内容标题。
|
||||
await expect(page.locator('.admin-topbar h1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_boundary_single_item', async ({ page }) => {
|
||||
await open(page, 700)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-brand-copy strong')).toHaveText('数富AI')
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
await open(page, 900)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await open(page, 901, '/admin-vue/account/users')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_invalid_input_rejected', async ({ page }) => {
|
||||
// 无折叠偏好了;刷新后壳层保持正常渲染(不因重载变样)。
|
||||
await open(page, 1440)
|
||||
await page.reload()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_017_desktop_shell_responsive_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||
// 桌面/窄屏两种宽度下壳层均无初始化错误、导航地标可见。
|
||||
await open(page, 1440)
|
||||
await expect(page.locator('.el-alert--error')).toHaveCount(0)
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
await open(page, 820, '/admin-vue/account/users')
|
||||
await expect(page.locator('.el-alert--error')).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
// 壳层可访问性对齐现状(task-276 已移除侧边栏收起按钮/is-collapsed 机制):
|
||||
// 断言主题改为「跳转链接、地标唯一性、标题层级、键盘可达」。
|
||||
|
||||
async function openUsers(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
}
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_primary_path', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_variant_input', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
const skip = page.locator('.skip-link')
|
||||
await expect(skip).toBeVisible()
|
||||
await expect(skip).toHaveAttribute('href', '#admin-content')
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
await page.reload()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_empty_input', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
await expect(page.locator('.skip-link')).toHaveAttribute('href', '#admin-content')
|
||||
await expect(page.locator('#admin-content')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_single_item', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 键盘可达:Tab 到跳转链接,回车跳到主内容(hash 定位)。
|
||||
await page.locator('.skip-link').focus()
|
||||
await expect(page.locator('.skip-link')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page).toHaveURL(/#admin-content/)
|
||||
await expect(page.locator('#admin-content')).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
await expect(page.locator('main#admin-content')).toBeVisible()
|
||||
await expect(page.getByRole('main')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_invalid_input_rejected', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 导航后无可见错误遮罩/弹层残留。
|
||||
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
|
||||
await expect(page.locator('.el-alert--error')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('test_task_018_shell_accessibility_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||
await openUsers(page)
|
||||
// 标题层级:顶栏唯一 h1(页面标题);页面面板标题用 h3 递进。
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('h1')).toHaveCount(1)
|
||||
const hasFocusStyle = await page.evaluate(() => {
|
||||
const rules: string[] = []
|
||||
for (const sheet of document.styleSheets) {
|
||||
try {
|
||||
for (const rule of sheet.cssRules) {
|
||||
if (rule.cssText.includes(':focus-visible')) rules.push(rule.cssText)
|
||||
}
|
||||
} catch {
|
||||
// 跨域样式表忽略
|
||||
}
|
||||
}
|
||||
return rules.length > 0
|
||||
})
|
||||
expect(hasFocusStyle).toBe(true)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
async function gotoRoute(page: Page, path: string) {
|
||||
await page.goto(path)
|
||||
}
|
||||
|
||||
test('test_task_019_first_batch_mount_normal_primary_path', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/users')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_normal_variant_input', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/menus')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/groups')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
await gotoRoute(page, '/admin-vue/account/users')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await gotoRoute(page, '/admin-vue/account/groups')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_boundary_empty_input', async ({ page }) => {
|
||||
// 根路由跳转到首个可见页面(用户管理)。
|
||||
await gotoRoute(page, '/admin-vue/')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page).toHaveURL(/\/account\/users$/)
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_boundary_single_item', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/account/menus')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
const active = page.locator('.el-menu-item.is-active')
|
||||
await expect(active).toHaveText('菜单管理')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
// 三个页面均可从侧边栏导航切换(挂载于同一壳层实例)。
|
||||
await gotoRoute(page, '/admin-vue/account/users')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await page.getByText('菜单管理', { exact: true }).first().click()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await page.getByText('数据权限分组', { exact: true }).first().click()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_invalid_input_rejected', async ({ page }) => {
|
||||
await gotoRoute(page, '/admin-vue/no-such-page')
|
||||
await expect(page.locator('.not-found')).toBeVisible()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('页面不存在')
|
||||
})
|
||||
|
||||
test('test_task_019_first_batch_mount_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||
// 分组内页面展示 分组->页面 两级面包屑。
|
||||
await gotoRoute(page, '/admin-vue/account/users')
|
||||
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
|
||||
await expect(page.locator('.admin-breadcrumb')).toContainText('账号权限')
|
||||
await expect(page.locator('.admin-breadcrumb')).toContainText('用户管理')
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
async function openAdmin(page: Page) {
|
||||
await page.goto('/admin-vue/')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
}
|
||||
|
||||
test('test_task_020_shell_browser_smoke_normal_primary_path', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
await expect(page.getByRole('navigation', { name: '侧边栏菜单' })).toBeVisible()
|
||||
await expect(page.locator('.admin-user-meta strong')).toHaveText('admin')
|
||||
await expect(page.locator('.admin-user-role')).toHaveText('超级管理员')
|
||||
await expect(page.getByText('账号权限', { exact: true }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_normal_variant_input', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
// 侧边栏菜单导航到「数据权限分组」,标题与面包屑跟随(对齐现状:无收起按钮)。
|
||||
await page.getByRole('menuitem', { name: '数据权限分组' }).click()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
await expect(page.locator('.admin-breadcrumb')).toBeVisible()
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_normal_repeated_operation_is_idempotent', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
await page.reload()
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
await expect(page.locator('.global-error-stack')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_boundary_empty_input', async ({ page }) => {
|
||||
await page.goto('/admin-vue/definitely/not/exists')
|
||||
await expect(page.locator('.not-found')).toBeVisible()
|
||||
await expect(page.locator('.not-found')).toContainText('404')
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_boundary_single_item', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await expect(page.locator('.el-message-box')).toBeVisible()
|
||||
await expect(page.locator('.el-message-box')).toContainText('确定要退出当前账号吗?')
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
|
||||
await expect(page.locator('.el-message-box')).toHaveCount(0)
|
||||
await expect(page).toHaveURL(/\/admin-vue\//)
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_boundary_limit_or_missing_field', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '退出' }).click()
|
||||
await page.waitForURL(/\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_invalid_input_rejected', async ({ page }) => {
|
||||
// 整段冒烟无未捕获脚本错误(错误不会静默遗留到控制台)。
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
await openAdmin(page)
|
||||
await page.goto('/admin-vue/account/menus')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('菜单管理')
|
||||
await page.goto('/admin-vue/account/groups')
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('数据权限分组')
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('test_task_020_shell_browser_smoke_dependency_failure_returns_actionable_message', async ({ page }) => {
|
||||
await openAdmin(page)
|
||||
// 取消确认后无“可见”的遮罩/弹层残留(隐藏的对话框 DOM 不算);继续导航正常。
|
||||
await page.getByRole('button', { name: '退出登录' }).click()
|
||||
await page.locator('.el-message-box').getByRole('button', { name: '取消' }).click()
|
||||
await expect(page.locator('.el-overlay:visible')).toHaveCount(0)
|
||||
await page.locator('.admin-brand-copy, .admin-topbar h1').first().click({ force: true })
|
||||
await expect(page.locator('.admin-topbar h1')).toHaveText('用户管理')
|
||||
})
|
||||
@@ -3,10 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>外观专利检测</title>
|
||||
<meta name="description" content="数富AI后台管理系统" />
|
||||
<title>数富AI - 后台管理</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/appearance-patent-main.ts"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2230
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "crawler-plugin-admin-frontend-vue",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host --port 5174",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test tests/*.test.ts",
|
||||
"test:live": "node --test tests/live/*.test.ts",
|
||||
"test:browser": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.6",
|
||||
"element-plus": "^2.11.4",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.63.0",
|
||||
"@types/node": "^26.4.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^7.3.1",
|
||||
"vue-tsc": "^3.0.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 浏览器验收:起 mock admin API(18100) + vite dev(5174, /api 代理到 mock)。
|
||||
* 本地用已安装的 MS Edge(channel: msedge)运行,无需下载 Chromium。
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 45_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5174',
|
||||
channel: 'msedge',
|
||||
headless: true,
|
||||
viewport: { width: 1440, height: 900 },
|
||||
locale: 'zh-CN',
|
||||
},
|
||||
webServer: [
|
||||
{
|
||||
command: 'node scripts/mock-admin-server.mjs',
|
||||
port: 18100,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
{
|
||||
command: 'set VITE_API_TARGET=http://127.0.0.1:18100&& npm run dev',
|
||||
port: 5174,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
],
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,43 @@
|
||||
// 样办校验:生成记录页旧版样式落地实测(本地真实栈)。
|
||||
import { chromium } from '@playwright/test'
|
||||
const BASE = 'http://localhost:5174/admin-vue'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(500)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
await page.goto(`${BASE}/records/history`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('.form-box').first().waitFor({ state: 'visible', timeout: 15000 })
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(600)
|
||||
const out = await page.evaluate(() => {
|
||||
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
|
||||
const fb = document.querySelector('.form-box')
|
||||
const pb = document.querySelector('.panel-box')
|
||||
const th = document.querySelector('.table-scroll th')
|
||||
const td = document.querySelector('.table-scroll td')
|
||||
const btn = document.querySelector('.form-box .btn')
|
||||
const inp = document.querySelector('.form-group input, .form-group select')
|
||||
return {
|
||||
formBox: { bg: cs(fb, 'backgroundColor'), radius: cs(fb, 'borderRadius'), border: cs(fb, 'borderTopColor') },
|
||||
panelBox: { bg: cs(pb, 'backgroundColor') },
|
||||
h3: cs(document.querySelector('.panel-box h3'), 'fontSize'),
|
||||
input: { minH: inp && cs(inp, 'minHeight'), bg: inp && cs(inp, 'backgroundColor'), radius: inp && cs(inp, 'borderRadius') },
|
||||
btn: { minH: cs(btn, 'minHeight'), bgImage: btn && getComputedStyle(btn).backgroundImage.slice(0, 60), radius: cs(btn, 'borderRadius'), color: cs(btn, 'color') },
|
||||
th: { bg: cs(th, 'backgroundColor'), color: cs(th, 'color'), fontSize: cs(th, 'fontSize'), weight: cs(th, 'fontWeight') },
|
||||
td: { color: td && cs(td, 'color'), fontSize: td && cs(td, 'fontSize') },
|
||||
tableScroll: { border: cs(document.querySelector('.table-scroll'), 'borderTopColor'), radius: cs(document.querySelector('.table-scroll'), 'borderRadius') },
|
||||
rowCount: document.querySelectorAll('.table-scroll tbody tr').length,
|
||||
thumbb: !!document.querySelector('.thumb'),
|
||||
pagination: (document.querySelector('.pagination')?.textContent || '').trim().slice(0, 60),
|
||||
modal: document.querySelector('.modal-mask') === null,
|
||||
}
|
||||
})
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
await browser.close()
|
||||
@@ -0,0 +1,266 @@
|
||||
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具。
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT || 18100)
|
||||
|
||||
const MENU_TREE = [
|
||||
{
|
||||
key: 'account',
|
||||
name: '账号权限',
|
||||
children: [
|
||||
{ key: 'admin_users', name: '用户管理', route: '/account/users' },
|
||||
{ key: 'admin_columns', name: '菜单管理', route: '/account/menus' },
|
||||
{ key: 'admin_group_manage', name: '数据权限分组', route: '/account/groups' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/* ===================== 撞款控制台夹具(对齐前端 parse 的 snake 契约) ===================== */
|
||||
const STORE_GROUP = { 店A1: '陈新辉', 店A2: '陈新辉', 店B1: '魏振峰', 店B2: '魏振峰', 店B3: '魏振峰', 店C1: '郭亚芳' }
|
||||
const GROUP_DEFS = [
|
||||
{ name: '陈新辉', shops: ['店A1', '店A2'] },
|
||||
{ name: '魏振峰', shops: ['店B1', '店B2', '店B3'] },
|
||||
{ name: '郭亚芳', shops: ['店C1'] },
|
||||
]
|
||||
|
||||
function occ(asin, brand, shop, country, day) {
|
||||
return { asin, date: `2026-08-${String(day).padStart(2, '0')} 09:00:00`, price: '9.90', brand, shop_name: shop, group_name: STORE_GROUP[shop], country_codes: [country], country }
|
||||
}
|
||||
|
||||
// 撞款种子(≥2 店在售)与单店种子
|
||||
const DUP_SEEDS = [
|
||||
{ asin: 'E0000001', brand: '品牌甲', occ: [occ('E0000001', '品牌甲', '店A1', 'UK', 20), occ('E0000001', '品牌甲', '店A1', 'UK', 24), occ('E0000001', '品牌甲', '店A2', 'DE', 22), occ('E0000001', '品牌甲', '店B1', 'FR', 21)] },
|
||||
{ asin: 'E0000002', brand: '品牌乙', occ: [occ('E0000002', '品牌乙', '店B1', 'UK', 10), occ('E0000002', '品牌乙', '店B2', 'DE', 12)] },
|
||||
{ asin: 'E0000003', brand: '品牌丙', occ: [occ('E0000003', '品牌丙', '店A2', 'DE', 15), occ('E0000003', '品牌丙', '店B3', 'IT', 16)] },
|
||||
]
|
||||
const SINGLE_SEEDS = [
|
||||
{ asin: 'E0000004', brand: '品牌甲', occ: [occ('E0000004', '品牌甲', '店A1', 'UK', 1)] },
|
||||
{ asin: 'E0000005', brand: '品牌丁', occ: [occ('E0000005', '品牌丁', '店B2', 'UK', 2), occ('E0000005', '品牌丁', '店B2', 'UK', 3)] },
|
||||
{ asin: 'E0000006', brand: '品牌戊', occ: [occ('E0000006', '品牌戊', '店C1', 'FR', 5)] },
|
||||
{ asin: 'E0000007', brand: '品牌己', occ: [occ('E0000007', '品牌己', '店B3', 'DE', 6)] },
|
||||
{ asin: 'E0000008', brand: '品牌庚', occ: [occ('E0000008', '品牌庚', '店A1', 'ES', 7)] },
|
||||
]
|
||||
|
||||
function finalizeRow(seed) {
|
||||
const stores = []
|
||||
const storeSet = new Set()
|
||||
const countries = new Set()
|
||||
let earliest = ''
|
||||
let latest = ''
|
||||
for (const o of seed.occ) {
|
||||
if (!storeSet.has(o.shop_name)) {
|
||||
storeSet.add(o.shop_name)
|
||||
stores.push(o.shop_name)
|
||||
}
|
||||
countries.add(o.country)
|
||||
if (!earliest || o.date < earliest) earliest = o.date
|
||||
if (o.date > latest) latest = o.date
|
||||
}
|
||||
const groups = [...new Set(stores.map((s) => STORE_GROUP[s] || ''))].filter(Boolean)
|
||||
return {
|
||||
asin: seed.asin,
|
||||
brand: seed.brand,
|
||||
store_count: stores.length,
|
||||
stores,
|
||||
groups,
|
||||
countries: [...countries],
|
||||
record_count: seed.occ.length,
|
||||
earliest,
|
||||
latest,
|
||||
occurrences: seed.occ,
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_ROWS = [...DUP_SEEDS, ...SINGLE_SEEDS].map(finalizeRow)
|
||||
const DUP_ROWS = ALL_ROWS.filter((row) => row.store_count >= 2)
|
||||
|
||||
const SHOPS = Object.keys(STORE_GROUP).map((name) => {
|
||||
const codes = new Set()
|
||||
let recordCount = 0
|
||||
let asinCount = 0
|
||||
for (const row of ALL_ROWS) {
|
||||
if (row.stores.includes(name)) {
|
||||
asinCount += 1
|
||||
for (const o of row.occurrences) {
|
||||
if (o.shop_name === name) {
|
||||
recordCount += 1
|
||||
codes.add(o.country)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { shop_name: name, group_name: STORE_GROUP[name], country_codes: [...codes], asin_count: asinCount, record_count: recordCount }
|
||||
})
|
||||
|
||||
function computeGroups() {
|
||||
return GROUP_DEFS.map((group) => {
|
||||
const members = group.shops
|
||||
const hit = DUP_ROWS.filter((row) => members.filter((shop) => row.stores.includes(shop)).length >= 2)
|
||||
const asinUnique = ALL_ROWS.filter((row) => row.stores.some((shop) => members.includes(shop))).length
|
||||
const recordCount = ALL_ROWS.reduce((sum, row) => {
|
||||
if (row.stores.some((shop) => members.includes(shop))) sum += row.recordCount
|
||||
return sum
|
||||
}, 0)
|
||||
return { name: group.name, shop_count: members.length, asin_unique: asinUnique, record_count: recordCount, dup_count: hit.length }
|
||||
})
|
||||
}
|
||||
|
||||
const GROUPS = computeGroups()
|
||||
const RECORD_TOTAL = ALL_ROWS.reduce((sum, row) => sum + row.recordCount, 0)
|
||||
|
||||
function consolePayload() {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
pending: false,
|
||||
scanned_at: '2026-09-05 08:30:00',
|
||||
summary: {
|
||||
shop_count: SHOPS.length,
|
||||
asin_total: ALL_ROWS.length,
|
||||
record_total: RECORD_TOTAL,
|
||||
duplicate_asin_total: DUP_ROWS.length,
|
||||
duplicate_shop_count: DUP_ROWS.length ? 6 : 0,
|
||||
site_count: 5,
|
||||
asin_per_shop: 4.8,
|
||||
source: 'job',
|
||||
},
|
||||
shops: SHOPS,
|
||||
dup: DUP_ROWS,
|
||||
groups: GROUPS,
|
||||
total_dup: DUP_ROWS.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function json(res, payload, status = 200) {
|
||||
const body = JSON.stringify(payload)
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
})
|
||||
res.end(body)
|
||||
}
|
||||
|
||||
/* ===================== 教程包夹具(教程管理页:版本号列 + 上传时间排序验收用) ===================== */
|
||||
// 故意乱序给出,且含一条无版本号的历史行:页面默认应按上传时间降序、空版本行沉底。
|
||||
const TUTORIAL_PACKAGES = [
|
||||
{ id: 3, file_name: '数富AI-教学客户端-v3.zip', version: 'v3.2.0', object_key: 'tutorial/20260912090000-数富AI-教学客户端-v3.zip', file_size: 1048576, file_url: 'https://oss.aishufu.top/client/tutorial/t3.zip', created_at: '2026-09-12 09:00' },
|
||||
{ id: 1, file_name: '数富AI-教学客户端.zip', version: '', object_key: 'tutorial/数富AI-教学客户端.zip', file_size: 0, file_url: 'https://oss.aishufu.top/client/tutorial/legacy.zip', created_at: '2026-09-01 08:00' },
|
||||
{ id: 4, file_name: '数富AI-教学客户端-v4.zip', version: 'v3.10.0', object_key: 'tutorial/20260914103000-数富AI-教学客户端-v4.zip', file_size: 2097152, file_url: 'https://oss.aishufu.top/client/tutorial/t4.zip', created_at: '2026-09-14 10:30' },
|
||||
{ id: 2, file_name: '数富AI-教学客户端-v2.zip', version: 'v3.1.0', object_key: 'tutorial/20260910120000-数富AI-教学客户端-v2.zip', file_size: 524288, file_url: 'https://oss.aishufu.top/client/tutorial/t2.zip', created_at: '2026-09-10 12:00' },
|
||||
]
|
||||
|
||||
/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */
|
||||
const NOTIFICATIONS = [
|
||||
...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)),
|
||||
...[4, 3, 2, 1].map((seq) => notif(20 + seq, '2026-09-12', 20, seq, true)),
|
||||
...[4, 3, 2, 1].map((seq) => notif(30 + seq, '2026-09-10', 18, seq, true)),
|
||||
]
|
||||
|
||||
function notif(id, day, hour, seq, read) {
|
||||
const minutes = String(seq * 7).padStart(2, '0')
|
||||
return {
|
||||
id,
|
||||
scene: id % 3 === 0 ? 'secret_balance' : 'task_failed',
|
||||
level: id % 3 === 0 ? 'error' : 'warning',
|
||||
title: id % 3 === 0 ? `用户密钥异常:测试用户${seq}` : `跟价任务失败`,
|
||||
content: id % 3 === 0 ? `用户 测试用户${seq}(uid=${1100 + seq})的代理设置对应服务商余额不足` : `用户 测试用户${seq} 有 ${seq} 个跟价任务失败`,
|
||||
read,
|
||||
readAt: read ? `${day} ${hour}:${minutes}:00` : null,
|
||||
createdAt: `${day} ${hour}:${minutes}:00`,
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知列表:关键字匹配标题/内容,日期按年月日区间(与 Java 侧同语义)。 */
|
||||
function notificationPage(searchParams) {
|
||||
const keyword = (searchParams.get('keyword') || '').trim()
|
||||
const startDate = searchParams.get('startDate') || ''
|
||||
const endDate = searchParams.get('endDate') || ''
|
||||
const page = Math.max(1, Number(searchParams.get('page') || 1))
|
||||
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('pageSize') || 20)))
|
||||
let rows = NOTIFICATIONS
|
||||
if (keyword) {
|
||||
rows = rows.filter((item) => `${item.title}${item.content}`.includes(keyword))
|
||||
}
|
||||
if (startDate) {
|
||||
rows = rows.filter((item) => item.createdAt.slice(0, 10) >= startDate)
|
||||
}
|
||||
if (endDate) {
|
||||
rows = rows.filter((item) => item.createdAt.slice(0, 10) <= endDate)
|
||||
}
|
||||
const start = (page - 1) * pageSize
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: rows.slice(start, start + pageSize),
|
||||
total: rows.length,
|
||||
page,
|
||||
pageSize,
|
||||
unreadCount: rows.filter((item) => !item.read).length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = (req.url || '').split('?')[0]
|
||||
const method = req.method || 'GET'
|
||||
console.log(`[mock] ${method} ${url}`)
|
||||
|
||||
if (url === '/api/admin/current-user') {
|
||||
return json(res, { success: true, data: { item: { id: 1, username: 'admin', role: 'super_admin' } } })
|
||||
}
|
||||
if (url === '/api/admin/current-user/menus') {
|
||||
return json(res, { success: true, data: { items: MENU_TREE } })
|
||||
}
|
||||
if (url === '/api/admin/logout') {
|
||||
return json(res, { success: true })
|
||||
}
|
||||
if (url === '/api/admin/users') {
|
||||
return json(res, {
|
||||
success: true,
|
||||
data: {
|
||||
items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }],
|
||||
total: 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url === '/api/admin/permission-menus') {
|
||||
return json(res, { success: true, data: { items: [] } })
|
||||
}
|
||||
if (url === '/api/admin/shop-manage-groups') {
|
||||
return json(res, { success: true, data: { items: [] } })
|
||||
}
|
||||
if (url === '/api/admin/shop-data-crawl/duplicate-check-console') {
|
||||
return json(res, consolePayload())
|
||||
}
|
||||
if (url === '/api/admin/shop-data-crawl/duplicate-check-ledger') {
|
||||
return json(res, {
|
||||
success: true,
|
||||
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
|
||||
})
|
||||
}
|
||||
if (url === '/api/admin/notifications/summary') {
|
||||
return json(res, {
|
||||
success: true,
|
||||
data: {
|
||||
unreadCount: NOTIFICATIONS.filter((item) => !item.read).length,
|
||||
latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (url === '/api/admin/notifications') {
|
||||
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
|
||||
}
|
||||
if (url === '/api/admin/tutorials') {
|
||||
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
|
||||
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||||
return json(res, { success: true, data: { items } })
|
||||
}
|
||||
if (url.startsWith('/api/')) {
|
||||
return json(res, { success: true, data: { items: [], total: 0 } })
|
||||
}
|
||||
return json(res, { success: true }, 200)
|
||||
})
|
||||
|
||||
server.listen(PORT, () => console.log(`mock admin api listening on ${PORT}`))
|
||||
@@ -0,0 +1,96 @@
|
||||
// round2 DOM 审计:登录本地真实栈后,对 15 页提取结构化观感事实(颜色/卡壳/分页坐标/按钮形态/标题/列头/空态)。
|
||||
// 用法:cd admin-frontend-vue && node scripts/round2-audit.mjs
|
||||
import { chromium } from '@playwright/test'
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
|
||||
|
||||
const PAGES = [
|
||||
['account-users', 'account/users'],
|
||||
['account-menus', 'account/menus'],
|
||||
['account-groups', 'account/groups'],
|
||||
['asin-registry', 'asin-center/registry'],
|
||||
['asin-invalid', 'asin-center/invalid'],
|
||||
['asin-query', 'asin-center/query'],
|
||||
['asin-categories', 'asin-center/categories'],
|
||||
['asin-skip-price', 'asin-center/skip-price'],
|
||||
['shop-keys', 'shop-center/keys'],
|
||||
['shop-shops', 'shop-center/shops'],
|
||||
['shop-data-tasks', 'shop-center/data-tasks'],
|
||||
['records-history', 'records/history'],
|
||||
['records-software-version', 'records/software-version'],
|
||||
['records-digital-human-version', 'records/digital-human-version'],
|
||||
['records-image-video-tasks', 'records/image-video-tasks'],
|
||||
]
|
||||
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
|
||||
const page = await context.newPage()
|
||||
|
||||
async function ensureLoggedIn() {
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(600)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
}
|
||||
await ensureLoggedIn()
|
||||
|
||||
const out = {}
|
||||
for (const [name, path] of PAGES) {
|
||||
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const fact = await page.evaluate(() => {
|
||||
const cs = (el, prop) => (el ? getComputedStyle(el)[prop] : null)
|
||||
const root = getComputedStyle(document.documentElement)
|
||||
const q = (sel) => document.querySelectorAll(sel)
|
||||
const text = (el) => (el ? (el.textContent || '').trim() : '')
|
||||
const heading = document.querySelector('.page-heading h1, .page-heading h2, .page-heading .page-title')
|
||||
const headingText = heading ? (heading.textContent || '').trim() : null
|
||||
const pags = [...q('.el-pagination')].map((p) => {
|
||||
const r = p.getBoundingClientRect()
|
||||
return { y: Math.round(r.top), x: Math.round(r.left), w: Math.round(r.width) }
|
||||
})
|
||||
const btns = [...q('.el-button')].map((b) => {
|
||||
const t = (b.textContent || '').trim().slice(0, 8)
|
||||
return { t, link: b.classList.contains('is-link'), text: b.classList.contains('is-text'), primary: b.classList.contains('el-button--primary'), danger: b.classList.contains('el-button--danger') }
|
||||
})
|
||||
const ths = [...q('.el-table__header th')].map((x) => (x.textContent || '').trim())
|
||||
const empty = q('.el-empty, .empty-tip, .empty').length
|
||||
const emptyText = text(document.querySelector('.el-empty, .empty-tip')) || null
|
||||
const indigoEls = [...q('body *')].filter((el) => {
|
||||
const b = cs(el, 'backgroundColor')
|
||||
const c = cs(el, 'color')
|
||||
return /rgb\(99,\s*102,\s*241\)|rgb\(238,\s*240,\s*254\)|#6366f1|#eef0fe/i.test(`${b} ${c}`)
|
||||
}).slice(0, 4).map((el) => ({ tag: el.tagName, cls: (el.className && String(el.className).slice(0, 60)) || '', bg: cs(el, 'backgroundColor'), color: cs(el, 'color') }))
|
||||
const cards = q('.el-card').length
|
||||
const toolbars = q('.table-toolbar, .list-toolbar').length
|
||||
const pageHeadingDesc = text(document.querySelector('.page-heading p'))
|
||||
return {
|
||||
heading: headingText, desc: (pageHeadingDesc || '').slice(0, 80),
|
||||
primary: root.getPropertyValue('--el-color-primary').trim(),
|
||||
bg: root.getPropertyValue('--el-bg-color').trim() || cs(document.body, 'backgroundColor'),
|
||||
paginations: pags, cards, toolbars,
|
||||
ths: ths.slice(0, 14), empty, emptyText,
|
||||
buttonShape: {
|
||||
total: btns.length,
|
||||
link: btns.filter((b) => b.link).length,
|
||||
textPlain: btns.filter((b) => b.text && !b.link).length,
|
||||
primarySolid: btns.filter((b) => b.primary && !b.text && !b.link).length,
|
||||
danger: btns.filter((b) => b.danger).length,
|
||||
sample: btns.slice(0, 12),
|
||||
},
|
||||
indigoEls,
|
||||
}
|
||||
})
|
||||
out[name] = { path, ...fact }
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(out, null, 2))
|
||||
await browser.close()
|
||||
@@ -0,0 +1,97 @@
|
||||
// round2 现状截图基线:本地真实栈(5174 -> 18080 Java),登录后逐页截图 + 收集 pageerror/console error。
|
||||
// 用法:cd admin-frontend-vue && node scripts/round2-shots.mjs
|
||||
import { chromium } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
|
||||
const BASE = process.env.BASE_URL || 'http://localhost:5174/admin-vue'
|
||||
const OUT = 'test-results/round2-baseline'
|
||||
mkdirSync(OUT, { recursive: true })
|
||||
|
||||
const PAGES = [
|
||||
['account-users', 'account/users'],
|
||||
['account-menus', 'account/menus'],
|
||||
['account-groups', 'account/groups'],
|
||||
['asin-registry', 'asin-center/registry'],
|
||||
['asin-invalid', 'asin-center/invalid'],
|
||||
['asin-query', 'asin-center/query'],
|
||||
['asin-categories', 'asin-center/categories'],
|
||||
['asin-skip-price', 'asin-center/skip-price'],
|
||||
['shop-keys', 'shop-center/keys'],
|
||||
['shop-shops', 'shop-center/shops'],
|
||||
['shop-data-tasks', 'shop-center/data-tasks'],
|
||||
['records-history', 'records/history'],
|
||||
['records-software-version', 'records/software-version'],
|
||||
['records-digital-human-version', 'records/digital-human-version'],
|
||||
['records-image-video-tasks', 'records/image-video-tasks'],
|
||||
]
|
||||
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'zh-CN' })
|
||||
const page = await context.newPage()
|
||||
|
||||
const problems = new Map() // page -> string[]
|
||||
page.on('pageerror', (e) => {
|
||||
const key = page.url()
|
||||
const list = problems.get(key) || []
|
||||
list.push(`pageerror: ${e.message}`)
|
||||
problems.set(key, list)
|
||||
})
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') {
|
||||
const key = page.url()
|
||||
const list = problems.get(key) || []
|
||||
list.push(`console.error: ${m.text().slice(0, 300)}`)
|
||||
problems.set(key, list)
|
||||
}
|
||||
})
|
||||
|
||||
// 登录(真实 UI 表单)
|
||||
async function ensureLoggedIn() {
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(600)
|
||||
const loginVisible = await page.locator('#loginUsername').isVisible().catch(() => false)
|
||||
if (loginVisible) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
// 等离开登录页(进入后台)
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
}
|
||||
|
||||
await ensureLoggedIn()
|
||||
console.log('logged-in url:', page.url())
|
||||
|
||||
const report = []
|
||||
for (const [name, path] of PAGES) {
|
||||
const url = `${BASE}/${path}`
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' })
|
||||
// 等页头或内容出现(不同页标题不同,抓第一个标题/内容容器)
|
||||
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 12000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(500)
|
||||
await page.screenshot({ path: `${OUT}/${name}.jpg`, type: 'jpeg', quality: 82 })
|
||||
report.push(`ok ${name} ${url}`)
|
||||
} catch (e) {
|
||||
report.push(`FAIL ${name} ${url} :: ${String(e).slice(0, 200)}`)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== screenshots ===')
|
||||
console.log(report.join('\n'))
|
||||
|
||||
console.log('\n=== page errors (运行时异常,供排查) ===')
|
||||
let anyError = false
|
||||
for (const [url, list] of problems) {
|
||||
const clean = url.replace(BASE, '')
|
||||
if (list.length) {
|
||||
anyError = true
|
||||
console.log(`-- ${clean}`)
|
||||
for (const l of [...new Set(list)]) console.log(' ', l)
|
||||
}
|
||||
}
|
||||
if (!anyError) console.log('(无 pageerror/console.error)')
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,45 @@
|
||||
// round2 单批验证:guides 渲染 + 类目色点 token + 行内按钮实心(本地真实栈)。
|
||||
import { chromium } from '@playwright/test'
|
||||
const BASE = 'http://localhost:5174/admin-vue'
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
await page.goto(`${BASE}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(500)
|
||||
if (await page.locator('#loginUsername').isVisible().catch(() => false)) {
|
||||
await page.locator('#loginUsername').fill('admin')
|
||||
await page.locator('#loginPassword').fill('admin123')
|
||||
await page.locator('.btn-login').click()
|
||||
await page.waitForURL(/admin-vue\/(account|asin|shop|records)/, { timeout: 20000 }).catch(() => {})
|
||||
await page.waitForTimeout(1200)
|
||||
}
|
||||
async function open(path) {
|
||||
await page.goto(`${BASE}/${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.locator('.page-heading').first().waitFor({ state: 'visible', timeout: 15000 }).catch(() => {})
|
||||
await page.waitForLoadState('networkidle').catch(() => {})
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
await open('account/users')
|
||||
const guide = await page.locator('.operation-guide').count()
|
||||
const guideText = guide ? (await page.locator('.og-text').textContent())?.trim() : null
|
||||
console.log('users guide visible:', guide > 0, '| text:', guideText)
|
||||
|
||||
await open('asin-center/categories')
|
||||
const token = await page.evaluate(() => {
|
||||
const el = document.querySelector('.tree-node-mark')
|
||||
if (!el) return null
|
||||
const cs = getComputedStyle(el)
|
||||
return { color: cs.color, bg: cs.backgroundColor }
|
||||
})
|
||||
console.log('categories tree-node-mark:', JSON.stringify(token))
|
||||
|
||||
await open('asin-center/registry')
|
||||
const solid = await page.evaluate(() => {
|
||||
const btns = [...document.querySelectorAll('.el-table .el-button--small')]
|
||||
const linkOrText = btns.filter((b) => b.classList.contains('is-link') || b.classList.contains('is-text')).length
|
||||
const primary = btns.filter((b) => b.classList.contains('el-button--primary')).length
|
||||
return { smallSolid: btns.length, linkOrText, primary }
|
||||
})
|
||||
console.log('registry row buttons:', JSON.stringify(solid))
|
||||
|
||||
await browser.close()
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,135 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 日志文件行(桌面客户端 / 麦象采集机上报)。 */
|
||||
export interface DeviceLogFileRow {
|
||||
id: number
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
username: string | null
|
||||
uid: number | null
|
||||
fileName: string
|
||||
logDate: string
|
||||
uploadedBytes: number
|
||||
partCount: number
|
||||
lastUploadAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogPage {
|
||||
items: DeviceLogFileRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 云端日志保留天数(页面提示用)。 */
|
||||
retentionDays: number
|
||||
}
|
||||
|
||||
export interface DeviceLogContent {
|
||||
fileId: number
|
||||
fileName: string
|
||||
content: string
|
||||
totalBytes: number
|
||||
shownBytes: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface DeviceLogDevice {
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
lastUploadAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogOverride {
|
||||
id: number
|
||||
source: string
|
||||
deviceId: string
|
||||
deviceName: string | null
|
||||
mode: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface DeviceLogConfigData {
|
||||
globalMode: string
|
||||
overrides: DeviceLogOverride[]
|
||||
}
|
||||
|
||||
export interface DeviceLogQuery {
|
||||
source?: string
|
||||
keyword?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询日志文件列表:GET /api/admin/device-logs/files */
|
||||
export async function fetchDeviceLogFiles(params: DeviceLogQuery): Promise<DeviceLogPage> {
|
||||
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||
if (params.source) query.source = params.source
|
||||
if (params.keyword) query.keyword = params.keyword
|
||||
if (params.startDate) query.startDate = params.startDate
|
||||
if (params.endDate) query.endDate = params.endDate
|
||||
const { data } = await http.get('/api/admin/device-logs/files', { params: query })
|
||||
return unwrap<DeviceLogPage>(data)
|
||||
}
|
||||
|
||||
/** 查看日志尾部内容:GET /api/admin/device-logs/content */
|
||||
export async function fetchDeviceLogContent(fileId: number, maxBytes?: number): Promise<DeviceLogContent> {
|
||||
const query: Record<string, number> = { fileId }
|
||||
if (maxBytes) query.maxBytes = maxBytes
|
||||
const { data } = await http.get('/api/admin/device-logs/content', { params: query })
|
||||
return unwrap<DeviceLogContent>(data)
|
||||
}
|
||||
|
||||
/** 完整日志下载地址(同域 cookie 鉴权,直接给 a[href] 或 window.open 用)。 */
|
||||
export function deviceLogDownloadUrl(fileId: number): string {
|
||||
return `/api/admin/device-logs/download?fileId=${fileId}`
|
||||
}
|
||||
|
||||
/** 删除日志文件(片段与元数据,不可恢复):DELETE /api/admin/device-logs/{id} */
|
||||
export async function deleteDeviceLogFile(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/device-logs/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 采集配置(全局默认 + 终端覆盖):GET /api/admin/device-logs/config */
|
||||
export async function fetchDeviceLogConfig(keyword?: string): Promise<DeviceLogConfigData> {
|
||||
const { data } = await http.get('/api/admin/device-logs/config', {
|
||||
params: keyword ? { keyword } : undefined,
|
||||
})
|
||||
return unwrap<DeviceLogConfigData>(data)
|
||||
}
|
||||
|
||||
/** 最近上报过的终端(覆盖选择用):GET /api/admin/device-logs/devices */
|
||||
export async function fetchDeviceLogDevices(): Promise<DeviceLogDevice[]> {
|
||||
const { data } = await http.get('/api/admin/device-logs/devices')
|
||||
return unwrap<DeviceLogDevice[]>(data)
|
||||
}
|
||||
|
||||
/** 设置全局采集模式:PUT /api/admin/device-logs/config/global */
|
||||
export async function updateDeviceLogGlobalMode(mode: string): Promise<void> {
|
||||
const { data } = await http.put('/api/admin/device-logs/config/global', undefined, { params: { mode } })
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 设置/更新终端覆盖:PUT /api/admin/device-logs/config/device */
|
||||
export async function updateDeviceLogOverride(
|
||||
source: string,
|
||||
deviceId: string,
|
||||
deviceName: string | null,
|
||||
mode: string,
|
||||
): Promise<void> {
|
||||
const { data } = await http.put('/api/admin/device-logs/config/device', undefined, {
|
||||
params: { source, deviceId, deviceName: deviceName || undefined, mode },
|
||||
})
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 删除终端覆盖(回落到全局默认):DELETE /api/admin/device-logs/config/device/{id} */
|
||||
export async function deleteDeviceLogOverride(id: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/device-logs/config/device/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/** API 响应解包与错误归一化(任务 22):纯函数,无框架依赖。 */
|
||||
|
||||
export const REQUEST_FALLBACK_MESSAGE = '网络异常,请稍后重试'
|
||||
|
||||
function textOf(value: Record<string, unknown>): string {
|
||||
const message = value.message
|
||||
const error = value.error
|
||||
if (typeof message === 'string' && message.trim()) return message.trim()
|
||||
if (typeof error === 'string' && error.trim()) return error.trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一解包 Java 响应:
|
||||
* - success=false -> 抛错(取 message/error,空则“请求失败”);
|
||||
* - 带非空 data -> 返回 data;
|
||||
* - 否则原样返回(允许 {item}/{items} 等信封由调用方再解)。
|
||||
*/
|
||||
export function unwrap<T>(payload: unknown): T {
|
||||
const value = payload as Record<string, unknown> | null
|
||||
if (!value || typeof value !== 'object') return payload as T
|
||||
if (value.success === false) {
|
||||
throw new Error(textOf(value) || '请求失败')
|
||||
}
|
||||
if ('data' in value && value.data !== null && value.data !== undefined) {
|
||||
return value.data as T
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
/** 从任意负载取错误文案;缺省回退。 */
|
||||
export function errorTextOf(payload: unknown): string {
|
||||
if (payload instanceof Error) return payload.message.trim() || REQUEST_FALLBACK_MESSAGE
|
||||
if (payload && typeof payload === 'object') {
|
||||
const text = textOf(payload as Record<string, unknown>)
|
||||
if (text) return text
|
||||
}
|
||||
if (typeof payload === 'string' && payload.trim()) return payload.trim()
|
||||
return REQUEST_FALLBACK_MESSAGE
|
||||
}
|
||||
|
||||
/** 负载/状态码是否 401(未登录)。 */
|
||||
export function isUnauthorized(payload: unknown): boolean {
|
||||
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
|
||||
if (!record || typeof record !== 'object') return false
|
||||
return [record.status, record.statusCode, record.code].some((v) => v === 401)
|
||||
}
|
||||
|
||||
/** 负载/状态码是否 403(已登录但无后台权限)。 */
|
||||
export function isForbidden(payload: unknown): boolean {
|
||||
const record = payload as { status?: unknown; statusCode?: unknown; code?: unknown } | null
|
||||
if (!record || typeof record !== 'object') return false
|
||||
return [record.status, record.statusCode, record.code].some((v) => v === 403)
|
||||
}
|
||||
|
||||
/** 单设备登录:账号已在其他设备登录(4011,与后端 DeviceSessionPolicy.CODE_KICKED 对齐)。 */
|
||||
export function isKicked(payload: unknown): boolean {
|
||||
const record = payload as { code?: unknown } | null
|
||||
if (!record || typeof record !== 'object') return false
|
||||
return record.code === 4011
|
||||
}
|
||||
|
||||
/** Axios 错误(带 response)或普通 Error 统一取可展示文案。 */
|
||||
export function requestErrorMessage(error: unknown): string {
|
||||
const response = (error as { response?: { data?: unknown; status?: number } })?.response
|
||||
if (response) {
|
||||
const text = errorTextOf(response.data)
|
||||
if (text) return text
|
||||
}
|
||||
return errorTextOf(error)
|
||||
}
|
||||
|
||||
/** 当前地址作为登录跳转的 redirect 参数(encodeURIComponent 后)。 */
|
||||
export function loginRedirectTarget(location: { pathname: string; search: string }): string {
|
||||
return encodeURIComponent(`${location.pathname}${location.search}`)
|
||||
}
|
||||
|
||||
/** 登录页统一入口;401 一律跳此路径。 */
|
||||
export const LOGIN_PATH = '/login'
|
||||
|
||||
/** 当前 pathname 是否处于登录页(去掉可能携带的查询串后比较)。 */
|
||||
export function isLoginLocation(pathname: string): boolean {
|
||||
if (typeof pathname !== 'string') return false
|
||||
const queryAt = pathname.indexOf('?')
|
||||
return (queryAt >= 0 ? pathname.slice(0, queryAt) : pathname) === LOGIN_PATH
|
||||
}
|
||||
|
||||
/** 把请求 URL(相对或绝对)规整为相对路径,便于与登录入口比较。 */
|
||||
export function requestRelativePath(url: string): string {
|
||||
if (typeof url !== 'string') return ''
|
||||
const noQuery = url.split('?')[0]
|
||||
const match = noQuery.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/]+(.*)$/)
|
||||
return match ? match[1] : noQuery
|
||||
}
|
||||
|
||||
/** 401 失败请求本身是否指向登录/认证端点(登录提交失败不应再被踢回登录页)。 */
|
||||
export function isAuthEndpointRequest(url: string | undefined): boolean {
|
||||
return requestRelativePath(url || '') === LOGIN_PATH
|
||||
}
|
||||
|
||||
/**
|
||||
* 401 是否应执行“跳 /login”:已处于登录页或失败请求即登录端点时不应再跳,
|
||||
* 否则会在登录页循环跳转或吞掉登录失败反馈。
|
||||
*/
|
||||
export function shouldRedirectUnauthorized(currentPathname: string, requestUrl?: string): boolean {
|
||||
if (isLoginLocation(currentPathname)) return false
|
||||
if (isAuthEndpointRequest(requestUrl)) return false
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import axios from 'axios'
|
||||
import {
|
||||
isForbidden,
|
||||
isKicked,
|
||||
isLoginLocation,
|
||||
isUnauthorized,
|
||||
loginRedirectTarget,
|
||||
shouldRedirectUnauthorized,
|
||||
} from './envelope'
|
||||
|
||||
export { unwrap } from './envelope'
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL: '/',
|
||||
withCredentials: true,
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
function redirectToLogin(requestUrl?: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
// 已在登录页或失败请求即登录端点时不再跳转,避免登录页循环与吞掉登录失败反馈。
|
||||
if (!shouldRedirectUnauthorized(window.location.pathname, requestUrl)) return
|
||||
const target = loginRedirectTarget(window.location)
|
||||
window.location.assign('/admin-vue/login?redirect=' + target)
|
||||
}
|
||||
|
||||
/** 单设备登录:被新设备顶下线,整页回登录页(整页 reload 顺带重置会话 store,避免守卫弹回)。 */
|
||||
function redirectToLoginKicked(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
if (isLoginLocation(window.location.pathname)) return
|
||||
window.location.assign('/admin-vue/login?kicked=1')
|
||||
}
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
// Java 未登录以 HTTP 200 + body.code=401 返回,需统一按未登录处理;
|
||||
// 403(已登录但无后台权限,如用工具前端账号 token 访问后台)与 401 同样跳登录页。
|
||||
// 4011(账号已在其他设备登录)单独提示,不按普通 401 处理。
|
||||
if (isKicked(response.data)) {
|
||||
redirectToLoginKicked()
|
||||
} else if (isUnauthorized(response.data) || isForbidden(response.data)) {
|
||||
redirectToLogin(response.config?.url)
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
if (isKicked(error?.response?.data)) {
|
||||
redirectToLoginKicked()
|
||||
} else if (
|
||||
error?.response?.status === 401 ||
|
||||
error?.response?.status === 403 ||
|
||||
isUnauthorized(error?.response?.data) ||
|
||||
isForbidden(error?.response?.data)
|
||||
) {
|
||||
redirectToLogin(error?.config?.url)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 单条通知(后台铃铛)。 */
|
||||
export interface AdminNotificationItem {
|
||||
id: number
|
||||
scene: string
|
||||
level: string
|
||||
title: string
|
||||
content: string
|
||||
read: boolean
|
||||
readAt: string | null
|
||||
createdAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminNotificationPage {
|
||||
items: AdminNotificationItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
unreadCount: number
|
||||
}
|
||||
|
||||
export interface AdminNotificationSummary {
|
||||
unreadCount: number
|
||||
latestId: number
|
||||
}
|
||||
|
||||
/** 铃铛轮询:未读数 + 最新通知 id。 */
|
||||
export async function fetchNotificationSummary(): Promise<AdminNotificationSummary> {
|
||||
const { data } = await http.get('/api/admin/notifications/summary')
|
||||
return unwrap<AdminNotificationSummary>(data)
|
||||
}
|
||||
|
||||
/** 列表查询参数:关键字匹配标题/内容;startDate/endDate 为年月日闭区间(yyyy-MM-dd)。 */
|
||||
export interface AdminNotificationListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
onlyUnread?: boolean
|
||||
keyword?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
}
|
||||
|
||||
export async function fetchNotificationList(
|
||||
params: AdminNotificationListParams = {},
|
||||
): Promise<AdminNotificationPage> {
|
||||
const keyword = (params.keyword ?? '').trim()
|
||||
const { data } = await http.get('/api/admin/notifications', {
|
||||
params: {
|
||||
page: params.page ?? 1,
|
||||
pageSize: params.pageSize ?? 20,
|
||||
onlyUnread: params.onlyUnread ? 'true' : 'false',
|
||||
keyword: keyword || undefined,
|
||||
startDate: params.startDate || undefined,
|
||||
endDate: params.endDate || undefined,
|
||||
},
|
||||
})
|
||||
return unwrap<AdminNotificationPage>(data)
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: number): Promise<boolean> {
|
||||
const { data } = await http.post(`/api/admin/notifications/${id}/read`)
|
||||
return unwrap<boolean>(data)
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<number> {
|
||||
const { data } = await http.post('/api/admin/notifications/read-all')
|
||||
return unwrap<number>(data)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { unwrap } from './envelope.ts'
|
||||
import type { AdminMenuNode, AdminUser } from '../types/admin'
|
||||
|
||||
/** 解析当前用户响应:支持 data:{item} 与 {item}/{直接对象} 信封。 */
|
||||
export function parseCurrentUser(payload: unknown): AdminUser {
|
||||
const value = unwrap<AdminUser | { item?: AdminUser }>(payload)
|
||||
const user =
|
||||
value && typeof value === 'object' && 'item' in value && value.item
|
||||
? value.item
|
||||
: (value as AdminUser)
|
||||
if (!user || typeof user.id !== 'number') {
|
||||
throw new Error('当前用户响应缺少有效 id')
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析菜单树响应:支持 data:{items} 与 {items}/数组 信封。
|
||||
* 返回后端树形节点;空/缺省按 [] 处理,与“接口失败”(抛错) 区分开。
|
||||
*/
|
||||
export function parseMenuTree(payload: unknown): AdminMenuNode[] {
|
||||
const value = unwrap<AdminMenuNode[] | { items?: AdminMenuNode[] }>(payload)
|
||||
if (Array.isArray(value)) return value
|
||||
return (value && Array.isArray(value.items) ? value.items : []) as AdminMenuNode[]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { http } from './http'
|
||||
import { parseCurrentUser, parseMenuTree } from './session-model'
|
||||
import type { AdminMenuNode, AdminUser, ApiEnvelope } from '../types/admin'
|
||||
|
||||
export async function fetchCurrentUser(): Promise<AdminUser> {
|
||||
const { data } = await http.get<ApiEnvelope<{ item: AdminUser }> | AdminUser>('/api/admin/current-user')
|
||||
return parseCurrentUser(data)
|
||||
}
|
||||
|
||||
export async function fetchAdminMenuTree(): Promise<AdminMenuNode[]> {
|
||||
const { data } = await http.get<
|
||||
ApiEnvelope<{ items: AdminMenuNode[] }> | { items: AdminMenuNode[] } | AdminMenuNode[]
|
||||
>('/api/admin/current-user/menus')
|
||||
return parseMenuTree(data)
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// 登出走 auth 模块根端点 POST /logout(下发清除会话 cookie);旧 api/admin 前缀路径后端无映射。
|
||||
await http.post('/logout', undefined, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 单用户一行:三类密钥调用次数 + 合计(口径=真实对外请求次数)。 */
|
||||
export interface AdminUserSecretUsageRow {
|
||||
userId: number
|
||||
username: string
|
||||
groups: string[]
|
||||
similarAsinCount: number
|
||||
appearancePatentCount: number
|
||||
proxyCount: number
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export interface AdminUserSecretUsagePage {
|
||||
items: AdminUserSecretUsageRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 所选范围内(不分页)三类总次数。 */
|
||||
totalCalls: number
|
||||
similarAsinCalls: number
|
||||
appearancePatentCalls: number
|
||||
proxyCalls: number
|
||||
groupOptions?: Array<{ id: number; groupName: string }>
|
||||
}
|
||||
|
||||
export interface UserSecretUsageQuery {
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
keyword?: string
|
||||
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||
groupId?: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询密钥用量:GET /api/admin/user-secret-usage(group_id 必须 snake_case)。 */
|
||||
export async function fetchUserSecretUsage(params: UserSecretUsageQuery): Promise<AdminUserSecretUsagePage> {
|
||||
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||
if (params.startDate) query.startDate = params.startDate
|
||||
if (params.endDate) query.endDate = params.endDate
|
||||
if (params.keyword) query.keyword = params.keyword
|
||||
if (params.groupId) query.group_id = params.groupId
|
||||
const { data } = await http.get('/api/admin/user-secret-usage', { params: query })
|
||||
return unwrap<AdminUserSecretUsagePage>(data)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
|
||||
/** 单模块状态(脱敏值 + 连通性)。 */
|
||||
export interface AdminUserSecretModule {
|
||||
moduleKey: string
|
||||
moduleLabel: string
|
||||
masked: string
|
||||
/** 完整明文值:仅代理列有值(后端对密钥列不下发明文)。 */
|
||||
full: string
|
||||
exists: boolean
|
||||
checkStatus: string
|
||||
checkCode: string
|
||||
checkMessage: string
|
||||
checkLatencyMs: number | null
|
||||
checkedAt: string | null
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** 一行一用户:三个字段列 + 行级状态(三类都检测通过才算 passed)。 */
|
||||
export interface AdminUserSecretRow {
|
||||
userId: number
|
||||
username: string
|
||||
/** 所属数据权限分组名(可能多个)。 */
|
||||
groups: string[]
|
||||
/** 所属主管(创建人)用户名;无分组时的兜底展示。 */
|
||||
leaderUsername: string
|
||||
similarAsin: AdminUserSecretModule
|
||||
appearancePatent: AdminUserSecretModule
|
||||
proxy: AdminUserSecretModule
|
||||
status: string
|
||||
statusMessage: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export interface AdminUserSecretPage {
|
||||
items: AdminUserSecretRow[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 分组筛选项(超管=全部;主管=自己带的分组)。 */
|
||||
groupOptions?: GroupOption[]
|
||||
}
|
||||
|
||||
export interface GroupOption {
|
||||
id: number
|
||||
groupName: string
|
||||
}
|
||||
|
||||
export interface UserSecretQuery {
|
||||
keyword?: string
|
||||
checkStatus?: string
|
||||
/** 按数据权限分组筛选(仅超管生效);后端参数为 snake_case。 */
|
||||
groupId?: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
/** 分页查询用户密钥(一行一用户):GET /api/admin/user-secrets(筛选参数必须 snake_case,camel 会被后端静默忽略)。 */
|
||||
export async function fetchUserSecretList(params: UserSecretQuery): Promise<AdminUserSecretPage> {
|
||||
const query: Record<string, string | number> = { page: params.page, pageSize: params.pageSize }
|
||||
if (params.keyword) query.keyword = params.keyword
|
||||
if (params.checkStatus) query.checkStatus = params.checkStatus
|
||||
if (params.groupId) query.group_id = params.groupId
|
||||
const { data } = await http.get('/api/admin/user-secrets', { params: query })
|
||||
return unwrap<AdminUserSecretPage>(data)
|
||||
}
|
||||
|
||||
/** 立即检测该用户全部已配置项:POST /api/admin/user-secrets/{userId}/check
|
||||
* 逐模块检测(每项最多「首查 + 传输层失败重试一次」,最坏约 31s/项),故放宽超时到 180s。 */
|
||||
export async function checkUserSecret(userId: number) {
|
||||
const { data } = await http.post(`/api/admin/user-secrets/${userId}/check`, undefined, { timeout: 180_000 })
|
||||
return unwrap<
|
||||
Array<{
|
||||
moduleKey: string
|
||||
checkStatus: string
|
||||
checkCode: string
|
||||
checkMessage: string
|
||||
checkLatencyMs: number | null
|
||||
checkedAt: string | null
|
||||
viaProxy: boolean
|
||||
}>
|
||||
>(data)
|
||||
}
|
||||
|
||||
/** 清空该用户全部密钥与代理配置:DELETE /api/admin/user-secrets/{userId} */
|
||||
export async function deleteUserSecret(userId: number): Promise<void> {
|
||||
const { data } = await http.delete(`/api/admin/user-secrets/${userId}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/** 用户管理列表/分页 DTO(任务 41):纯逻辑,无框架依赖,与 Java AdminUserController 对齐。 */
|
||||
import type { AdminUser } from '../types/admin'
|
||||
|
||||
export const USER_PAGE_DEFAULT_SIZE = 10
|
||||
export const USER_PAGE_MIN_PAGE = 1
|
||||
export const USER_PAGE_MAX_SIZE = 200
|
||||
|
||||
/** 前端用户列表筛选/分页状态(页面局部,不入 Pinia)。 */
|
||||
export interface UserListParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 用户名模糊关键字(对应后端 username|search 的 kw)。 */
|
||||
keyword?: string
|
||||
createdById?: number | null
|
||||
adminId?: number | null
|
||||
/** 角色过滤(''=全部)。 */
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** 序列化到 GET /api/admin/users 的查询参数(Java snake_case)。 */
|
||||
export interface UserListQuery {
|
||||
page: number
|
||||
page_size: number
|
||||
username?: string
|
||||
search?: string
|
||||
created_by_id?: number
|
||||
admin_id?: number
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** 用户列表项即 AdminUser(与 Java AdminUserItemVo 字段对齐)。 */
|
||||
export type AdminUserItem = AdminUser
|
||||
|
||||
/** 分页结果:与 Java AdminUserListVo.items/total/page/page_size 归一后的前端 DTO。 */
|
||||
export interface UserPageResult {
|
||||
items: AdminUserItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
currentUserId?: number | null
|
||||
currentUserUsername?: string
|
||||
admins?: Array<{ id: number; username: string }>
|
||||
}
|
||||
|
||||
function finiteInt(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||
}
|
||||
|
||||
/** 归一化分页/筛选参数:页码下限 1、页大小 1..上限,空关键字视为无过滤。 */
|
||||
export function normalizeUserPageParams(raw: Partial<UserListParams>): UserListParams {
|
||||
const page = Math.max(finiteInt(raw.page) ?? USER_PAGE_MIN_PAGE, USER_PAGE_MIN_PAGE)
|
||||
const rawSize = finiteInt(raw.pageSize)
|
||||
const pageSize =
|
||||
rawSize === null || rawSize < USER_PAGE_MIN_PAGE
|
||||
? USER_PAGE_DEFAULT_SIZE
|
||||
: Math.min(rawSize, USER_PAGE_MAX_SIZE)
|
||||
const keyword = typeof raw.keyword === 'string' ? raw.keyword.trim() || undefined : undefined
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
createdById: typeof raw.createdById === 'number' ? raw.createdById : null,
|
||||
adminId: typeof raw.adminId === 'number' ? raw.adminId : null,
|
||||
role: typeof raw.role === 'string' ? raw.role.trim() || undefined : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 把前端分页/筛选状态转成 Java 查询参数(缺省字段不下发)。 */
|
||||
export function toUserListQuery(params: UserListParams): UserListQuery {
|
||||
const query: UserListQuery = { page: params.page, page_size: params.pageSize }
|
||||
if (params.keyword) query.username = params.keyword
|
||||
if (params.createdById != null) query.created_by_id = params.createdById
|
||||
if (params.adminId != null) query.admin_id = params.adminId
|
||||
if (params.role) query.role = params.role
|
||||
return query
|
||||
}
|
||||
|
||||
/** 无数据分页结果缺省值(首屏/加载前占位)。 */
|
||||
export function emptyUserPageResult(): UserPageResult {
|
||||
return { items: [], total: 0, page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/** 用户 API 响应解析模型:列表分页(任务 42)与新建用户(任务 46)负载归一。 */
|
||||
import { unwrap } from './envelope.ts'
|
||||
import { emptyUserPageResult, type UserPageResult } from './users-dto.ts'
|
||||
import type { AdminUser } from '../types/admin'
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
/** 把 Java AdminUserItemVo(snake_case) 映射为前端 AdminUser(camelCase);缺 id 视为无效。 */
|
||||
export function toAdminUserItem(raw: unknown): AdminUser | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const id = numberOrNull(r.id)
|
||||
if (id === null) return null
|
||||
const item: AdminUser = { id, username: text(r.username), role: text(r.role) }
|
||||
if (typeof r.is_admin === 'boolean') item.isAdmin = r.is_admin
|
||||
const createdById = numberOrNull(r.created_by_id)
|
||||
if (createdById !== null) item.createdById = createdById
|
||||
const createdAt = text(r.created_at)
|
||||
if (createdAt) item.createdAt = createdAt
|
||||
const creator = text(r.creator_username)
|
||||
if (creator) item.creatorUsername = creator
|
||||
const abbr = text(r.pinyin_abbr)
|
||||
if (abbr) item.pinyinAbbr = abbr
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化 Java 用户列表负载(data:{items,total,page,page_size} 或已解包 VO)为前端
|
||||
* UserPageResult;空/缺省字段回默认,success=false 抛后端 message。
|
||||
*/
|
||||
export function parseUserPage(payload: unknown): UserPageResult {
|
||||
const out = emptyUserPageResult()
|
||||
const core = unwrap<unknown>(payload)
|
||||
if (!core || typeof core !== 'object') return out
|
||||
const record = core as Record<string, unknown>
|
||||
if (Array.isArray(record.items)) {
|
||||
out.items = record.items
|
||||
.map((raw) => toAdminUserItem(raw))
|
||||
.filter((item): item is AdminUser => item !== null)
|
||||
}
|
||||
if (typeof record.total === 'number') out.total = record.total
|
||||
if (typeof record.page === 'number' && record.page >= 1) out.page = Math.floor(record.page)
|
||||
const rawSize = record.page_size ?? record.pageSize
|
||||
if (typeof rawSize === 'number' && rawSize >= 1) out.pageSize = Math.floor(rawSize)
|
||||
if (typeof record.current_user_id === 'number') out.currentUserId = record.current_user_id
|
||||
if (typeof record.current_user_username === 'string') out.currentUserUsername = record.current_user_username
|
||||
if (Array.isArray(record.admins)) {
|
||||
out.admins = record.admins
|
||||
.map((raw) => {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const r = raw as Record<string, unknown>
|
||||
const id = numberOrNull(r.id)
|
||||
if (id === null) return null
|
||||
return { id, username: text(r.username) }
|
||||
})
|
||||
.filter((a): a is { id: number; username: string } => a !== null)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** 解包新建用户响应并取回新用户 id(POST /api/admin/user 的 data 为 userId)。 */
|
||||
export function parseCreatedUserId(payload: unknown): number {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const id = typeof data === 'number' && Number.isFinite(data) ? Math.floor(data) : null
|
||||
if (id === null || id < 1) throw new Error('创建用户响应异常:未返回有效的用户 ID')
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { http } from './http'
|
||||
import { unwrap } from './envelope'
|
||||
import { parseUserPage } from './users-model'
|
||||
import {
|
||||
normalizeUserPageParams,
|
||||
toUserListQuery,
|
||||
type UserListParams,
|
||||
type UserPageResult,
|
||||
} from './users-dto'
|
||||
|
||||
/** 分页查询用户列表:归一化参数 → GET /api/admin/users → 解析 Java 分页负载。 */
|
||||
export async function fetchUserList(params: Partial<UserListParams> = {}): Promise<UserPageResult> {
|
||||
const normalized = normalizeUserPageParams(params)
|
||||
const { data } = await http.get('/api/admin/users', { params: toUserListQuery(normalized) })
|
||||
return parseUserPage(data)
|
||||
}
|
||||
|
||||
/** 删除用户:DELETE /api/admin/user/{uid},success=false 由 unwrap 抛后端 message。 */
|
||||
export async function deleteUser(uid: number): Promise<void> {
|
||||
const { data } = await http.delete<unknown>(`/api/admin/user/${uid}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue'
|
||||
import { openAdminConfirm } from './admin-confirm'
|
||||
|
||||
/**
|
||||
* AdminConfirm 共享确认组件(module 13 task 245):renderless 单例承载。
|
||||
* 使用:<AdminConfirm ref="confirmRef" />,需确认时 await confirmRef.open({...})。
|
||||
*/
|
||||
export default defineComponent({
|
||||
name: 'AdminConfirm',
|
||||
setup(_props, { expose }) {
|
||||
expose({ open: openAdminConfirm })
|
||||
return () => null
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { copyFeedback, isCopySupported } from './copy-model'
|
||||
|
||||
const props = defineProps<{ text: string }>()
|
||||
|
||||
async function handleCopy(): Promise<void> {
|
||||
if (!props.text || !isCopySupported()) {
|
||||
ElMessage.warning(copyFeedback(''))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.text)
|
||||
ElMessage.success(copyFeedback(props.text))
|
||||
} catch {
|
||||
ElMessage.error(copyFeedback(''))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="copy-text" role="button" tabindex="0" :title="text" @click="handleCopy"><slot>{{ text }}</slot></span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.copy-text { cursor: pointer; }
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { dialogTitleOf, drawerLayoutSpec } from './modal-model'
|
||||
|
||||
/**
|
||||
* DrawerShell 右侧抽屉容器(module 13 task 248):封装 el-drawer,统一 admin 尺寸
|
||||
* 与关闭语义(遮罩/Esc),标题透传默认头部、footer 槽承接底部按钮,构成头/体/脚三段。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{ modelValue: boolean; title?: string; subtitle?: string; width?: string | number }>(),
|
||||
{ title: '', subtitle: '', width: drawerLayoutSpec().width },
|
||||
)
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
const spec = computed(() => drawerLayoutSpec())
|
||||
const head = computed(() => dialogTitleOf({ title: props.title, subtitle: props.subtitle }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:size="width"
|
||||
:title="head.title"
|
||||
:close-on-click-modal="spec.maskClosable"
|
||||
:close-on-press-escape="spec.escClosable"
|
||||
class="admin-drawer-shell"
|
||||
>
|
||||
<p v-if="head.subtitle" class="admin-drawer-subtitle">{{ head.subtitle }}</p>
|
||||
<slot />
|
||||
<template #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-drawer-subtitle { margin: 0 0 12px; color: var(--admin-muted); font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { secretPlaceholder } from '@/pages/shop/secret-mask'
|
||||
|
||||
/**
|
||||
* MaskReveal 掩码揭示(module 13 task 247):默认以店铺 secret-mask 掩码占位展示,
|
||||
* 点击切换时向父组件发 reveal 事件取明文(以 value prop 回填);短时后自动收回。
|
||||
*/
|
||||
const props = withDefaults(defineProps<{ value?: string; revealMs?: number }>(), {
|
||||
value: '',
|
||||
revealMs: 5000,
|
||||
})
|
||||
const emit = defineEmits<{ (e: 'reveal'): void }>()
|
||||
|
||||
const revealed = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearTimer(): void {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function show(): void {
|
||||
revealed.value = true
|
||||
emit('reveal')
|
||||
clearTimer()
|
||||
timer = setTimeout(() => {
|
||||
revealed.value = false
|
||||
}, props.revealMs)
|
||||
}
|
||||
|
||||
function hide(): void {
|
||||
clearTimer()
|
||||
revealed.value = false
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
if (revealed.value) hide()
|
||||
else show()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
(v) => {
|
||||
if (!v) revealed.value = false
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(clearTimer)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="mask-reveal">
|
||||
<span class="mask-reveal-text">{{ revealed && value ? value : secretPlaceholder() }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mask-reveal-toggle"
|
||||
:aria-label="revealed ? '隐藏' : '显示'"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ revealed ? '隐藏' : '显示' }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mask-reveal { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.mask-reveal-text { font-variant-numeric: tabular-nums; }
|
||||
.mask-reveal-toggle {
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
padding: 1px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mask-reveal-toggle:hover { color: #2f5d8b; border-color: #95b1cb; background: #edf5fb; }
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { dialogTitleOf, modalLayoutSpec } from './modal-model'
|
||||
|
||||
/**
|
||||
* ModalShell 居中模态容器(module 13 task 248):封装 el-dialog,统一 admin 尺寸/圆角
|
||||
* 与关闭语义,标题透传给 Element 默认头部;body 用默认槽、操作区走 footer 槽。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{ modelValue: boolean; title?: string; subtitle?: string; width?: string | number }>(),
|
||||
{ title: '', subtitle: '', width: modalLayoutSpec().width },
|
||||
)
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
const spec = computed(() => modalLayoutSpec())
|
||||
const head = computed(() => dialogTitleOf({ title: props.title, subtitle: props.subtitle }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:width="width"
|
||||
:title="head.title"
|
||||
:close-on-click-modal="spec.maskClosable"
|
||||
:close-on-press-escape="spec.escClosable"
|
||||
class="admin-modal-shell"
|
||||
>
|
||||
<p v-if="head.subtitle" class="admin-modal-subtitle">{{ head.subtitle }}</p>
|
||||
<slot />
|
||||
<template #footer>
|
||||
<slot name="footer" />
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal-subtitle { margin: 0 0 12px; color: var(--admin-muted); font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,683 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="admin-notification-bell">
|
||||
<button
|
||||
type="button"
|
||||
class="bell-trigger"
|
||||
:class="{ 'bell-trigger--active': panelOpen }"
|
||||
:title="unreadCount > 0 ? `通知(${unreadCount} 条未读)` : '通知'"
|
||||
aria-label="通知"
|
||||
@click="togglePanel"
|
||||
>
|
||||
<svg
|
||||
class="bell-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
<span v-if="badgeText" class="bell-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<!-- 面板挂到 body 上(fixed 定位):顶栏内绝对定位时会被列表页 el-select 等页面内容压住,提 z-index 无效 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="panelOpen"
|
||||
ref="panelRef"
|
||||
class="bell-panel"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-label="通知列表"
|
||||
>
|
||||
<div class="bell-panel-head">
|
||||
<span class="bell-panel-title">通知</span>
|
||||
<button
|
||||
v-if="unreadCount > 0"
|
||||
type="button"
|
||||
class="bell-read-all"
|
||||
:disabled="markingAll"
|
||||
@click="readAll"
|
||||
>
|
||||
{{ markingAll ? '处理中...' : '全部已读' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bell-search">
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
class="bell-search-input"
|
||||
placeholder="搜索标题或内容"
|
||||
aria-label="搜索通知"
|
||||
@input="onKeywordInput"
|
||||
/>
|
||||
<div class="bell-search-days">
|
||||
<el-date-picker
|
||||
:model-value="dateRangeValue"
|
||||
type="daterange"
|
||||
value-format="YYYY-MM-DD"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
range-separator="至"
|
||||
size="small"
|
||||
class="bell-date-picker"
|
||||
popper-class="bell-date-popper"
|
||||
:clearable="true"
|
||||
@update:model-value="onDateRangeChange"
|
||||
@change="reload"
|
||||
/>
|
||||
<button v-if="hasFilter" type="button" class="bell-search-reset" @click="resetFilters">
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !items.length" class="bell-empty">正在加载...</div>
|
||||
<div v-else-if="loadError && !items.length" class="bell-empty bell-empty--error">{{ loadError }}</div>
|
||||
<div v-else-if="!items.length" class="bell-empty">{{ hasFilter ? '没有符合条件的通知' : '暂无通知' }}</div>
|
||||
<ul v-else class="bell-list">
|
||||
<template v-for="group in groupedItems" :key="group.day || 'unknown'">
|
||||
<li class="bell-day-head">{{ group.label }}</li>
|
||||
<li
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="bell-item"
|
||||
:class="[`bell-item--${item.level || 'warning'}`, { 'bell-item--unread': !item.read }]"
|
||||
@click="onItemClick(item)"
|
||||
>
|
||||
<div class="bell-item-head">
|
||||
<span class="bell-item-title">{{ item.title }}</span>
|
||||
<span class="bell-item-time">{{ formatTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="bell-item-content">{{ item.content }}</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
|
||||
<div class="bell-panel-foot">
|
||||
<button
|
||||
type="button"
|
||||
class="bell-page-btn"
|
||||
:disabled="page <= 1 || loading"
|
||||
@click="goPage(page - 1)"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span class="bell-page-info">{{ page }} / {{ totalPages }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="bell-page-btn"
|
||||
:disabled="page >= totalPages || loading"
|
||||
@click="goPage(page + 1)"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<span class="bell-page-total">共 {{ total }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import {
|
||||
fetchNotificationList,
|
||||
fetchNotificationSummary,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
type AdminNotificationItem,
|
||||
} from '@/api/notifications'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import {
|
||||
NOTIFICATION_POLL_INTERVAL_MS,
|
||||
formatNotificationTime,
|
||||
formatUnreadBadge,
|
||||
groupNotificationsByDay,
|
||||
hasNewNotification,
|
||||
readLastNotifiedId,
|
||||
writeLastNotifiedId,
|
||||
} from '@/layout/notification-bell-model'
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const panelRef = ref<HTMLElement | null>(null)
|
||||
const panelStyle = ref<Record<string, string>>({})
|
||||
|
||||
const unreadCount = ref(0)
|
||||
const items = ref<AdminNotificationItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const panelOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
const markingAll = ref(false)
|
||||
const loadError = ref('')
|
||||
const keyword = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
|
||||
const badgeText = computed(() => formatUnreadBadge(unreadCount.value))
|
||||
const groupedItems = computed(() => groupNotificationsByDay(items.value))
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||
const hasFilter = computed(() => Boolean(keyword.value.trim() || startDate.value || endDate.value))
|
||||
|
||||
/**
|
||||
* el-date-picker 的绑定桥:日期区间在内部仍用 startDate/endDate 两个 ref 表示
|
||||
* (hasFilter/normalizedDayRange/resetFilters 都基于它们)。
|
||||
* 此前模板写的是 v-model="dateRange",而 dateRange 从未声明 → vue-tsc 直接报 TS2339 构建失败,
|
||||
* 且日期筛选完全不下发参数。
|
||||
*/
|
||||
const dateRangeValue = computed<[string, string] | null>(() =>
|
||||
startDate.value && endDate.value ? [startDate.value, endDate.value] : null
|
||||
)
|
||||
|
||||
function onDateRangeChange(value: [string, string] | null) {
|
||||
const start = value?.[0]
|
||||
const end = value?.[1]
|
||||
startDate.value = start ? String(start) : ''
|
||||
endDate.value = end ? String(end) : ''
|
||||
}
|
||||
|
||||
let pollTimer: number | null = null
|
||||
let searchTimer: number | null = null
|
||||
|
||||
function currentUid(): number | string {
|
||||
return session.user?.id ?? 0
|
||||
}
|
||||
|
||||
/** 起止日期倒挂时自动交换,避免用户只看到「没有符合条件的通知」不知原因。 */
|
||||
function normalizedDayRange(): { startDate?: string; endDate?: string } {
|
||||
let start = startDate.value
|
||||
let end = endDate.value
|
||||
if (start && end && start > end) {
|
||||
const swapped = start
|
||||
start = end
|
||||
end = swapped
|
||||
}
|
||||
return { startDate: start || undefined, endDate: end || undefined }
|
||||
}
|
||||
|
||||
/** 拉未读数:发现新通知提醒一次(localStorage 记录已提醒过的最大 id,避免重复弹)。 */
|
||||
async function refreshSummary() {
|
||||
try {
|
||||
const summary = await fetchNotificationSummary()
|
||||
unreadCount.value = Number(summary?.unreadCount ?? 0)
|
||||
const latestId = Number(summary?.latestId ?? 0)
|
||||
if (hasNewNotification(latestId, readLastNotifiedId(currentUid()))) {
|
||||
ElMessage.warning('收到新的告警通知,请点击右上角铃铛查看')
|
||||
writeLastNotifiedId(currentUid(), latestId)
|
||||
console.log('[admin-notification] 检测到新通知 latestId=', latestId)
|
||||
}
|
||||
} catch (error) {
|
||||
// 通知接口失败静默降级:不显示红点、不打扰使用者
|
||||
console.warn('[admin-notification] 未读数刷新失败(静默降级):', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPage(targetPage: number) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchNotificationList({
|
||||
page: targetPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
keyword: keyword.value,
|
||||
...normalizedDayRange(),
|
||||
})
|
||||
const list = Array.isArray(result?.items) ? result.items : []
|
||||
items.value = list
|
||||
total.value = Number(result?.total ?? list.length)
|
||||
unreadCount.value = Number(result?.unreadCount ?? unreadCount.value)
|
||||
page.value = targetPage
|
||||
loadError.value = ''
|
||||
// 无筛选时首页就是最新数据,顺带记录「已提醒过的最大 id」;有筛选时最新 id 不代表真实最新
|
||||
if (targetPage <= 1 && !hasFilter.value && list.length) {
|
||||
const latest = Number(list[0].id)
|
||||
if (latest > 0) {
|
||||
writeLastNotifiedId(currentUid(), Math.max(readLastNotifiedId(currentUid()), latest))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 列表加载失败在面板内提示,不弹全局消息打扰用户
|
||||
console.warn('[admin-notification] 通知列表加载失败:', error)
|
||||
loadError.value = error instanceof Error ? error.message : '通知加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goPage(targetPage: number) {
|
||||
const safe = Math.min(Math.max(1, targetPage), totalPages.value)
|
||||
if (safe === page.value || loading.value) return
|
||||
void loadPage(safe)
|
||||
}
|
||||
|
||||
/** 搜索/日期变化后回到第一页重新查询。 */
|
||||
function reload() {
|
||||
void loadPage(1)
|
||||
}
|
||||
|
||||
/** 关键字输入防抖 300ms,避免每敲一个字都打一次接口。 */
|
||||
function onKeywordInput() {
|
||||
if (searchTimer != null) {
|
||||
window.clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = window.setTimeout(() => {
|
||||
searchTimer = null
|
||||
void loadPage(1)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
keyword.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
void loadPage(1)
|
||||
}
|
||||
|
||||
/** 面板挂到 body 后用 fixed 定位,位置按触发器实时算(右对齐在铃铛下方)。 */
|
||||
function syncPanelPosition() {
|
||||
const trigger = rootRef.value
|
||||
if (!trigger) return
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
panelStyle.value = {
|
||||
top: `${Math.round(rect.bottom + 10)}px`,
|
||||
right: `${Math.max(8, Math.round(window.innerWidth - rect.right))}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
panelOpen.value = !panelOpen.value
|
||||
if (panelOpen.value) {
|
||||
syncPanelPosition()
|
||||
void loadPage(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function onItemClick(item: AdminNotificationItem) {
|
||||
if (item.read) return
|
||||
try {
|
||||
await markNotificationRead(item.id)
|
||||
item.read = true
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1)
|
||||
} catch (error) {
|
||||
console.warn('[admin-notification] 标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function readAll() {
|
||||
if (markingAll.value || unreadCount.value === 0) return
|
||||
markingAll.value = true
|
||||
try {
|
||||
await markAllNotificationsRead()
|
||||
for (const item of items.value) {
|
||||
item.read = true
|
||||
}
|
||||
unreadCount.value = 0
|
||||
} catch (error) {
|
||||
console.warn('[admin-notification] 全部已读失败:', error)
|
||||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||||
} finally {
|
||||
markingAll.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
return formatNotificationTime(value)
|
||||
}
|
||||
|
||||
/** 点击组件外部关闭面板(面板已 Teleport 到 body,需连同面板自身一起判断)。 */
|
||||
function onDocumentMouseDown(event: MouseEvent) {
|
||||
if (!panelOpen.value) return
|
||||
const target = event.target
|
||||
if (!(target instanceof Node)) return
|
||||
if (rootRef.value?.contains(target) || panelRef.value?.contains(target)) return
|
||||
panelOpen.value = false
|
||||
}
|
||||
|
||||
/** 窗口尺寸变化时重新对齐面板。 */
|
||||
function onWindowResize() {
|
||||
if (panelOpen.value) {
|
||||
syncPanelPosition()
|
||||
}
|
||||
}
|
||||
|
||||
/** 页面回到前台时立即刷新一次未读数。 */
|
||||
function onVisibilityChange() {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'visible') {
|
||||
void refreshSummary()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onDocumentMouseDown)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
void refreshSummary()
|
||||
pollTimer = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return
|
||||
}
|
||||
void refreshSummary()
|
||||
}, NOTIFICATION_POLL_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousedown', onDocumentMouseDown)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
if (pollTimer != null) {
|
||||
window.clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (searchTimer != null) {
|
||||
window.clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-notification-bell {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bell-trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--admin-muted, #5b6f83);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.bell-trigger:hover,
|
||||
.bell-trigger--active {
|
||||
background: #edf5fb;
|
||||
border-color: #cbd9e6;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
}
|
||||
|
||||
.bell-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.bell-badge {
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
right: 0;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 999px;
|
||||
background: #d64545;
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bell-panel {
|
||||
position: fixed;
|
||||
z-index: 3200;
|
||||
width: 380px;
|
||||
max-width: calc(100vw - 32px);
|
||||
max-height: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--admin-border, #d8e3ee);
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(39, 67, 94, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bell-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.bell-panel-title {
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bell-read-all {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bell-read-all:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-empty {
|
||||
padding: 36px 0;
|
||||
color: #8a99a8;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bell-empty--error {
|
||||
color: #b23c3c;
|
||||
}
|
||||
|
||||
.bell-list {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bell-item {
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid #f0f3f7;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.bell-item:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.bell-item--unread {
|
||||
background: #f2f6ff;
|
||||
}
|
||||
|
||||
.bell-item--unread .bell-item-title::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
margin-right: 7px;
|
||||
border-radius: 50%;
|
||||
background: #d64545;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.bell-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bell-item-title {
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bell-item--error .bell-item-title {
|
||||
color: #b23c3c;
|
||||
}
|
||||
|
||||
.bell-item--warning .bell-item-title {
|
||||
color: #a8793e;
|
||||
}
|
||||
|
||||
.bell-item--info .bell-item-title {
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
}
|
||||
|
||||
.bell-item-time {
|
||||
flex-shrink: 0;
|
||||
color: #9aa6b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bell-item-content {
|
||||
margin-top: 4px;
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.bell-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
background: #fafcfe;
|
||||
}
|
||||
|
||||
.bell-search-input,
|
||||
.bell-date-input {
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bell-search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bell-search-input:focus,
|
||||
.bell-date-input:focus {
|
||||
border-color: var(--admin-primary-strong, #2f5d8b);
|
||||
}
|
||||
|
||||
.bell-search-days {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bell-date-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bell-date-sep {
|
||||
color: #8a99a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bell-search-reset {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
height: 28px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bell-search-reset:hover {
|
||||
background: #edf5fb;
|
||||
}
|
||||
|
||||
.bell-day-head {
|
||||
padding: 6px 14px;
|
||||
background: #f4f7fa;
|
||||
color: #7c8b9a;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.bell-panel-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid #edf1f5;
|
||||
}
|
||||
|
||||
.bell-page-btn {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bell-page-btn:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
}
|
||||
|
||||
.bell-page-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bell-page-info {
|
||||
color: var(--admin-text, #24384d);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bell-page-total {
|
||||
color: #9aa6b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
/** 旧版风格分页(像素复刻 admin.html .pagination)+ 每页条数下拉(10/20/50/100)。
|
||||
* props: total / page / pageSize / sizes(默认 10/20/50/100) / showTotal(默认 true) / jump(默认 true)。 */
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
sizes?: number[]
|
||||
showTotal?: boolean
|
||||
jump?: boolean
|
||||
}>(),
|
||||
{ sizes: () => [10, 20, 50, 100], showTotal: true, jump: true },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', page: number): void
|
||||
(e: 'size-change', size: number): void
|
||||
}>()
|
||||
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)))
|
||||
|
||||
function go(next: number) {
|
||||
if (next < 1 || next > totalPages.value || next === props.page) return
|
||||
emit('change', next)
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) return
|
||||
jumpPage.value = ''
|
||||
go(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function onSizeChange(event: Event) {
|
||||
const value = Number((event.target as HTMLSelectElement).value)
|
||||
if (Number.isFinite(value) && value > 0) emit('size-change', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="old-pagination">
|
||||
<span v-if="showTotal" class="page-total">共 {{ total }} 条</span>
|
||||
<select class="page-size-select" :value="pageSize" @change="onSizeChange">
|
||||
<option v-for="size in sizes" :key="size" :value="size">{{ size }}条/页</option>
|
||||
</select>
|
||||
<button type="button" :disabled="page <= 1" @click="go(page - 1)">上一页</button>
|
||||
<span class="page-cur">第 {{ page }} / {{ totalPages }} 页</span>
|
||||
<button type="button" :disabled="page >= totalPages" @click="go(page + 1)">下一页</button>
|
||||
<span v-if="jump" class="page-jump">
|
||||
跳至
|
||||
<input v-model="jumpPage" type="text" inputmode="numeric" @keyup.enter="goJump" />
|
||||
页
|
||||
<button type="button" @click="goJump">跳转</button>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.old-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.old-pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.old-pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.old-pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-size-select {
|
||||
min-height: 30px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { pillToneStyle, type PillTone } from './status-pill-model'
|
||||
|
||||
const props = withDefaults(defineProps<{ text: string; tone?: PillTone }>(), { tone: 'info' })
|
||||
const style = computed(() => pillToneStyle(props.tone))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-pill" :style="{ color: style.text, background: style.bg }">{{ text }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 62px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 二次确认模型(module 13 task 245):文案/按钮键规则对齐 admin-interactions.js ——
|
||||
* 优先显式 message,否则由行名拼接「确认删除“X”吗?此操作可能影响已有数据。」;
|
||||
* danger(删除/清空/删除导入类)默认标题「删除前确认」、确认键「确认删除」。
|
||||
* 纯逻辑、无 UI 依赖,便于单测;打开入口见 admin-confirm.ts / AdminConfirm.vue。
|
||||
*/
|
||||
export const ADMIN_CONFIRM = {
|
||||
dangerTitle: '删除前确认',
|
||||
defaultTitle: '请确认操作',
|
||||
confirmDeleteOk: '确认删除',
|
||||
confirmOk: '确认操作',
|
||||
cancel: '取消',
|
||||
deleteMessageSuffix: '此操作可能影响已有数据。',
|
||||
} as const
|
||||
|
||||
export interface AdminConfirmPlan {
|
||||
danger: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText: string
|
||||
cancelText: string
|
||||
type: 'warning' | 'info'
|
||||
}
|
||||
|
||||
/** 由目标名称生成删除确认文案;无名称时退化为「该项」。 */
|
||||
export function confirmMessageFor(targetName?: string): string {
|
||||
const name = typeof targetName === 'string' && targetName.trim() ? `“${targetName.trim()}”` : '该项'
|
||||
return `确认删除${name}吗?${ADMIN_CONFIRM.deleteMessageSuffix}`
|
||||
}
|
||||
|
||||
export interface AdminConfirmOptions {
|
||||
danger?: boolean
|
||||
targetName?: string
|
||||
message?: string
|
||||
title?: string
|
||||
confirmText?: string
|
||||
}
|
||||
|
||||
/** 归一出二次确认计划:显式字段优先,否则按 danger/目标名落默认值。 */
|
||||
export function createConfirmPlan(opts: AdminConfirmOptions): AdminConfirmPlan {
|
||||
const danger = opts.danger !== false
|
||||
const message = opts.message && opts.message.trim() ? opts.message.trim() : confirmMessageFor(opts.targetName)
|
||||
return {
|
||||
danger,
|
||||
title: opts.title ?? (danger ? ADMIN_CONFIRM.dangerTitle : ADMIN_CONFIRM.defaultTitle),
|
||||
message,
|
||||
confirmText: opts.confirmText ?? (danger ? ADMIN_CONFIRM.confirmDeleteOk : ADMIN_CONFIRM.confirmOk),
|
||||
cancelText: ADMIN_CONFIRM.cancel,
|
||||
type: danger ? 'warning' : 'info',
|
||||
}
|
||||
}
|
||||
|
||||
// 并发防护:同一时刻只允许一个确认框在途(拒绝重复弹出)。
|
||||
let confirmLocked = false
|
||||
|
||||
export function acquireConfirmLock(): boolean {
|
||||
if (confirmLocked) return false
|
||||
confirmLocked = true
|
||||
return true
|
||||
}
|
||||
|
||||
export function releaseConfirmLock(): void {
|
||||
confirmLocked = false
|
||||
}
|
||||
|
||||
export function isConfirmLocked(): boolean {
|
||||
return confirmLocked
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 二次确认打开入口(module 13 task 245):把模型计划交给 ElMessageBox,并对
|
||||
* 确认/取消统一归约为 boolean;并发锁拒绝重复弹出。
|
||||
*/
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
acquireConfirmLock,
|
||||
createConfirmPlan,
|
||||
releaseConfirmLock,
|
||||
type AdminConfirmOptions,
|
||||
} from './admin-confirm-model'
|
||||
|
||||
export async function openAdminConfirm(options: AdminConfirmOptions = {}): Promise<boolean> {
|
||||
const plan = createConfirmPlan(options)
|
||||
if (!acquireConfirmLock()) return false
|
||||
try {
|
||||
return await ElMessageBox.confirm(plan.message, plan.title, {
|
||||
confirmButtonText: plan.confirmText,
|
||||
cancelButtonText: plan.cancelText,
|
||||
type: plan.type,
|
||||
}).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
} finally {
|
||||
releaseConfirmLock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 反馈 UI 接线(module 13 task 246):把模型语义接到 ElMessage 与 busy 锁上。
|
||||
* 页面统一用 showAdminFeedback / runBusy,避免各页 ElMessage 直接散打文案。
|
||||
*/
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
endBusy,
|
||||
normalizeFeedback,
|
||||
tryBeginBusy,
|
||||
type AdminFeedbackKind,
|
||||
} from './admin-feedback'
|
||||
|
||||
export function showAdminFeedback(message?: string, kind?: AdminFeedbackKind): void {
|
||||
const { kind: k, message: m } = normalizeFeedback(message, kind)
|
||||
if (k === 'success') ElMessage.success(m)
|
||||
else if (k === 'error') ElMessage.error(m)
|
||||
else ElMessage.warning(m)
|
||||
}
|
||||
|
||||
/** 以 busy 锁包裹一个提交动作;已在途则直接返回 undefined,防止重复提交。 */
|
||||
export async function runBusy<T>(task: () => Promise<T>): Promise<T | undefined> {
|
||||
if (!tryBeginBusy()) return undefined
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
endBusy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 反馈语义模型(module 13 task 246):成功/错误/警告统一文案与 busy 防重入。
|
||||
* 纯逻辑、不依赖 Element;UI 接线见 admin-feedback-ui.ts。
|
||||
*/
|
||||
export type AdminFeedbackKind = 'success' | 'error' | 'warning'
|
||||
|
||||
export const FEEDBACK_TEXT = {
|
||||
successDefault: '操作成功',
|
||||
errorDefault: '操作失败,请稍后重试',
|
||||
warningDefault: '请确认后再继续',
|
||||
busySuffix: '中…',
|
||||
} as const
|
||||
|
||||
export interface NormalizedFeedback {
|
||||
kind: AdminFeedbackKind
|
||||
message: string
|
||||
}
|
||||
|
||||
const KIND_DEFAULT: Record<AdminFeedbackKind, string> = {
|
||||
success: FEEDBACK_TEXT.successDefault,
|
||||
error: FEEDBACK_TEXT.errorDefault,
|
||||
warning: FEEDBACK_TEXT.warningDefault,
|
||||
}
|
||||
|
||||
/** 归一反饋消息:显式 message 保留并去首尾空白,空值按 kind 落默认文案。 */
|
||||
export function normalizeFeedback(message?: string, kind?: AdminFeedbackKind): NormalizedFeedback {
|
||||
const k: AdminFeedbackKind = kind ?? 'warning'
|
||||
const trimmed = typeof message === 'string' ? message.trim() : ''
|
||||
return { kind: k, message: trimmed ? trimmed : KIND_DEFAULT[k] }
|
||||
}
|
||||
|
||||
/** 忙碌按钮文案:「保存」→「保存中…」;已以「中」结尾则不重复补。 */
|
||||
export function busyLabel(verb: string): string {
|
||||
const v = (verb || '处理').trim()
|
||||
return v.endsWith('中') ? `${v}…` : `${v}${FEEDBACK_TEXT.busySuffix}`
|
||||
}
|
||||
|
||||
/** 把 Error/字符串/未知错误转成可行动错误文案;空值回默认错误。 */
|
||||
export function actionableErrorText(error: unknown): string {
|
||||
if (error instanceof Error && error.message && error.message.trim()) return error.message.trim()
|
||||
if (typeof error === 'string' && error.trim()) return error.trim()
|
||||
return FEEDBACK_TEXT.errorDefault
|
||||
}
|
||||
|
||||
// busy 单例锁:表单提交/导出等防重复进入。
|
||||
let busy = false
|
||||
|
||||
export function tryBeginBusy(): boolean {
|
||||
if (busy) return false
|
||||
busy = true
|
||||
return true
|
||||
}
|
||||
|
||||
export function endBusy(): void {
|
||||
busy = false
|
||||
}
|
||||
|
||||
export function isBusy(): boolean {
|
||||
return busy
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 复制文本反馈模型(module 13 task 247):非空给出「已复制 X」,失败/空值给降级文案。
|
||||
*/
|
||||
export const COPY_FEEDBACK_FALLBACK = '复制失败,请手动选择'
|
||||
|
||||
export function copyFeedback(text: string): string {
|
||||
const t = typeof text === 'string' ? text.trim() : ''
|
||||
return t ? `已复制 ${t}` : COPY_FEEDBACK_FALLBACK
|
||||
}
|
||||
|
||||
export function isCopySupported(): boolean {
|
||||
return typeof navigator !== 'undefined' && typeof navigator.clipboard?.writeText === 'function'
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 模态/抽屉布局模型(module 13 task 248):统一两种承载容器的尺寸与关闭语义,
|
||||
* 对齐 admin.html —— 居中 modal width min(920px,100vw-48)、radius14;右侧 drawer
|
||||
* width min(460px,100%);遮罩点击与 Esc 均关闭。页面接入统一尺寸与头/脚槽。
|
||||
*/
|
||||
export const ADMIN_MODAL_WIDTH = 'min(920px, 100vw - 48px)'
|
||||
export const ADMIN_DRAWER_WIDTH = 'min(460px, 100%)'
|
||||
export const ADMIN_MODAL_RADIUS = 14
|
||||
|
||||
export interface AdminContainerSpec {
|
||||
width: string
|
||||
radius?: number
|
||||
maskClosable: boolean
|
||||
escClosable: boolean
|
||||
}
|
||||
|
||||
export function modalLayoutSpec(): AdminContainerSpec {
|
||||
return { width: ADMIN_MODAL_WIDTH, radius: ADMIN_MODAL_RADIUS, maskClosable: true, escClosable: true }
|
||||
}
|
||||
|
||||
export function drawerLayoutSpec(): AdminContainerSpec {
|
||||
return { width: ADMIN_DRAWER_WIDTH, maskClosable: true, escClosable: true }
|
||||
}
|
||||
|
||||
/** 页面头标题归一:空/空白标题兜底「操作」,副标题去空白后无则省略。 */
|
||||
export function dialogTitleOf(opts: { title?: string; subtitle?: string }): { title: string; subtitle?: string } {
|
||||
const title = opts.title?.trim()
|
||||
const subtitle = opts.subtitle?.trim()
|
||||
return { title: title || '操作', ...(subtitle ? { subtitle } : {}) }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 状态药丸配色/分类模型(module 13 task 247):统一 admin.html 的三态语义色
|
||||
* (success 绿 / danger 红 / warning 琥珀 / info 蓝 / primary 深蓝),供视频任务、
|
||||
* 店铺数据任务、数字人版本等页面复用;未知文本回落 info。
|
||||
*/
|
||||
export type PillTone = 'success' | 'danger' | 'warning' | 'info' | 'primary'
|
||||
|
||||
export interface PillToneStyle {
|
||||
text: string
|
||||
bg: string
|
||||
}
|
||||
|
||||
export const PILL_TONES: Record<PillTone, PillToneStyle> = {
|
||||
success: { text: '#4e806d', bg: '#e6f6ec' },
|
||||
danger: { text: '#b35f6a', bg: '#fdecec' },
|
||||
warning: { text: '#a8793e', bg: '#fdf0dc' },
|
||||
info: { text: '#4f78a5', bg: '#e7f0f8' },
|
||||
primary: { text: '#2f5d8b', bg: '#e6f0f8' },
|
||||
}
|
||||
|
||||
/** 取某 tone 的配色;未知 tone 回退 info(不抛错)。 */
|
||||
export function pillToneStyle(tone: PillTone): PillToneStyle {
|
||||
return PILL_TONES[tone] ?? PILL_TONES.info
|
||||
}
|
||||
|
||||
const SUCCESS_PATTERN = /success|succeed|已发布|发布|成功|正常|可用/
|
||||
const DANGER_PATTERN = /fail|error|已废弃|失败|不可用|异常/
|
||||
const WARNING_PATTERN = /running|pending|waiting|进行|处理中|草稿|待执行|待/
|
||||
|
||||
/** 由状态文本(中英文)归类语义 tone。 */
|
||||
export function statusTone(raw: string): PillTone {
|
||||
const s = (raw || '').toLowerCase()
|
||||
if (SUCCESS_PATTERN.test(s)) return 'success'
|
||||
if (DANGER_PATTERN.test(s)) return 'danger'
|
||||
if (WARNING_PATTERN.test(s)) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* /admin-vue/ 基准路径与 History 路由约定(任务 2 冻结的单一事实源)。
|
||||
* Vite base、Vue Router history base、Nginx location 三者必须共用此常量。
|
||||
*/
|
||||
export const APP_BASE_PATH = '/admin-vue/'
|
||||
|
||||
/** 前端路由一律使用 History 模式,不引入 hash(旧 #tab 兼容不在目标契约内)。 */
|
||||
export const APP_HISTORY_MODE = 'history' as const
|
||||
|
||||
function invalidBaseMessage(value: unknown): string {
|
||||
return `管理后台 base 路径非法: ${JSON.stringify(value)},必须以 / 开头且形如 /xxx/,不允许空值或 ..`
|
||||
}
|
||||
|
||||
/** 校验并归一化 base 路径:保证以 / 开头、无 ..、无内部连续斜杠、以单个 / 结尾。 */
|
||||
export function ensureAppBasePath(value: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new Error(invalidBaseMessage(value))
|
||||
if (!value.startsWith('/')) throw new Error(invalidBaseMessage(value))
|
||||
if (value.includes('//')) throw new Error(invalidBaseMessage(value))
|
||||
if (value.includes('..')) throw new Error(invalidBaseMessage(value))
|
||||
return value.endsWith('/') ? value : `${value}/`
|
||||
}
|
||||
|
||||
/** 把后台页面相对路径段拼成绝对 URL(不含 base 前缀的历史兼容子路径段由路由负责)。 */
|
||||
export function joinAdminPath(...segments: string[]): string {
|
||||
const parts: string[] = []
|
||||
for (const segment of segments) {
|
||||
for (const raw of segment.split('/')) {
|
||||
const part = raw.trim()
|
||||
if (!part || part === '.') continue
|
||||
if (part === '..') {
|
||||
throw new Error(`admin 路由段不允许出现 ..(越权跳转): ${segments.join('/')}`)
|
||||
}
|
||||
parts.push(part)
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) return APP_BASE_PATH
|
||||
return `${APP_BASE_PATH}${parts.join('/')}`
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
export const STANDALONE_PACKAGE_NAME = 'crawler-plugin-admin-frontend-vue'
|
||||
|
||||
/** 后台壳层允许存在的源码目录(任务 1 冻结的工作区边界,供验收脚本复用)。 */
|
||||
export const ADMIN_SOURCE_DIRS = [
|
||||
'src/layout',
|
||||
'src/router',
|
||||
'src/styles',
|
||||
'src/types',
|
||||
'src/config',
|
||||
'src/pages',
|
||||
] as const
|
||||
|
||||
export const REQUIRED_MANIFEST_FILES = [
|
||||
'package.json',
|
||||
'index.html',
|
||||
'vite.config.ts',
|
||||
'tsconfig.json',
|
||||
'src/main.ts',
|
||||
'src/App.vue',
|
||||
] as const
|
||||
|
||||
// 客户端工程耦合标记:后台工程内出现即代表越界。用带路径/产物语义的标记,
|
||||
// 避免与本工程自身包名后缀(…admin-frontend-vue)误匹配。扫描时排除本文件自身。
|
||||
export const FORBIDDEN_COUPLING_TOKENS = [
|
||||
'new_web_source',
|
||||
'frontend-vue/',
|
||||
'app_client',
|
||||
] as const
|
||||
|
||||
const SELF_SCAN_EXCLUSIONS = new Set(['src/config/workspace.ts', 'playwright.config.ts'])
|
||||
const SCAN_EXTENSIONS = /\.(ts|tsx|vue|css|html|json|js)$/
|
||||
|
||||
export function walk(rootDir: string, base = ''): string[] {
|
||||
const found: string[] = []
|
||||
for (const entry of readdirSync(join(rootDir, base), { withFileTypes: true })) {
|
||||
if (['node_modules', 'dist', '.git', 'tests', 'e2e', 'scripts', 'deploy'].includes(entry.name)) continue
|
||||
const rel = base ? `${base}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) found.push(...walk(rootDir, rel))
|
||||
else if (SCAN_EXTENSIONS.test(entry.name)) found.push(rel)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
function fileExists(rootDir: string, path: string): boolean {
|
||||
return existsSync(join(rootDir, path))
|
||||
}
|
||||
|
||||
/** 从当前目录向上定位独立后台工程根目录,找不到时给出可操作错误。 */
|
||||
export function resolveWorkspaceRoot(cwd = process.cwd()): string {
|
||||
let dir = resolve(cwd)
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
if (fileExists(dir, 'package.json')) return dir
|
||||
const parent = resolve(dir, '..')
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
throw new Error(`在 ${cwd} 向上 6 层内找不到 package.json,请在 admin-frontend-vue 工程目录内执行`)
|
||||
}
|
||||
|
||||
export interface WorkspacePackage {
|
||||
name: string
|
||||
private: boolean
|
||||
scripts: Record<string, string>
|
||||
dependencies: Record<string, string>
|
||||
devDependencies: Record<string, string>
|
||||
}
|
||||
|
||||
export function readWorkspacePackage(rootDir = resolveWorkspaceRoot()): WorkspacePackage {
|
||||
const raw = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')) as Partial<WorkspacePackage> & { private?: boolean }
|
||||
return {
|
||||
name: raw.name || '',
|
||||
private: raw.private !== false,
|
||||
scripts: raw.scripts || {},
|
||||
dependencies: raw.dependencies || {},
|
||||
devDependencies: raw.devDependencies || {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 校验独立工程边界,返回违规清单(空数组 = 边界成立)。 */
|
||||
export function collectBoundaryViolations(rootDir = resolveWorkspaceRoot()): string[] {
|
||||
const violations: string[] = []
|
||||
const pkgPath = join(rootDir, 'package.json')
|
||||
const pkg: WorkspacePackage = fileExists(rootDir, 'package.json')
|
||||
? readWorkspacePackage(rootDir)
|
||||
: { name: '', private: false, scripts: {}, dependencies: {}, devDependencies: {} }
|
||||
if (!fileExists(rootDir, 'package.json')) violations.push(`缺少必需清单文件 package.json(在 ${pkgPath})`)
|
||||
|
||||
if (pkg.name !== STANDALONE_PACKAGE_NAME) {
|
||||
violations.push(`package.name 应为 ${STANDALONE_PACKAGE_NAME},实际为 ${pkg.name || '(空)'}`)
|
||||
}
|
||||
if (!pkg.private) violations.push('package.private 应为 true(后台工程不允许被外部发布依赖)')
|
||||
|
||||
for (const file of REQUIRED_MANIFEST_FILES) {
|
||||
if (!fileExists(rootDir, file)) violations.push(`缺少必需清单文件 ${file}`)
|
||||
}
|
||||
for (const dir of ADMIN_SOURCE_DIRS) {
|
||||
if (!fileExists(rootDir, dir)) violations.push(`缺少允许源码目录 ${dir}`)
|
||||
}
|
||||
|
||||
for (const file of walk(rootDir)) {
|
||||
if (SELF_SCAN_EXCLUSIONS.has(file)) continue
|
||||
const content = readFileSync(join(rootDir, file), 'utf8')
|
||||
for (const token of FORBIDDEN_COUPLING_TOKENS) {
|
||||
if (content.includes(token)) {
|
||||
violations.push(`${file} 引用了客户端耦合 token "${token}",超出后台工程边界`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { joinAdminPath } from '@/config/app'
|
||||
import { pageTitleOf, topbarUserOf } from '@/layout/topbar-model'
|
||||
import { groupKeysForActive, toSidebarEntries } from '@/layout/menu-mapper'
|
||||
import {
|
||||
EMPTY_MENU_DESCRIPTION,
|
||||
EMPTY_MENU_TITLE,
|
||||
shouldShowEmptyMenu,
|
||||
} from '@/layout/empty-state'
|
||||
import GlobalErrorContainer from '@/layout/GlobalErrorContainer.vue'
|
||||
import NotificationBell from '@/components/NotificationBell.vue'
|
||||
import OperationGuide from '@/layout/OperationGuide.vue'
|
||||
import { crumbsForActiveRoute, resolveDocumentTitle, updateDocumentTitle } from '@/layout/title-breadcrumb'
|
||||
import {
|
||||
LOGOUT_CONFIRM_CANCEL,
|
||||
LOGOUT_CONFIRM_MESSAGE,
|
||||
LOGOUT_CONFIRM_OK,
|
||||
LOGOUT_CONFIRM_TITLE,
|
||||
runLogout,
|
||||
} from '@/layout/logout'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useAdminSessionStore()
|
||||
|
||||
const activePath = computed(() => route.path)
|
||||
const menuEntries = computed(() => toSidebarEntries(session.menuTree))
|
||||
const openedKeys = computed(() => groupKeysForActive(menuEntries.value, route.path))
|
||||
const emptyMenu = computed(() => shouldShowEmptyMenu(menuEntries.value.length > 0, session.loading, session.initialized))
|
||||
const pageTitle = computed(() => pageTitleOf(route.meta.title))
|
||||
const userVm = computed(() => topbarUserOf(session.user))
|
||||
const breadcrumbs = computed(() => crumbsForActiveRoute(session.menuTree, route.path))
|
||||
const logoUrl = joinAdminPath('assets', 'logo.jpg')
|
||||
|
||||
watch(
|
||||
pageTitle,
|
||||
(title) => updateDocumentTitle(resolveDocumentTitle(title), typeof document !== 'undefined' ? document : null),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function signOut() {
|
||||
try {
|
||||
await runLogout({
|
||||
confirm: () =>
|
||||
ElMessageBox.confirm(LOGOUT_CONFIRM_MESSAGE, LOGOUT_CONFIRM_TITLE, {
|
||||
confirmButtonText: LOGOUT_CONFIRM_OK,
|
||||
cancelButtonText: LOGOUT_CONFIRM_CANCEL,
|
||||
type: 'warning',
|
||||
}).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
signOut: () => session.signOut(),
|
||||
})
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '退出登录失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-shell">
|
||||
<a class="skip-link" href="#admin-content">跳到主内容</a>
|
||||
<aside class="admin-sidebar" role="navigation" aria-label="侧边栏菜单">
|
||||
<div class="admin-brand">
|
||||
<img :src="logoUrl" alt="数富AI" class="admin-brand-logo" />
|
||||
<div class="admin-brand-copy">
|
||||
<strong>数富AI</strong>
|
||||
<span>电商运营管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-scrollbar class="admin-menu-scroll">
|
||||
<el-menu :default-active="activePath" :default-openeds="openedKeys" router class="admin-menu">
|
||||
<template v-for="entry in menuEntries" :key="entry.key">
|
||||
<el-sub-menu v-if="entry.kind === 'group'" :index="entry.key">
|
||||
<template #title>
|
||||
<span class="menu-group-title">{{ entry.name }}</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in entry.children" :key="item.key" :index="item.route">
|
||||
{{ item.name }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :key="entry.key" :index="entry.route">
|
||||
{{ entry.name }}
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
<el-empty v-if="emptyMenu" :description="EMPTY_MENU_TITLE" :image-size="72" />
|
||||
</el-scrollbar>
|
||||
</aside>
|
||||
|
||||
<section class="admin-main">
|
||||
<header class="admin-topbar">
|
||||
<div class="admin-topbar-title">
|
||||
<span class="admin-kicker">运营管理控制台</span>
|
||||
<h1>{{ pageTitle }}</h1>
|
||||
</div>
|
||||
<div class="admin-user">
|
||||
<NotificationBell />
|
||||
<div class="admin-user-meta">
|
||||
<strong>{{ userVm.username }}</strong>
|
||||
</div>
|
||||
<span v-if="userVm.role" class="admin-user-role">{{ userVm.role }}</span>
|
||||
<el-button class="admin-user-logout" @click="signOut">退出登录</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<main id="admin-content" class="admin-content">
|
||||
<el-breadcrumb v-if="breadcrumbs.length > 1" class="admin-breadcrumb" separator="/">
|
||||
<el-breadcrumb-item v-for="crumb in breadcrumbs" :key="crumb.key">{{ crumb.name }}</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
<OperationGuide />
|
||||
<GlobalErrorContainer />
|
||||
<el-alert v-if="session.error" :title="session.error" type="error" show-icon :closable="false" />
|
||||
<el-empty
|
||||
v-if="emptyMenu && route.path === '/'"
|
||||
:title="EMPTY_MENU_TITLE"
|
||||
:description="EMPTY_MENU_DESCRIPTION"
|
||||
:image-size="96"
|
||||
/>
|
||||
<RouterView v-else />
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-topbar-title { min-width: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
dismissGlobalError,
|
||||
subscribeGlobalError,
|
||||
type GlobalErrorItem,
|
||||
} from '@/layout/error-bus'
|
||||
|
||||
const errors = ref<GlobalErrorItem[]>([])
|
||||
let unsubscribe: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
unsubscribe = subscribeGlobalError((list) => {
|
||||
errors.value = list
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
unsubscribe?.()
|
||||
unsubscribe = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="errors.length" class="global-error-stack" role="alert" aria-live="assertive">
|
||||
<el-alert
|
||||
v-for="item in errors"
|
||||
:key="item.id"
|
||||
:title="item.message"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="true"
|
||||
@close="dismissGlobalError(item.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
/** 当前页面操作提示条:对齐旧版 admin-interactions.js 的 adminOperationGuide(14 页有独立文案,余用通用 fallback)。
|
||||
* 挂在 AdminLayout 内容区顶部,随 route.meta.menuKey 切换文案;折叠态存 localStorage。 */
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { OPERATION_GUIDE_STORAGE_KEY, operationGuideFor } from '@/layout/operation-guides'
|
||||
|
||||
const route = useRoute()
|
||||
const guide = computed(() => operationGuideFor(route.meta.menuKey))
|
||||
|
||||
function readCollapsed(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(OPERATION_GUIDE_STORAGE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
function persistCollapsed(value: boolean) {
|
||||
try {
|
||||
localStorage.setItem(OPERATION_GUIDE_STORAGE_KEY, value ? '1' : '0')
|
||||
} catch {
|
||||
// 忽略隐私模式写入失败
|
||||
}
|
||||
}
|
||||
const collapsed = ref(readCollapsed())
|
||||
function toggle() {
|
||||
collapsed.value = !collapsed.value
|
||||
persistCollapsed(collapsed.value)
|
||||
}
|
||||
watch(collapsed, (v) => persistCollapsed(v))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside v-if="guide" class="operation-guide" :class="{ 'is-collapsed': collapsed }" aria-label="当前页面操作提示">
|
||||
<div class="og-head">
|
||||
<span class="og-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"></circle><path d="M12 11v5"></path><path d="M12 8h.01"></path></svg>
|
||||
当前页面操作提示
|
||||
</span>
|
||||
<button type="button" class="og-toggle" :aria-expanded="!collapsed" @click="toggle">
|
||||
{{ collapsed ? '展开提示' : '收起提示' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-show="!collapsed" class="og-body">
|
||||
<p class="og-text">{{ guide.text }}</p>
|
||||
<ol v-if="guide.steps.length" class="og-steps">
|
||||
<li v-for="step in guide.steps" :key="step">{{ step }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.operation-guide {
|
||||
display: flex; flex-direction: column;
|
||||
margin: 0 0 18px; padding: 12px 16px;
|
||||
border: 1px solid #cddbe6; border-radius: 12px;
|
||||
background: linear-gradient(180deg, #fbfdfe, #f4f8fc);
|
||||
box-shadow: 0 10px 26px -24px rgba(39, 67, 94, 0.3);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
.operation-guide.is-collapsed { padding: 8px 16px; }
|
||||
.og-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.og-title {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 13px; font-weight: 650; color: var(--admin-primary-strong);
|
||||
}
|
||||
.og-title svg { width: 15px; height: 15px; }
|
||||
.og-toggle {
|
||||
padding: 0; border: 0; background: none; cursor: pointer;
|
||||
font: inherit; font-size: 12px; color: #6b8096;
|
||||
}
|
||||
.og-toggle:hover { color: var(--admin-primary-strong); }
|
||||
.og-body { margin-top: 8px; padding-left: 22px; }
|
||||
.og-text { margin: 0; color: #40586e; font-size: 13px; line-height: 1.7; }
|
||||
.og-steps { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; padding: 0; list-style: none; }
|
||||
.og-steps li {
|
||||
padding: 2px 10px; border-radius: 999px;
|
||||
background: #e9f1f8; border: 1px solid #d3e0ea;
|
||||
color: #2f4a63; font-size: 12px; line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
/** 无菜单权限空状态(任务 11)文案与判定。 */
|
||||
export const EMPTY_MENU_TITLE = '暂无可用菜单'
|
||||
export const EMPTY_MENU_DESCRIPTION = '请联系管理员为你的账号分配菜单权限'
|
||||
|
||||
/**
|
||||
* 是否展示无菜单空态:会话已初始化、未在加载、且没有任何可渲染菜单。
|
||||
* 加载中不闪空态;初始化失败由错误条提示而非空态。
|
||||
*/
|
||||
export function shouldShowEmptyMenu(hasMenus: boolean, loading: boolean, initialized: boolean): boolean {
|
||||
return Boolean(initialized) && !loading && !hasMenus
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 全局请求错误提示容器(任务 13):框架无关的订阅式错误总线。 */
|
||||
|
||||
export interface GlobalErrorItem {
|
||||
id: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const GLOBAL_ERROR_LIMIT = 3
|
||||
export const GLOBAL_ERROR_FALLBACK = '请求失败,请稍后重试'
|
||||
|
||||
/** 把任意负载归一化为可展示文案。 */
|
||||
export function normalizeErrorMessage(message: unknown): string {
|
||||
if (message instanceof Error) return message.message.trim() || GLOBAL_ERROR_FALLBACK
|
||||
if (typeof message === 'string') return message.trim() || GLOBAL_ERROR_FALLBACK
|
||||
return GLOBAL_ERROR_FALLBACK
|
||||
}
|
||||
|
||||
type Listener = (items: GlobalErrorItem[]) => void
|
||||
|
||||
let items: GlobalErrorItem[] = []
|
||||
let nextId = 1
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener(items.slice())
|
||||
}
|
||||
|
||||
/** 订阅错误流,返回取消订阅函数(组件卸载时必须调用以防泄漏)。 */
|
||||
export function subscribeGlobalError(listener: Listener): () => void {
|
||||
listeners.add(listener)
|
||||
listener(items.slice())
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知一条全局请求错误;超出上限丢弃最旧,保证不堆积。 */
|
||||
export function notifyGlobalError(message: unknown): void {
|
||||
items = [...items, { id: nextId++, message: normalizeErrorMessage(message) }].slice(-GLOBAL_ERROR_LIMIT)
|
||||
emit()
|
||||
}
|
||||
|
||||
/** 按 id 关闭单条。 */
|
||||
export function dismissGlobalError(id: number): void {
|
||||
items = items.filter((item) => item.id !== id)
|
||||
emit()
|
||||
}
|
||||
|
||||
export function clearGlobalErrors(): void {
|
||||
items = []
|
||||
emit()
|
||||
}
|
||||
|
||||
export function snapshotGlobalErrors(): GlobalErrorItem[] {
|
||||
return items.slice()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 壳层退出登录交互(任务 16):确认后执行,取消/拒绝一律不退出。 */
|
||||
|
||||
export const LOGOUT_CONFIRM_TITLE = '退出登录'
|
||||
export const LOGOUT_CONFIRM_MESSAGE = '确定要退出当前账号吗?'
|
||||
export const LOGOUT_CONFIRM_OK = '退出'
|
||||
export const LOGOUT_CONFIRM_CANCEL = '取消'
|
||||
|
||||
export interface LogoutDeps {
|
||||
/** 用户确认对话框,resolve true 表示确认退出。 */
|
||||
confirm: () => Promise<boolean>
|
||||
/** 真正执行退出(清状态 + 跳登录页)。 */
|
||||
signOut: () => Promise<void>
|
||||
}
|
||||
|
||||
/** 退出流程:先确认;确认后执行退出;signOut/confirm 异常向上抛由调用方反馈。 */
|
||||
export async function runLogout({ confirm, signOut }: LogoutDeps): Promise<void> {
|
||||
const proceed = await confirm()
|
||||
if (!proceed) return
|
||||
await signOut()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AdminMenuNode } from '../types/admin'
|
||||
|
||||
export interface SidebarItem {
|
||||
key: string
|
||||
name: string
|
||||
route: string
|
||||
}
|
||||
|
||||
/** 侧边栏渲染项:分组(含子页面)或单页面项(含顶级路由叶节点)。 */
|
||||
export type SidebarEntry =
|
||||
| { kind: 'group'; key: string; name: string; children: SidebarItem[] }
|
||||
| { kind: 'item'; key: string; name: string; route: string }
|
||||
|
||||
/**
|
||||
* 后端菜单树 -> 侧边栏节点映射规则(任务 6):
|
||||
* - 有可路由子节点的节点渲染为分组,只保留带 route 的子项;
|
||||
* - 无子节点但有 route 的节点渲染为单页面项(不丢顶级叶菜单);
|
||||
* - 既无子节点也无 route 的节点跳过(点不进去的纯占位不展示)。
|
||||
*/
|
||||
export function toSidebarEntries(nodes: AdminMenuNode[] | null | undefined): SidebarEntry[] {
|
||||
const entries: SidebarEntry[] = []
|
||||
for (const node of nodes || []) {
|
||||
const name = node.name || ''
|
||||
const key = node.key || ''
|
||||
const children: SidebarItem[] = (node.children || [])
|
||||
.filter((child): child is AdminMenuNode & { route: string } => Boolean(child.route))
|
||||
.map((child) => ({ key: child.key || '', name: child.name || '', route: child.route }))
|
||||
if (children.length > 0) {
|
||||
entries.push({ kind: 'group', key, name, children })
|
||||
} else if (node.route) {
|
||||
entries.push({ kind: 'item', key, name, route: node.route })
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
/** 计算需默认展开的分组 key:分组下含当前激活路由子项时返回该分组 key。 */
|
||||
export function groupKeysForActive(entries: SidebarEntry[], activeRoute: string): string[] {
|
||||
const keys: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'group' && entry.children.some((child) => child.route === activeRoute)) {
|
||||
keys.push(entry.key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 后台铃铛通知纯逻辑:未读徽标文案、新通知判定、「已提醒过」去重记录、时间展示。
|
||||
* 轮询调度与渲染在 NotificationBell.vue;这里只放可单测的纯函数与存储读写。
|
||||
*/
|
||||
|
||||
/** 未读轮询间隔(60 秒)。 */
|
||||
export const NOTIFICATION_POLL_INTERVAL_MS = 60_000
|
||||
|
||||
const NOTIFIED_KEY_PREFIX = 'admin-notification:last-notified-id'
|
||||
|
||||
/** 未读徽标文案:0 或非法值显示空串,>99 显示 99+。 */
|
||||
export function formatUnreadBadge(count: number | null | undefined): string {
|
||||
const value = Number(count ?? 0)
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return ''
|
||||
}
|
||||
return value > 99 ? '99+' : String(Math.floor(value))
|
||||
}
|
||||
|
||||
/** 是否出现新通知:latestId 大于上次已提醒过的 id。 */
|
||||
export function hasNewNotification(
|
||||
latestId: number | null | undefined,
|
||||
lastNotifiedId: number | null | undefined,
|
||||
): boolean {
|
||||
const latest = Number(latestId ?? 0)
|
||||
const notified = Number(lastNotifiedId ?? 0)
|
||||
if (!Number.isFinite(latest) || latest <= 0) {
|
||||
return false
|
||||
}
|
||||
return latest > (Number.isFinite(notified) && notified > 0 ? notified : 0)
|
||||
}
|
||||
|
||||
function storageKey(uid: string | number | null | undefined): string {
|
||||
const normalized = uid === null || uid === undefined || String(uid).trim() === '' ? '0' : String(uid)
|
||||
return `${NOTIFIED_KEY_PREFIX}:${normalized}`
|
||||
}
|
||||
|
||||
/** 读取当前管理员「已提醒过的最大通知 id」。 */
|
||||
export function readLastNotifiedId(uid: string | number | null | undefined): number {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey(uid))
|
||||
const value = Number(raw ?? 0)
|
||||
return Number.isFinite(value) && value > 0 ? value : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function writeLastNotifiedId(uid: string | number | null | undefined, id: number): void {
|
||||
try {
|
||||
if (Number.isFinite(id) && id > 0) {
|
||||
window.localStorage.setItem(storageKey(uid), String(Math.floor(id)))
|
||||
}
|
||||
} catch {
|
||||
/* 存储不可用时静默忽略(退化为每次都提醒,不影响功能) */
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知所属日期键(本地时区 yyyy-MM-dd);时间缺失/非法返回空串。 */
|
||||
export function notificationDayKey(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const pad = (input: number) => String(input).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||
}
|
||||
|
||||
/** 日期组头文案:今天 / 昨天 / 2026年9月11日;空键兜底「未知时间」。 */
|
||||
export function formatDayLabel(day: string, now: Date = new Date()): string {
|
||||
if (!day) {
|
||||
return '未知时间'
|
||||
}
|
||||
if (day === notificationDayKey(now.toISOString())) {
|
||||
return '今天'
|
||||
}
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
if (day === notificationDayKey(yesterday.toISOString())) {
|
||||
return '昨天'
|
||||
}
|
||||
const [year, month, date] = day.split('-').map((part) => Number(part))
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(date)) {
|
||||
return day
|
||||
}
|
||||
return `${year}年${month}月${date}日`
|
||||
}
|
||||
|
||||
export interface NotificationDayGroup<T> {
|
||||
day: string
|
||||
label: string
|
||||
items: T[]
|
||||
}
|
||||
|
||||
/** 按年月日分组(组内保持入参顺序,列表本身已按 id 倒序)。 */
|
||||
export function groupNotificationsByDay<T extends { createdAt: string | null }>(
|
||||
items: T[],
|
||||
now: Date = new Date(),
|
||||
): NotificationDayGroup<T>[] {
|
||||
const groups: NotificationDayGroup<T>[] = []
|
||||
const indexByDay = new Map<string, NotificationDayGroup<T>>()
|
||||
for (const item of items ?? []) {
|
||||
const day = notificationDayKey(item.createdAt)
|
||||
let group = indexByDay.get(day)
|
||||
if (!group) {
|
||||
group = { day, label: formatDayLabel(day, now), items: [] }
|
||||
indexByDay.set(day, group)
|
||||
groups.push(group)
|
||||
}
|
||||
group.items.push(item)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
/** 通知时间展示:今天只显示 HH:mm;昨天显示「昨天 HH:mm」;更早显示 MM-DD HH:mm。 */
|
||||
export function formatNotificationTime(
|
||||
value: string | null | undefined,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
const pad = (input: number) => String(input).padStart(2, '0')
|
||||
const hourMinute = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
const sameDay =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate()
|
||||
if (sameDay) {
|
||||
return hourMinute
|
||||
}
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
const isYesterday =
|
||||
date.getFullYear() === yesterday.getFullYear() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getDate() === yesterday.getDate()
|
||||
if (isYesterday) {
|
||||
return `昨天 ${hourMinute}`
|
||||
}
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hourMinute}`
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 每菜单页的操作提示(guides)文案,对齐旧版 backend/static/admin-interactions.js 的 guides 表原文
|
||||
* (除 group-manage / duplicate-check 用通用 fallback)。key = 路由 meta.menuKey。
|
||||
*/
|
||||
export interface OperationGuideData {
|
||||
text: string
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
export const OPERATION_GUIDE_FALLBACK: OperationGuideData = {
|
||||
text: '先使用筛选定位记录,再进行新增、编辑、导出等操作。涉及删除的数据会要求二次确认。',
|
||||
steps: ['选择筛选条件', '处理记录', '核对反馈'],
|
||||
}
|
||||
|
||||
/** 折叠状态 localStorage key(沿用旧版 shufuAdminGuideCollapsed 语义)。 */
|
||||
export const OPERATION_GUIDE_STORAGE_KEY = 'shufuAdminGuideCollapsed'
|
||||
|
||||
export const OPERATION_GUIDES: Record<string, OperationGuideData> = {
|
||||
admin_users: {
|
||||
text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。',
|
||||
steps: ['筛选账号', '编辑权限', '确认保存'],
|
||||
},
|
||||
admin_columns: {
|
||||
text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。',
|
||||
steps: ['新增或调整菜单', '设置层级', '拖动排序'],
|
||||
},
|
||||
admin_group_manage: OPERATION_GUIDE_FALLBACK,
|
||||
admin_user_secrets: {
|
||||
text: '用户密钥按账号绑定,列表只展示脱敏值。可对单条立即检测连通性;清空后该用户需要重新配置密钥。',
|
||||
steps: ['筛选用户或状态', '立即检测连通性', '必要时清空'],
|
||||
},
|
||||
admin_dedupe_total_data: {
|
||||
text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。',
|
||||
steps: ['选择分组', '筛选或导入', '核对并导出'],
|
||||
},
|
||||
admin_invalid_asin_data: {
|
||||
text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。',
|
||||
steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'],
|
||||
},
|
||||
admin_query_asin: {
|
||||
text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。',
|
||||
steps: ['筛选店铺', '打开配置抽屉', '保存或导出'],
|
||||
},
|
||||
admin_product_categories: {
|
||||
text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。',
|
||||
steps: ['搜索类目', '展开层级', '新增或编辑'],
|
||||
},
|
||||
admin_skip_price_asin: {
|
||||
text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。',
|
||||
steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'],
|
||||
},
|
||||
admin_shop_keys: {
|
||||
text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。',
|
||||
steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'],
|
||||
},
|
||||
admin_shop_manage: {
|
||||
text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。',
|
||||
steps: ['选择分组', '维护店铺信息', '筛选核对结果'],
|
||||
},
|
||||
admin_shop_data_crawl_tasks: {
|
||||
text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。',
|
||||
steps: ['筛选任务', '检查状态', '执行批量操作'],
|
||||
},
|
||||
// admin_shop_data_duplicate_check(店铺撞款监控)为例外页:不展示全局提示条,不参与对齐。
|
||||
admin_image_video_tasks: {
|
||||
text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。',
|
||||
steps: ['设置筛选', '查看任务结果', '按需处理任务'],
|
||||
},
|
||||
admin_history: {
|
||||
text: '生成记录可按用户和时间范围回溯,用于核对结果文件和执行时间。',
|
||||
steps: ['设置时间范围', '筛选记录', '查看结果预览'],
|
||||
},
|
||||
admin_version: {
|
||||
text: '上传版本后请核对版本号和下载链接,再通知用户更新。',
|
||||
steps: ['上传压缩包', '检查版本记录', '维护历史版本'],
|
||||
},
|
||||
admin_tutorial: {
|
||||
text: '上传新的教程压缩包后即刻生效:工具台首页「立即下载教程」以下载最新上传的包为准。确认旧包不再需要后再删除。',
|
||||
steps: ['上传教程压缩包', '到工具台核对下载', '清理历史包'],
|
||||
},
|
||||
digital_human_version: {
|
||||
text: '数字人版本需先上传草稿,再发布并标记最新版本。',
|
||||
steps: ['上传草稿', '确认更新日志', '发布或设为最新'],
|
||||
},
|
||||
admin_user_secret_usage: {
|
||||
text: '按日期范围统计各用户的密钥调用次数(货源查询与外观专利按 LLM 请求次数、代理按提取次数),用于评估用户资源损耗。',
|
||||
steps: ['选择日期范围', '筛选用户或分组', '核对各模块次数'],
|
||||
},
|
||||
}
|
||||
|
||||
/** 全部有独立/兜底提示的后台业务菜单 key。 */
|
||||
export const OPERATION_GUIDE_MENU_KEYS = Object.keys(OPERATION_GUIDES)
|
||||
|
||||
/** 返回指定菜单 key 的提示数据;menuKey 不在已知集合时返回 null(登录/404 等页面不展示)。 */
|
||||
export function operationGuideFor(menuKey: unknown): OperationGuideData | null {
|
||||
if (typeof menuKey !== 'string' || !menuKey) return null
|
||||
return OPERATION_GUIDES[menuKey] ?? null
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 侧边栏折叠与窄屏布局状态(任务 14):宽高常量、阈值、偏好持久化。 */
|
||||
|
||||
export const SIDEBAR_EXPANDED_WIDTH = 248
|
||||
export const SIDEBAR_COLLAPSED_WIDTH = 64
|
||||
export const SIDEBAR_NARROW_THRESHOLD = 900
|
||||
|
||||
/** 视口窄到需要自动收起侧边栏。 */
|
||||
export function isNarrowScreen(width: number): boolean {
|
||||
return width > 0 && width <= SIDEBAR_NARROW_THRESHOLD
|
||||
}
|
||||
|
||||
/** 反转折叠状态。 */
|
||||
export function toggleCollapsed(current: boolean): boolean {
|
||||
return !current
|
||||
}
|
||||
|
||||
export type CollapseStorage = Pick<Storage, 'getItem' | 'setItem'> | null
|
||||
|
||||
const PREFERENCE_KEY = 'admin.sidebar.collapsed'
|
||||
|
||||
function parsePreference(raw: string | null): boolean | null {
|
||||
return raw === 'true' ? true : raw === 'false' ? false : null
|
||||
}
|
||||
|
||||
/** 读取持久化偏好;无存储或值非法返回 null。 */
|
||||
export function readCollapsePreference(storage: CollapseStorage): boolean | null {
|
||||
try {
|
||||
return parsePreference(storage?.getItem(PREFERENCE_KEY) ?? null)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入折叠偏好;存储不可用时静默失败(不阻断交互)。 */
|
||||
export function writeCollapsePreference(storage: CollapseStorage, collapsed: boolean): void {
|
||||
try {
|
||||
storage?.setItem(PREFERENCE_KEY, String(collapsed))
|
||||
} catch {
|
||||
// localStorage 不可用(隐私模式等)时忽略
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始折叠值:优先持久化偏好;无偏好时窄屏自动折叠、宽屏展开。
|
||||
* width<=0(如 SSR/未知)视为无法判定,回退 false。
|
||||
*/
|
||||
export function initialCollapsed(width: number, storage: CollapseStorage): boolean {
|
||||
const preference = readCollapsePreference(storage)
|
||||
if (preference !== null) return preference
|
||||
return isNarrowScreen(width)
|
||||
}
|
||||
|
||||
/** 侧边栏应占用的像素宽(用于宽度计算/测试,不直接改 CSS)。 */
|
||||
export function sidebarPixelWidth(collapsed: boolean): number {
|
||||
return collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { AdminMenuNode } from '../types/admin'
|
||||
|
||||
export const ADMIN_BRAND = '数富AI'
|
||||
|
||||
/** 文档标题规则:`页面标题 - 数富AI`;无页面标题时仅品牌。 */
|
||||
export function resolveDocumentTitle(pageTitle: string, brand: string = ADMIN_BRAND): string {
|
||||
const trimmed = (pageTitle || '').trim()
|
||||
return trimmed ? `${trimmed} - ${brand}` : brand
|
||||
}
|
||||
|
||||
/** 同步 HTML 文档标题;doc 为空(如非浏览器环境)时静默跳过。 */
|
||||
export function updateDocumentTitle(title: string, doc: { title: string } | null | undefined): void {
|
||||
if (doc) doc.title = title
|
||||
}
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
key: string
|
||||
name: string
|
||||
route?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 面包屑规则:在菜单树中定位激活路由,返回“分组 -> 页面”层级(顶层叶子只返回自身)。
|
||||
* 无命中返回空数组,不伪造路径。
|
||||
*/
|
||||
export function crumbsForActiveRoute(nodes: AdminMenuNode[] | null | undefined, activeRoute: string): BreadcrumbItem[] {
|
||||
for (const node of nodes || []) {
|
||||
if (node.route === activeRoute) return [{ key: node.key, name: node.name, route: node.route }]
|
||||
const children = node.children || []
|
||||
for (const child of children) {
|
||||
if (child.route === activeRoute) {
|
||||
return [
|
||||
{ key: node.key, name: node.name },
|
||||
{ key: child.key, name: child.name, route: child.route },
|
||||
]
|
||||
}
|
||||
}
|
||||
const nested = crumbsForActiveRoute(children, activeRoute)
|
||||
if (nested.length) return [{ key: node.key, name: node.name }, ...nested]
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AdminUser } from '../types/admin.ts'
|
||||
import { roleLabel } from '../types/admin.ts'
|
||||
|
||||
/** 无 meta.title 时的壳层缺省标题。 */
|
||||
export const ADMIN_DEFAULT_TITLE = '管理后台'
|
||||
|
||||
/** 页面标题模型:由路由 meta.title 驱动,空值回落缺省标题。 */
|
||||
export function pageTitleOf(title: string | null | undefined): string {
|
||||
const value = typeof title === 'string' ? title.trim() : ''
|
||||
return value || ADMIN_DEFAULT_TITLE
|
||||
}
|
||||
|
||||
export interface TopbarUserViewModel {
|
||||
username: string
|
||||
role: string
|
||||
hasUser: boolean
|
||||
}
|
||||
|
||||
/** 顶部栏用户模型:用户信息或未登录空态统一为可渲染视图模型。 */
|
||||
export function topbarUserOf(user: AdminUser | null | undefined): TopbarUserViewModel {
|
||||
if (!user) return { username: '当前用户', role: '', hasUser: false }
|
||||
return { username: user.username || '当前用户', role: roleLabel(user.role) || user.role || '', hasUser: true }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/styles/main.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 全局中文语言包:日期控件(周/月/年份)、分页组件(上一页/下一页)等均显示中文。
|
||||
createApp(App).use(createPinia()).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { submitCreateUser } from './user-create-adapter'
|
||||
import {
|
||||
createEmptyCreateUserForm,
|
||||
type CreateUserForm,
|
||||
type CreateUserFormErrors,
|
||||
validateCreateUserForm,
|
||||
} from './user-create-model'
|
||||
import { showAdminFeedback } from '@/components/admin-feedback-ui'
|
||||
import { actionableErrorText } from '@/components/admin-feedback'
|
||||
import UserMenuAuthTree from './UserMenuAuthTree.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
operatorSuper?: boolean
|
||||
/** 所属管理员候选(列表响应 admins;仅超管创建普通账号时展示)。 */
|
||||
adminOptions?: Array<{ id: number; username: string }>
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'created'): void }>()
|
||||
|
||||
const form = reactive<CreateUserForm>(createEmptyCreateUserForm())
|
||||
const errors = reactive<CreateUserFormErrors>({})
|
||||
const busy = ref(false)
|
||||
|
||||
/** 角色下拉(对齐 admin.html:普通账号/管理员;超管才显示可改)。 */
|
||||
const ROLE_CHOICES: Array<{ value: 'admin' | 'normal'; label: string }> = [
|
||||
{ value: 'normal', label: '普通账号' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
]
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
form.role = 'normal'
|
||||
form.columnIds = []
|
||||
form.createdBy = null
|
||||
errors.username = undefined
|
||||
errors.password = undefined
|
||||
},
|
||||
)
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function create(): Promise<void> {
|
||||
const { valid, errors: errs } = validateCreateUserForm(form)
|
||||
Object.assign(errors, errs)
|
||||
if (!valid) return
|
||||
busy.value = true
|
||||
try {
|
||||
await submitCreateUser(form)
|
||||
showAdminFeedback('创建成功', 'success')
|
||||
// 对齐 admin:成功后弹窗不关,仅清空用户名/密码并刷新列表。
|
||||
form.username = ''
|
||||
form.password = ''
|
||||
errors.username = undefined
|
||||
errors.password = undefined
|
||||
emit('created')
|
||||
} catch (error) {
|
||||
showAdminFeedback(actionableErrorText(error), 'error')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="创建用户"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="用户名" :error="errors.username">
|
||||
<el-input v-model="form.username" autocomplete="username" placeholder="用户名(至少 2 个字符)" @input="errors.username = undefined" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" :error="errors.password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="密码(至少 6 个字符)"
|
||||
@input="errors.password = undefined"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="operatorSuper" label="角色">
|
||||
<el-select filterable v-model="form.role" style="width: 200px">
|
||||
<el-option v-for="opt in ROLE_CHOICES" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="operatorSuper && form.role === 'normal'" label="所属管理员">
|
||||
<el-select v-model="form.createdBy" clearable filterable placeholder="请选择管理员" style="width: 200px">
|
||||
<el-option
|
||||
v-for="admin in props.adminOptions || []"
|
||||
:key="admin.id"
|
||||
:label="admin.username"
|
||||
:value="admin.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单权限(勾选上级菜单会继承全部子菜单)">
|
||||
<div class="auth-tree-box">
|
||||
<UserMenuAuthTree v-model:checked="form.columnIds" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="busy" @click="create">创建用户</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-tree-box {
|
||||
width: 100%;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import { fillEditUserForm, validateEditUserForm, type EditUserForm, type EditUserFormErrors } from './user-edit-model'
|
||||
import { submitEditUser } from './user-edit-adapter'
|
||||
import { loadUserMenuAuth } from './user-menu-auth-api'
|
||||
import { showAdminFeedback } from '@/components/admin-feedback-ui'
|
||||
import { actionableErrorText } from '@/components/admin-feedback'
|
||||
import UserMenuAuthTree from './UserMenuAuthTree.vue'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; user: AdminUser | null; operatorSuper?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'updated'): void }>()
|
||||
|
||||
const form = reactive<EditUserForm>({ uid: 0, username: '', password: '', role: 'normal', columnIds: [], originalRole: '' })
|
||||
const errors = reactive<EditUserFormErrors>({})
|
||||
const busy = ref(false)
|
||||
const loadingAuth = ref(false)
|
||||
/** 授权是否已成功加载:未就绪时保存会把空 columnIds 提交为“清空授权”,必须拦住。 */
|
||||
const authReady = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open || !props.user) return
|
||||
const base = fillEditUserForm(props.user)
|
||||
form.uid = base.uid
|
||||
form.username = base.username
|
||||
form.password = ''
|
||||
form.role = base.role
|
||||
form.columnIds = []
|
||||
form.originalRole = base.originalRole
|
||||
errors.password = undefined
|
||||
authReady.value = false
|
||||
void refreshAuth()
|
||||
},
|
||||
)
|
||||
|
||||
async function refreshAuth(): Promise<void> {
|
||||
if (!props.user) return
|
||||
loadingAuth.value = true
|
||||
try {
|
||||
const { checkedIds } = await loadUserMenuAuth(props.user.id)
|
||||
form.columnIds = checkedIds
|
||||
authReady.value = true
|
||||
} catch (error) {
|
||||
// 加载失败保持 authReady=false:保存按钮禁用,避免以空数组整树清空该用户授权。
|
||||
authReady.value = false
|
||||
showAdminFeedback(actionableErrorText(error), 'error')
|
||||
} finally {
|
||||
loadingAuth.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!authReady.value) {
|
||||
showAdminFeedback('菜单权限尚未加载完成,请稍后重试', 'error')
|
||||
return
|
||||
}
|
||||
const { valid, errors: errs } = validateEditUserForm(form)
|
||||
Object.assign(errors, errs)
|
||||
if (!valid) return
|
||||
busy.value = true
|
||||
try {
|
||||
await submitEditUser(form)
|
||||
showAdminFeedback('更新成功', 'success')
|
||||
emit('updated')
|
||||
close()
|
||||
} catch (error) {
|
||||
showAdminFeedback(actionableErrorText(error), 'error')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="编辑用户"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="用户名">
|
||||
<el-input :model-value="form.username" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码(不修改可留空)" :error="errors.password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
placeholder="留空则不修改密码"
|
||||
@input="errors.password = undefined"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="operatorSuper" label="角色">
|
||||
<el-radio-group v-model="form.role">
|
||||
<el-radio-button value="normal">普通账号</el-radio-button>
|
||||
<el-radio-button value="admin">管理员</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="props.user && props.user.creatorUsername" label="所属管理员">
|
||||
<el-input :model-value="props.user.creatorUsername" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单权限(勾选上级菜单会继承全部子菜单)">
|
||||
<div class="auth-tree-box">
|
||||
<UserMenuAuthTree v-model:checked="form.columnIds" />
|
||||
<div v-if="loadingAuth" class="auth-loading">菜单权限加载中…</div>
|
||||
<div v-else-if="!authReady" class="auth-loading auth-loading-error">
|
||||
菜单权限加载失败,保存已禁用;请关闭后重试
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="busy" :disabled="!authReady" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-tree-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 9px;
|
||||
padding: 8px;
|
||||
}
|
||||
.auth-loading {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
.auth-loading-error {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import {
|
||||
eligibleGroupMembers,
|
||||
groupMemberCandidateHint,
|
||||
validateShopGroupForm,
|
||||
type ShopGroupItem,
|
||||
} from './shop-group-model'
|
||||
import { createShopGroup, updateShopGroup } from './shop-group-api'
|
||||
import { fetchUserList } from '@/api/users'
|
||||
import { showAdminFeedback } from '@/components/admin-feedback-ui'
|
||||
import { actionableErrorText } from '@/components/admin-feedback'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; group: ShopGroupItem | null; operatorSuper?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const groupName = ref('')
|
||||
const memberIds = ref<number[]>([])
|
||||
const errorText = ref('')
|
||||
const busy = ref(false)
|
||||
const users = ref<AdminUser[]>([])
|
||||
|
||||
/** 组长:新建=当前登录用户;编辑=分组原组长(不可改,只读展示)。 */
|
||||
const leaderUserId = computed<number | null>(() =>
|
||||
props.group ? (props.group.leaderUserId ?? null) : (session.user?.id ?? null),
|
||||
)
|
||||
const leaderUsername = computed<string>(() =>
|
||||
props.group ? (props.group.leaderUsername || '') : (session.user?.username || '当前登录用户'),
|
||||
)
|
||||
const operatorRole = computed<string>(() => session.user?.role || '')
|
||||
|
||||
/** 组员候选:按组长归属过滤(超管全量、非超管仅本组长创建的普通账号)。 */
|
||||
const memberCandidates = computed(() =>
|
||||
eligibleGroupMembers(users.value, leaderUserId.value, operatorRole.value),
|
||||
)
|
||||
const candidateHint = computed(() => groupMemberCandidateHint(memberCandidates.value.length, operatorRole.value))
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
groupName.value = ''
|
||||
memberIds.value = []
|
||||
errorText.value = ''
|
||||
if (props.group) {
|
||||
groupName.value = props.group.name
|
||||
memberIds.value = props.group.memberUserIds ?? []
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function loadCandidates(): Promise<void> {
|
||||
try {
|
||||
// 对齐 admin.js loadUserOptions:page=1&page_size=999 全量拉取,避免归属过滤漏人。
|
||||
const page = await fetchUserList({ page: 1, pageSize: 999 })
|
||||
users.value = page.items
|
||||
} catch (error) {
|
||||
showAdminFeedback(actionableErrorText(error), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const errs = validateShopGroupForm(groupName.value)
|
||||
errorText.value = errs.groupName || ''
|
||||
if (errorText.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (props.group) {
|
||||
await updateShopGroup(props.group.id, groupName.value, memberIds.value)
|
||||
showAdminFeedback('更新成功', 'success')
|
||||
} else {
|
||||
// 对齐 admin:组长恒为当前登录用户,提交 createdBy=当前用户 id。
|
||||
const operatorId = session.user?.id ?? 0
|
||||
if (!operatorId) {
|
||||
showAdminFeedback('无法获取当前登录用户,请重新登录', 'error')
|
||||
return
|
||||
}
|
||||
await createShopGroup(groupName.value, memberIds.value, operatorId)
|
||||
showAdminFeedback('创建成功', 'success')
|
||||
}
|
||||
emit('saved')
|
||||
close()
|
||||
} catch (error) {
|
||||
showAdminFeedback(actionableErrorText(error), 'error')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadCandidates)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="group ? '编辑分组' : '新建分组'"
|
||||
width="600px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="(v: boolean) => emit('update:modelValue', v)"
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="分组名称" :error="errorText || undefined">
|
||||
<el-input v-model="groupName" placeholder="请输入分组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="组长">
|
||||
<el-input :model-value="leaderUsername" readonly placeholder="当前登录用户" />
|
||||
</el-form-item>
|
||||
<el-form-item label="组员">
|
||||
<el-select
|
||||
v-model="memberIds"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
placeholder="选择组员(普通账号)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="u in memberCandidates" :key="u.id" :label="u.username" :value="u.id" />
|
||||
</el-select>
|
||||
<div class="candidate-hint">{{ candidateHint }}(共 {{ memberCandidates.length }} 名)</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="close">取消</el-button>
|
||||
<el-button type="primary" :loading="busy" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.candidate-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 分组管理页 · 像素复刻旧版 admin.html panel-group-manage(自绘:顶部数据权限分组摘要 chips + 分组列表表格 + 新建分组;全量无分页同旧版)。
|
||||
* script 逻辑沿用现有 Vue 实现;编辑器保留现有组件。 */
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { openAdminConfirm } from '@/components/admin-confirm'
|
||||
import { deleteShopGroup, fetchShopGroups } from './shop-group-api'
|
||||
import type { ShopGroupItem } from './shop-group-model'
|
||||
import GroupEditorDialog from './GroupEditorDialog.vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const rows = ref<ShopGroupItem[]>([])
|
||||
const editorVisible = ref(false)
|
||||
const editingGroup = ref<ShopGroupItem | null>(null)
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
const currentUserId = computed(() => session.user?.id ?? null)
|
||||
/** 筛选:分组名称/组长 模糊过滤(用户要求账户域子菜单补筛选条件)。 */
|
||||
const filterKeyword = ref('')
|
||||
const filterLeader = ref('')
|
||||
const filteredRows = computed(() => {
|
||||
const kw = (filterKeyword.value || '').trim().toLowerCase()
|
||||
const ldr = (filterLeader.value || '').trim().toLowerCase()
|
||||
if (!kw && !ldr) return rows.value
|
||||
return rows.value.filter((g) => {
|
||||
const nameOk = !kw || (g.name || '').toLowerCase().includes(kw)
|
||||
const leaderOk = !ldr || (g.leaderUsername || '').toLowerCase().includes(ldr)
|
||||
return nameOk && leaderOk
|
||||
})
|
||||
})
|
||||
/** 全站分页统一:分组列表客户端分页(10/20/50/100)。 */
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pagedGroups = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
function changeGroupPage(p: number) { page.value = p }
|
||||
function changeGroupSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||
|
||||
function canEditOf(group: ShopGroupItem): boolean {
|
||||
if (isSuperAdmin.value) return true
|
||||
return currentUserId.value != null && group.leaderUserId != null && currentUserId.value === group.leaderUserId
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await fetchShopGroups()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '数据权限分组加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingGroup.value = null
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(group: ShopGroupItem) {
|
||||
if (!canEditOf(group)) return
|
||||
editingGroup.value = group
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
async function removeGroup(group: ShopGroupItem) {
|
||||
const ok = await openAdminConfirm({ message: `确定删除分组“${group.name}”吗?`, danger: true })
|
||||
if (!ok) return
|
||||
try {
|
||||
await deleteShopGroup(group.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadGroups()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadGroups)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="groups-view">
|
||||
<section class="panel-box">
|
||||
<div class="groups-head">
|
||||
<h3>分组列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新建分组</button>
|
||||
</div>
|
||||
|
||||
<div class="form-row groups-filter-row">
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>分组名称</label>
|
||||
<input v-model="filterKeyword" type="text" placeholder="输入分组名称" @keyup.enter="filterKeyword = filterKeyword" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<label>组长</label>
|
||||
<input v-model="filterLeader" type="text" placeholder="输入组长用户名" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 16%">分组名称</th>
|
||||
<th style="width: 12%">组长</th>
|
||||
<th style="width: 10%">组员数量</th>
|
||||
<th>组员</th>
|
||||
<th style="width: 16%">创建时间</th>
|
||||
<th style="width: 16%">修改时间</th>
|
||||
<th style="width: 160px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="filteredRows.length">
|
||||
<tr v-for="(group, index) in pagedGroups" :key="group.id">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ group.name }}</td>
|
||||
<td>{{ group.leaderUsername || '-' }}</td>
|
||||
<td>{{ group.memberCount ?? (group.memberUsernames?.length ?? 0) }}</td>
|
||||
<td>
|
||||
<div class="shop-group-members">
|
||||
<span v-for="name in group.memberUsernames" :key="name" class="shop-group-member-chip">{{ name }}</span>
|
||||
<span v-if="!group.memberUsernames.length" class="member-none">无</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDateTime(group.createdAt) }}</td>
|
||||
<td>{{ formatDateTime(group.updatedAt || group.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<template v-if="canEditOf(group)">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(group)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeGroup(group)">删除</button>
|
||||
</template>
|
||||
<span v-else class="member-none">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="8" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="8" class="empty-tip">{{ filterKeyword || filterLeader ? '暂无匹配分组' : '暂无分组' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination v-if="filteredRows.length > 0" :total="filteredRows.length" :page="page" :page-size="pageSize" @change="changeGroupPage" @size-change="changeGroupSize" />
|
||||
</section>
|
||||
|
||||
<GroupEditorDialog
|
||||
v-model="editorVisible"
|
||||
:group="editingGroup"
|
||||
:operator-super="isSuperAdmin"
|
||||
@saved="loadGroups"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-group-manage(蓝白末层)。 */
|
||||
.groups-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.form-box,
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.groups-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.groups-filter-row .form-group {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.groups-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.table-scroll td {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
.shop-group-members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 96px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.shop-group-member-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 150px;
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
background: #e7f0f8;
|
||||
color: #2f5d8b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.member-none,
|
||||
.member-more {
|
||||
color: #8293a5;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,552 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 菜单权限配置页 · 像素复刻旧版 admin.html panel-columns(自绘:面板头+新增菜单、拖拽手柄 ⠿、树形缩进+└、旧式表格、全量无分页同旧版)。
|
||||
* script 逻辑沿用现有 Vue 实现(拖拽分组/落点/重排保存、弹窗表单)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
|
||||
import {
|
||||
MENU_TYPE_OPTIONS,
|
||||
createMenuForm,
|
||||
flattenMenuNodes,
|
||||
menuDeleteReason,
|
||||
menuFormFromNode,
|
||||
menuParentCandidates,
|
||||
menuParentChangeReason,
|
||||
menuSiblingGroupKey,
|
||||
menuTypeLabel,
|
||||
nextSiblingSort,
|
||||
siblingOrderAfterDrop,
|
||||
validateMenuManageForm,
|
||||
type MenuManageForm,
|
||||
type MenuManageNode,
|
||||
} from './menu-manage-model'
|
||||
|
||||
/** 树行视图:展平 + 深度(缩进与 └ 前缀)。 */
|
||||
interface MenuRow extends MenuManageNode {
|
||||
depth: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<MenuManageNode[]>([])
|
||||
/** 筛选:菜单名称/类型(用户要求账户域子菜单补筛选条件)。 */
|
||||
const filterName = ref('')
|
||||
const filterType = ref('')
|
||||
const filteredMenuRows = computed<MenuRow[]>(() => {
|
||||
const kw = (filterName.value || '').trim().toLowerCase()
|
||||
const type = filterType.value || ''
|
||||
return menuRows.value.filter((row) => {
|
||||
const nameOk = !kw || (row.name || '').toLowerCase().includes(kw)
|
||||
const typeOk = !type || row.menuType === type
|
||||
return nameOk && typeOk
|
||||
})
|
||||
})
|
||||
/** 全站分页统一:菜单树展平行客户端分页(10/20/50/100)。 */
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pagedMenuRows = computed(() => filteredMenuRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
function changeMenuPage(p: number) { page.value = p }
|
||||
function changeMenuSize(size: number) { pageSize.value = size; page.value = 1 }
|
||||
/** 展平视图:前序 + depth。 */
|
||||
const menuRows = computed<MenuRow[]>(() => {
|
||||
const out: MenuRow[] = []
|
||||
const walk = (list: MenuManageNode[], depth: number) => {
|
||||
for (const node of list) {
|
||||
out.push({ ...node, depth })
|
||||
if (node.children?.length) walk(node.children, depth + 1)
|
||||
}
|
||||
}
|
||||
walk(rows.value, 0)
|
||||
return out
|
||||
})
|
||||
const createVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingNode = ref<MenuManageNode | null>(null)
|
||||
const menuForm = reactive<MenuManageForm>(createMenuForm(null, 0))
|
||||
const parentOptions = computed(() => menuParentCandidates(rows.value, menuForm.menuType))
|
||||
const dialogTitle = computed(() => (editingNode.value ? '编辑菜单' : '新增菜单'))
|
||||
/** 节点 id -> 名称,供「上级菜单」列展示。 */
|
||||
const nameById = computed(() => {
|
||||
const map = new Map<number, string>()
|
||||
for (const node of flattenMenuNodes(rows.value)) map.set(node.id, node.name)
|
||||
return map
|
||||
})
|
||||
|
||||
/** 拖拽状态:同"菜单类型+父级"分组内才可落位(对齐 admin.js columnDragState)。 */
|
||||
const dragState = ref<{ id: number; groupKey: string; menuType: string } | null>(null)
|
||||
|
||||
function parentNameOf(node: MenuManageNode): string {
|
||||
if (node.parentId == null) return '-'
|
||||
return nameById.value.get(node.parentId) ?? `#${node.parentId}`
|
||||
}
|
||||
|
||||
async function loadMenus() {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await loadMenuManageTree()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '菜单列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingNode.value = null
|
||||
Object.assign(menuForm, createMenuForm(null, nextSiblingSort(rows.value, null)))
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(node: MenuManageNode) {
|
||||
editingNode.value = node
|
||||
Object.assign(menuForm, menuFormFromNode(node))
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
/** 菜单类型切换时:当前父菜单不属于新类型则重置为根。 */
|
||||
function onMenuTypeChange(): void {
|
||||
const candidates = menuParentCandidates(rows.value, menuForm.menuType)
|
||||
if (menuForm.parentId != null && !candidates.some((node) => node.id === menuForm.parentId)) {
|
||||
menuForm.parentId = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, node: MenuManageNode): void {
|
||||
dragState.value = { id: node.id, groupKey: menuSiblingGroupKey(node), menuType: node.menuType }
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
try {
|
||||
event.dataTransfer.setData('text/plain', String(node.id))
|
||||
} catch {
|
||||
// 某些浏览器 setData 受限,忽略即可。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
dragState.value = null
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent, node: MenuManageNode): void {
|
||||
const state = dragState.value
|
||||
if (!state || state.groupKey !== menuSiblingGroupKey(node) || state.id === node.id) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent, target: MenuManageNode): Promise<void> {
|
||||
const state = dragState.value
|
||||
dragState.value = null
|
||||
if (!state || state.groupKey !== menuSiblingGroupKey(target) || state.id === target.id) return
|
||||
event.preventDefault()
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const placeAfter = event.clientY - rect.top > rect.height / 2
|
||||
const groupIds = flattenMenuNodes(rows.value)
|
||||
.filter((node) => menuSiblingGroupKey(node) === state.groupKey)
|
||||
.map((node) => node.id)
|
||||
const next = siblingOrderAfterDrop(groupIds, state.id, target.id, placeAfter)
|
||||
if (next.join(',') === groupIds.join(',')) return
|
||||
try {
|
||||
await reorderMenus(state.menuType, next)
|
||||
ElMessage.success('排序已保存')
|
||||
await loadMenus()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMenu() {
|
||||
if (editingNode.value) {
|
||||
const reason = menuParentChangeReason(editingNode.value, menuForm.parentId)
|
||||
if (reason) {
|
||||
ElMessage.warning(reason)
|
||||
return
|
||||
}
|
||||
}
|
||||
const { valid, errors } = validateMenuManageForm(menuForm)
|
||||
if (!valid) {
|
||||
ElMessage.warning(Object.values(errors)[0])
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingNode.value) {
|
||||
await updateMenu(editingNode.value.id, menuForm)
|
||||
ElMessage.success('保存成功')
|
||||
} else {
|
||||
await createMenu(menuForm)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
createVisible.value = false
|
||||
await loadMenus()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMenu(node: MenuManageNode) {
|
||||
const reason = menuDeleteReason(node)
|
||||
if (reason) {
|
||||
ElMessage.warning(reason)
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(`确定删除菜单“${node.name}”吗?`)) return
|
||||
try {
|
||||
await deleteMenu(node.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadMenus()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMenus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menus-view">
|
||||
<section class="panel-box">
|
||||
<div class="menus-head">
|
||||
<h3>菜单列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新增菜单</button>
|
||||
</div>
|
||||
<p class="columns-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||||
|
||||
<div class="form-row menus-filter-row">
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>菜单名称</label>
|
||||
<input v-model="filterName" type="text" placeholder="输入菜单名称" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 160px">
|
||||
<label>菜单类型</label>
|
||||
<select v-model="filterType">
|
||||
<option value="">全部</option>
|
||||
<option value="admin">后台</option>
|
||||
<option value="app">软件</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll columns-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="column-drag-cell"><span class="visually-hidden">排序</span></th>
|
||||
<th style="width: 60px">ID</th>
|
||||
<th style="width: 200px">菜单名称</th>
|
||||
<th style="width: 100px">菜单类型</th>
|
||||
<th style="width: 120px">上级菜单</th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th style="width: 170px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="filteredMenuRows.length">
|
||||
<tr
|
||||
v-for="row in pagedMenuRows"
|
||||
:key="row.id"
|
||||
:class="{ 'is-column-dragging': dragState?.id === row.id }"
|
||||
@dragover="onDragOver($event, row)"
|
||||
@drop="onDrop($event, row)"
|
||||
>
|
||||
<td class="column-drag-cell">
|
||||
<span
|
||||
class="column-drag-handle"
|
||||
draggable="true"
|
||||
role="button"
|
||||
:title="`拖动调整“${row.name}”的顺序`"
|
||||
@dragstart="onDragStart($event, row)"
|
||||
@dragend="onDragEnd"
|
||||
>⠿</span>
|
||||
</td>
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="name-cell">
|
||||
<span v-if="row.depth > 0" class="column-child-mark" :style="{ marginLeft: `${row.depth * 16}px` }">└</span>
|
||||
<span class="menu-name-text">{{ row.name }}</span>
|
||||
</td>
|
||||
<td><span class="menu-type-text">{{ menuTypeLabel(row.menuType) }}</span></td>
|
||||
<td>{{ parentNameOf(row) }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="menuDeleteReason(row) !== undefined"
|
||||
:title="menuDeleteReason(row) || undefined"
|
||||
@click="removeMenu(row)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="7" class="empty-tip">{{ filterName || filterType ? '暂无匹配菜单' : '暂无菜单,请先在上方新增' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination v-if="filteredMenuRows.length > 0" :total="filteredMenuRows.length" :page="page" :page-size="pageSize" @change="changeMenuPage" @size-change="changeMenuSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createVisible" :title="dialogTitle" width="520px">
|
||||
<el-form label-width="96px">
|
||||
<el-form-item label="菜单名称">
|
||||
<el-input v-model="menuForm.name" placeholder="如:用户管理" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单类型">
|
||||
<el-select filterable v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||||
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="父菜单">
|
||||
<el-select filterable v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||||
<el-option
|
||||
v-for="node in parentOptions"
|
||||
:key="node.id"
|
||||
:label="node.name"
|
||||
:value="node.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="menuForm.sortOrder" :min="0" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveMenu">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-columns(蓝白末层)。 */
|
||||
.menus-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.menus-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.columns-drag-tip {
|
||||
margin: 10px 0 16px;
|
||||
font-size: 13px;
|
||||
color: #5b6f83;
|
||||
}
|
||||
.menus-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.columns-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.column-drag-cell {
|
||||
width: 48px;
|
||||
}
|
||||
.column-drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
color: #93a6b8;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
.column-drag-handle:hover {
|
||||
border-color: #cbd9e6;
|
||||
background: #eef4fa;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.column-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
tr.is-column-dragging td {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.column-child-mark {
|
||||
margin-right: 6px;
|
||||
color: #9fb1c2;
|
||||
}
|
||||
.menu-name-text {
|
||||
font-weight: 500;
|
||||
}
|
||||
.menu-type-text {
|
||||
color: #5b6f83;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import type { MenuOptionNode } from './user-menu-auth'
|
||||
import { compactDirectGrantIds } from './user-menu-auth'
|
||||
import { fetchGrantableMenus } from './user-menu-auth-api'
|
||||
|
||||
const props = defineProps<{ checked?: number[] }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:checked', ids: number[]): void
|
||||
(e: 'load', ok: boolean): void
|
||||
}>()
|
||||
|
||||
/** 仅暴露 el-tree 我们需要的两个方法,避免整组件类型耦合。 */
|
||||
type TreeRef = { setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown }
|
||||
const adminTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||
const appTree = ref<{ setCheckedKeys(keys: unknown[]): void; getCheckedKeys(): unknown } | null>(null)
|
||||
const data = ref<MenuOptionNode[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 两种一级分类…(与 API menuType 分区一致):后台(admin) / 桌面端(app)。 */
|
||||
const adminNodes = computed(() => data.value.filter((node) => node.type === 'admin'))
|
||||
const appNodes = computed(() => data.value.filter((node) => node.type === 'app'))
|
||||
|
||||
function applyChecked(): void {
|
||||
const ids = Array.isArray(props.checked) ? props.checked.filter((id) => typeof id === 'number' && id > 0) : []
|
||||
adminTree.value?.setCheckedKeys(ids)
|
||||
appTree.value?.setCheckedKeys(ids)
|
||||
}
|
||||
|
||||
function onCheck(): void {
|
||||
const collect = (el: { getCheckedKeys(): unknown } | null): number[] => {
|
||||
const keys = el ? el.getCheckedKeys() : []
|
||||
return (Array.isArray(keys) ? keys : []).map((key) => Number(key)).filter((id) => id > 0)
|
||||
}
|
||||
const ids = [...collect(adminTree.value), ...collect(appTree.value)]
|
||||
// 父子联动模式(未启用严格勾选)下勾选父菜单会自动全选全部子菜单;
|
||||
// 提交前压缩为最小直接授权集:父级已勾选时后代不再重复提交(后端按“父级授权
|
||||
// 展开全部后代”判断权限,与旧 admin.html 勾父清后代语义一致)。
|
||||
emit('update:checked', compactDirectGrantIds(ids, data.value))
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
data.value = await fetchGrantableMenus()
|
||||
loaded.value = true
|
||||
emit('load', true)
|
||||
// el-tree 的 store 通过异步 watch 同步 props.data,setCheckedKeys 必须等树
|
||||
// 渲染完成后再应用已勾选 id,否则作用在空 store 上导致已授权不回显。
|
||||
await nextTick()
|
||||
applyChecked()
|
||||
} catch {
|
||||
loaded.value = false
|
||||
emit('load', false)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(
|
||||
() => props.checked,
|
||||
() => {
|
||||
if (loaded.value) applyChecked()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-scrollbar max-height="300px" class="user-menu-tree-scroll">
|
||||
<template v-if="adminNodes.length || appNodes.length">
|
||||
<div class="menu-group-columns">
|
||||
<div v-if="adminNodes.length" class="menu-group-block">
|
||||
<div class="menu-group-title">后台</div>
|
||||
<!-- 父子联动模式(未启用严格勾选):勾选父菜单自动全选全部子菜单,
|
||||
勾父即代表全部后代,与后端“父级授权展开后代”的权限语义一致。 -->
|
||||
<el-tree
|
||||
ref="adminTree"
|
||||
:data="adminNodes"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="appNodes.length" class="menu-group-block">
|
||||
<div class="menu-group-title">桌面端</div>
|
||||
<el-tree
|
||||
ref="appTree"
|
||||
:data="appNodes"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
:props="{ label: 'name', children: 'children', disabled: 'disabled' }"
|
||||
@check="onCheck"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="menu-group-empty">暂无可用菜单</p>
|
||||
</el-scrollbar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-group-columns {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
.menu-group-block {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
padding: 0 10px 8px 0;
|
||||
}
|
||||
.menu-group-block:last-child {
|
||||
padding-right: 0;
|
||||
border-left: 1px solid #e6edf4;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.menu-group-title {
|
||||
padding: 4px 0 6px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 650;
|
||||
color: #40586e;
|
||||
border-bottom: 1px solid #e6edf4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.menu-group-empty {
|
||||
margin: 12px 0;
|
||||
color: #8293a5;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,395 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import {
|
||||
fetchUserSecretUsage,
|
||||
type AdminUserSecretUsageRow,
|
||||
} from '@/api/user-secret-usage'
|
||||
import type { GroupOption } from '@/api/user-secrets'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<AdminUserSecretUsageRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const keyword = ref('')
|
||||
const startDate = ref('')
|
||||
const endDate = ref('')
|
||||
const groupFilter = ref<number | null>(null)
|
||||
const groupOptions = ref<GroupOption[]>([])
|
||||
|
||||
/** 所选范围内三类总次数(后端按筛选条件统计,不分页)。 */
|
||||
const summary = ref({ totalCalls: 0, similarAsinCalls: 0, appearancePatentCalls: 0, proxyCalls: 0 })
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
function formatCount(value: number | undefined) {
|
||||
const num = Number(value || 0)
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchUserSecretUsage({
|
||||
startDate: startDate.value || undefined,
|
||||
endDate: endDate.value || undefined,
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
groupId: groupFilter.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
rows.value = result?.items || []
|
||||
total.value = Number(result?.total || 0)
|
||||
groupOptions.value = result?.groupOptions || []
|
||||
summary.value = {
|
||||
totalCalls: Number(result?.totalCalls || 0),
|
||||
similarAsinCalls: Number(result?.similarAsinCalls || 0),
|
||||
appearancePatentCalls: Number(result?.appearancePatentCalls || 0),
|
||||
proxyCalls: Number(result?.proxyCalls || 0),
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
startDate.value = ''
|
||||
endDate.value = ''
|
||||
groupFilter.value = null
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="secret-usage-view">
|
||||
<section class="panel-box">
|
||||
<div class="usage-head">
|
||||
<h3>密钥用量统计</h3>
|
||||
<span class="usage-note">口径:真实对外请求次数(LLM 每次请求、代理每次成功提取)</span>
|
||||
</div>
|
||||
|
||||
<div class="summary-row">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">总调用次数</div>
|
||||
<div class="summary-value">{{ formatCount(summary.totalCalls) }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">货源查询密钥</div>
|
||||
<div class="summary-value">{{ formatCount(summary.similarAsinCalls) }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">外观专利密钥</div>
|
||||
<div class="summary-value">{{ formatCount(summary.appearancePatentCalls) }}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">代理提取</div>
|
||||
<div class="summary-value">{{ formatCount(summary.proxyCalls) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row usage-filter-row">
|
||||
<div class="form-group" style="min-width: 160px">
|
||||
<label>开始日期</label>
|
||||
<input v-model="startDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 160px">
|
||||
<label>结束日期</label>
|
||||
<input v-model="endDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>用户名</label>
|
||||
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||
</div>
|
||||
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
|
||||
<label>分组</label>
|
||||
<select v-model="groupFilter">
|
||||
<option :value="null">全部分组</option>
|
||||
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<div class="filter-actions">
|
||||
<button class="btn" type="button" @click="search">查询</button>
|
||||
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="usage-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 220px">用户</th>
|
||||
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||
<th style="width: 170px">货源查询密钥</th>
|
||||
<th style="width: 170px">外观专利密钥</th>
|
||||
<th style="width: 150px">代理提取</th>
|
||||
<th style="width: 130px">合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||
<td>
|
||||
<span class="user-name">{{ row.username || `UID ${row.userId}` }}</span>
|
||||
</td>
|
||||
<td v-if="isSuperAdmin">
|
||||
<span class="group-name">{{ row.groups?.length ? row.groups.join('、') : '—' }}</span>
|
||||
</td>
|
||||
<td><span class="count-value">{{ formatCount(row.similarAsinCount) }}</span></td>
|
||||
<td><span class="count-value">{{ formatCount(row.appearancePatentCount) }}</span></td>
|
||||
<td><span class="count-value">{{ formatCount(row.proxyCount) }}</span></td>
|
||||
<td><span class="count-value count-total">{{ formatCount(row.totalCount) }}</span></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">
|
||||
{{ keyword || startDate || endDate || groupFilter ? '暂无匹配记录' : '暂无用量记录' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 与密钥管理页保持同一视觉语言(像素复刻后台面板风格)。 */
|
||||
.secret-usage-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.usage-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.usage-note {
|
||||
color: #7d8fa2;
|
||||
font-size: 12px;
|
||||
}
|
||||
.summary-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.summary-card {
|
||||
flex: 1 1 160px;
|
||||
min-width: 150px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #f7fafd;
|
||||
}
|
||||
.summary-label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.summary-value {
|
||||
margin-top: 6px;
|
||||
color: #2f5d8b;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.usage-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: #ffffff;
|
||||
border-color: #c7d7e5;
|
||||
color: #4f78a5;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.usage-table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.usage-table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
min-width: 1020px;
|
||||
}
|
||||
.usage-table-scroll th,
|
||||
.usage-table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.usage-table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.usage-table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.usage-table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.user-name {
|
||||
color: #24384d;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.group-name {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.count-value {
|
||||
color: #2f5d8b;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.count-total {
|
||||
font-weight: 700;
|
||||
}
|
||||
.empty-tip {
|
||||
color: #8293a5;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,560 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import {
|
||||
checkUserSecret,
|
||||
deleteUserSecret,
|
||||
fetchUserSecretList,
|
||||
type AdminUserSecretModule,
|
||||
type AdminUserSecretRow,
|
||||
type GroupOption,
|
||||
} from '@/api/user-secrets'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<AdminUserSecretRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
const keyword = ref('')
|
||||
const statusFilter = ref('')
|
||||
const groupFilter = ref<number | null>(null)
|
||||
/** 分组下拉(超管=全部;主管=自己带的分组,由列表接口带回)。 */
|
||||
const groupOptions = ref<GroupOption[]>([])
|
||||
/** 正在检测的行 userId,用于按钮 loading 态。 */
|
||||
const checkingId = ref<number | null>(null)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'passed', label: '检测通过' },
|
||||
{ value: 'failed', label: '检测失败' },
|
||||
{ value: 'incomplete', label: '未配齐' },
|
||||
{ value: 'error', label: '无法判定' },
|
||||
{ value: 'unknown', label: '未检测' },
|
||||
]
|
||||
|
||||
/** 行级状态药丸:三类都检测通过才显示「检测通过」。 */
|
||||
function rowStatusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'passed':
|
||||
return { label: '检测通过', tone: 'is-allowed' }
|
||||
case 'failed':
|
||||
return { label: '检测失败', tone: 'is-blocked' }
|
||||
case 'incomplete':
|
||||
return { label: '未配齐', tone: 'is-warn' }
|
||||
case 'error':
|
||||
return { label: '无法判定', tone: 'is-warn' }
|
||||
default:
|
||||
return { label: '未检测', tone: 'is-unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 单格状态药丸:未配置时统一灰色;欠费单独标识。 */
|
||||
function moduleStatusMeta(module: AdminUserSecretModule | undefined) {
|
||||
if (!module || !module.exists) {
|
||||
return { label: '未配置', tone: 'is-unknown' }
|
||||
}
|
||||
switch (module.checkStatus) {
|
||||
case 'passed':
|
||||
return { label: '通过', tone: 'is-allowed' }
|
||||
case 'failed':
|
||||
if (module.checkCode === 'insufficient_balance') {
|
||||
return { label: '欠费', tone: 'is-warn' }
|
||||
}
|
||||
return { label: '失败', tone: 'is-blocked' }
|
||||
case 'error':
|
||||
return { label: '无法判定', tone: 'is-warn' }
|
||||
default:
|
||||
return { label: '未检测', tone: 'is-unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/** 单格悬停详情:完整值(代理为全量地址)+ 检测消息 + 检测时间 + 耗时。 */
|
||||
function moduleTooltip(module: AdminUserSecretModule | undefined) {
|
||||
if (!module || !module.exists) return '未配置'
|
||||
const parts: string[] = []
|
||||
if (module.full) parts.push(module.full)
|
||||
if (module.checkMessage) parts.push(module.checkMessage)
|
||||
if (module.checkedAt) parts.push(`检测时间:${formatDateTime(module.checkedAt)}`)
|
||||
if (module.checkLatencyMs != null) parts.push(`耗时:${module.checkLatencyMs}ms`)
|
||||
return parts.join(';') || '暂无检测记录'
|
||||
}
|
||||
|
||||
/** 分组列文案:优先分组名;未建分组的用户回退展示所属主管,便于判断归属。 */
|
||||
function groupTextOf(row: AdminUserSecretRow) {
|
||||
if (row.groups?.length) return row.groups.join('、')
|
||||
if (row.leaderUsername) return `${row.leaderUsername}(未建分组)`
|
||||
return '—'
|
||||
}
|
||||
|
||||
function rowStatusTooltip(row: AdminUserSecretRow) {
|
||||
const parts: string[] = []
|
||||
if (row.statusMessage) parts.push(row.statusMessage)
|
||||
if (row.updatedAt) parts.push(`最近更新:${formatDateTime(row.updatedAt)}`)
|
||||
return parts.join(';') || '暂无检测记录'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchUserSecretList({
|
||||
keyword: keyword.value.trim() || undefined,
|
||||
checkStatus: statusFilter.value || undefined,
|
||||
groupId: groupFilter.value || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
rows.value = result?.items || []
|
||||
total.value = Number(result?.total || 0)
|
||||
groupOptions.value = result?.groupOptions || []
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
keyword.value = ''
|
||||
statusFilter.value = ''
|
||||
groupFilter.value = null
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
/** 立即检测:真实请求该用户全部已配置项并把结果落库。 */
|
||||
async function check(row: AdminUserSecretRow) {
|
||||
checkingId.value = row.userId
|
||||
try {
|
||||
const results = await checkUserSecret(row.userId)
|
||||
if (!results || !results.length) {
|
||||
ElMessage.warning('该用户尚未配置任何密钥或代理')
|
||||
} else {
|
||||
const failed = results.filter((item) => item.checkStatus === 'failed')
|
||||
const errors = results.filter((item) => item.checkStatus === 'error')
|
||||
if (failed.length) {
|
||||
ElMessage.warning(failed[0].checkMessage || '存在检测失败项')
|
||||
} else if (errors.length) {
|
||||
ElMessage.warning('部分配置本次无法判定')
|
||||
} else {
|
||||
ElMessage.success('检测通过')
|
||||
}
|
||||
}
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '检测失败')
|
||||
} finally {
|
||||
checkingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: AdminUserSecretRow) {
|
||||
const who = row.username || `UID ${row.userId}`
|
||||
if (!window.confirm(`确定清空「${who}」的全部密钥与代理配置吗?清空后该用户需要重新配置。`)) return
|
||||
try {
|
||||
await deleteUserSecret(row.userId)
|
||||
ElMessage.success('已清空')
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '清空失败')
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="user-secrets-view">
|
||||
<section class="panel-box">
|
||||
<div class="secrets-head">
|
||||
<h3>用户密钥列表</h3>
|
||||
</div>
|
||||
|
||||
<div class="form-row secrets-filter-row">
|
||||
<div class="form-group" style="min-width: 220px">
|
||||
<label>用户名</label>
|
||||
<input v-model="keyword" type="text" placeholder="模糊搜索用户名" @keyup.enter="search" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 170px">
|
||||
<label>状态(三项都通过才算通过)</label>
|
||||
<select v-model="statusFilter">
|
||||
<option v-for="option in STATUS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" v-if="isSuperAdmin && groupOptions.length" style="min-width: 170px">
|
||||
<label>分组</label>
|
||||
<select v-model="groupFilter">
|
||||
<option :value="null">全部分组</option>
|
||||
<option v-for="group in groupOptions" :key="group.id" :value="group.id">{{ group.groupName }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label> </label>
|
||||
<div class="filter-actions">
|
||||
<button class="btn" type="button" @click="search">查询</button>
|
||||
<button class="btn btn-ghost" type="button" @click="reset">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="secrets-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 58px">序号</th>
|
||||
<th style="width: 260px">用户</th>
|
||||
<th v-if="isSuperAdmin" style="width: 130px">分组</th>
|
||||
<th style="width: 230px">货源查询密钥</th>
|
||||
<th style="width: 230px">外观专利密钥</th>
|
||||
<th style="width: 380px">代理设置</th>
|
||||
<th style="width: 130px">状态</th>
|
||||
<th style="width: 170px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="(row, index) in rows" :key="row.userId">
|
||||
<td>{{ (page - 1) * pageSize + index + 1 }}</td>
|
||||
<td>
|
||||
<span class="user-name">{{ row.username || '—' }}</span>
|
||||
</td>
|
||||
<td v-if="isSuperAdmin">
|
||||
<span class="creator-name">{{ groupTextOf(row) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="module-cell">
|
||||
<span v-if="row.similarAsin?.exists" class="mono-mask" :title="moduleTooltip(row.similarAsin)">{{ row.similarAsin.masked }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
<span class="wh-pill" :class="moduleStatusMeta(row.similarAsin).tone" :title="moduleTooltip(row.similarAsin)">
|
||||
{{ moduleStatusMeta(row.similarAsin).label }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="module-cell">
|
||||
<span v-if="row.appearancePatent?.exists" class="mono-mask" :title="moduleTooltip(row.appearancePatent)">{{ row.appearancePatent.masked }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
<span class="wh-pill" :class="moduleStatusMeta(row.appearancePatent).tone" :title="moduleTooltip(row.appearancePatent)">
|
||||
{{ moduleStatusMeta(row.appearancePatent).label }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="module-cell module-cell--stack">
|
||||
<span v-if="row.proxy?.exists" class="mono-mask mono-full" :title="moduleTooltip(row.proxy)">{{ row.proxy.full || row.proxy.masked }}</span>
|
||||
<span v-else class="empty-value">未配置</span>
|
||||
<span class="wh-pill" :class="moduleStatusMeta(row.proxy).tone" :title="moduleTooltip(row.proxy)">
|
||||
{{ moduleStatusMeta(row.proxy).label }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="wh-pill" :class="rowStatusMeta(row.status).tone" :title="rowStatusTooltip(row)">
|
||||
{{ rowStatusMeta(row.status).label }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="ops-cell">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="checkingId === row.userId"
|
||||
@click="check(row)"
|
||||
>
|
||||
{{ checkingId === row.userId ? '检测中…' : '立即检测' }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">清空</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td :colspan="isSuperAdmin ? 8 : 7" class="empty-tip">{{ keyword || statusFilter || groupFilter ? '暂无匹配记录' : '暂无用户密钥记录' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html 面板风格(与店铺密钥页同一视觉语言)。 */
|
||||
.user-secrets-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.secrets-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.secrets-filter-row {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-ghost {
|
||||
background: #ffffff;
|
||||
border-color: #c7d7e5;
|
||||
color: #4f78a5;
|
||||
box-shadow: none;
|
||||
}
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.secrets-table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.secrets-table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
min-width: 1440px;
|
||||
}
|
||||
.secrets-table-scroll th,
|
||||
.secrets-table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.secrets-table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.secrets-table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.secrets-table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.user-name {
|
||||
color: #24384d;
|
||||
font-weight: 600;
|
||||
/* 用户名(含中文)可完整换行展示,不截断 */
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.creator-name {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.module-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 代理列:完整地址独占一行,状态药丸另起一行,避免长地址被药丸挤压折行 */
|
||||
.module-cell--stack {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
.module-cell .mono-mask {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* 代理列展示完整明文:允许折行显示,不截断、不省略 */
|
||||
.module-cell .mono-full {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.45;
|
||||
cursor: text;
|
||||
user-select: all;
|
||||
}
|
||||
.mono-mask {
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.empty-value {
|
||||
color: #a7b4c1;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty-tip {
|
||||
color: #8293a5;
|
||||
text-align: center;
|
||||
}
|
||||
.wh-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid;
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
cursor: help;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wh-pill.is-allowed {
|
||||
background: #e8f6ee;
|
||||
border-color: #b9e0c9;
|
||||
color: #2f7d52;
|
||||
}
|
||||
.wh-pill.is-blocked {
|
||||
background: #fdecef;
|
||||
border-color: #f2c4cd;
|
||||
color: #b04a5a;
|
||||
}
|
||||
.wh-pill.is-warn {
|
||||
background: #fdf6e3;
|
||||
border-color: #f0dfae;
|
||||
color: #8f6d1e;
|
||||
}
|
||||
.wh-pill.is-unknown {
|
||||
background: #f0f3f6;
|
||||
border-color: #d6dee6;
|
||||
color: #6b7d8f;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,475 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 用户管理页 · 像素复刻旧版 admin.html panel-users(自绘:panel-box + 页头按钮 + 筛选 form-row + 旧式表格/分页 + 原生 confirm 删除)。
|
||||
* script 逻辑沿用现有 Vue 实现;创建/编辑弹窗保留现有组件(含用户菜单权限树)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { AdminUser } from '@/types/admin'
|
||||
import { roleLabel } from '@/types/admin'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { deleteUser, fetchUserList } from '@/api/users'
|
||||
import { createUserFilterState, toUserListParams, type UserFilterState } from './users-filter'
|
||||
import { totalPageCount } from './user-pagination'
|
||||
import { deleteBlockReason, deleteConfirmMessage } from './user-delete-model'
|
||||
import { listPhaseOf, userListEmptyHint } from './user-list-state'
|
||||
import { isEditableUser } from './user-edit-model'
|
||||
import CreateUserDialog from './CreateUserDialog.vue'
|
||||
import EditUserDialog from './EditUserDialog.vue'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
const rows = ref<AdminUser[]>([])
|
||||
const total = ref(0)
|
||||
/** 所属管理员候选(来自列表响应的 admins),供超管筛选下拉使用。 */
|
||||
const admins = ref<Array<{ id: number; username: string }>>([])
|
||||
const filters = reactive<UserFilterState>(createUserFilterState())
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
const currentUserId = computed(() => session.user?.id ?? null)
|
||||
const statePhase = computed(() => listPhaseOf({ loading: loading.value, error: loadError.value, items: rows.value, total: total.value }))
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, totalPageCount(total.value, filters.pageSize)))
|
||||
|
||||
const createVisible = ref(false)
|
||||
const editVisible = ref(false)
|
||||
const editUser = ref<AdminUser | null>(null)
|
||||
|
||||
function openCreate(): void {
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: AdminUser): void {
|
||||
if (!isEditableUser(row.role)) return
|
||||
editUser.value = row
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
function editableOf(row: AdminUser): boolean {
|
||||
return isEditableUser(row.role)
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const page = await fetchUserList(toUserListParams(filters))
|
||||
rows.value = page.items
|
||||
total.value = page.total
|
||||
if (Array.isArray(page.admins)) admins.value = page.admins
|
||||
// 数据被删/筛选后总页数收缩:页码越界时回钳到末页并重新加载一次。
|
||||
const last = totalPageCount(page.total, filters.pageSize)
|
||||
if (last > 0 && filters.page > last) {
|
||||
filters.page = last
|
||||
const retried = await fetchUserList(toUserListParams(filters))
|
||||
rows.value = retried.items
|
||||
total.value = retried.total
|
||||
}
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '用户列表加载失败'
|
||||
ElMessage.error(loadError.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 错误态重试:重新拉取当前筛选列表。 */
|
||||
function retry() {
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
/** 提交新筛选条件:重置回第一页再查询。 */
|
||||
function search() {
|
||||
filters.page = 1
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
async function removeUser(row: AdminUser) {
|
||||
const reason = deleteBlockReason(row, currentUserId.value)
|
||||
if (reason) {
|
||||
ElMessage.warning(reason)
|
||||
return
|
||||
}
|
||||
// 对齐旧版:浏览器原生 confirm 确认后删除。
|
||||
if (!window.confirm(deleteConfirmMessage(row))) return
|
||||
try {
|
||||
await deleteUser(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function changePage(page: number) {
|
||||
filters.page = page
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
filters.pageSize = size
|
||||
filters.page = 1
|
||||
void loadUsers()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="users-view">
|
||||
<section class="panel-box">
|
||||
<div class="users-head">
|
||||
<h3>用户列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新建用户</button>
|
||||
</div>
|
||||
<p class="users-desc">当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。</p>
|
||||
|
||||
<div class="form-row users-filter-row">
|
||||
<div class="form-group" style="min-width: 180px">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input v-model="filters.keyword" type="text" placeholder="输入用户名关键字" @keyup.enter="search" />
|
||||
</div>
|
||||
<div v-if="isSuperAdmin" class="form-group" style="min-width: 160px">
|
||||
<label>所属管理员</label>
|
||||
<el-select v-model="filters.createdByAdminId" class="admin-filter" filterable clearable placeholder="全部" @change="search">
|
||||
<el-option v-for="admin in admins" :key="admin.id" :label="admin.username" :value="admin.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 140px">
|
||||
<label>角色</label>
|
||||
<el-select v-model="filters.role" class="admin-filter" filterable clearable placeholder="全部角色" @change="search">
|
||||
<el-option label="超级管理员" value="super_admin" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
<el-option label="普通账号" value="normal" />
|
||||
</el-select>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="search">查询</button>
|
||||
</div>
|
||||
|
||||
<div v-if="statePhase === 'error' && loadError" class="msg err">
|
||||
<span>加载失败: {{ loadError }}</span>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="retry">重试</button>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll user-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px">ID</th>
|
||||
<th style="width: 180px">用户名</th>
|
||||
<th style="width: 140px">角色</th>
|
||||
<th style="width: 160px">所属管理员</th>
|
||||
<th style="width: 180px">创建时间</th>
|
||||
<th style="width: 160px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td>{{ row.username }}</td>
|
||||
<td>{{ roleLabel(row.role) }}</td>
|
||||
<td>{{ row.creatorUsername || '-' }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
type="button"
|
||||
:disabled="!editableOf(row)"
|
||||
:title="!editableOf(row) ? '该账号不可编辑' : undefined"
|
||||
@click="openEdit(row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="deleteBlockReason(row, currentUserId) !== undefined"
|
||||
:title="deleteBlockReason(row, currentUserId) || undefined"
|
||||
@click="removeUser(row)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">{{ userListEmptyHint() }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<OldPagination :total="total" :page="filters.page" :page-size="filters.pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<CreateUserDialog v-model="createVisible" :operator-super="isSuperAdmin" :admin-options="admins" @created="loadUsers" />
|
||||
<EditUserDialog v-model="editVisible" :user="editUser" :operator-super="isSuperAdmin" @updated="loadUsers" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-users(蓝白末层)。 */
|
||||
.users-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.users-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.users-desc {
|
||||
margin: 10px 0 16px;
|
||||
font-size: 13px;
|
||||
color: #5b6f83;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid;
|
||||
border-radius: 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.msg.err {
|
||||
color: #91474f;
|
||||
background: #f8ebeb;
|
||||
border-color: #e4c2c5;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.user-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-size-select {
|
||||
min-height: 30px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
/** 菜单管理列表加载适配(任务 54):GET /api/admin/permission-menus(admin) → 展示树。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
buildMenuManageTree,
|
||||
parseMenuManageItem,
|
||||
parseMenuManageList,
|
||||
toMenuCreateRequest,
|
||||
toMenuUpdateRequest,
|
||||
type MenuManageForm,
|
||||
type MenuManageNode,
|
||||
} from './menu-manage-model'
|
||||
|
||||
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||
export const REORDER_ENDPOINT = '/api/admin/column/reorder'
|
||||
|
||||
/** 拉取扁平可编辑菜单节点(全量类型,对齐 admin.js loadColumns 的 /api/admin/columns 语义)。 */
|
||||
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
|
||||
const { data } = await http.get<unknown>(MENUS_ENDPOINT)
|
||||
return parseMenuManageList(data)
|
||||
}
|
||||
|
||||
/** 拉取并按 parentId 组装成展示树。 */
|
||||
export async function loadMenuManageTree(): Promise<MenuManageNode[]> {
|
||||
return buildMenuManageTree(await fetchMenuManageNodes())
|
||||
}
|
||||
|
||||
/** 新建菜单:POST /api/admin/permission-menus,返回后端落库节点。 */
|
||||
export async function createMenu(form: MenuManageForm): Promise<MenuManageNode> {
|
||||
const { data } = await http.post<unknown>(MENUS_ENDPOINT, toMenuCreateRequest(form))
|
||||
const created = parseMenuManageItem(unwrap<unknown>(data))
|
||||
if (!created) throw new Error('创建菜单响应异常:缺少菜单数据')
|
||||
return created
|
||||
}
|
||||
|
||||
/** 编辑菜单:PUT /api/admin/permission-menus/{id},success=false 由 unwrap 抛后端 message。 */
|
||||
export async function updateMenu(id: number, form: MenuManageForm): Promise<void> {
|
||||
const { data } = await http.put<unknown>(`${MENUS_ENDPOINT}/${id}`, toMenuUpdateRequest(form))
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 删除菜单:DELETE /api/admin/permission-menus/{id},success=false 由 unwrap 抛后端 message。 */
|
||||
export async function deleteMenu(id: number): Promise<void> {
|
||||
const { data } = await http.delete<unknown>(`${MENUS_ENDPOINT}/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
|
||||
/** 同级菜单排序:POST /api/admin/column/reorder 提交 { menu_type, ordered_ids }(对齐 admin.js persistColumnOrder)。 */
|
||||
export async function reorderMenus(menuType: string, orderedIds: number[]): Promise<void> {
|
||||
const { data } = await http.post<unknown>(REORDER_ENDPOINT, { menu_type: menuType, ordered_ids: orderedIds })
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
/** 菜单管理 DTO/模型(任务 53):扁平 PermissionMenuItemVo → 可编辑节点 + 创建/更新请求体。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
/** 后台 Vue 控制台菜单类型。 */
|
||||
export const ADMIN_MENU_TYPE = 'admin'
|
||||
|
||||
export interface MenuManageNode {
|
||||
id: number
|
||||
name: string
|
||||
/** column_key,稳定权限标识。 */
|
||||
columnKey: string
|
||||
parentId: number | null
|
||||
rootColumnKey?: string
|
||||
menuType: string
|
||||
routePath: string
|
||||
sortOrder: number
|
||||
createdAt?: string
|
||||
children?: MenuManageNode[]
|
||||
}
|
||||
|
||||
/** 菜单类型展示文案(对齐 admin.js:6824:admin → 后台,其余一律 → 软件)。 */
|
||||
export function menuTypeLabel(type: string | null | undefined): string {
|
||||
return (type || '').toLowerCase() === 'admin' ? '后台' : '软件'
|
||||
}
|
||||
|
||||
/** 菜单类型下拉选项(对齐 admin.html:5931-5934 后台/软件)。 */
|
||||
export const MENU_TYPE_OPTIONS: Array<{ value: 'admin' | 'app'; label: string }> = [
|
||||
{ value: 'admin', label: '后台' },
|
||||
{ value: 'app', label: '软件' },
|
||||
]
|
||||
|
||||
export interface MenuManageForm {
|
||||
name: string
|
||||
columnKey: string
|
||||
parentId: number | null
|
||||
menuType: string
|
||||
routePath: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/** 请求体字段与 Java PermissionMenuCreate/UpdateRequest 对齐(camelCase)。
|
||||
* 权限标识/路由由后台自动生成,不随表单提交,故为可选字段。 */
|
||||
export interface MenuManagePayload {
|
||||
name: string
|
||||
columnKey?: string
|
||||
parentId: number | null
|
||||
menuType: string
|
||||
routePath?: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface MenuManageFormErrors {
|
||||
name?: string
|
||||
columnKey?: string
|
||||
menuType?: string
|
||||
routePath?: string
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
/** 排序归一:非负整数,非法回 0。 */
|
||||
export function normalizeMenuSort(value: unknown): number {
|
||||
const n = numberOrNull(value)
|
||||
return n !== null && n >= 0 ? n : 0
|
||||
}
|
||||
|
||||
/** 把单条 PermissionMenuItemVo(snake_case) 归一为可编辑节点;缺 id 视为无效。 */
|
||||
export function parseMenuManageItem(raw: unknown): MenuManageNode | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = numberOrNull(record.id)
|
||||
if (id === null) return null
|
||||
const parentId = numberOrNull(record.parent_id)
|
||||
const node: MenuManageNode = {
|
||||
id,
|
||||
name: text(record.name),
|
||||
columnKey: text(record.column_key),
|
||||
parentId: parentId === null ? null : parentId,
|
||||
menuType: text(record.menu_type) || ADMIN_MENU_TYPE,
|
||||
routePath: text(record.route_path),
|
||||
sortOrder: normalizeMenuSort(record.sort_order),
|
||||
}
|
||||
const rootColumnKey = text(record.root_column_key)
|
||||
if (rootColumnKey) node.rootColumnKey = rootColumnKey
|
||||
const createdAt = text(record.created_at)
|
||||
if (createdAt) node.createdAt = createdAt
|
||||
return node
|
||||
}
|
||||
|
||||
/** 归一化 permission-menus 响应(信封或裸数组)为可编辑节点列表。 */
|
||||
export function parseMenuManageList(payload: unknown): MenuManageNode[] {
|
||||
const data = unwrap<unknown>(payload)
|
||||
if (!Array.isArray(data)) return []
|
||||
return data
|
||||
.map((raw) => parseMenuManageItem(raw))
|
||||
.filter((node): node is MenuManageNode => node !== null)
|
||||
}
|
||||
|
||||
/** 按 parentId 组装展示树;同级按 (sortOrder,name) 升序,父缺失按根处理。 */
|
||||
export function buildMenuManageTree(items: MenuManageNode[]): MenuManageNode[] {
|
||||
const nodes = new Map<number, MenuManageNode>()
|
||||
for (const item of items) {
|
||||
nodes.set(item.id, { ...item, children: [] })
|
||||
}
|
||||
const roots: MenuManageNode[] = []
|
||||
for (const item of items) {
|
||||
const node = nodes.get(item.id)
|
||||
if (!node) continue
|
||||
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
|
||||
if (parent) {
|
||||
parent.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const order = (a: MenuManageNode, b: MenuManageNode) =>
|
||||
a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)
|
||||
const sortRecursive = (nodes: MenuManageNode[]) => {
|
||||
nodes.sort(order)
|
||||
for (const node of nodes) node.children?.length && sortRecursive(node.children)
|
||||
}
|
||||
sortRecursive(roots)
|
||||
for (const item of items) {
|
||||
const node = nodes.get(item.id)
|
||||
if (node && !node.children?.length) delete node.children
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
/** 展平树为列表(前序),供父级下拉/统计复用。 */
|
||||
export function flattenMenuNodes(nodes: MenuManageNode[]): MenuManageNode[] {
|
||||
const out: MenuManageNode[] = []
|
||||
const walk = (list: MenuManageNode[]) => {
|
||||
for (const node of list) {
|
||||
out.push(node)
|
||||
if (node.children?.length) walk(node.children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
return out
|
||||
}
|
||||
|
||||
/** 同级建议排序:该父下最大 sortOrder + 1;无同级返回 0。 */
|
||||
export function nextSiblingSort(items: MenuManageNode[], parentId: number | null): number {
|
||||
let max = -1
|
||||
for (const item of items) {
|
||||
if (item.parentId === parentId && item.sortOrder > max) max = item.sortOrder
|
||||
}
|
||||
return max + 1
|
||||
}
|
||||
|
||||
/** 新建菜单表单工厂:指定父节点与建议排序,其余默认(menuType=admin)。 */
|
||||
export function createMenuForm(parentId: number | null, sortOrder: number): MenuManageForm {
|
||||
return {
|
||||
...emptyMenuManageForm(),
|
||||
parentId: typeof parentId === 'number' ? parentId : null,
|
||||
sortOrder: normalizeMenuSort(sortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑表单回填:从可编辑节点还原表单字段。 */
|
||||
export function menuFormFromNode(node: MenuManageNode): MenuManageForm {
|
||||
return {
|
||||
name: node.name,
|
||||
columnKey: node.columnKey,
|
||||
parentId: node.parentId,
|
||||
menuType: node.menuType,
|
||||
routePath: node.routePath,
|
||||
sortOrder: node.sortOrder,
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑时父节点防环校验:不可选自身或其子孙为父;返回可操作原因,否则 undefined。 */
|
||||
export function menuParentChangeReason(node: MenuManageNode, parentId: number | null): string | undefined {
|
||||
if (parentId === null || parentId === undefined) return undefined
|
||||
if (parentId === node.id) return '不能选择自身作为父菜单'
|
||||
const descendants = flattenMenuNodes(node.children ?? [])
|
||||
if (descendants.some((item) => item.id === parentId)) return '不能选择自己的子菜单作为父菜单'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 删除前置校验(镜像后端:仅叶子可删,避免孤儿层级);可删返回 undefined。 */
|
||||
export function menuDeleteReason(node: MenuManageNode | null | undefined): string | undefined {
|
||||
const children = node?.children
|
||||
if (Array.isArray(children) && children.length > 0) return '请先删除该菜单下的子菜单'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 同级拖拽/上下移:把 targetId 移动 dir 位(±1),返回新的同级 id 顺序;越界/缺失则原序。 */
|
||||
export function siblingOrderAfterMove(group: number[], targetId: number, dir: number): number[] {
|
||||
if (!Array.isArray(group) || group.length === 0) return []
|
||||
const next = [...group]
|
||||
const idx = next.indexOf(targetId)
|
||||
if (idx < 0) return next
|
||||
const to = idx + (dir === -1 || dir === 1 ? dir : 0)
|
||||
if (to < 0 || to >= next.length) return next
|
||||
const [moved] = next.splice(idx, 1)
|
||||
next.splice(to, 0, moved)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 拖拽分组键:同"菜单类型 + 父级"才算同级(对齐 admin.js columnSiblingGroupKey)。 */
|
||||
export function menuSiblingGroupKey(node: MenuManageNode): string {
|
||||
return `${node.menuType || 'app'}|${node.parentId ?? ''}`
|
||||
}
|
||||
|
||||
/** 拖放排序:把 draggedId 插到 targetId 前/后,返回新的同级 id 顺序;任一 id 缺失或同 id 则原序。 */
|
||||
export function siblingOrderAfterDrop(
|
||||
group: number[],
|
||||
draggedId: number,
|
||||
targetId: number,
|
||||
placeAfter: boolean,
|
||||
): number[] {
|
||||
if (!Array.isArray(group) || group.length === 0) return []
|
||||
if (draggedId === targetId) return [...group]
|
||||
const next = [...group]
|
||||
const from = next.indexOf(draggedId)
|
||||
if (from < 0) return next
|
||||
const target = next.indexOf(targetId)
|
||||
if (target < 0) return next
|
||||
next.splice(from, 1)
|
||||
const insertAt = next.indexOf(targetId) + (placeAfter ? 1 : 0)
|
||||
next.splice(insertAt, 0, draggedId)
|
||||
return next
|
||||
}
|
||||
|
||||
/** 父菜单候选:仅同菜单类型的节点(对齐 admin 的类型联动父级下拉)。 */
|
||||
export function menuParentCandidates(nodes: MenuManageNode[], menuType: string): MenuManageNode[] {
|
||||
const type = (menuType || '').trim()
|
||||
return flattenMenuNodes(nodes).filter((node) => (node.menuType || '') === type)
|
||||
}
|
||||
|
||||
/** 同级重排请求体(对齐 admin.js persistColumnOrder:{ menu_type, ordered_ids })。 */
|
||||
export function toMenuReorderRequest(
|
||||
menuType: string,
|
||||
orderedIds: number[],
|
||||
): { menu_type: string; ordered_ids: number[] } {
|
||||
return { menu_type: text(menuType) || 'app', ordered_ids: dedupeOrderIds(orderedIds) }
|
||||
}
|
||||
|
||||
function dedupeOrderIds(ids: unknown[]): number[] {
|
||||
if (!Array.isArray(ids)) return []
|
||||
const set = new Set<number>()
|
||||
for (const v of ids) {
|
||||
const n = numberOrNull(v)
|
||||
if (n !== null && n > 0) set.add(n)
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
|
||||
export function emptyMenuManageForm(): MenuManageForm {
|
||||
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
|
||||
}
|
||||
|
||||
/** 表单校验(镜像后端 NotBlank 文案);返回逐字段错误。权限标识/路由由后台自动生成,不参与校验。 */
|
||||
export function validateMenuManageForm(form: MenuManageForm): { valid: boolean; errors: MenuManageFormErrors } {
|
||||
const errors: MenuManageFormErrors = {}
|
||||
if (!text(form.name)) errors.name = '菜单名称不能为空'
|
||||
if (!text(form.menuType)) errors.menuType = '菜单类型不能为空'
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
function toMenuManagePayload(form: MenuManageForm): MenuManagePayload {
|
||||
// 权限标识(column_key)/路由(route_path) 不随表单提交:由后台自动生成(创建)或保持原值(编辑)。
|
||||
return {
|
||||
name: text(form.name),
|
||||
parentId: typeof form.parentId === 'number' ? form.parentId : null,
|
||||
menuType: text(form.menuType),
|
||||
sortOrder: normalizeMenuSort(form.sortOrder),
|
||||
}
|
||||
}
|
||||
|
||||
/** 表单 → POST /api/admin/permission-menus 请求体。 */
|
||||
export function toMenuCreateRequest(form: MenuManageForm): MenuManagePayload {
|
||||
return toMenuManagePayload(form)
|
||||
}
|
||||
|
||||
/** 表单 → PUT /api/admin/permission-menus/{id} 请求体。 */
|
||||
export function toMenuUpdateRequest(form: MenuManageForm): MenuManagePayload {
|
||||
return toMenuManagePayload(form)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** 数据权限分组 API 适配(任务 59;module 13 task 253 扩展 CRUD)。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
parseShopGroupItem,
|
||||
parseShopGroupList,
|
||||
toShopGroupCreatePayload,
|
||||
toShopGroupUpdatePayload,
|
||||
type ShopGroupItem,
|
||||
} from './shop-group-model'
|
||||
|
||||
export const SHOP_GROUPS_ENDPOINT = '/api/admin/shop-manages/groups'
|
||||
|
||||
export async function fetchShopGroups(): Promise<ShopGroupItem[]> {
|
||||
const { data } = await http.get<unknown>(SHOP_GROUPS_ENDPOINT)
|
||||
return parseShopGroupList(data)
|
||||
}
|
||||
|
||||
/** 新建分组:POST /api/admin/shop-manages/groups(创建人 = 组长 = 当前操作者)。 */
|
||||
export async function createShopGroup(name: string, memberUserIds: number[], createdById: number): Promise<ShopGroupItem> {
|
||||
const { data } = await http.post<unknown>(SHOP_GROUPS_ENDPOINT, toShopGroupCreatePayload(name, memberUserIds, createdById))
|
||||
const created = parseShopGroupItem(unwrap<unknown>(data))
|
||||
if (!created) throw new Error('创建分组响应异常:缺少分组数据')
|
||||
return created
|
||||
}
|
||||
|
||||
/** 更新分组:PUT /api/admin/shop-manages/groups/{id}。 */
|
||||
export async function updateShopGroup(id: number, name: string, memberUserIds: number[]): Promise<ShopGroupItem> {
|
||||
const { data } = await http.put<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`, toShopGroupUpdatePayload(name, memberUserIds))
|
||||
const updated = parseShopGroupItem(unwrap<unknown>(data))
|
||||
if (!updated) throw new Error('更新分组响应异常:缺少分组数据')
|
||||
return updated
|
||||
}
|
||||
|
||||
/** 删除分组:DELETE /api/admin/shop-manages/groups/{id}。 */
|
||||
export async function deleteShopGroup(id: number): Promise<void> {
|
||||
const { data } = await http.delete<unknown>(`${SHOP_GROUPS_ENDPOINT}/${id}`)
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/** 数据权限分组列表模型(任务 59;module 13 task 253 扩展编辑能力):解析 VO + 摘要 + 提交载荷。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
export interface ShopGroupItem {
|
||||
id: number
|
||||
name: string
|
||||
leaderUsername: string
|
||||
leaderUserId?: number
|
||||
memberCount: number
|
||||
memberUsernames: string[]
|
||||
memberUserIds?: number[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface ShopGroupFormErrors {
|
||||
groupName?: string
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||
}
|
||||
|
||||
/** 解析单条分组项;缺 id 视为无效。 */
|
||||
export function parseShopGroupItem(raw: unknown): ShopGroupItem | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = numberOrNull(record.id)
|
||||
if (id === null) return null
|
||||
const names = Array.isArray(record.memberUsernames)
|
||||
? record.memberUsernames.map((v) => text(v)).filter(Boolean)
|
||||
: []
|
||||
const memberIds = Array.isArray(record.memberUserIds)
|
||||
? record.memberUserIds.map((v) => numberOrNull(v)).filter((v): v is number => v !== null && v > 0)
|
||||
: []
|
||||
const count = numberOrNull(record.memberCount)
|
||||
const item: ShopGroupItem = {
|
||||
id,
|
||||
name: text(record.groupName ?? record.name) || `分组${id}`,
|
||||
leaderUsername: text(record.leaderUsername ?? record.leader_name),
|
||||
memberCount: count === null ? names.length : count,
|
||||
memberUsernames: names,
|
||||
}
|
||||
const leaderId = numberOrNull(record.leaderUserId ?? record.leader_user_id)
|
||||
if (leaderId !== null && leaderId > 0) item.leaderUserId = leaderId
|
||||
if (memberIds.length) item.memberUserIds = memberIds
|
||||
const createdAt = text(record.createdAt ?? record.created_at)
|
||||
if (createdAt) item.createdAt = createdAt
|
||||
const updatedAt = text(record.updatedAt ?? record.updated_at)
|
||||
if (updatedAt) item.updatedAt = updatedAt
|
||||
return item
|
||||
}
|
||||
|
||||
/** 归一化分组列表响应(信封或裸数组)。 */
|
||||
export function parseShopGroupList(payload: unknown): ShopGroupItem[] {
|
||||
const data = unwrap<unknown>(payload)
|
||||
if (!Array.isArray(data)) return []
|
||||
return data
|
||||
.map((raw) => parseShopGroupItem(raw))
|
||||
.filter((item): item is ShopGroupItem => item !== null)
|
||||
}
|
||||
|
||||
/** 分组摘要:分组总数 + 组员总数。 */
|
||||
export function shopGroupSummary(groups: ShopGroupItem[]): { groupCount: number; memberTotal: number } {
|
||||
let memberTotal = 0
|
||||
for (const group of groups) memberTotal += group.memberCount || 0
|
||||
return { groupCount: groups.length, memberTotal }
|
||||
}
|
||||
|
||||
/** 分组名必填校验(镜像后端 NotBlank 文案「请输入分组名称」)。 */
|
||||
export function validateShopGroupForm(name: string): ShopGroupFormErrors {
|
||||
const errors: ShopGroupFormErrors = {}
|
||||
if (!text(name)) errors.groupName = '请输入分组名称'
|
||||
return errors
|
||||
}
|
||||
|
||||
/** 创建载荷:groupName + 成员 + 创建人(= 组长,当前操作者)。 */
|
||||
export function toShopGroupCreatePayload(
|
||||
name: string,
|
||||
memberUserIds: number[],
|
||||
createdById: number,
|
||||
): { groupName: string; memberUserIds: number[]; createdById: number } {
|
||||
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds), createdById }
|
||||
}
|
||||
|
||||
/** 更新载荷:groupName + 成员(组长/创建人不可改)。 */
|
||||
export function toShopGroupUpdatePayload(name: string, memberUserIds: number[]): { groupName: string; memberUserIds: number[] } {
|
||||
return { groupName: text(name), memberUserIds: dedupeIds(memberUserIds) }
|
||||
}
|
||||
|
||||
/** 组员候选用户(对齐 admin.js getEligibleShopManageGroupUsers 的过滤语义)。 */
|
||||
export interface GroupMemberCandidateUser {
|
||||
id: number
|
||||
username: string
|
||||
role: string
|
||||
createdById?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 组员候选过滤(对齐 admin.js:3020-3028):
|
||||
* - 组长本人永不进候选;
|
||||
* - 超管可选全部用户(不论角色/归属);
|
||||
* - 非超管仅可选"组长(创建人)名下"的普通账号。
|
||||
*/
|
||||
export function eligibleGroupMembers(
|
||||
users: GroupMemberCandidateUser[],
|
||||
leaderUserId: number | null,
|
||||
operatorRole: string,
|
||||
): GroupMemberCandidateUser[] {
|
||||
const leaderKey = leaderUserId != null && leaderUserId > 0 ? String(leaderUserId) : ''
|
||||
const isSuper = (operatorRole || '').toLowerCase() === 'super_admin'
|
||||
return users.filter((u) => {
|
||||
if (String(u.id) === leaderKey) return false
|
||||
if (isSuper) return true
|
||||
return (
|
||||
(u.role || '').toLowerCase() === 'normal' &&
|
||||
String(u.createdById ?? '') === leaderKey
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 组员候选帮助文案(对齐 admin.js:3179-3182)。 */
|
||||
export function groupMemberCandidateHint(candidateCount: number, operatorRole: string): string {
|
||||
if (candidateCount > 0) return '可添加当前组长创建的普通员工账号'
|
||||
return (operatorRole || '').toLowerCase() === 'normal'
|
||||
? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。'
|
||||
: '当前组长名下暂无可添加的普通员工账号。'
|
||||
}
|
||||
|
||||
function dedupeIds(ids: unknown[]): number[] {
|
||||
if (!Array.isArray(ids)) return []
|
||||
const set = new Set<number>()
|
||||
for (const v of ids) {
|
||||
const id = numberOrNull(v)
|
||||
if (id !== null && id > 0) set.add(id)
|
||||
}
|
||||
return [...set].sort((a, b) => a - b)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 新建用户请求适配(任务 46):表单模型 → POST /api/admin/user → 新用户 id。 */
|
||||
import { http } from '@/api/http'
|
||||
import { parseCreatedUserId } from '@/api/users-model'
|
||||
import {
|
||||
toCreateUserRequest,
|
||||
validateCreateUserForm,
|
||||
type CreateUserForm,
|
||||
} from './user-create-model'
|
||||
|
||||
/** Java AdminUserController.createUser 端点。 */
|
||||
export const CREATE_USER_ENDPOINT = '/api/admin/user'
|
||||
|
||||
/**
|
||||
* 提交新建用户:先校验表单(不通过直接抛首条错误,不发请求),再序列化请求体 POST,
|
||||
* 最后解析返回的新用户 id;http/后端失败交由调用方提示,不吞异常。
|
||||
*/
|
||||
export async function submitCreateUser(form: CreateUserForm): Promise<number> {
|
||||
const { valid, errors } = validateCreateUserForm(form)
|
||||
if (!valid) {
|
||||
throw new Error(Object.values(errors)[0] || '创建用户表单校验未通过')
|
||||
}
|
||||
const { data } = await http.post<unknown>(CREATE_USER_ENDPOINT, toCreateUserRequest(form))
|
||||
return parseCreatedUserId(data)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/** 新建用户表单模型(任务 45):纯逻辑,镜像 Java AdminUserCreateRequest 字段与后端校验。 */
|
||||
|
||||
export const CREATE_USERNAME_MIN_LEN = 2
|
||||
export const CREATE_PASSWORD_MIN_LEN = 6
|
||||
|
||||
/** 新建用户可选角色:super_admin 不可由表单创建(后端只收 admin|normal)。 */
|
||||
export type CreatableUserRole = 'normal' | 'admin'
|
||||
|
||||
export interface CreateUserForm {
|
||||
username: string
|
||||
password: string
|
||||
role: CreatableUserRole
|
||||
/** 授权菜单 id(后端 replaceDirectPermissions 用)。 */
|
||||
columnIds: number[]
|
||||
/** 所属管理员 id(超管创建普通账号时可指定,对齐 admin 的 created_by_id)。 */
|
||||
createdBy?: number | null
|
||||
}
|
||||
|
||||
export interface CreateUserFormErrors {
|
||||
username?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
/** 发送 POST /api/admin/user 的请求体(与 Java AdminUserCreateRequest camelCase 对齐)。 */
|
||||
export interface CreateUserPayload {
|
||||
username: string
|
||||
password: string
|
||||
role: string
|
||||
columnIds: number[]
|
||||
/** 仅 role=normal 且选了所属管理员时发送,与参考 admin.js 的 created_by_id 语义一致。 */
|
||||
createdById?: number
|
||||
}
|
||||
|
||||
export function createEmptyCreateUserForm(): CreateUserForm {
|
||||
return { username: '', password: '', role: 'normal', columnIds: [], createdBy: null }
|
||||
}
|
||||
|
||||
/** 未知/空白/超管角色一律归一到 normal,与后端 "admin"/"normal" 白名单一致。 */
|
||||
export function normalizeCreateRole(role: string | null | undefined): CreatableUserRole {
|
||||
return (role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'normal'
|
||||
}
|
||||
|
||||
/** 表单校验(镜像后端:用户名≥2、密码≥6、均必填);返回逐字段错误。 */
|
||||
export function validateCreateUserForm(form: CreateUserForm): { valid: boolean; errors: CreateUserFormErrors } {
|
||||
const errors: CreateUserFormErrors = {}
|
||||
const username = (form.username || '').trim()
|
||||
if (!username) {
|
||||
errors.username = '用户名不能为空'
|
||||
} else if (username.length < CREATE_USERNAME_MIN_LEN) {
|
||||
errors.username = `用户名至少${CREATE_USERNAME_MIN_LEN}个字符`
|
||||
}
|
||||
const password = form.password || ''
|
||||
if (!password) {
|
||||
errors.password = '密码不能为空'
|
||||
} else if (password.length < CREATE_PASSWORD_MIN_LEN) {
|
||||
errors.password = `密码至少${CREATE_PASSWORD_MIN_LEN}个字符`
|
||||
}
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
/** 表单 → 创建请求体:用户名去空格、角色归一、菜单 id 拷贝不共享引用;
|
||||
* createdById 仅在 role=normal 且选了所属管理员时携带(对齐 admin.js 的 created_by_id 语义)。 */
|
||||
export function toCreateUserRequest(form: CreateUserForm): CreateUserPayload {
|
||||
const payload: CreateUserPayload = {
|
||||
username: (form.username || '').trim(),
|
||||
password: form.password || '',
|
||||
role: normalizeCreateRole(form.role),
|
||||
columnIds: Array.isArray(form.columnIds) ? form.columnIds.slice() : [],
|
||||
}
|
||||
if (payload.role === 'normal' && form.createdBy != null) {
|
||||
payload.createdById = form.createdBy
|
||||
}
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** 用户删除确认模型(任务 51):删除确认文案 + 不可删原因,镜像 Java deleteUser 约束。 */
|
||||
import type { AdminUser } from '../../types/admin'
|
||||
|
||||
/** 删除二次确认文案(对齐 admin.js:970 原文:半角引号、无补充句)。 */
|
||||
export function deleteConfirmMessage(user: AdminUser | null | undefined): string {
|
||||
const username = user?.username || ''
|
||||
return `确定删除用户 "${username}" 吗?`
|
||||
}
|
||||
|
||||
/** 返回不可删除原因;可删除时返回 undefined。 */
|
||||
export function deleteBlockReason(
|
||||
user: AdminUser | null | undefined,
|
||||
currentUserId?: number | null,
|
||||
): string | undefined {
|
||||
if (!user) return '用户信息缺失,无法删除'
|
||||
if (currentUserId != null && user.id === currentUserId) return '不能删除当前登录账号'
|
||||
if ((user.role || '').trim().toLowerCase() === 'super_admin') return '不能删除超级管理员'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 该用户当前是否允许删除(非缺失/非自己/非超管)。 */
|
||||
export function isDeletableUser(user: AdminUser | null | undefined, currentUserId?: number | null): boolean {
|
||||
return deleteBlockReason(user, currentUserId) === undefined
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 编辑用户请求适配(module 13 task 251):表单模型 → PUT /api/admin/user/{uid}。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
toEditUserUpdateRequest,
|
||||
validateEditUserForm,
|
||||
type EditUserForm,
|
||||
} from './user-edit-model'
|
||||
|
||||
/** Java AdminUserController.updateUser 端点前缀。 */
|
||||
export const EDIT_USER_ENDPOINT = '/api/admin/user'
|
||||
|
||||
/**
|
||||
* 提交编辑用户:先校验(不通过抛首条错误),再序列化 PUT;http/后端失败交由调用方提示。
|
||||
*/
|
||||
export async function submitEditUser(form: EditUserForm): Promise<void> {
|
||||
const { valid, errors } = validateEditUserForm(form)
|
||||
if (!valid) {
|
||||
throw new Error(Object.values(errors)[0] || '编辑用户表单校验未通过')
|
||||
}
|
||||
const { data } = await http.put<unknown>(`${EDIT_USER_ENDPOINT}/${form.uid}`, toEditUserUpdateRequest(form))
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/** 编辑用户表单回填模型(任务 47):从用户行回填可编辑表单,镜像 Java AdminUserUpdateRequest。 */
|
||||
import type { AdminUser } from '../../types/admin'
|
||||
import { CREATE_PASSWORD_MIN_LEN } from './user-create-model.ts'
|
||||
|
||||
/** 编辑可选角色:超管不可改,未知归 normal;后端只收 admin|normal。 */
|
||||
export type EditUserRole = 'normal' | 'admin'
|
||||
|
||||
export interface EditUserForm {
|
||||
uid: number
|
||||
/** 仅展示用,后端不支持改用户名。 */
|
||||
username: string
|
||||
/** 空串 = 不修改密码。 */
|
||||
password: string
|
||||
role: EditUserRole
|
||||
/** 授权菜单 id;由授权数据加载后填充。 */
|
||||
columnIds: number[]
|
||||
/** 后端原角色字符串,用于超管禁止编辑判定。 */
|
||||
originalRole: string
|
||||
}
|
||||
|
||||
export interface EditUserFormErrors {
|
||||
password?: string
|
||||
}
|
||||
|
||||
/** 序列化到 PUT /api/admin/user/{uid} 的请求体(Java AdminUserUpdateRequest,空密码不下发)。 */
|
||||
export interface EditUserUpdateRequest {
|
||||
role: EditUserRole
|
||||
columnIds: number[]
|
||||
password?: string
|
||||
}
|
||||
|
||||
/** 行角色 → 可编辑角色:仅 admin 映射 admin,其余(含 unknown)归 normal。 */
|
||||
export function editRoleOf(role: string | null | undefined): EditUserRole {
|
||||
return (role || '').trim().toLowerCase() === 'admin' ? 'admin' : 'normal'
|
||||
}
|
||||
|
||||
/** 该角色是否允许被编辑:超级管理员不可编辑(镜像后端“不能修改超级管理员”)。 */
|
||||
export function isEditableUser(role: string | null | undefined): boolean {
|
||||
return (role || '').trim().toLowerCase() !== 'super_admin'
|
||||
}
|
||||
|
||||
/** 从用户行回填编辑表单:用户名只读、密码留空、columnIds 待授权加载填充。 */
|
||||
export function fillEditUserForm(user: AdminUser): EditUserForm {
|
||||
const role = typeof user.role === 'string' ? user.role.trim() : ''
|
||||
return {
|
||||
uid: user.id,
|
||||
username: user.username || '',
|
||||
password: '',
|
||||
role: editRoleOf(role),
|
||||
columnIds: [],
|
||||
originalRole: role,
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑表单校验:密码可留空(不改),非空时须达到最小长度。 */
|
||||
export function validateEditUserForm(form: EditUserForm): { valid: boolean; errors: EditUserFormErrors } {
|
||||
const errors: EditUserFormErrors = {}
|
||||
const password = form.password || ''
|
||||
if (password && password.length < CREATE_PASSWORD_MIN_LEN) {
|
||||
errors.password = `密码至少${CREATE_PASSWORD_MIN_LEN}个字符,留空则不修改`
|
||||
}
|
||||
return { valid: Object.keys(errors).length === 0, errors }
|
||||
}
|
||||
|
||||
/** 编辑表单 → 更新请求体:超管拒绝、空密码不下发、columnIds 拷贝不共享引用。 */
|
||||
export function toEditUserUpdateRequest(form: EditUserForm): EditUserUpdateRequest {
|
||||
const originalRole = (form.originalRole || '').trim().toLowerCase()
|
||||
if (originalRole === 'super_admin') {
|
||||
throw new Error('不能修改超级管理员')
|
||||
}
|
||||
const role: EditUserRole = form.role === 'admin' ? 'admin' : 'normal'
|
||||
const request: EditUserUpdateRequest = {
|
||||
role,
|
||||
columnIds: Array.isArray(form.columnIds) ? form.columnIds.slice() : [],
|
||||
}
|
||||
const password = form.password || ''
|
||||
if (password) request.password = password
|
||||
return request
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** 用户列表加载/空/错状态(任务 52):纯 phase 归一无 UI 依赖,供页面展示决策复用。 */
|
||||
|
||||
export type ListPhase = 'loading' | 'error' | 'empty' | 'ready'
|
||||
|
||||
export interface UserListPhaseInput {
|
||||
loading?: boolean
|
||||
error?: string
|
||||
items?: unknown[]
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** 归一列表展示相位:loading 优先,其次 error,其次空,否则 ready。 */
|
||||
export function listPhaseOf(input: UserListPhaseInput): ListPhase {
|
||||
if (input.loading) return 'loading'
|
||||
if (typeof input.error === 'string' && input.error.trim()) return 'error'
|
||||
const items = Array.isArray(input.items) ? input.items : []
|
||||
const total = typeof input.total === 'number' ? input.total : items.length
|
||||
if (items.length === 0 || total === 0) return 'empty'
|
||||
return 'ready'
|
||||
}
|
||||
|
||||
/** 空态提示文案。 */
|
||||
export function userListEmptyHint(): string {
|
||||
return '暂无用户数据'
|
||||
}
|
||||
|
||||
/** 错误态可操作提示:带上具体原因,缺省给通用文案。 */
|
||||
export function userListErrorHint(message?: string | null): string {
|
||||
const text = (message || '').trim()
|
||||
return text ? `加载失败:${text}` : '加载失败,请稍后重试'
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** 用户菜单授权数据加载适配(任务 49):拉取可授权菜单树 + 用户已授权 id。 */
|
||||
import { http } from '@/api/http'
|
||||
import { unwrap } from '@/api/envelope'
|
||||
import {
|
||||
ADMIN_MENU_TYPE,
|
||||
APP_MENU_TYPE,
|
||||
buildMenuOptionTree,
|
||||
buildUserColumnPermissionPayload,
|
||||
normalizeMenuIds,
|
||||
parseMenuOptionList,
|
||||
parseUserGrantedColumnIds,
|
||||
type MenuOptionNode,
|
||||
} from './user-menu-auth'
|
||||
|
||||
export const GRANT_MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||
export const USER_PERMISSION_COLUMNS_ENDPOINT = '/api/admin/permission-users'
|
||||
|
||||
/** 拉取可授权菜单树(后台 admin + 前端客户端 app 两类合并,按 sort 排序)。 */
|
||||
export async function fetchGrantableMenus(): Promise<MenuOptionNode[]> {
|
||||
const [adminRes, appRes] = await Promise.all([
|
||||
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }),
|
||||
http.get<unknown>(GRANT_MENUS_ENDPOINT, { params: { menuType: APP_MENU_TYPE } }),
|
||||
])
|
||||
const nodes = [
|
||||
...parseMenuOptionList(adminRes.data, ADMIN_MENU_TYPE),
|
||||
...parseMenuOptionList(appRes.data, APP_MENU_TYPE),
|
||||
]
|
||||
return buildMenuOptionTree(nodes)
|
||||
}
|
||||
|
||||
/** 拉取某用户当前直接授权菜单 id 列表(admin + app 合并去重)。 */
|
||||
export async function fetchUserGrantedColumnIds(userId: number): Promise<number[]> {
|
||||
const [adminRes, appRes] = await Promise.all([
|
||||
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: ADMIN_MENU_TYPE },
|
||||
}),
|
||||
http.get<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, {
|
||||
params: { menuType: APP_MENU_TYPE },
|
||||
}),
|
||||
])
|
||||
return normalizeMenuIds([
|
||||
...parseUserGrantedColumnIds(adminRes.data),
|
||||
...parseUserGrantedColumnIds(appRes.data),
|
||||
])
|
||||
}
|
||||
|
||||
/** 编辑用户菜单授权所需数据:可选菜单树 + 已勾选 id。 */
|
||||
export interface UserMenuAuthData {
|
||||
tree: MenuOptionNode[]
|
||||
checkedIds: number[]
|
||||
}
|
||||
|
||||
export async function loadUserMenuAuth(userId: number): Promise<UserMenuAuthData> {
|
||||
const [tree, checkedIds] = await Promise.all([fetchGrantableMenus(), fetchUserGrantedColumnIds(userId)])
|
||||
return { tree, checkedIds }
|
||||
}
|
||||
|
||||
/** 提交某用户的直接授权菜单 id(整树替换);success=false 由 unwrap 抛后端 message。 */
|
||||
export async function submitUserMenuAuth(userId: number, columnIds: unknown): Promise<void> {
|
||||
const payload = buildUserColumnPermissionPayload(columnIds)
|
||||
const { data } = await http.put<unknown>(`${USER_PERMISSION_COLUMNS_ENDPOINT}/${userId}/columns`, payload, {
|
||||
params: { menuType: ADMIN_MENU_TYPE },
|
||||
})
|
||||
unwrap<unknown>(data)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/** 用户菜单授权数据解析模型(任务 49):permission-menus 扁平项 → 嵌套树 + 用户已授权 id。 */
|
||||
import { unwrap } from '../../api/envelope.ts'
|
||||
|
||||
/** 后台 Vue 控制台菜单类型(与 /api/admin/current-user/menus 一致)。 */
|
||||
export const ADMIN_MENU_TYPE = 'admin'
|
||||
/** 前端客户端菜单类型(桌面端功能模块,如视频/前端工具/运营工具/后勤工具)。 */
|
||||
export const APP_MENU_TYPE = 'app'
|
||||
|
||||
/** 授权用可点选菜单节点(携带后端 numeric column id,用于提交授权)。 */
|
||||
export interface MenuOptionNode {
|
||||
id: number
|
||||
/** column_key,稳定权限标识;缺省用 id 字符串兜底。 */
|
||||
key: string
|
||||
name: string
|
||||
sort: number
|
||||
parentId: number | null
|
||||
/** 所属菜单类型(admin 后台 / app 前端客户端),提交时按类型分区落库。 */
|
||||
type: string
|
||||
/**
|
||||
* 当前操作者无权授予(后端 grantable=false)时置灰:仍展示并回显已勾选,
|
||||
* 但不允许改勾选。非超管只能授自己已有的菜单,勾到越权项会让整笔保存回滚。
|
||||
*/
|
||||
disabled?: boolean
|
||||
children?: MenuOptionNode[]
|
||||
}
|
||||
|
||||
function numberOrNull(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||
}
|
||||
|
||||
/** 把单条 PermissionMenuItemVo(snake_case) 归一为授权节点;缺 id 视为无效。 */
|
||||
export function parsePermissionMenuItem(raw: unknown, type = ''): MenuOptionNode | null {
|
||||
if (!raw || typeof raw !== 'object') return null
|
||||
const record = raw as Record<string, unknown>
|
||||
const id = numberOrNull(record.id)
|
||||
if (id === null) return null
|
||||
const parentId = numberOrNull(record.parent_id)
|
||||
const sortRaw = numberOrNull(record.sort_order)
|
||||
const columnKey = typeof record.column_key === 'string' ? record.column_key.trim() : ''
|
||||
return {
|
||||
id,
|
||||
key: columnKey || String(id),
|
||||
name: typeof record.name === 'string' && record.name.trim() ? record.name.trim() : '未命名菜单',
|
||||
sort: sortRaw === null ? 0 : sortRaw,
|
||||
parentId: parentId === null ? null : parentId,
|
||||
type,
|
||||
// 缺省(菜单管理页等未标记的接口)按可授予处理,保持旧行为
|
||||
disabled: record.grantable === false,
|
||||
}
|
||||
}
|
||||
|
||||
/** 归一化 permission-menus 响应(信封或裸数组)为扁平授权节点列表。 */
|
||||
export function parseMenuOptionList(payload: unknown, type = ''): MenuOptionNode[] {
|
||||
const data = unwrap<unknown>(payload)
|
||||
if (!Array.isArray(data)) return []
|
||||
return data
|
||||
.map((raw) => parsePermissionMenuItem(raw, type))
|
||||
.filter((node): node is MenuOptionNode => node !== null)
|
||||
}
|
||||
|
||||
/** 按 parent_id 组装嵌套树;同级按 (sort,key) 升序,父缺失的节点按根处理。 */
|
||||
export function buildMenuOptionTree(items: MenuOptionNode[]): MenuOptionNode[] {
|
||||
const nodes = new Map<number, MenuOptionNode>()
|
||||
for (const item of items) {
|
||||
nodes.set(item.id, { ...item, children: [] })
|
||||
}
|
||||
const roots: MenuOptionNode[] = []
|
||||
for (const item of items) {
|
||||
const node = nodes.get(item.id)
|
||||
if (!node) continue
|
||||
const parent = item.parentId === null ? undefined : nodes.get(item.parentId)
|
||||
if (parent) {
|
||||
parent.children!.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
const order = (a: MenuOptionNode, b: MenuOptionNode) =>
|
||||
a.sort - b.sort || a.key.localeCompare(b.key)
|
||||
const sortRecursive = (nodes: MenuOptionNode[]) => {
|
||||
nodes.sort(order)
|
||||
for (const node of nodes) node.children?.length && sortRecursive(node.children)
|
||||
}
|
||||
sortRecursive(roots)
|
||||
for (const item of items) {
|
||||
const node = nodes.get(item.id)
|
||||
if (node && !node.children?.length) delete node.children
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
/** 解包用户已授权 columnIds(data.columnIds)→ 去重升序正整数。 */
|
||||
export function parseUserGrantedColumnIds(payload: unknown): number[] {
|
||||
const data = unwrap<unknown>(payload)
|
||||
const record = data && typeof data === 'object' ? (data as Record<string, unknown>) : null
|
||||
if (!record || !Array.isArray(record.columnIds)) return []
|
||||
return normalizeMenuIds(record.columnIds)
|
||||
}
|
||||
|
||||
/** 任意输入 id 集合归一为去重升序正整数(镜像后端 normalizeColumnIds)。 */
|
||||
export function normalizeMenuIds(raw: unknown): number[] {
|
||||
if (!Array.isArray(raw)) return []
|
||||
const ids = new Set<number>()
|
||||
for (const value of raw) {
|
||||
const id = numberOrNull(value)
|
||||
if (id !== null && id >= 1) ids.add(id)
|
||||
}
|
||||
return [...ids].sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/** 提交授权请求体(任务 50):{columnIds} 与 Java UserColumnPermissionUpdateRequest 对齐。 */
|
||||
export function buildUserColumnPermissionPayload(raw: unknown): { columnIds: number[] } {
|
||||
return { columnIds: normalizeMenuIds(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 把权限树勾选 id 压缩为后端意义上的“直接授权最小集”:父级已勾选时后代无需
|
||||
* 重复提交(后端读取/校验时父级授权会向全部后代展开,与旧 admin.html“勾父清
|
||||
* 后代”语义一致,避免 user_column_permission 存储冗余行)。未勾选父级但单独
|
||||
* 勾选子级的部分授权原样保留。
|
||||
*/
|
||||
export function compactDirectGrantIds(checkedIds: unknown, tree: MenuOptionNode[]): number[] {
|
||||
const ids = normalizeMenuIds(checkedIds)
|
||||
if (ids.length === 0 || tree.length === 0) return ids
|
||||
const idSet = new Set(ids)
|
||||
/** 因“有已勾选祖先”而被父级覆盖、无需直接授权的 id。 */
|
||||
const coveredByAncestor = new Set<number>()
|
||||
const walk = (nodes: MenuOptionNode[], ancestorChecked: boolean): void => {
|
||||
for (const node of nodes) {
|
||||
if (ancestorChecked) {
|
||||
coveredByAncestor.add(node.id)
|
||||
if (node.children?.length) walk(node.children, true)
|
||||
continue
|
||||
}
|
||||
if (node.children?.length) walk(node.children, idSet.has(node.id))
|
||||
}
|
||||
}
|
||||
walk(tree, false)
|
||||
return ids.filter((id) => !coveredByAncestor.has(id))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** 用户列表分页边界(任务 44):纯函数,处理总页数、越界页码回钳与空数据。 */
|
||||
import { USER_PAGE_DEFAULT_SIZE } from '../../api/users-dto.ts'
|
||||
|
||||
/** 总页数;无数据返回 0,pageSize 非法时用默认。 */
|
||||
export function totalPageCount(total: number, pageSize: number): number {
|
||||
if (!Number.isFinite(total) || total <= 0) return 0
|
||||
const size = Number.isFinite(pageSize) && pageSize >= 1 ? Math.floor(pageSize) : USER_PAGE_DEFAULT_SIZE
|
||||
return Math.ceil(total / size)
|
||||
}
|
||||
|
||||
/** 请求页是否超过末页(无数据时不算越界)。 */
|
||||
export function isPageOutOfRange(page: number, total: number, pageSize: number): boolean {
|
||||
const requested = Number.isFinite(page) ? Math.floor(page) : 1
|
||||
if (requested < 1) return false
|
||||
const last = totalPageCount(total, pageSize)
|
||||
return last > 0 && requested > last
|
||||
}
|
||||
|
||||
/** 把请求页钳到合法范围:<1 回首页,超过末页回末页;无数据允许第 1 页空态。 */
|
||||
export function clampPage(page: number, total: number, pageSize: number): number {
|
||||
const requested = Number.isFinite(page) ? Math.floor(page) : 1
|
||||
if (requested < 1) return 1
|
||||
const last = totalPageCount(total, pageSize)
|
||||
if (last === 0) return 1
|
||||
return Math.min(requested, last)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/** 用户角色字段映射(任务 48):行 role/is_admin → 角色域 → 可编辑值/锁定原因,镜像 Java 约束。 */
|
||||
import { roleKind } from '../../types/admin.ts'
|
||||
|
||||
/** 后台角色域(与 AdminUserItemVo.role / is_admin 对齐)。 */
|
||||
export type RoleDomain = 'super_admin' | 'admin' | 'normal'
|
||||
|
||||
/** 用户角色下拉可选值;super_admin 只能由系统持有,不入下拉。 */
|
||||
export type EditableRole = 'admin' | 'normal'
|
||||
|
||||
export interface UserRoleOption {
|
||||
value: EditableRole
|
||||
label: string
|
||||
}
|
||||
|
||||
/** 编辑/创建表单角色下拉候选(镜像后端 admin|normal 白名单)。 */
|
||||
export const USER_ROLE_OPTIONS: readonly UserRoleOption[] = [
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'normal', label: '普通用户' },
|
||||
]
|
||||
|
||||
const ROLE_DOMAIN_LABELS: Record<RoleDomain, string> = {
|
||||
super_admin: '超级管理员',
|
||||
admin: '管理员',
|
||||
normal: '普通用户',
|
||||
}
|
||||
|
||||
/** 行字段 → 角色域:优先 role;为空时按 is_admin 布尔推断(1=管理员);未知归 normal。 */
|
||||
export function roleDomainOf(row: { role?: string | null; isAdmin?: boolean }): RoleDomain {
|
||||
const kind = roleKind(row?.role)
|
||||
if (kind === 'super_admin' || kind === 'admin' || kind === 'normal') return kind
|
||||
return row?.isAdmin ? 'admin' : 'normal'
|
||||
}
|
||||
|
||||
/** 行 → 可编辑角色值:超级管理员返回 null(不可编辑),其余返回 admin/normal。 */
|
||||
export function selectRoleOf(row: { role?: string | null; isAdmin?: boolean }): EditableRole | null {
|
||||
const domain = roleDomainOf(row)
|
||||
return domain === 'super_admin' ? null : domain
|
||||
}
|
||||
|
||||
/** 角色域展示文案。 */
|
||||
export function roleDomainLabel(domain: RoleDomain): string {
|
||||
return ROLE_DOMAIN_LABELS[domain] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色下拉锁定原因:目标为超管、或操作者非超管(镜像后端:admin 编辑时强制不调角色、
|
||||
* super_admin 不可改)。无锁定时返回 undefined。
|
||||
*/
|
||||
export function userRoleLockMessage(
|
||||
operatorRole: string | null | undefined,
|
||||
target: { role?: string | null; isAdmin?: boolean },
|
||||
): string | undefined {
|
||||
if (roleDomainOf(target) === 'super_admin') return '不能修改超级管理员'
|
||||
if (roleKind(operatorRole) !== 'super_admin') return '仅超级管理员可调整角色'
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** 用户页局部筛选/分页状态(任务 43):纯逻辑、不入 Pinia,页面级可测状态机。 */
|
||||
import { USER_PAGE_DEFAULT_SIZE, USER_PAGE_MIN_PAGE, type UserListParams } from '../../api/users-dto.ts'
|
||||
|
||||
/** 页面局部查询状态:筛选条件 + 分页;业务表状态不落 Pinia。 */
|
||||
export interface UserFilterState {
|
||||
/** 用户名模糊关键字。 */
|
||||
keyword: string
|
||||
/** 按创建管理员过滤(可选)。 */
|
||||
createdByAdminId: number | null
|
||||
/** 角色过滤(''=全部;super_admin/admin/normal)。 */
|
||||
role: string
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export function createUserFilterState(): UserFilterState {
|
||||
return { keyword: '', createdByAdminId: null, role: '', page: USER_PAGE_MIN_PAGE, pageSize: USER_PAGE_DEFAULT_SIZE }
|
||||
}
|
||||
|
||||
/** 修改筛选条件时重置回第一页(避免停在可能越界的旧页)。 */
|
||||
export function withFilterReset(
|
||||
state: UserFilterState,
|
||||
next: Partial<Pick<UserFilterState, 'keyword' | 'createdByAdminId' | 'role'>>,
|
||||
): UserFilterState {
|
||||
return { ...state, ...next, page: USER_PAGE_MIN_PAGE }
|
||||
}
|
||||
|
||||
/** 翻页只改页码并钳到 >=1,保留筛选条件。 */
|
||||
export function withPage(state: UserFilterState, page: number): UserFilterState {
|
||||
return { ...state, page: Math.max(page, USER_PAGE_MIN_PAGE) }
|
||||
}
|
||||
|
||||
/** 是否存在生效筛选(仅空白关键字不算)。 */
|
||||
export function isFilterActive(state: UserFilterState): boolean {
|
||||
return Boolean(state.keyword && state.keyword.trim()) || state.createdByAdminId != null
|
||||
}
|
||||
|
||||
/** 页面状态 → 查询适配层参数(空关键字不下发)。 */
|
||||
export function toUserListParams(state: UserFilterState): UserListParams {
|
||||
const keyword = state.keyword.trim()
|
||||
const params: UserListParams = {
|
||||
page: state.page,
|
||||
pageSize: state.pageSize,
|
||||
keyword: keyword || undefined,
|
||||
createdById: state.createdByAdminId,
|
||||
}
|
||||
const role = (state.role || '').trim()
|
||||
if (role) params.role = role
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 品牌数据库(不符合ASIN)页 · 像素复刻旧版 admin.html panel-invalid-asin-data(自绘:面板头+新增、筛选、旧式表格/分页、原生 confirm 删除)。
|
||||
* script 逻辑沿用现有 Vue 实现(分组锁定/来源 AUTO 编辑隐藏分组);弹窗保留现有组件。 */
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
import { createInvalidAsin, deleteInvalidAsin, fetchInvalidAsinPage, updateInvalidAsin } from '../invalidasin/invalid-asin-api.ts'
|
||||
import type { InvalidAsinItem } from '../invalidasin/invalid-asin-model.ts'
|
||||
import { invalidAsinDeleteText, invalidAsinLockedGroupId, recordSourceLabel } from '../invalidasin/invalid-asin-model.ts'
|
||||
import { createEmptyInvalidAsinForm, invalidAsinFormFromItem, validateInvalidAsinForm } from '../invalidasin/invalid-asin-form-model.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
const loading = ref(false)
|
||||
const rows = ref<InvalidAsinItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const filter = reactive({ dataValue: '', brand: '', groupId: null as number | null })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
/** 当前编辑行来源:自动导入(AUTO)记录不可改分组。 */
|
||||
const editingSource = ref('')
|
||||
const saving = ref(false)
|
||||
const form = reactive(createEmptyInvalidAsinForm())
|
||||
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
/** 非超管锁定的分组 id(对齐 admin.js getInvalidAsinDataLockedGroupId);超管为 null。 */
|
||||
const lockedGroupId = computed(() => invalidAsinLockedGroupId(groups.value, null, session.user?.role || ''))
|
||||
/** 分组下拉可用选项:超管全量,非超管仅锁定分组。 */
|
||||
const groupOptions = computed<ShopGroupOption[]>(() => {
|
||||
if (isSuperAdmin.value) return groups.value
|
||||
const locked = lockedGroupId.value
|
||||
return locked == null ? [] : groups.value.filter((group) => group.id === locked)
|
||||
})
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups.value = await fetchShopManageGroups()
|
||||
} catch {
|
||||
groups.value = []
|
||||
}
|
||||
// 非超管:筛选强制锁定到本人分组(与参考 refreshShopGroupSelects 的锁定语义一致)。
|
||||
if (!isSuperAdmin.value && lockedGroupId.value != null) {
|
||||
filter.groupId = lockedGroupId.value
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchInvalidAsinPage({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
dataValue: filter.dataValue.trim() || undefined,
|
||||
brand: filter.brand.trim() || undefined,
|
||||
groupId: filter.groupId,
|
||||
})
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
editingSource.value = ''
|
||||
Object.assign(form, createEmptyInvalidAsinForm())
|
||||
// 非超管新增预置锁定分组(下拉同时被禁用)。
|
||||
if (!isSuperAdmin.value && lockedGroupId.value != null) {
|
||||
form.groupId = lockedGroupId.value
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: InvalidAsinItem) {
|
||||
editingId.value = row.id
|
||||
editingSource.value = row.recordSource
|
||||
Object.assign(form, invalidAsinFormFromItem(row))
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const { valid, errors } = validateInvalidAsinForm({ ...form }, { requireGroup: editingId.value == null })
|
||||
if (!valid) {
|
||||
ElMessage.warning(Object.values(errors)[0] || '表单校验未通过')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value == null) {
|
||||
await createInvalidAsin({ ...form })
|
||||
ElMessage.success('已新增')
|
||||
} else {
|
||||
const includeGroup = editingSource.value.toUpperCase() !== 'AUTO'
|
||||
await updateInvalidAsin(editingId.value, { ...form }, includeGroup)
|
||||
ElMessage.success('已保存')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: InvalidAsinItem) {
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(invalidAsinDeleteText(row))) return
|
||||
try {
|
||||
await deleteInvalidAsin(row.id)
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
watch(isSuperAdmin, () => {
|
||||
if (isSuperAdmin.value) filter.groupId = null
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="invalid-view">
|
||||
<section class="panel-box">
|
||||
<div class="invalid-head">
|
||||
<h3>品牌数据列表</h3>
|
||||
<button class="btn" type="button" @click="openCreate">新增</button>
|
||||
</div>
|
||||
|
||||
<div class="form-row invalid-filter-row">
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>ASIN</label>
|
||||
<input v-model="filter.dataValue" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 200px">
|
||||
<label>品牌</label>
|
||||
<input v-model="filter.brand" type="text" placeholder="输入品牌" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div v-if="isSuperAdmin" class="form-group" style="min-width: 180px">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll invalid-asin-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 70px">ID</th>
|
||||
<th style="width: 170px">ASIN</th>
|
||||
<th style="width: 150px">品牌</th>
|
||||
<th v-if="isSuperAdmin" style="width: 140px">分组</th>
|
||||
<th style="width: 110px">来源</th>
|
||||
<th style="width: 170px">创建时间</th>
|
||||
<th style="width: 150px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||
<td>{{ row.brand || '—' }}</td>
|
||||
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||
<td>
|
||||
<span class="source-text" :class="{ 'is-auto': row.recordSource !== 'MANUAL' }">
|
||||
{{ recordSourceLabel(row.recordSource) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="remove(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId == null ? '新增不符合ASIN' : '编辑不符合ASIN'" width="520px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="ASIN" required>
|
||||
<el-input v-model="form.dataValue" placeholder="不符合的 ASIN" />
|
||||
</el-form-item>
|
||||
<el-form-item label="品牌">
|
||||
<el-input v-model="form.brand" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingId == null || editingSource.toUpperCase() !== 'AUTO'" label="分组" required>
|
||||
<el-select v-model="form.groupId" placeholder="选择分组" filterable :disabled="!isSuperAdmin">
|
||||
<el-option v-for="group in groupOptions" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-invalid-asin-data(蓝白末层)。 */
|
||||
.invalid-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.invalid-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.invalid-asin-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mono-cell {
|
||||
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.source-text {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #cbd9e6;
|
||||
background: #fff;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
}
|
||||
.source-text.is-auto {
|
||||
background: #e7f0f8;
|
||||
border-color: #c2d5e6;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,825 @@
|
||||
<script setup lang="ts">
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
/** 数据去重总数据(registry) · 像素复刻旧版 admin.html panel-dedupe-total-data(自绘:分组摘要 + 双行筛选 + 旧式表格/分页 + 编辑/导入弹窗 + 导出等待遮罩)。
|
||||
* script 逻辑沿用现有 Vue 实现(查询/编辑/删除/导入轮询/导出秒表)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
fetchDedupeTotalList,
|
||||
updateDedupeTotal,
|
||||
deleteDedupeTotal,
|
||||
fetchDedupeTotalGroups,
|
||||
type DedupeGroupOption,
|
||||
} from './dedupe-total-api.ts'
|
||||
import type { DedupeTotalItem } from './dedupe-total-model.ts'
|
||||
import { ASIN_COUNTRY_CODES, asinCountryLabel } from './asin-country.ts'
|
||||
import { createDedupeTotalFilterState, toDedupeListParams } from './dedupe-total-filter.ts'
|
||||
import {
|
||||
fetchDedupeDeleteImportProgress,
|
||||
fetchDedupeImportProgress,
|
||||
startDedupeDeleteImport,
|
||||
startDedupeImport,
|
||||
} from './dedupe-import-api.ts'
|
||||
import { importOutcomeText, importTaskFinished, isAllowedImportFile } from './dedupe-import-model.ts'
|
||||
import type { DedupeImportProgress } from './dedupe-import-model.ts'
|
||||
import { toExportUrl } from './dedupe-total-export.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<DedupeTotalItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const filter = reactive(createDedupeTotalFilterState())
|
||||
const groups = ref<DedupeGroupOption[]>([])
|
||||
/** 非超管仅有一个可访问分组时,编辑/导入弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||
const lockedGroupId = computed<number | null>(() => {
|
||||
if (isSuperAdmin.value) return null
|
||||
return groups.value.length === 1 ? groups.value[0].id : null
|
||||
})
|
||||
const jumpPage = ref('')
|
||||
/** 顺序翻页游标:上一页响应给的 nextLastId;仅在"下一页"时使用,跳页/筛选时清空走 OFFSET。 */
|
||||
const pageCursor = ref<number | null>(null)
|
||||
/** 本次请求实际使用的游标(load 时决定) */
|
||||
let pendingCursor: number | null = null
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editSaving = ref(false)
|
||||
const editTarget = ref<DedupeTotalItem | null>(null)
|
||||
const editValue = ref('')
|
||||
const editGroupId = ref<number | null>(null)
|
||||
|
||||
type ImportMode = 'add' | 'delete'
|
||||
const importVisible = ref(false)
|
||||
const importMode = ref<ImportMode>('add')
|
||||
const importGroupId = ref<number | null>(null)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importRunning = ref(false)
|
||||
const importProgress = ref('')
|
||||
|
||||
const exporting = ref(false)
|
||||
/** 导出等待遮罩与秒表(对齐 admin.js showDedupeTotalDataExportWait)。 */
|
||||
const exportWaitVisible = ref(false)
|
||||
const exportWaitSeconds = ref(0)
|
||||
let exportWaitTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function startExportWait(): void {
|
||||
exportWaitSeconds.value = 0
|
||||
exportWaitVisible.value = true
|
||||
exportWaitTimer = setInterval(() => {
|
||||
exportWaitSeconds.value += 1
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stopExportWait(): void {
|
||||
if (exportWaitTimer) {
|
||||
clearInterval(exportWaitTimer)
|
||||
exportWaitTimer = null
|
||||
}
|
||||
exportWaitVisible.value = false
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchDedupeTotalList(
|
||||
toDedupeListParams(filter, page.value, pageSize.value, pendingCursor),
|
||||
)
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
if (result.page >= 1) page.value = result.page
|
||||
// 本页响应回传的游标留作"下一页"用;空页则清空(没有更多)
|
||||
pageCursor.value = result.nextLastId ?? null
|
||||
pendingCursor = null
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '去重数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
page.value = 1
|
||||
pageCursor.value = null
|
||||
pendingCursor = null
|
||||
void load()
|
||||
}
|
||||
|
||||
function changePage(next: number): void {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
// 只有"顺序下一页"用 keyset 游标(避免深分页 offset);跳页/回退走 OFFSET,行为不变
|
||||
pendingCursor = next === page.value + 1 ? pageCursor.value : null
|
||||
page.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
function goJump(): void {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
pageCursor.value = null
|
||||
pendingCursor = null
|
||||
void load()
|
||||
}
|
||||
|
||||
function openEdit(row: DedupeTotalItem): void {
|
||||
editTarget.value = row
|
||||
editValue.value = row.dataValue
|
||||
editGroupId.value = row.groupId ?? lockedGroupId.value
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function saveEdit(): Promise<void> {
|
||||
const value = editValue.value.trim()
|
||||
const target = editTarget.value
|
||||
if (!target) return
|
||||
if (!value) {
|
||||
ElMessage.warning('请填写总数据值')
|
||||
return
|
||||
}
|
||||
if (!editGroupId.value) {
|
||||
ElMessage.warning('请选择分组')
|
||||
return
|
||||
}
|
||||
editSaving.value = true
|
||||
try {
|
||||
await updateDedupeTotal(target.id, value, editGroupId.value)
|
||||
editVisible.value = false
|
||||
ElMessage.success('保存成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
editSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRow(row: DedupeTotalItem): Promise<void> {
|
||||
// 对齐旧版:浏览器原生 confirm 确认后删除。
|
||||
if (!window.confirm(`确定删除总数据“${row.dataValue}”吗?`)) return
|
||||
try {
|
||||
await deleteDedupeTotal(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function resetImport(): void {
|
||||
importGroupId.value = lockedGroupId.value
|
||||
importFile.value = null
|
||||
importProgress.value = ''
|
||||
importRunning.value = false
|
||||
}
|
||||
|
||||
function openImportAdd(): void {
|
||||
importMode.value = 'add'
|
||||
resetImport()
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function openImportDelete(): void {
|
||||
importMode.value = 'delete'
|
||||
resetImport()
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function onPickFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
importFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
function importValid(): boolean {
|
||||
if (!importGroupId.value) {
|
||||
ElMessage.warning('请先选择数据权限分组')
|
||||
return false
|
||||
}
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请选择 Excel 文件')
|
||||
return false
|
||||
}
|
||||
if (!isAllowedImportFile(importFile.value.name)) {
|
||||
ElMessage.warning('仅支持 .xlsx/.xls 文件')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function pollImport(poll: () => Promise<DedupeImportProgress>): Promise<DedupeImportProgress> {
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
const progress = await poll()
|
||||
importProgress.value = importOutcomeText(progress)
|
||||
if (importTaskFinished(progress)) return progress
|
||||
await sleep(1000)
|
||||
}
|
||||
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
|
||||
}
|
||||
|
||||
async function submitImport(): Promise<void> {
|
||||
if (!importValid()) return
|
||||
const file = importFile.value as File
|
||||
const groupId = importGroupId.value as number
|
||||
if (importMode.value === 'delete') {
|
||||
if (!window.confirm('确定按 Excel 中的 ASIN 批量删除匹配的总数据吗?')) return
|
||||
}
|
||||
const mode = importMode.value
|
||||
importRunning.value = true
|
||||
importProgress.value = mode === 'add' ? '正在上传并解析文件…' : '正在上传删除清单…'
|
||||
try {
|
||||
const started =
|
||||
mode === 'add' ? await startDedupeImport(file, groupId) : await startDedupeDeleteImport(file, groupId)
|
||||
const poll = mode === 'add' ? () => fetchDedupeImportProgress(started) : () => fetchDedupeDeleteImportProgress(started)
|
||||
const finished = await pollImport(poll)
|
||||
importVisible.value = false
|
||||
resetImport()
|
||||
if (finished.status === 'success') {
|
||||
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
|
||||
await load()
|
||||
} else {
|
||||
ElMessage.error(importOutcomeText(finished))
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '导入失败')
|
||||
} finally {
|
||||
importRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doExport(): Promise<void> {
|
||||
const { startDate, endDate } = filter
|
||||
if (startDate && endDate && startDate > endDate) {
|
||||
ElMessage.warning('开始日期不能晚于结束日期')
|
||||
return
|
||||
}
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
startExportWait()
|
||||
try {
|
||||
// 对齐 admin.js:fetch 流式下载,带筛选参数;失败响应(JSON)解析错误提示。
|
||||
const response = await window.fetch(toExportUrl(filter), { credentials: 'include' })
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
if (!response.ok || contentType.includes('application/json')) {
|
||||
const body = (await response.json().catch(() => ({}))) as { error?: string; message?: string; msg?: string }
|
||||
throw new Error(body.error || body.message || body.msg || '导出失败')
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const disposition = response.headers.get('content-disposition') || ''
|
||||
const matched = /filename\*?=(?:UTF-8'')?"?([^";]+)"?/i.exec(disposition)
|
||||
const filename = matched ? decodeURIComponent(matched[1]) : 'dedupe-total-data.xlsx'
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出完成')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '导出失败,请重试')
|
||||
} finally {
|
||||
stopExportWait()
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
fetchDedupeTotalGroups().then((items) => {
|
||||
groups.value = items
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dedupe-view">
|
||||
<section class="panel-box">
|
||||
<div class="dedupe-panel-head">
|
||||
<h3>ASIN列表</h3>
|
||||
<div class="dedupe-head-actions">
|
||||
<button class="btn" type="button" @click="openImportAdd">新增导入</button>
|
||||
<button class="btn" type="button" @click="openImportDelete">删除导入</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dedupe-filter-row">
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="filter.keyword" type="text" placeholder="输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>用户名(模糊搜索)</label>
|
||||
<input v-model="filter.username" type="text" placeholder="输入用户名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div v-if="isSuperAdmin" class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>国家</label>
|
||||
<select v-model="filter.country">
|
||||
<option value="">全部国家</option>
|
||||
<option v-for="code in ASIN_COUNTRY_CODES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dedupe-filter-actions">
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dedupe-filter-date-row">
|
||||
<div class="form-group">
|
||||
<label>开始日期</label>
|
||||
<input v-model="filter.startDate" type="date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>结束日期</label>
|
||||
<input v-model="filter.endDate" type="date" />
|
||||
</div>
|
||||
<div class="dedupe-filter-actions">
|
||||
<button class="btn btn-secondary" type="button" :disabled="exporting" @click="doExport">
|
||||
{{ exporting ? '导出中...' : '导出 XLSX' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll dedupe-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>ASIN</th>
|
||||
<th>国家</th>
|
||||
<th>用户名</th>
|
||||
<th v-if="isSuperAdmin">分组</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="rows.length">
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.id }}</td>
|
||||
<td class="mono-cell">{{ row.dataValue }}</td>
|
||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||
<td>{{ row.username || '-' }}</td>
|
||||
<td v-if="isSuperAdmin">{{ row.groupName || '未分组' }}</td>
|
||||
<td>{{ formatDateTime(row.createdAt) }}</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row)">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无总数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<div v-if="editVisible" class="modal-mask" @click.self="editVisible = false">
|
||||
<div class="modal">
|
||||
<h3>编辑ASIN</h3>
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="editValue" type="text" placeholder="请输入总数据值" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<select v-model="editGroupId" :disabled="lockedGroupId != null">
|
||||
<option disabled :value="null">请选择分组</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn" type="button" :disabled="editSaving" @click="saveEdit">保存</button>
|
||||
<button class="btn btn-secondary" type="button" @click="editVisible = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importVisible" class="modal-mask" @click.self="importVisible = false">
|
||||
<div class="modal">
|
||||
<h3>{{ importMode === 'add' ? '新增导入' : '删除导入' }}</h3>
|
||||
<div class="form-group">
|
||||
<label>{{ importMode === 'add' ? '上传 Excel 文件(读取 ASIN 列,可选 国家 列)' : '上传 Excel 文件(读取 ASIN 列)' }}</label>
|
||||
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickFile" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>分组</label>
|
||||
<select v-model="importGroupId" :disabled="importRunning || lockedGroupId != null">
|
||||
<option disabled :value="null">请选择分组</option>
|
||||
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="importProgress" class="progress-wrap">
|
||||
<div class="progress-text">{{ importProgress }}</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
class="btn"
|
||||
:class="{ 'btn-danger': importMode === 'delete' }"
|
||||
type="button"
|
||||
:disabled="importRunning"
|
||||
@click="submitImport"
|
||||
>
|
||||
{{ importMode === 'add' ? '上传并导入' : '上传并删除' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" type="button" :disabled="importRunning" @click="importVisible = false">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="exportWaitVisible" class="dedupe-export-wait-mask" role="dialog" aria-modal="true" aria-labelledby="dedupe-export-wait-title">
|
||||
<div class="dedupe-export-wait">
|
||||
<span class="request-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<div class="dedupe-export-wait-title" id="dedupe-export-wait-title">正在生成导出文件</div>
|
||||
<div class="dedupe-export-wait-detail">
|
||||
数据量较大,请耐心等待并保持页面打开。已等待 <span>{{ exportWaitSeconds }}</span> 秒
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-dedupe-total-data(蓝白末层样式)。 */
|
||||
.dedupe-view {
|
||||
font-family: inherit;
|
||||
color: var(--c-text, #24384d);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.form-box,
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.dedupe-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dedupe-panel-head h3 {
|
||||
margin: 0;
|
||||
}
|
||||
.dedupe-head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.dedupe-filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr)) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dedupe-filter-row .form-group {
|
||||
min-width: 0;
|
||||
}
|
||||
.dedupe-filter-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dedupe-filter-date-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.dedupe-filter-date-row .form-group {
|
||||
flex: 0 0 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
.dedupe-filter-date-row .dedupe-filter-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.form-group input[type="file"] {
|
||||
background: #ffffff;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.form-group input[type="date"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.dedupe-table-scroll > table {
|
||||
min-width: 900px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.mono-cell {
|
||||
font-family: "Cascadia Mono", "SF Mono", Consolas, "Courier New", monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
background: rgba(45, 66, 86, 0.36);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 100%);
|
||||
max-height: 84vh;
|
||||
padding: 22px 24px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal .form-group {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.progress-wrap {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.progress-text {
|
||||
font-size: 12.5px;
|
||||
color: #5b6f83;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dedupe-export-wait-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 3100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(45, 66, 86, 0.36);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.dedupe-export-wait {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 22px 28px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 22px 60px rgba(39, 67, 94, 0.2);
|
||||
}
|
||||
.request-spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
border: 3px solid #d5e3ef;
|
||||
border-top-color: #5f85ad;
|
||||
border-radius: 50%;
|
||||
animation: dedupe-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes dedupe-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.dedupe-export-wait-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #24384d;
|
||||
}
|
||||
.dedupe-export-wait-detail {
|
||||
margin-top: 4px;
|
||||
font-size: 12.5px;
|
||||
color: #5b6f83;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,756 @@
|
||||
<script setup lang="ts">
|
||||
/** 商品类目 · 像素复刻旧版 admin.html panel-product-categories(自绘:面板头搜索框组+新增类目 同行、原生树表格[+/−]折叠+懒加载「加载更多 已加载 x/y」、搜索态旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(懒加载树/编辑删除/导出/弹窗)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fetchProductCategoryChildren, searchProductCategories } from './product-category-api.ts'
|
||||
import { createProductCategory, deleteProductCategory, updateProductCategory } from './product-category-editor-api.ts'
|
||||
import {
|
||||
appendCategoryChildren,
|
||||
isCategoryLoadMoreNode,
|
||||
makeCategoryLoadMoreNode,
|
||||
PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE,
|
||||
productCategorySourceLabel,
|
||||
type ProductCategoryNode,
|
||||
} from './product-category-model.ts'
|
||||
import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts'
|
||||
import { buildProductCategoryExportUrl } from './product-category-export.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const tree = ref<ProductCategoryNode[]>([])
|
||||
const keyword = ref('')
|
||||
const selected = ref<ProductCategoryNode | null>(null)
|
||||
|
||||
const searchLoading = ref(false)
|
||||
const searchRows = ref<ProductCategoryNode[]>([])
|
||||
const searchPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
const searchTotal = ref(0)
|
||||
const searchPage = ref(1)
|
||||
const searchHasMore = ref(false)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const saving = ref(false)
|
||||
const draft = reactive<ProductCategoryDraft>(createProductCategoryDraft())
|
||||
|
||||
/** 每父级(根=null)的子级分页状态:page/total/hasMore/loading,支撑「加载更多 已加载 x/y」。 */
|
||||
const childrenState = new Map<string, { page: number; total: number; hasMore: boolean; loading: boolean }>()
|
||||
const ROOT_KEY = '__root__'
|
||||
|
||||
function stateKeyOf(parentId: number | null): string {
|
||||
return parentId == null ? ROOT_KEY : String(parentId)
|
||||
}
|
||||
|
||||
function flatten(nodes: ProductCategoryNode[]): ProductCategoryNode[] {
|
||||
const out: ProductCategoryNode[] = []
|
||||
for (const node of nodes || []) {
|
||||
if (!isCategoryLoadMoreNode(node)) {
|
||||
out.push(node)
|
||||
out.push(...flatten(node.children))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const allNodes = computed(() => flatten(tree.value))
|
||||
const searching = computed(() => (keyword.value || '').trim().length > 0)
|
||||
const dialogTitle = computed(() => (editingId.value == null ? '新增商品类目' : '编辑商品类目'))
|
||||
const searchTotalPages = computed(() => Math.max(1, Math.ceil(searchTotal.value / searchPageSize.value)))
|
||||
|
||||
/** 顶层翻页(服务端分页,10/20/50/100 可选;子级保留懒加载+加载更多对齐旧版)。 */
|
||||
const rootPage = ref(1)
|
||||
const rootPageSize = ref(PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
const rootTotal = ref(0)
|
||||
|
||||
/** 拉取根级当前页(对齐 admin.js loadProductCategoryChildren(null) 懒加载根)。 */
|
||||
async function loadTree(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchProductCategoryChildren(null, rootPage.value, rootPageSize.value)
|
||||
const state = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
|
||||
childrenState.set(ROOT_KEY, state)
|
||||
// 顶层当前页直接作为树根(不含合成「加载更多」节点,翻页由 OldPagination 承担)。
|
||||
tree.value = result.items
|
||||
rootTotal.value = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '类目加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function changeRootPage(p: number) {
|
||||
rootPage.value = p
|
||||
void loadTree()
|
||||
}
|
||||
|
||||
function changeRootSize(size: number) {
|
||||
rootPageSize.value = size
|
||||
rootPage.value = 1
|
||||
void loadTree()
|
||||
}
|
||||
|
||||
/** 树形表格(对齐旧版 panel-product-categories):客户端展开状态 + 按展开扁平化行集。 */
|
||||
interface TreeRow {
|
||||
node: ProductCategoryNode
|
||||
depth: number
|
||||
}
|
||||
|
||||
const expandedIds = ref<Set<number>>(new Set())
|
||||
|
||||
function collectRows(nodes: ProductCategoryNode[], depth: number, out: TreeRow[]): void {
|
||||
for (const node of nodes || []) {
|
||||
out.push({ node, depth })
|
||||
if (!isCategoryLoadMoreNode(node) && expandedIds.value.has(node.id)) {
|
||||
collectRows(node.children, depth + 1, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const treeRows = computed<TreeRow[]>(() => {
|
||||
const out: TreeRow[] = []
|
||||
collectRows(tree.value, 0, out)
|
||||
return out
|
||||
})
|
||||
|
||||
/** 展开/折叠:未加载过子级的先拉取第一页(懒加载),加载更多行点击翻页。 */
|
||||
async function toggleExpand(node: ProductCategoryNode): Promise<void> {
|
||||
if (isCategoryLoadMoreNode(node)) {
|
||||
void loadMoreChildren(node)
|
||||
return
|
||||
}
|
||||
if (node.childCount <= 0) return
|
||||
if (expandedIds.value.has(node.id)) {
|
||||
const next = new Set(expandedIds.value)
|
||||
next.delete(node.id)
|
||||
expandedIds.value = next
|
||||
return
|
||||
}
|
||||
const hasRealChildren = node.children.some((child) => !isCategoryLoadMoreNode(child))
|
||||
if (!hasRealChildren) {
|
||||
await loadChildrenPage(node, 1, false)
|
||||
}
|
||||
const next = new Set(expandedIds.value)
|
||||
next.add(node.id)
|
||||
expandedIds.value = next
|
||||
}
|
||||
|
||||
/** 加载某父级一页子级(append=false 重置;append=true 追加并重建「加载更多」节点)。 */
|
||||
async function loadChildrenPage(parent: ProductCategoryNode | null, page: number, append: boolean): Promise<void> {
|
||||
const key = stateKeyOf(parent?.id ?? null)
|
||||
const state = childrenState.get(key)
|
||||
if (state?.loading) return
|
||||
childrenState.set(key, { page: state?.page ?? 0, total: state?.total ?? 0, hasMore: state?.hasMore ?? false, loading: true })
|
||||
try {
|
||||
const result = await fetchProductCategoryChildren(parent?.id ?? null, page, PRODUCT_CATEGORY_DEFAULT_PAGE_SIZE)
|
||||
const loaded = append ? (state?.page ?? 0) + result.items.length : result.items.length
|
||||
const nextState = { page: result.page, total: result.total, hasMore: result.hasMore, loading: false }
|
||||
childrenState.set(key, nextState)
|
||||
if (parent) {
|
||||
const current = parent.children || []
|
||||
parent.children = append
|
||||
? appendCategoryChildren(current, result.items, parent.id, loaded, result.total, result.hasMore)
|
||||
: (result.hasMore
|
||||
? [...result.items, makeCategoryLoadMoreNode(parent.id, result.items.length, result.total)]
|
||||
: result.items)
|
||||
} else {
|
||||
const current = tree.value
|
||||
tree.value = append
|
||||
? appendCategoryChildren(current, result.items, null, loaded, result.total, result.hasMore)
|
||||
: (result.hasMore
|
||||
? [...result.items, makeCategoryLoadMoreNode(null, result.items.length, result.total)]
|
||||
: result.items)
|
||||
}
|
||||
} catch (error) {
|
||||
const fallback = childrenState.get(key) ?? { page: 0, total: 0, hasMore: false, loading: false }
|
||||
fallback.loading = false
|
||||
childrenState.set(key, fallback)
|
||||
ElMessage.error(error instanceof Error ? error.message : '子类目加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击「加载更多」合成节点:翻下一页追加。 */
|
||||
async function loadMoreChildren(more: ProductCategoryNode): Promise<void> {
|
||||
if (!isCategoryLoadMoreNode(more)) return
|
||||
const parentId = more.parentId
|
||||
const parent = parentId == null ? null : allNodes.value.find((node) => node.id === parentId) ?? null
|
||||
if (parentId != null && !parent) return
|
||||
const state = childrenState.get(stateKeyOf(parentId))
|
||||
if (state?.loading) return
|
||||
await loadChildrenPage(parent, (state?.page ?? 0) + 1, true)
|
||||
}
|
||||
|
||||
/** 「加载更多」按钮的加载态:取其父级分页状态的 loading。 */
|
||||
function moreLoading(more: ProductCategoryNode): boolean {
|
||||
return isCategoryLoadMoreNode(more) && (childrenState.get(stateKeyOf(more.parentId))?.loading ?? false)
|
||||
}
|
||||
|
||||
function changeSearchSize(size: number) {
|
||||
searchPageSize.value = size
|
||||
void runSearch(1)
|
||||
}
|
||||
|
||||
async function runSearch(page: number): Promise<void> {
|
||||
searchLoading.value = true
|
||||
try {
|
||||
const result = await searchProductCategories(keyword.value, page, searchPageSize.value)
|
||||
searchRows.value = result.items
|
||||
searchTotal.value = result.total
|
||||
searchPage.value = result.page
|
||||
searchHasMore.value = result.hasMore
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '类目搜索失败')
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apply(): void {
|
||||
selected.value = null
|
||||
if (searching.value) void runSearch(1)
|
||||
else void loadTree()
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
keyword.value = ''
|
||||
selected.value = null
|
||||
searchRows.value = []
|
||||
searchHasMore.value = false
|
||||
void loadTree()
|
||||
}
|
||||
|
||||
function openCreate(parent: ProductCategoryNode | null) {
|
||||
editingId.value = null
|
||||
Object.assign(draft, createProductCategoryDraft())
|
||||
draft.parentId = parent ? parent.id : null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(node: ProductCategoryNode) {
|
||||
editingId.value = node.id
|
||||
Object.assign(draft, createProductCategoryDraft())
|
||||
draft.parentId = node.parentId
|
||||
draft.name = node.name
|
||||
draft.sortOrder = node.sortOrder == null ? '' : String(node.sortOrder)
|
||||
draft.description = node.description
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function onNodeClick(node: ProductCategoryNode) {
|
||||
if (isCategoryLoadMoreNode(node)) {
|
||||
void loadMoreChildren(node)
|
||||
return
|
||||
}
|
||||
selected.value = node
|
||||
}
|
||||
|
||||
function canDelete(node: ProductCategoryNode | null): boolean {
|
||||
if (!node || isCategoryLoadMoreNode(node)) return false
|
||||
return !(node.childCount > 0 || (Array.isArray(node.children) && node.children.some((child) => !isCategoryLoadMoreNode(child))))
|
||||
}
|
||||
|
||||
async function removeCategory(node: ProductCategoryNode): Promise<void> {
|
||||
if (!canDelete(node)) {
|
||||
ElMessage.warning('存在子类目,不能删除')
|
||||
return
|
||||
}
|
||||
// 对齐旧版:原生 confirm。
|
||||
if (!window.confirm(`确定删除“${node.name}”?`)) return
|
||||
try {
|
||||
await deleteProductCategory(node.id)
|
||||
ElMessage.success('已删除')
|
||||
if (selected.value?.id === node.id) selected.value = null
|
||||
if (searching.value) void runSearch(1)
|
||||
else void loadTree()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const error = categoryDraftError({ ...draft })
|
||||
if (error) {
|
||||
ElMessage.warning(error)
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const body = toCategorySaveRequest({ ...draft })
|
||||
if (editingId.value == null) {
|
||||
await createProductCategory(body)
|
||||
ElMessage.success('已新增')
|
||||
} else {
|
||||
await updateProductCategory(editingId.value, body)
|
||||
ElMessage.success('已保存')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
if (searching.value) void runSearch(1)
|
||||
else void loadTree()
|
||||
} catch (err) {
|
||||
ElMessage.error(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCategories(): void {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = buildProductCategoryExportUrl(keyword.value)
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
ElMessage.success('类目导出已开始下载')
|
||||
}
|
||||
|
||||
onMounted(loadTree)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="category-view">
|
||||
<section class="panel-box">
|
||||
<div class="category-head">
|
||||
<h3>商品类目</h3>
|
||||
<div class="category-head-tools">
|
||||
<input v-model="keyword" type="text" placeholder="搜索类目名称、编码或备注" class="category-keyword" @keyup.enter="apply" />
|
||||
<button class="btn btn-sm" type="button" @click="apply">搜索</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="reset">清空</button>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="exportCategories">导出</button>
|
||||
<button v-if="selected && !searching" class="btn btn-sm" type="button" @click="openCreate(selected)">新增子类目</button>
|
||||
<button class="btn" type="button" @click="openCreate(null)">新增类目</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll category-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 34%">类目</th>
|
||||
<th>层级路径</th>
|
||||
<th style="width: 90px">排序</th>
|
||||
<th style="width: 120px">来源</th>
|
||||
<th>备注</th>
|
||||
<th style="width: 150px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="!searching">
|
||||
<template v-if="treeRows.length">
|
||||
<template v-for="row in treeRows" :key="String(row.node.id)">
|
||||
<tr v-if="isCategoryLoadMoreNode(row.node)" class="load-more-row">
|
||||
<td colspan="6">
|
||||
<div class="load-more-cell">
|
||||
<span class="tree-indent" :style="{ width: `${(row.depth + 1) * 24}px` }"></span>
|
||||
<button class="btn btn-sm btn-secondary" type="button" :disabled="moreLoading(row.node)" @click="loadMoreChildren(row.node)">
|
||||
{{ moreLoading(row.node) ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
<span class="loaded-count">已加载 {{ row.node.loadedCount }} / {{ row.node.totalCount }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td>
|
||||
<div class="tree-name-cell" @click="onNodeClick(row.node)">
|
||||
<span class="tree-indent" :style="{ width: `${row.depth * 24}px` }"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="tree-node-mark"
|
||||
:class="{ 'is-leaf': row.node.childCount <= 0 }"
|
||||
:disabled="row.node.childCount <= 0"
|
||||
:title="expandedIds.has(row.node.id) ? '折叠' : '展开'"
|
||||
@click.stop="toggleExpand(row.node)"
|
||||
>
|
||||
{{ row.node.childCount > 0 ? (expandedIds.has(row.node.id) ? '−' : '+') : '' }}
|
||||
</button>
|
||||
<strong class="node-name">{{ row.node.name }}</strong>
|
||||
<span v-if="selected?.id === row.node.id" class="selected-flag">已选</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="category-path-value" :title="row.node.path || row.node.name">{{ row.node.path || row.node.name }}</span>
|
||||
</td>
|
||||
<td>{{ row.node.sortOrder ?? 0 }}</td>
|
||||
<td>
|
||||
<span class="category-tag" :class="{ 'is-custom': !row.node.isBuiltin }">{{ productCategorySourceLabel(row.node) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.node.description" class="node-desc" :title="row.node.description">{{ row.node.description }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row.node)">编辑</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="!canDelete(row.node)"
|
||||
:title="!canDelete(row.node) ? '存在子类目,不能删除' : undefined"
|
||||
@click="removeCategory(row.node)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td colspan="6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">暂无商品类目</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody v-else>
|
||||
<template v-if="searchRows.length">
|
||||
<tr v-for="row in searchRows" :key="row.id">
|
||||
<td><strong class="node-name">{{ row.name }}</strong></td>
|
||||
<td><span class="category-path-value" :title="row.path || row.name">{{ row.path || row.name }}</span></td>
|
||||
<td>{{ row.sortOrder ?? '—' }}</td>
|
||||
<td>
|
||||
<span class="category-tag" :class="{ 'is-custom': !row.isBuiltin }">{{ productCategorySourceLabel(row) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="row.description" class="node-desc" :title="row.description">{{ row.description }}</span>
|
||||
<span v-else class="dim">—</span>
|
||||
</td>
|
||||
<td class="ops-cell">
|
||||
<button class="btn btn-sm" type="button" @click="openEdit(row)">编辑</button>
|
||||
<button
|
||||
class="btn btn-sm btn-danger"
|
||||
type="button"
|
||||
:disabled="!canDelete(row)"
|
||||
:title="!canDelete(row) ? '存在子类目,不能删除' : undefined"
|
||||
@click="removeCategory(row)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="searchLoading">
|
||||
<td colspan="6" class="empty-tip">搜索中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td colspan="6" class="empty-tip">暂无搜索结果</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<OldPagination v-if="searching" :total="searchTotal" :page="searchPage" :page-size="searchPageSize" @change="runSearch" @size-change="changeSearchSize" />
|
||||
<OldPagination v-else-if="rootTotal > 0" :total="rootTotal" :page="rootPage" :page-size="rootPageSize" @change="changeRootPage" @size-change="changeRootSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="父级类目">
|
||||
<el-select v-model="draft.parentId" placeholder="无(根级)" clearable filterable>
|
||||
<el-option
|
||||
v-for="node in allNodes"
|
||||
:key="node.id"
|
||||
:label="node.path || node.name"
|
||||
:value="node.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类目名称" required>
|
||||
<el-input v-model="draft.name" placeholder="如:洁面" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input v-model="draft.sortOrder" placeholder="如 10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="draft.description" type="textarea" :rows="3" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">{{ editingId == null ? '取消' : '取消编辑' }}</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submit">{{ editingId == null ? '保存' : '保存类目' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-product-categories(蓝白末层)。 */
|
||||
.category-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.category-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.category-head-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.category-keyword {
|
||||
width: 240px;
|
||||
min-height: 36px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.category-keyword:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.category-keyword:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.category-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.tree-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tree-indent {
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
}
|
||||
.tree-node-mark {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: var(--admin-primary-soft, #e7f0f8);
|
||||
color: var(--admin-primary-strong, #2f5d8b);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
flex: none;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.tree-node-mark:not(.is-leaf):hover {
|
||||
background: var(--admin-primary-strong, #2f5d8b);
|
||||
color: #ffffff;
|
||||
}
|
||||
.tree-node-mark.is-leaf {
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.node-name {
|
||||
font-weight: 600;
|
||||
color: #24384d;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.selected-flag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e7f0f8;
|
||||
color: #2f5d8b;
|
||||
font-size: 11px;
|
||||
}
|
||||
.category-tag {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #c2d5e6;
|
||||
background: #e7f0f8;
|
||||
color: #2f5d8b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.category-tag.is-custom {
|
||||
border-color: #bcd9c6;
|
||||
background: #eef7f0;
|
||||
color: #3d7158;
|
||||
}
|
||||
.category-path-value {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.node-desc {
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 320px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dim {
|
||||
color: #8293a5;
|
||||
}
|
||||
.ops-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.load-more-row td {
|
||||
background: #fbfdfe;
|
||||
}
|
||||
.load-more-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.loaded-count {
|
||||
color: #8293a5;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,781 @@
|
||||
<script setup lang="ts">
|
||||
/** 查询 ASIN · 像素复刻旧版 admin.html panel-query-asin(自绘:面板头操作组、筛选+导出 XLSX、原生 rowspan 表格+可复制 ASIN、旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(新增级联/配置抽屉/导入轮询/导出)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import CopyText from '@/components/CopyText.vue'
|
||||
import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||
import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts'
|
||||
import { asinCountryLabel } from './asin-country.ts'
|
||||
import { createQueryAsinFilterState, type QueryAsinFilterState } from './query-asin-filter.ts'
|
||||
import { toQueryAsinExportUrl } from './query-asin-export.ts'
|
||||
import { deleteQueryAsinCountryAsin, updateQueryAsinCountryAsin } from './query-asin-detail-api.ts'
|
||||
import { fetchQueryAsinImportProgress, startQueryAsinImport } from './query-asin-import-api.ts'
|
||||
import { fetchQueryAsinDeleteImportProgress, startQueryAsinDeleteImport } from './query-asin-delete-import-api.ts'
|
||||
import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<QueryAsinItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||
const lockedGroupId = computed<number | null>(() => {
|
||||
if (isSuperAdmin.value) return null
|
||||
return groups.value.length === 1 ? groups.value[0].id : null
|
||||
})
|
||||
const filter = reactive<QueryAsinFilterState>(createQueryAsinFilterState())
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderQueryAsinRows)。 */
|
||||
const displayRows = computed(() => queryAsinDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||
|
||||
function asinOf(row: QueryAsinItem, country: string): string {
|
||||
const map: Record<string, string> = { DE: row.asinDe, UK: row.asinUk, FR: row.asinFr, IT: row.asinIt, ES: row.asinEs }
|
||||
return map[country] || ''
|
||||
}
|
||||
|
||||
// ---- 行内配置抽屉 ----
|
||||
const drawerVisible = ref(false)
|
||||
const drawerItem = ref<QueryAsinItem | null>(null)
|
||||
const drawerSaving = ref(false)
|
||||
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
|
||||
|
||||
function openConfig(row: QueryAsinItem): void {
|
||||
drawerItem.value = row
|
||||
for (const country of QUERY_ASIN_COUNTRIES) {
|
||||
draftAsin[country] = asinOf(row, country)
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function normalizeAsinInput(country: string): void {
|
||||
draftAsin[country] = draftAsin[country].trim().toUpperCase()
|
||||
}
|
||||
|
||||
|
||||
async function removeShopAsin(item: QueryAsinItem) {
|
||||
// 删除该店铺全部站点的 ASIN 配置(逐国调用现有端点)。
|
||||
if (!window.confirm(`确定删除店铺“${item.shopName}”的全部站点 ASIN 配置吗?`)) return
|
||||
try {
|
||||
for (const country of QUERY_ASIN_COUNTRIES) {
|
||||
if (asinOf(item, country)) await deleteQueryAsinCountryAsin(item.id, country)
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
const item = drawerItem.value
|
||||
if (!item) return
|
||||
drawerSaving.value = true
|
||||
const errors: string[] = []
|
||||
try {
|
||||
for (const country of QUERY_ASIN_COUNTRIES) {
|
||||
const oldAsin = asinOf(item, country)
|
||||
const next = (draftAsin[country] || '').trim().toUpperCase()
|
||||
if (next === oldAsin) continue
|
||||
try {
|
||||
if (!next) {
|
||||
if (oldAsin) await deleteQueryAsinCountryAsin(item.id, country)
|
||||
} else {
|
||||
await updateQueryAsinCountryAsin(item.id, country, next)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${asinCountryLabel(country)}:${error instanceof Error ? error.message : '保存失败'}`)
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
ElMessage.error(errors.join(';'))
|
||||
} else {
|
||||
ElMessage.success('保存成功')
|
||||
drawerVisible.value = false
|
||||
drawerItem.value = null
|
||||
await load()
|
||||
}
|
||||
} finally {
|
||||
drawerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateQueryAsin/btnCreateQueryAsin) ----
|
||||
const createVisible = ref(false)
|
||||
const createGroupId = ref<number | null>(null)
|
||||
const createShopName = ref('')
|
||||
const createCountry = ref('')
|
||||
const createAsin = ref('')
|
||||
const createMsg = ref('')
|
||||
const createMsgOk = ref(false)
|
||||
const shopNames = ref<string[]>([])
|
||||
const shopNamesLoading = ref(false)
|
||||
const creating = ref(false)
|
||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||
|
||||
function openCreate(): void {
|
||||
createGroupId.value = lockedGroupId.value
|
||||
createShopName.value = ''
|
||||
createCountry.value = ''
|
||||
createAsin.value = ''
|
||||
createMsg.value = ''
|
||||
createMsgOk.value = false
|
||||
shopNames.value = []
|
||||
createVisible.value = true
|
||||
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||
if (createGroupId.value != null) void onCreateGroupChange()
|
||||
}
|
||||
|
||||
async function onCreateGroupChange(): Promise<void> {
|
||||
createShopName.value = ''
|
||||
if (createGroupId.value == null) {
|
||||
shopNames.value = []
|
||||
return
|
||||
}
|
||||
shopNamesLoading.value = true
|
||||
try {
|
||||
shopNames.value = await fetchShopNamesByGroup(createGroupId.value)
|
||||
} catch (error) {
|
||||
shopNames.value = []
|
||||
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
|
||||
} finally {
|
||||
shopNamesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateAsinInput(): void {
|
||||
createAsin.value = createAsin.value.toUpperCase()
|
||||
}
|
||||
|
||||
async function submitCreate(): Promise<void> {
|
||||
createMsg.value = ''
|
||||
createMsgOk.value = false
|
||||
const groupId = createGroupId.value
|
||||
const shopName = createShopName.value.trim()
|
||||
const country = (createCountry.value || '').trim()
|
||||
const asin = createAsin.value.trim().toUpperCase()
|
||||
if (!groupId || !shopName || !country || !asin) {
|
||||
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const message = await createQueryAsin({
|
||||
groupId,
|
||||
shopName,
|
||||
countries: [country],
|
||||
asin,
|
||||
asinMappings: { [country]: asin },
|
||||
})
|
||||
// 对齐 admin:保留分组/店铺/国家,清空 ASIN 方便连续录入,成功后刷新列表。
|
||||
createMsg.value = message
|
||||
createMsgOk.value = true
|
||||
createAsin.value = ''
|
||||
createAsinInputRef.value?.focus()
|
||||
await load()
|
||||
} catch (error) {
|
||||
createMsg.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 导入添加 / 删除导入 ----
|
||||
type ImportMode = 'add' | 'delete'
|
||||
const importVisible = ref(false)
|
||||
const importMode = ref<ImportMode>('add')
|
||||
const importGroupId = ref<number | null>(null)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importRunning = ref(false)
|
||||
const importProgress = ref('')
|
||||
|
||||
function resetImport(): void {
|
||||
importGroupId.value = lockedGroupId.value
|
||||
importFile.value = null
|
||||
importProgress.value = ''
|
||||
importRunning.value = false
|
||||
}
|
||||
|
||||
function openImportAdd(): void {
|
||||
importMode.value = 'add'
|
||||
resetImport()
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function openImportDelete(): void {
|
||||
importMode.value = 'delete'
|
||||
resetImport()
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function onPickImportFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
importFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function submitImport(): Promise<void> {
|
||||
const file = importFile.value
|
||||
if (!file) {
|
||||
ElMessage.warning('请选择 Excel 文件')
|
||||
return
|
||||
}
|
||||
if (!isAllowedExcelImportFile(file.name)) {
|
||||
ElMessage.warning('仅支持 .xlsx/.xls 文件')
|
||||
return
|
||||
}
|
||||
if (importMode.value === 'delete') {
|
||||
if (!window.confirm('确定按 Excel 中的分组、店铺名和国家 ASIN 批量删除吗?')) return
|
||||
}
|
||||
const mode = importMode.value
|
||||
const groupId = importGroupId.value ?? undefined
|
||||
importRunning.value = true
|
||||
importProgress.value = '正在上传并解析文件…'
|
||||
try {
|
||||
const started = mode === 'add' ? await startQueryAsinImport(file, groupId) : await startQueryAsinDeleteImport(file, groupId)
|
||||
const poll = mode === 'add' ? () => fetchQueryAsinImportProgress(started) : () => fetchQueryAsinDeleteImportProgress(started)
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
const progress = await poll()
|
||||
if (progress.status === 'success') {
|
||||
importVisible.value = false
|
||||
resetImport()
|
||||
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
|
||||
await load()
|
||||
return
|
||||
}
|
||||
if (progress.status === 'failed') {
|
||||
importVisible.value = false
|
||||
resetImport()
|
||||
ElMessage.error((progress as { errorMessage?: string }).errorMessage || (mode === 'add' ? '导入失败' : '删除导入失败'))
|
||||
return
|
||||
}
|
||||
importProgress.value = progress.status === 'pending' ? '等待导入任务开始…' : '导入处理中…'
|
||||
await sleep(1200)
|
||||
}
|
||||
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '导入失败')
|
||||
} finally {
|
||||
importRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 导出 ----
|
||||
function doExport(): void {
|
||||
// 带当前筛选(分组/店铺名/ASIN/国家)导出,范围与列表筛选一致(此前不带参数导致导出恒为全量)。
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = toQueryAsinExportUrl(filter)
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
ElMessage.success('导出文件已开始下载')
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups.value = await fetchShopManageGroups()
|
||||
} catch {
|
||||
groups.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchQueryAsinList({
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
groupId: filter.groupId,
|
||||
shopName: filter.shopName.trim() || undefined,
|
||||
asin: filter.asin.trim() || undefined,
|
||||
country: filter.country || undefined,
|
||||
})
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadGroups()
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="query-view">
|
||||
<section class="panel-box">
|
||||
<div class="query-head">
|
||||
<h3>店铺列表</h3>
|
||||
<div class="query-head-actions">
|
||||
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
|
||||
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入添加</button>
|
||||
<button class="btn" type="button" @click="openImportDelete">导入删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row query-filter-row">
|
||||
<div v-if="isSuperAdmin" class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>店铺名</label>
|
||||
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>国家</label>
|
||||
<select v-model="filter.country">
|
||||
<option value="">全部国家</option>
|
||||
<option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll query-asin-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 6%">序号</th>
|
||||
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||
<th style="width: 15%">店铺名</th>
|
||||
<th style="width: 24%">ASIN</th>
|
||||
<th style="width: 12%">国家</th>
|
||||
<th style="width: 9%">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="displayRows.length">
|
||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||
</template>
|
||||
<td>
|
||||
<template v-if="row.asin">
|
||||
<CopyText :text="row.asin" class="asin-cell" />
|
||||
</template>
|
||||
<span v-else class="asin-empty">-</span>
|
||||
</td>
|
||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||||
<button class="btn btn-sm" type="button" @click="openConfig(row.item as QueryAsinItem)">配置</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeShopAsin(row.item as QueryAsinItem)">删除</button>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td :colspan="isSuperAdmin ? 6 : 5" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增查询 ASIN" width="560px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="店铺名">
|
||||
<el-select
|
||||
v-model="createShopName"
|
||||
:disabled="createGroupId == null"
|
||||
:loading="shopNamesLoading"
|
||||
:placeholder="createGroupId == null ? '请先选择分组' : '请选择店铺'"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="name in shopNames" :key="name" :label="name" :value="name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||
<el-option v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="ASIN">
|
||||
<el-input ref="createAsinInputRef" v-model="createAsin" placeholder="请输入 ASIN" @input="onCreateAsinInput" />
|
||||
</el-form-item>
|
||||
<el-alert v-if="createMsg" :title="createMsg" :type="createMsgOk ? 'success' : 'error'" :closable="false" show-icon />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="drawerVisible" :title="drawerItem ? `${drawerItem.shopName} · ASIN 配置` : 'ASIN 配置'" size="min(520px, 92%)">
|
||||
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与记录。</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item v-for="code in QUERY_ASIN_COUNTRIES" :key="code" :label="`${asinCountryLabel(code)} ASIN`">
|
||||
<el-input
|
||||
v-model="draftAsin[code]"
|
||||
:placeholder="asinOf(drawerItem as QueryAsinItem, code) ? '留空删除该站点 ASIN' : '输入 ASIN'"
|
||||
@input="normalizeAsinInput(code)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="drawerSaving" @click="saveConfig">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入添加查询 ASIN' : '删除导入查询 ASIN'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="分组(Excel 未提供时的兜底,可选)">
|
||||
<el-select filterable v-model="importGroupId" placeholder="可不选" clearable style="width: 100%" :disabled="lockedGroupId != null">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Excel 文件">
|
||||
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickImportFile" />
|
||||
<p class="import-tip">按 Excel 中的分组、店铺名与国家 ASIN 批量{{ importMode === 'add' ? '添加' : '删除' }},仅支持 .xlsx/.xls。</p>
|
||||
</el-form-item>
|
||||
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importRunning" @click="submitImport">
|
||||
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-query-asin(蓝白末层)。 */
|
||||
.query-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.query-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.query-head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.query-filter-row > .form-group {
|
||||
flex: 1 1 140px;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.query-asin-table-scroll > table {
|
||||
min-width: 860px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.table-scroll td {
|
||||
vertical-align: top;
|
||||
}
|
||||
.asin-cell {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: -3px 0 -3px -7px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 13.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.asin-cell:hover {
|
||||
border-color: #c7d7e5;
|
||||
background: #eef4fa;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.asin-empty {
|
||||
color: #8b9aaa;
|
||||
}
|
||||
.asin-col-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
background: #ffffff;
|
||||
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.drawer-tip {
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.import-tip {
|
||||
margin: 6px 0 0;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,868 @@
|
||||
<script setup lang="ts">
|
||||
/** 最低价 ASIN(跳过跟价)· 像素复刻旧版 admin.html panel-skip-price-asin(自绘:面板头操作组、筛选(分组/店铺/国家/ASIN/最低价≥≤)+导出 XLSX、原生 rowspan 表格、旧式分页)。
|
||||
* script 逻辑沿用现有 Vue 实现(新增级联+最低价、配置抽屉双列、导入轮询、导出)。 */
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import CopyText from '@/components/CopyText.vue'
|
||||
import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts'
|
||||
import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts'
|
||||
import { asinCountryLabel } from './asin-country.ts'
|
||||
import { createSkipPriceFilterState, toSkipPriceParams, type SkipPriceFilterState } from './skip-price-filter.ts'
|
||||
import { deleteSkipPriceAsin, deleteSkipPriceCountry, updateSkipPriceCountry } from './skip-price-detail-api.ts'
|
||||
import { fetchSkipPriceDeleteImportProgress, fetchSkipPriceImportProgress, startSkipPriceDeleteImport, startSkipPriceImport } from './skip-price-import-api.ts'
|
||||
import { isAllowedExcelImportFile } from './import-progress-model.ts'
|
||||
import { fetchShopNamesByGroup } from './query-asin-api.ts'
|
||||
import { fetchShopManageGroups } from '../shop/shop-manage-api.ts'
|
||||
import type { ShopGroupOption } from '../shop/shop-dto.ts'
|
||||
import OldPagination from '@/components/OldPagination.vue'
|
||||
import { useAdminSessionStore } from '@/stores/admin-session'
|
||||
|
||||
const session = useAdminSessionStore()
|
||||
/** 仅超管需要分组维度:非超管数据由后端裁剪到本人分组,分组筛选/列一律不展示。 */
|
||||
const isSuperAdmin = computed(() => session.isSuperAdmin)
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<SkipPriceItem[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const groups = ref<ShopGroupOption[]>([])
|
||||
/** 非超管仅有一个可访问分组时,弹窗分组选择锁定为该组(保留展示、不可改)。 */
|
||||
const lockedGroupId = computed<number | null>(() => {
|
||||
if (isSuperAdmin.value) return null
|
||||
return groups.value.length === 1 ? groups.value[0].id : null
|
||||
})
|
||||
const filter = reactive<SkipPriceFilterState>(createSkipPriceFilterState())
|
||||
const jumpPage = ref('')
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||
|
||||
const COUNTRIES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
|
||||
const ASIN_MAP: Record<string, keyof SkipPriceItem> = { DE: 'asinDe', UK: 'asinUk', FR: 'asinFr', IT: 'asinIt', ES: 'asinEs' }
|
||||
const PRICE_MAP: Record<string, keyof SkipPriceItem> = { DE: 'minimumPriceDe', UK: 'minimumPriceUk', FR: 'minimumPriceFr', IT: 'minimumPriceIt', ES: 'minimumPriceEs' }
|
||||
|
||||
function asinOf(row: SkipPriceItem, code: string): string {
|
||||
return (row[ASIN_MAP[code]] as string) || ''
|
||||
}
|
||||
|
||||
function priceOf(row: SkipPriceItem, code: string): number | null {
|
||||
const value = row[PRICE_MAP[code]] as number | null
|
||||
return typeof value === 'number' ? value : null
|
||||
}
|
||||
|
||||
/** 纵向展示行:分组/店铺/操作 按 rowspan 合并,逐国一行(对齐 admin.js renderSkipPriceAsinRows)。 */
|
||||
const displayRows = computed(() => skipPriceDisplayRows(rows.value, (page.value - 1) * pageSize.value + 1))
|
||||
|
||||
// ---- 新增 ASIN 弹窗(对齐 admin.js btnOpenCreateSkipPriceAsin/btnCreateSkipPriceAsin) ----
|
||||
const createVisible = ref(false)
|
||||
const createGroupId = ref<number | null>(null)
|
||||
const createShopName = ref('')
|
||||
const createCountry = ref('')
|
||||
const createAsin = ref('')
|
||||
const createMinimumPrice = ref('')
|
||||
const createMsg = ref('')
|
||||
const createMsgOk = ref(false)
|
||||
const shopNames = ref<string[]>([])
|
||||
const shopNamesLoading = ref(false)
|
||||
const creating = ref(false)
|
||||
const createAsinInputRef = ref<InstanceType<typeof import('element-plus').ElInput> | null>(null)
|
||||
|
||||
function openCreate(): void {
|
||||
createGroupId.value = lockedGroupId.value
|
||||
createShopName.value = ''
|
||||
createCountry.value = ''
|
||||
createAsin.value = ''
|
||||
createMinimumPrice.value = ''
|
||||
createMsg.value = ''
|
||||
createMsgOk.value = false
|
||||
shopNames.value = []
|
||||
createVisible.value = true
|
||||
// 非超管分组已锁定:直接联动加载该分组店铺,省去一次无意义的选择。
|
||||
if (createGroupId.value != null) void onCreateGroupChange()
|
||||
}
|
||||
|
||||
async function onCreateGroupChange(): Promise<void> {
|
||||
createShopName.value = ''
|
||||
if (createGroupId.value == null) {
|
||||
shopNames.value = []
|
||||
return
|
||||
}
|
||||
shopNamesLoading.value = true
|
||||
try {
|
||||
shopNames.value = await fetchShopNamesByGroup(createGroupId.value)
|
||||
} catch (error) {
|
||||
shopNames.value = []
|
||||
ElMessage.error(error instanceof Error ? error.message : '店铺列表加载失败')
|
||||
} finally {
|
||||
shopNamesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateAsinInput(): void {
|
||||
createAsin.value = createAsin.value.toUpperCase()
|
||||
}
|
||||
|
||||
async function submitCreate(): Promise<void> {
|
||||
createMsg.value = ''
|
||||
createMsgOk.value = false
|
||||
const groupId = createGroupId.value
|
||||
const shopName = createShopName.value.trim()
|
||||
const country = (createCountry.value || '').trim()
|
||||
const asin = createAsin.value.trim().toUpperCase()
|
||||
const minimumPrice = createMinimumPrice.value.trim()
|
||||
if (!groupId || !shopName || !country || !asin) {
|
||||
createMsg.value = '请完整填写分组、店铺名、国家和 ASIN'
|
||||
return
|
||||
}
|
||||
if (minimumPrice) {
|
||||
const minimumPriceNumber = Number(minimumPrice)
|
||||
if (!Number.isFinite(minimumPriceNumber) || minimumPriceNumber < 0) {
|
||||
createMsg.value = '最低价格式不正确'
|
||||
return
|
||||
}
|
||||
}
|
||||
creating.value = true
|
||||
try {
|
||||
const message = await createSkipPriceAsin({
|
||||
groupId,
|
||||
shopName,
|
||||
countries: [country],
|
||||
asin,
|
||||
asinMappings: { [country]: asin },
|
||||
minimumPriceMappings: minimumPrice ? { [country]: Number(minimumPrice) } : undefined,
|
||||
})
|
||||
// 对齐 admin:保留分组/店铺/国家,清空 ASIN/最低价方便连续录入,成功后刷新列表。
|
||||
createMsg.value = message
|
||||
createMsgOk.value = true
|
||||
createAsin.value = ''
|
||||
createMinimumPrice.value = ''
|
||||
createAsinInputRef.value?.focus()
|
||||
await load()
|
||||
} catch (error) {
|
||||
createMsg.value = error instanceof Error ? error.message : '保存失败'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 行内配置抽屉 ----
|
||||
const drawerVisible = ref(false)
|
||||
const drawerItem = ref<SkipPriceItem | null>(null)
|
||||
const drawerSaving = ref(false)
|
||||
const draftAsin = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
|
||||
const draftPrice = reactive<Record<string, string>>({ DE: '', UK: '', FR: '', IT: '', ES: '' })
|
||||
|
||||
function openConfig(row: SkipPriceItem): void {
|
||||
drawerItem.value = row
|
||||
for (const code of COUNTRIES) {
|
||||
draftAsin[code] = asinOf(row, code)
|
||||
const price = priceOf(row, code)
|
||||
draftPrice[code] = price == null ? '' : String(price)
|
||||
}
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function normalizeAsin(code: string): void {
|
||||
draftAsin[code] = draftAsin[code].trim().toUpperCase()
|
||||
}
|
||||
|
||||
function parsePriceText(raw: string): number | null {
|
||||
const value = (raw || '').trim()
|
||||
if (!value) return null
|
||||
if (!/^\d+(\.\d{1,2})?$/.test(value)) return undefined as unknown as number | null
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
async function saveConfig(): Promise<void> {
|
||||
const item = drawerItem.value
|
||||
if (!item) return
|
||||
drawerSaving.value = true
|
||||
const errors: string[] = []
|
||||
try {
|
||||
for (const code of COUNTRIES) {
|
||||
const asin = draftAsin[code].trim().toUpperCase()
|
||||
const price = parsePriceText(draftPrice[code])
|
||||
if (price === undefined) {
|
||||
errors.push(`${asinCountryLabel(code)}:最低价格式不正确`)
|
||||
continue
|
||||
}
|
||||
if (draftPrice[code].trim() !== '' && !asin) {
|
||||
errors.push(`${asinCountryLabel(code)}:填写最低价时必须填写 ASIN`)
|
||||
continue
|
||||
}
|
||||
const oldAsin = asinOf(item, code)
|
||||
const oldPrice = priceOf(item, code)
|
||||
if (asin === oldAsin && price === oldPrice) continue
|
||||
try {
|
||||
if (!asin) {
|
||||
if (oldAsin) await deleteSkipPriceCountry(item.id, code)
|
||||
} else {
|
||||
await updateSkipPriceCountry(item.id, code, asin, price)
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${asinCountryLabel(code)}:${error instanceof Error ? error.message : '保存失败'}`)
|
||||
}
|
||||
}
|
||||
if (errors.length) {
|
||||
ElMessage.error(errors.join(';'))
|
||||
} else {
|
||||
ElMessage.success('保存成功')
|
||||
drawerVisible.value = false
|
||||
drawerItem.value = null
|
||||
await load()
|
||||
}
|
||||
} finally {
|
||||
drawerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 整条删除 ----
|
||||
async function removeRow(item: SkipPriceItem): Promise<void> {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除「${item.shopName}」${item.groupName ? `(${item.groupName})` : ''}全部站点的最低价 ASIN 配置吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await deleteSkipPriceAsin(item.id)
|
||||
ElMessage.success('删除成功')
|
||||
// 删除后当前页若已空且非首页,回退一页避免展示空页。
|
||||
if (rows.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
}
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 导入添加 / 删除导入(group_id 必填) ----
|
||||
type ImportMode = 'add' | 'delete'
|
||||
const importVisible = ref(false)
|
||||
const importMode = ref<ImportMode>('add')
|
||||
const importGroupId = ref<number | null>(null)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importRunning = ref(false)
|
||||
const importProgress = ref('')
|
||||
|
||||
function resetImport(): void {
|
||||
importGroupId.value = lockedGroupId.value
|
||||
importFile.value = null
|
||||
importProgress.value = ''
|
||||
importRunning.value = false
|
||||
}
|
||||
|
||||
function openImportAdd(): void {
|
||||
importMode.value = 'add'
|
||||
resetImport()
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function openImportDelete(): void {
|
||||
importMode.value = 'delete'
|
||||
resetImport()
|
||||
importVisible.value = true
|
||||
}
|
||||
|
||||
function onPickImportFile(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
importFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function submitImport(): Promise<void> {
|
||||
if (!importGroupId.value) {
|
||||
ElMessage.warning('请先选择分组后再导入')
|
||||
return
|
||||
}
|
||||
const file = importFile.value
|
||||
if (!file) {
|
||||
ElMessage.warning('请选择 Excel 文件')
|
||||
return
|
||||
}
|
||||
if (!isAllowedExcelImportFile(file.name)) {
|
||||
ElMessage.warning('仅支持 .xlsx/.xls 文件')
|
||||
return
|
||||
}
|
||||
if (importMode.value === 'delete') {
|
||||
if (!window.confirm('确定按 Excel 中的删除ASIN批量删除该店铺跳过跟价 ASIN 吗?')) return
|
||||
}
|
||||
const mode = importMode.value
|
||||
const groupId = importGroupId.value as number
|
||||
importRunning.value = true
|
||||
importProgress.value = '正在上传并解析文件…'
|
||||
try {
|
||||
const started = mode === 'add' ? await startSkipPriceImport(file, groupId) : await startSkipPriceDeleteImport(file, groupId)
|
||||
const poll = mode === 'add' ? () => fetchSkipPriceImportProgress(started) : () => fetchSkipPriceDeleteImportProgress(started)
|
||||
for (let i = 0; i < 300; i += 1) {
|
||||
const progress = await poll()
|
||||
if (progress.status === 'success') {
|
||||
importVisible.value = false
|
||||
resetImport()
|
||||
ElMessage.success(mode === 'add' ? '导入完成' : '删除导入完成')
|
||||
await load()
|
||||
return
|
||||
}
|
||||
if (progress.status === 'failed') {
|
||||
importVisible.value = false
|
||||
resetImport()
|
||||
ElMessage.error(progress.errorMessage || (mode === 'add' ? '导入失败' : '删除导入失败'))
|
||||
return
|
||||
}
|
||||
importProgress.value = progress.status === 'pending' ? '等待导入任务开始…' : '导入处理中…'
|
||||
await sleep(1200)
|
||||
}
|
||||
throw new Error('查询导入进度超时,请稍后刷新列表确认结果')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '导入失败')
|
||||
} finally {
|
||||
importRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function doExport(): void {
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = '/api/admin/skip-price-asins/export'
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
ElMessage.success('导出文件已开始下载')
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups.value = await fetchShopManageGroups()
|
||||
} catch {
|
||||
groups.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fetchSkipPriceList(toSkipPriceParams(filter, page.value, pageSize.value))
|
||||
rows.value = result.items
|
||||
total.value = result.total
|
||||
page.value = result.page
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function apply() {
|
||||
page.value = 1
|
||||
void load()
|
||||
}
|
||||
|
||||
function changePage(next: number) {
|
||||
if (next < 1 || next > totalPages.value) return
|
||||
page.value = next
|
||||
void load()
|
||||
}
|
||||
|
||||
|
||||
function changeSize(size: number) {
|
||||
pageSize.value = size
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
|
||||
function goJump() {
|
||||
const n = Number.parseInt(jumpPage.value, 10)
|
||||
if (Number.isNaN(n)) {
|
||||
ElMessage.warning('请输入页码')
|
||||
return
|
||||
}
|
||||
changePage(Math.min(Math.max(n, 1), totalPages.value))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadGroups()
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skip-view">
|
||||
<section class="panel-box">
|
||||
<div class="skip-head">
|
||||
<h3>店铺列表</h3>
|
||||
<div class="skip-head-actions">
|
||||
<button class="btn" type="button" @click="openCreate">新增 ASIN</button>
|
||||
<button class="btn btn-secondary" type="button" @click="openImportAdd">导入文件新增</button>
|
||||
<button class="btn" type="button" @click="openImportDelete">导入文件删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row skip-filter-row">
|
||||
<div v-if="isSuperAdmin" class="form-group">
|
||||
<label>分组</label>
|
||||
<el-select v-model="filter.groupId" class="admin-filter" filterable clearable placeholder="全部分组">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>店铺名</label>
|
||||
<input v-model="filter.shopName" type="text" placeholder="请输入店铺名" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>国家</label>
|
||||
<select v-model="filter.country">
|
||||
<option value="">全部国家</option>
|
||||
<option v-for="code in COUNTRIES" :key="code" :value="code">{{ asinCountryLabel(code) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>ASIN</label>
|
||||
<input v-model="filter.asin" type="text" placeholder="请输入 ASIN" @keyup.enter="apply" />
|
||||
</div>
|
||||
<div class="form-group price-range">
|
||||
<label>最低价范围</label>
|
||||
<div class="price-range-row">
|
||||
<input v-model="filter.minimumPriceFrom" type="number" min="0" step="0.01" placeholder="最低价 ≥" @keyup.enter="apply" />
|
||||
<span class="range-sep">-</span>
|
||||
<input v-model="filter.minimumPriceTo" type="number" min="0" step="0.01" placeholder="最高价 ≤" @keyup.enter="apply" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="apply">查询</button>
|
||||
<button class="btn btn-secondary" type="button" @click="doExport">导出 XLSX</button>
|
||||
</div>
|
||||
|
||||
<div class="table-scroll skip-price-asin-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 6%">序号</th>
|
||||
<th v-if="isSuperAdmin" style="width: 13%">分组</th>
|
||||
<th style="width: 15%">店铺名</th>
|
||||
<th style="width: 24%">ASIN</th>
|
||||
<th style="width: 12%">国家</th>
|
||||
<th style="width: 10%">最低价</th>
|
||||
<th style="width: 9%">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="displayRows.length">
|
||||
<tr v-for="row in displayRows" :key="`${row.item.id}-${row.country}`">
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan">{{ row.rowNo }}</td>
|
||||
<td v-if="isSuperAdmin" :rowspan="row.rowspan">{{ row.item.groupName || '—' }}</td>
|
||||
<td :rowspan="row.rowspan">{{ row.item.shopName }}</td>
|
||||
</template>
|
||||
<td>
|
||||
<template v-if="row.asin">
|
||||
<CopyText :text="row.asin" class="asin-cell" />
|
||||
</template>
|
||||
<span v-else class="asin-empty">-</span>
|
||||
</td>
|
||||
<td>{{ asinCountryLabel(row.country || '') }}</td>
|
||||
<td class="price-cell">{{ row.minimumPrice !== '' ? row.minimumPrice : '-' }}</td>
|
||||
<template v-if="row.isFirst">
|
||||
<td :rowspan="row.rowspan" class="asin-col-actions">
|
||||
<button class="btn btn-sm" type="button" @click="openConfig(row.item as SkipPriceItem)">配置</button>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeRow(row.item as SkipPriceItem)">删除</button>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="loading">
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">加载中...</td>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<td :colspan="isSuperAdmin ? 7 : 6" class="empty-tip">暂无数据</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<OldPagination :total="total" :page="page" :page-size="pageSize" @change="changePage" @size-change="changeSize" />
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="createVisible" title="新增 ASIN" width="560px" :close-on-click-modal="false">
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="分组">
|
||||
<el-select v-model="createGroupId" placeholder="请选择分组" filterable clearable style="width: 100%" :disabled="lockedGroupId != null" @change="onCreateGroupChange">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="店铺名">
|
||||
<el-select
|
||||
v-model="createShopName"
|
||||
:disabled="createGroupId == null"
|
||||
:loading="shopNamesLoading"
|
||||
:placeholder="createGroupId == null ? '请先选择分组' : '请选择店铺'"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="name in shopNames" :key="name" :label="name" :value="name" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="国家">
|
||||
<el-select filterable v-model="createCountry" placeholder="请选择国家" clearable style="width: 100%">
|
||||
<el-option v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)" :value="code" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="ASIN">
|
||||
<el-input ref="createAsinInputRef" v-model="createAsin" placeholder="请输入 ASIN" @input="onCreateAsinInput" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最低价">
|
||||
<el-input v-model="createMinimumPrice" type="number" min="0" step="0.01" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<el-alert v-if="createMsg" :title="createMsg" :type="createMsgOk ? 'success' : 'error'" :closable="false" show-icon />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="drawerVisible" :title="drawerItem ? `${drawerItem.shopName} · 最低价 ASIN 配置` : '最低价 ASIN 配置'" size="min(560px, 94%)">
|
||||
<p class="drawer-tip">留空表示删除该站点已有的 ASIN 与最低价。</p>
|
||||
<el-form label-position="top">
|
||||
<el-form-item v-for="code in COUNTRIES" :key="code" :label="asinCountryLabel(code)">
|
||||
<div class="country-cell">
|
||||
<el-input
|
||||
v-model="draftAsin[code]"
|
||||
placeholder="ASIN(留空删除该站点)"
|
||||
@input="normalizeAsin(code)"
|
||||
/>
|
||||
<el-input v-model="draftPrice[code]" placeholder="最低价(填最低价须填 ASIN)" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="drawerSaving" @click="saveConfig">保存</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="importVisible" :title="importMode === 'add' ? '导入最低价 ASIN' : '删除导入最低价 ASIN'" width="520px">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="分组(必选)">
|
||||
<el-select filterable v-model="importGroupId" placeholder="请选择分组" style="width: 100%" :disabled="lockedGroupId != null">
|
||||
<el-option v-for="group in groups" :key="group.id" :label="group.groupName" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Excel 文件">
|
||||
<input type="file" accept=".xlsx,.xls" :disabled="importRunning" @change="onPickImportFile" />
|
||||
<p class="import-tip">按 Excel 中的分组、店铺名、国家 ASIN{{ importMode === 'add' ? '与最低价' : '' }}批量{{ importMode === 'add' ? '添加' : '删除' }},仅支持 .xlsx/.xls。</p>
|
||||
</el-form-item>
|
||||
<el-alert v-if="importProgress" :title="importProgress" type="info" :closable="false" show-icon />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="importRunning" @click="importVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="importRunning" @click="submitImport">
|
||||
{{ importMode === 'add' ? '开始导入' : '开始删除导入' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 像素复刻旧版 admin.html panel-skip-price-asin(蓝白末层)。 */
|
||||
.skip-view {
|
||||
font-family: inherit;
|
||||
color: #24384d;
|
||||
}
|
||||
.panel-box {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 20px 22px 24px;
|
||||
border: 1px solid #d8e3ee;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(145deg, #ffffff, #f9fbfd);
|
||||
box-shadow: 0 1px 2px rgba(39, 67, 94, 0.04), 0 14px 30px -24px rgba(39, 67, 94, 0.28);
|
||||
}
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: #24384d;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.skip-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skip-head-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 14px 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.skip-filter-row > .form-group {
|
||||
flex: 1 1 130px;
|
||||
min-width: 0;
|
||||
}
|
||||
.skip-filter-row .form-group.price-range {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.form-group label {
|
||||
color: #5b6f83;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 9px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13.5px;
|
||||
font-family: inherit;
|
||||
color-scheme: light;
|
||||
outline: none;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
.form-group input:hover,
|
||||
.form-group select:hover {
|
||||
border-color: #9fb7cd;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
background: #ffffff;
|
||||
border-color: #5f85ad;
|
||||
box-shadow: 0 0 0 3px rgba(95, 133, 173, 0.16);
|
||||
}
|
||||
.price-range-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-range-row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.price-range-row .range-sep {
|
||||
color: #8293a5;
|
||||
flex: none;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #4f78a5;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #5f85ad, #4f78a5);
|
||||
color: #ffffff;
|
||||
font-family: inherit;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 8px 18px -14px rgba(79, 120, 165, 0.72);
|
||||
transition: background 160ms ease, box-shadow 160ms ease, transform 120ms ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #7094ba, #5d83ac);
|
||||
box-shadow: 0 11px 22px -14px rgba(79, 120, 165, 0.8);
|
||||
}
|
||||
.btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
border-color: #c7d7e5;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
color: #2f5d8b;
|
||||
border-color: #95b1cb;
|
||||
background: #edf5fb;
|
||||
}
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #c06d77, #b35f6a);
|
||||
border-color: #b35f6a;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #cb7c84, #b96570);
|
||||
}
|
||||
.btn-sm {
|
||||
min-height: 36px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
.table-scroll {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #dbe5ee;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.table-scroll table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.skip-price-asin-table-scroll > table {
|
||||
min-width: 900px;
|
||||
}
|
||||
.table-scroll th,
|
||||
.table-scroll td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-bottom: 1px solid #e0e8ef;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table-scroll th {
|
||||
background: #edf4fa;
|
||||
color: #4e6479;
|
||||
border-bottom-color: #d5e1eb;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.table-scroll tbody tr:hover td {
|
||||
background: #f1f7fb;
|
||||
}
|
||||
.table-scroll tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.table-scroll td {
|
||||
vertical-align: top;
|
||||
}
|
||||
.asin-cell {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
margin: -3px 0 -3px -7px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 13.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.asin-cell:hover {
|
||||
border-color: #c7d7e5;
|
||||
background: #eef4fa;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.asin-empty {
|
||||
color: #8b9aaa;
|
||||
}
|
||||
.price-cell {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.asin-col-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
background: #ffffff;
|
||||
box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45);
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 44px 24px;
|
||||
text-align: center;
|
||||
color: #8293a5;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
color: #5b6f83;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pagination button {
|
||||
min-height: 30px;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid #c7d7e5;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #5b6f83;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover:not(:disabled) {
|
||||
background: #edf5fb;
|
||||
border-color: #95b1cb;
|
||||
color: #2f5d8b;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background: #eef3f7;
|
||||
color: #9baaba;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.page-total {
|
||||
margin-right: 4px;
|
||||
}
|
||||
.page-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.page-jump input {
|
||||
width: 56px;
|
||||
min-height: 30px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #cbd9e6;
|
||||
border-radius: 8px;
|
||||
background: #f8fbfd;
|
||||
color: #24384d;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.drawer-tip {
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.country-cell {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.import-tip {
|
||||
margin: 6px 0 0;
|
||||
color: #5b6f83;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
/** ASIN 数据中心共享国家字典(任务 75):支持国家码与中文标签,纯逻辑。 */
|
||||
|
||||
export const ASIN_COUNTRY_CODES = ['DE', 'UK', 'FR', 'IT', 'ES'] as const
|
||||
export type AsinCountryCode = (typeof ASIN_COUNTRY_CODES)[number]
|
||||
|
||||
export const ASIN_COUNTRY_LABELS: Record<AsinCountryCode, string> = {
|
||||
DE: '德国',
|
||||
UK: '英国',
|
||||
FR: '法国',
|
||||
IT: '意大利',
|
||||
ES: '西班牙',
|
||||
}
|
||||
|
||||
/** 国家码 → 中文标签;不属白名单原样返回。 */
|
||||
export function asinCountryLabel(country: string): string {
|
||||
return ASIN_COUNTRY_LABELS[country as AsinCountryCode] || country
|
||||
}
|
||||
|
||||
/** 国家码归一:大小写不敏感;不属白名单时返回空串。 */
|
||||
export function normalizeAsinCountry(value: string): string {
|
||||
const upper = (value || '').trim().toUpperCase()
|
||||
return ASIN_COUNTRY_CODES.includes(upper as AsinCountryCode) ? upper : ''
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/** ASIN 数据中心共享分页/筛选类型(任务 61):与 Java 各列表接口(snake)对齐,纯逻辑。 */
|
||||
|
||||
export const ASIN_PAGE_DEFAULT_SIZE = 10
|
||||
export const ASIN_PAGE_MIN_PAGE = 1
|
||||
export const ASIN_PAGE_MAX_SIZE = 200
|
||||
|
||||
/** ISO 日期(yyyy-MM-dd)或空。 */
|
||||
export function validIsoDateOrUndefined(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const text = value.trim()
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : undefined
|
||||
}
|
||||
|
||||
/** ASIN 中心列表页共享筛选/分页状态。 */
|
||||
export interface AsinListParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
/** 值/商品模糊关键字。 */
|
||||
keyword?: string
|
||||
/** 上传用户名模糊搜索。 */
|
||||
username?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
groupId?: number | null
|
||||
/** 国家代码(如 DE、UK)。 */
|
||||
country?: string
|
||||
/** 顺序翻页游标(上一页返回的 nextLastId):传了就忽略 page 偏移,走 keyset。 */
|
||||
lastId?: number
|
||||
}
|
||||
|
||||
/** 序列化到 Java 分页接口的查询参数(snake_case)。 */
|
||||
export interface AsinPageQuery {
|
||||
page: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
username?: string
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
group_id?: number
|
||||
country?: string
|
||||
/** 顺序翻页游标(keyset):传了就忽略 page 偏移。 */
|
||||
last_id?: number
|
||||
}
|
||||
|
||||
function finiteInt(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null
|
||||
}
|
||||
|
||||
/** 归一化共享分页/筛选:页码下限 1、页大小 1..上限,空条件视为无筛选。 */
|
||||
export function normalizeAsinPageParams(raw: Partial<AsinListParams>): AsinListParams {
|
||||
const page = Math.max(finiteInt(raw.page) ?? ASIN_PAGE_MIN_PAGE, ASIN_PAGE_MIN_PAGE)
|
||||
const rawSize = finiteInt(raw.pageSize)
|
||||
const pageSize =
|
||||
rawSize === null || rawSize < ASIN_PAGE_MIN_PAGE
|
||||
? ASIN_PAGE_DEFAULT_SIZE
|
||||
: Math.min(rawSize, ASIN_PAGE_MAX_SIZE)
|
||||
const textOrUndefined = (value: unknown) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined
|
||||
const keyword = textOrUndefined(raw.keyword)
|
||||
const username = textOrUndefined(raw.username)
|
||||
const country = textOrUndefined(raw.country)
|
||||
const groupId = finiteInt(raw.groupId)
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
username,
|
||||
startDate: validIsoDateOrUndefined(raw.startDate),
|
||||
endDate: validIsoDateOrUndefined(raw.endDate),
|
||||
groupId: groupId === null || groupId < 1 ? null : groupId,
|
||||
country,
|
||||
}
|
||||
}
|
||||
|
||||
/** 前端状态 → Java 查询参数;仅下发非空字段。 */
|
||||
export function toAsinPageQuery(params: AsinListParams): AsinPageQuery {
|
||||
const query: AsinPageQuery = { page: params.page, page_size: params.pageSize }
|
||||
if (params.keyword) query.keyword = params.keyword
|
||||
if (params.username) query.username = params.username
|
||||
if (params.startDate) query.start_date = params.startDate
|
||||
if (params.endDate) query.end_date = params.endDate
|
||||
if (params.groupId != null) query.group_id = params.groupId
|
||||
if (params.country) query.country = params.country
|
||||
if (typeof params.lastId === 'number' && params.lastId > 0) query.last_id = params.lastId
|
||||
return query
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** 去重汇总导入启动适配(任务 65):multipart POST /import(file+groupId) → importId。 */
|
||||
import { http } from '@/api/http'
|
||||
import { parseDedupeImportProgress, parseDedupeImportStart, type DedupeImportProgress } from './dedupe-import-model'
|
||||
|
||||
export const DEDUPE_IMPORT_ENDPOINT = '/api/admin/dedupe-total-data/import'
|
||||
export const DEDUPE_DELETE_IMPORT_ENDPOINT = '/api/admin/dedupe-total-data/delete-import'
|
||||
|
||||
export async function startDedupeImport(file: File, groupId: number): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
form.append('groupId', String(groupId))
|
||||
const { data } = await http.post<unknown>(DEDUPE_IMPORT_ENDPOINT, form)
|
||||
return parseDedupeImportStart(data)
|
||||
}
|
||||
|
||||
/** 查询导入进度:GET /import/{importId}。 */
|
||||
export async function fetchDedupeImportProgress(importId: string): Promise<DedupeImportProgress> {
|
||||
const { data } = await http.get<unknown>(`${DEDUPE_IMPORT_ENDPOINT}/${importId}`)
|
||||
return parseDedupeImportProgress(data)
|
||||
}
|
||||
|
||||
/** 删除导入启动:multipart POST /delete-import(file+groupId) → importId。 */
|
||||
export async function startDedupeDeleteImport(file: File, groupId: number): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
form.append('groupId', String(groupId))
|
||||
const { data } = await http.post<unknown>(DEDUPE_DELETE_IMPORT_ENDPOINT, form)
|
||||
return parseDedupeImportStart(data)
|
||||
}
|
||||
|
||||
/** 查询删除导入进度:GET /delete-import/{importId}。 */
|
||||
export async function fetchDedupeDeleteImportProgress(importId: string): Promise<DedupeImportProgress> {
|
||||
const { data } = await http.get<unknown>(`${DEDUPE_DELETE_IMPORT_ENDPOINT}/${importId}`)
|
||||
return parseDedupeImportProgress(data)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user