mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-14 08:36:07 +00:00
Compare commits
689 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7035b56fe | |||
| 5f526e5201 | |||
| bd8d33980f | |||
| 5dc02a9af3 | |||
| 033c5993e0 | |||
| 2204c8231d | |||
| 01a7dc807b | |||
| 6bf4a2c4f0 | |||
| 21058eb63c | |||
| 194de8869e | |||
| 26accfd8f7 | |||
| 2551a673c3 | |||
| ce4c42e09c | |||
| 18614bd6ea | |||
| e564c9283d | |||
| 933567d423 | |||
| 771bc7c8ef | |||
| 61ab602887 | |||
| adc262a238 | |||
| 67b098dfd3 | |||
| 5543466fcc | |||
| b10a9f1de7 | |||
| 4399fe2a85 | |||
| 6c6b40e063 | |||
| b97ff40ad6 | |||
| 46b6f8c66c | |||
| 102df7a290 | |||
| f29c8a5e29 | |||
| ad81649c16 | |||
| b47f794ed0 | |||
| 4e1b597914 | |||
| bbefe91011 | |||
| e40554a7d5 | |||
| 3569b1be73 | |||
| 38da210ded | |||
| 9fc47b3d41 | |||
| 210c25cf13 | |||
| 5dd7e44594 | |||
| 4e4a8e9ff7 | |||
| 23970e72a7 | |||
| 8bdb093d6e | |||
| 3b0bcb910e | |||
| 428f1333ac | |||
| 406cb6dbc0 | |||
| 5fb36d34c9 | |||
| 4884a2972a | |||
| 6e12329d9d | |||
| 9f7e8178d4 | |||
| 60e6b12f4c | |||
| 0dbadf82c0 | |||
| 48e90bba51 | |||
| 6de9b24229 | |||
| 07bc74a521 | |||
| f570b991e7 | |||
| 80031e67cc | |||
| fdaa65ad7e | |||
| d86e87ed30 | |||
| 89a8f549f2 | |||
| 355bb4c9c0 | |||
| 9feeccffc0 | |||
| cb962175c2 | |||
| 8f3202f431 | |||
| 0cb6568fd5 | |||
| 6a90f98412 | |||
| 9318c2105f | |||
| e642f7324e | |||
| 7214ffafc5 | |||
| 88061bac10 | |||
| b5479f3f30 | |||
| d8aedcdde4 | |||
| 5f3e9ed0ea | |||
| 3e8a0eb93e | |||
| 4c2915586c | |||
| 9f06bffbea | |||
| e20d73ba7e | |||
| 8834e5fbbe | |||
| 6d732d8d32 | |||
| e4900f1bd4 | |||
| 04828246fc | |||
| c1efc48694 | |||
| f1760b0a28 | |||
| 745e394c74 | |||
| 737300b14b | |||
| 30469fcd10 | |||
| 887fca86ec | |||
| 6efc4b0665 | |||
| 94a7dbfe3c | |||
| e2649f98df | |||
| 3d839e0ee1 | |||
| a96612f595 | |||
| 8e7d215b4a | |||
| 24cd271486 | |||
| dee2525d5f | |||
| 81b4ae5661 | |||
| 9cbba130ab | |||
| cf5767acd1 | |||
| 444b05cac9 | |||
| f70e131dfe | |||
| 14165fc54d | |||
| 7cd26a0583 | |||
| 267fb1c866 | |||
| 5ac88271af | |||
| b776b33497 | |||
| 1478124712 | |||
| 9735d26b3d | |||
| 113a29733e | |||
| 3505430e57 | |||
| f68a14a3ca | |||
| 60e2af088d | |||
| 917f9b307e | |||
| 61c84e8223 | |||
| 72d8ebd269 | |||
| b885a1f8a6 | |||
| 439f4cf1e8 | |||
| bc00d37ad8 | |||
| 12c10dbd98 | |||
| 2fd2cd0af1 | |||
| 37fb48ffff | |||
| d8198f543b | |||
| f2bc4938b7 | |||
| 7f703f927e | |||
| f2c79b57fa | |||
| c394938f01 | |||
| b84b58ef21 | |||
| 10ebc6cbdc | |||
| a1b2382877 | |||
| 59c55dfc92 | |||
| 28a3dddb60 | |||
| 39bf31bd56 | |||
| 42b2ebc00b | |||
| 3b64a62137 | |||
| 79a7e7a5b5 | |||
| 3349dcbc13 | |||
| ad30298700 | |||
| 9be11e109e | |||
| 7117d19fd1 | |||
| c88627a839 | |||
| c718e7ca5b | |||
| 6a483fa987 | |||
| 47163c1418 | |||
| 09f4f09b84 | |||
| 3313086071 | |||
| 03d8ad4d5a | |||
| a8dff126c7 | |||
| 74e97fec4c | |||
| 50603fd430 | |||
| 8bea0fde2b | |||
| b2d32f588f | |||
| 8177f6dc66 | |||
| 77d94b25d0 | |||
| 32b7ada549 | |||
| 6099a07ff0 | |||
| e9806832ec | |||
| 15ebf3df10 | |||
| d44b70682c | |||
| fb75e3d7c7 | |||
| e9979b6774 | |||
| 2b83dc047b | |||
| c90f8a05bf | |||
| 9f96ef83ec | |||
| e19061d513 | |||
| 51e2fb6dbf | |||
| f21ed92296 | |||
| 22de983752 | |||
| 0b5c239f98 | |||
| 03393c9f52 | |||
| b56db67759 | |||
| 6d05702d00 | |||
| 9791b05a4e | |||
| 0aca2d3b3d | |||
| 8529f4f0cf | |||
| abc5cf3439 | |||
| a7e7788e29 | |||
| 8620344925 | |||
| 47e229e323 | |||
| 4521beab7c | |||
| a62c637632 | |||
| 35609b7b13 | |||
| a4b1b3d06d | |||
| 5f7c7c5f3d | |||
| 6bcaf61c44 | |||
| ff25072690 | |||
| 530c1597b8 | |||
| c8e16d8c41 | |||
| 17f67ef3a5 | |||
| eb4791a1cd | |||
| 71ac920436 | |||
| e6d0c33937 | |||
| eef2d311f4 | |||
| 772d2b6de4 | |||
| 8f30d14716 | |||
| 9611c9def6 | |||
| 292eb992f4 | |||
| 814e6ad69c | |||
| 0a38624ba7 | |||
| b86473df02 | |||
| 15be803da9 | |||
| c79b45e512 | |||
| 86a8eb16b4 | |||
| 0fd0389d5c | |||
| 2983ac3f8e | |||
| 975d6d1bad | |||
| ab7a7f7c6b | |||
| 733f44ef0f | |||
| faec3ca038 | |||
| df163854bd | |||
| 1af795fad8 | |||
| 085cb8c216 | |||
| 2a9ba2badc | |||
| 53fb4fe8f9 | |||
| 8d512d55e5 | |||
| a9d8905393 | |||
| ca2fd3814f | |||
| 394fafd29b | |||
| 9f0055d193 | |||
| 88dafa6cdf | |||
| 2b3b2770b4 | |||
| 094ea9faaa | |||
| eb16cca551 | |||
| aef0503f8f | |||
| 86304226a9 | |||
| 6d0e7ec495 | |||
| 04b4fb4384 | |||
| ae5ad505d0 | |||
| c188056f64 | |||
| 0a424a9f16 | |||
| 36b2a58675 | |||
| 59e9859225 | |||
| 4e5f144def | |||
| ea53da9341 | |||
| 3e1a102e9d | |||
| d580086361 | |||
| 1e3b366fba | |||
| c2a2a36f56 | |||
| e986a133f8 | |||
| 7466916e02 | |||
| 96b568b838 | |||
| fec714a243 | |||
| e02f78ac68 | |||
| 169b216d7e | |||
| 7e6d80efa5 | |||
| 38d87230d3 | |||
| f0f98c7122 | |||
| 554981d9d3 | |||
| a08f1c6c13 | |||
| 7f7ae0c547 | |||
| 60abeaad66 | |||
| a6d0100381 | |||
| 6767f76ccf | |||
| e4add73c9e | |||
| ff72090e1a | |||
| a3e1bd59df | |||
| 5bbb48a8fd | |||
| ee84d585f9 | |||
| 7b03346cfc | |||
| 258b08fff3 | |||
| a2097ad062 | |||
| 52fdf5d429 | |||
| 34d8885075 | |||
| 5740996436 | |||
| 874aae8080 | |||
| 842fae18d7 | |||
| ccd223aeea | |||
| 96b8fe472c | |||
| 59b695ba83 | |||
| 159b85f979 | |||
| 3ec5b3589f | |||
| 2b1d3e7347 | |||
| 37f0880f8f | |||
| 5b796672e9 | |||
| 3fa0da38c9 | |||
| 8eb1225734 | |||
| e5c0fe3edf | |||
| f4057989f5 | |||
| 84013b0b3f | |||
| 511adffc5b | |||
| fc6344b840 | |||
| b3555ce1b8 | |||
| c2f409c3c4 | |||
| 0994f8756f | |||
| 4779939424 | |||
| 4a455aa532 | |||
| 25f64738e4 | |||
| 5bb87fd3d4 | |||
| 491e3f9f8b | |||
| d8fb09faae | |||
| f87c68ea68 | |||
| 687e8cf1ba | |||
| 03f04194f2 | |||
| 248700a8a3 | |||
| ff128a7275 | |||
| e8d2973be7 | |||
| f3d47ebb3f | |||
| 06c49b92f8 | |||
| e35213bc73 | |||
| aa6a886977 | |||
| 9d603c5ad2 | |||
| a973fa6d68 | |||
| 3af6497577 | |||
| c59f54bb0e | |||
| 6b3da4fe5e | |||
| ea0da32e81 | |||
| d5ea8d0f38 | |||
| fd5f591737 | |||
| 8a4c9a98cb | |||
| 70b365171f | |||
| 328ba3b45e | |||
| 5370b6943a | |||
| d8c783a296 | |||
| 809f69729a | |||
| 93b7ce199f | |||
| 2a76cec804 | |||
| 88eab032be | |||
| 20ec863f51 | |||
| 2f4018bbe5 | |||
| f273708f6d | |||
| e6318d57e4 | |||
| 77fa976ee9 | |||
| 8098d2b1b1 | |||
| a691eaea8d | |||
| da447e5669 | |||
| f8c9aac97c | |||
| e42c17f2b2 | |||
| 427b7b67d8 | |||
| ccf08086ac | |||
| 7b0a3929ff | |||
| 570ab8e5e0 | |||
| 1240e4c962 | |||
| c117b8b272 | |||
| 6041d10e3d | |||
| 4800f8fb70 | |||
| a9770e1da2 | |||
| 3f15d21f13 | |||
| a6b3623634 | |||
| 947fd4fae1 | |||
| e69a31dd59 | |||
| 719ae0e014 | |||
| 5bcf6a8aeb | |||
| 945fefde12 | |||
| 313a2acbf6 | |||
| b747730211 | |||
| 692a73788a | |||
| 3287fa4d80 | |||
| 1393f981bc | |||
| 9a2c1c6b43 | |||
| 278aa1c85c | |||
| 8fe297ef9d | |||
| c881d1015a | |||
| c061337ce7 | |||
| 260eedf8c4 | |||
| 69ccdba734 | |||
| 4c797dc154 | |||
| f000322a06 | |||
| 0ea8b5352a | |||
| 68240061aa | |||
| 0695f677ba | |||
| 70f6d6b21a | |||
| e8c509c720 | |||
| 83a1c721c7 | |||
| 7ccc0877a1 | |||
| ad659e48cf | |||
| 784ed39930 | |||
| 538f7fd5d7 | |||
| cf38226b5d | |||
| 575ee854c8 | |||
| 9936af80dd | |||
| 4a75bd0a48 | |||
| b0c223c631 | |||
| 313b51f96f | |||
| 020cd63e22 | |||
| 6e46e9b16e | |||
| 713a7328f6 | |||
| 01d4a7488d | |||
| 2b2ed3349a | |||
| d8523bbdac | |||
| 8afa39144e | |||
| 00baeffe74 | |||
| b578a33518 | |||
| 8153e0ac05 | |||
| 2eb9d2e2e8 | |||
| a824875c4f | |||
| cafcb250ec | |||
| e7cfee570b | |||
| 90c3529301 | |||
| b65ec83c39 | |||
| 28a17a80ec | |||
| 3056583388 | |||
| 172f2ddaa7 | |||
| d69af328dc | |||
| ee0e3093ba | |||
| 89def9aee6 | |||
| b2b0024648 | |||
| 5822758b7c | |||
| 49430b3991 | |||
| 104526aab2 | |||
| a0c07241c0 | |||
| adf3242602 | |||
| 3f62592e4b | |||
| 02bff4db6c | |||
| 8ff4e1ff31 | |||
| 26c6438ec2 | |||
| b3e96230c4 | |||
| 1016f3b4f9 | |||
| 020bc9d77c | |||
| 5620d739c6 | |||
| d518979e4f | |||
| 83f8a03b50 | |||
| b45e63a14a | |||
| 3007bcff97 | |||
| 55f1d72af5 | |||
| 806ecbd7c5 | |||
| ae79b43cdb | |||
| e64e6327ef | |||
| 9f024b9e6a | |||
| eacfbc86b5 | |||
| 37c17357fc | |||
| b35d339665 | |||
| 5e7a3db873 | |||
| 6ced549dea | |||
| f60682a6b7 | |||
| 50bd7a8040 | |||
| 7465768ff7 | |||
| 5b00a52c65 | |||
| 151f1173a1 | |||
| e262132b9d | |||
| ca0a7aeb5a | |||
| 7447cec17e | |||
| 0ffd27c0aa | |||
| 054cb1dea0 | |||
| 3757ae0b11 | |||
| e3883fca87 | |||
| b46a0b404b | |||
| 0ce58a095a | |||
| 59ea2645db | |||
| 8c8d280f14 | |||
| c720008187 | |||
| 170d24499e | |||
| 99c79d4056 | |||
| fcdeb1fc79 | |||
| 0a58b5e745 | |||
| db7e7dcd29 | |||
| 01b8a27996 | |||
| 3764ece26c | |||
| d7efc2aef9 | |||
| 2eb8abf61e | |||
| 299572a4c2 | |||
| 22afa50901 | |||
| bc274d1e1f | |||
| dc21f41932 | |||
| f137b1af76 | |||
| c4871ef8fe | |||
| ecfffa882a | |||
| 3af5026abe | |||
| 1de7accd7c | |||
| 76afff2a6f | |||
| 9623e87511 | |||
| bc0518391e | |||
| 5408a2f82c | |||
| c8d71ea748 | |||
| 46de886b53 | |||
| 6d41320ed7 | |||
| bf9d2e6aeb | |||
| ed96fa090b | |||
| 3ac1d7f546 | |||
| 10025ffa66 | |||
| 5ee62b25ca | |||
| 311d11a3c1 | |||
| 40b6d7707a | |||
| cbf316db31 | |||
| 33a36ada4b | |||
| 82ddd10627 | |||
| 2401c99817 | |||
| 2f36a4047c | |||
| dc3b0d218a | |||
| 610d29765a | |||
| b1ea8005e4 | |||
| 3f0bfa2472 | |||
| 1e2ff650ad | |||
| c2d6dd923f | |||
| 723ec25fb2 | |||
| 7dc52e9a53 | |||
| fe9f0d1d0e | |||
| 18d74d54ca | |||
| c7ba6ae909 | |||
| 3edf79e589 | |||
| 5420e643cf | |||
| 9fcd0387ca | |||
| 7b039d219e | |||
| dbec28b915 | |||
| e5126806d7 | |||
| b008ff4ad2 | |||
| da6b89fdcd | |||
| d7882c25d1 | |||
| ed2a0a0bcf | |||
| 4a0914cb1e | |||
| 664269d513 | |||
| d0796b26c9 | |||
| 2750f46c01 | |||
| 023eb513e4 | |||
| 0c7b59ed47 | |||
| 3087c1b123 | |||
| 2198397197 | |||
| d10c312e62 | |||
| 24a3411465 | |||
| 2198e7a28f | |||
| 6b23b416a7 | |||
| 16f53ce4c2 | |||
| 27445b30e9 | |||
| 3d0212c21d | |||
| 978755960f | |||
| 9b51e9a5c5 | |||
| 6879a8fbcb | |||
| 7258841491 | |||
| 23dd80fbb0 | |||
| 6556884c7f | |||
| d5c532c64f | |||
| ad5f774a1e | |||
| aa285914fa | |||
| 4d02756e1e | |||
| 825d93d95f | |||
| 5ea6386815 | |||
| d064e85ecd | |||
| 9fc03bd10a | |||
| ae08a29cde | |||
| 4f25eb230e | |||
| ce72d53d1a | |||
| 5e641ff9e8 | |||
| 58898e5758 | |||
| 569550d5f6 | |||
| 419ea63dd0 | |||
| 6a17285935 | |||
| 4b03e9d919 | |||
| 7e9c3bdbaf | |||
| 957f3dbb54 | |||
| 05e60af283 | |||
| 5e40458116 | |||
| baf6fdd29d | |||
| 45f78d3521 | |||
| 01f984e054 | |||
| e4ba5ba53a | |||
| 6ff555c8bb | |||
| 3c1634ca7c | |||
| 561c4810be | |||
| eb1b96643d | |||
| de5314c01f | |||
| 1088d1faf3 | |||
| 267024c43f | |||
| 0d595f56e4 | |||
| a4c4f9efb3 | |||
| 73a5722cca | |||
| 30264043f8 | |||
| c6062eb15c | |||
| f1b7944828 | |||
| 7a57b31ff3 | |||
| 6e1b949081 | |||
| 0ad708b1b6 | |||
| 71f13ebcbd | |||
| f5f4a530cc | |||
| 702f03e4b7 | |||
| 487ec74e0b | |||
| b4dae36345 | |||
| 761728255c | |||
| b1ab156e42 | |||
| fa45bf87de | |||
| 75416eebd7 | |||
| 87042d77ba | |||
| 011e0f309a | |||
| b7164805f8 | |||
| bbdeb65291 | |||
| 038cf34219 | |||
| 98a1517470 | |||
| ce76cedb0d | |||
| 24a313d605 | |||
| c81c27073c | |||
| 5d11e6e13f | |||
| f3d0b92e4a | |||
| c8c0e77714 | |||
| 49b8f46864 | |||
| cad07be847 | |||
| 4b20f16024 | |||
| d642774a44 | |||
| 1644904755 | |||
| 5c10035bd9 | |||
| 2e6faf69e6 | |||
| f88b7b07f0 | |||
| e5752239f4 | |||
| cb22b4ad47 | |||
| 6a2e0071cf | |||
| f86219f4de | |||
| e272c160b1 | |||
| ba50c99c10 | |||
| 00b61de646 | |||
| dff4ad31ff | |||
| 13baf77893 | |||
| 4531574de3 | |||
| d1e07954c5 | |||
| d9922d93af | |||
| c7d315f848 | |||
| 1781790dce | |||
| 29f950046a | |||
| 5dae785786 | |||
| 1b1cbfff42 | |||
| c93467b852 | |||
| c988d55256 | |||
| ef625c75d8 | |||
| 182e591c48 | |||
| 3666d1193f | |||
| 7a5a833af3 | |||
| 58f978bb0a | |||
| 6d47496069 | |||
| e5c19759db | |||
| 295a8b6e37 | |||
| 384e23aeb2 | |||
| 23293813bb | |||
| c15ec5315a | |||
| 1ddfe4aba3 | |||
| fe3b1c9b52 | |||
| d39ccf4b8f | |||
| 1aed2d8cdc | |||
| c3084aaece | |||
| 13cf7271d6 | |||
| 63edc63ab0 | |||
| 85cbad3ef4 | |||
| 3d54e33051 | |||
| 01be9fec95 | |||
| 0306e75c2a | |||
| 255ff9cc20 | |||
| 2fbb1ca6c9 | |||
| 3b47028060 | |||
| d9ab8b4ce4 | |||
| e6389f3fb3 | |||
| 96fd7d0e7c | |||
| cf02f02210 | |||
| 4dc8974af0 | |||
| b527a528ea | |||
| 1a53af0434 | |||
| be8d55dadb | |||
| d54e7a9b14 | |||
| 45c3d730d4 | |||
| aab01ff11a | |||
| 236dddf482 | |||
| 8e472838d8 | |||
| b75a1ef5e1 | |||
| d956f78347 | |||
| 8ef447a997 | |||
| 520f7a2d15 | |||
| 3ded4ee658 | |||
| bea19a263d | |||
| 878e0d02cd | |||
| b15ea1f74d | |||
| 431d7350a5 | |||
| 127bea7f73 | |||
| 7c58bcbb46 | |||
| fec9b25248 | |||
| 728166bd1a | |||
| d376ce057c | |||
| 5e6e900e64 | |||
| 19f7938617 | |||
| fe791b6e99 | |||
| a02bf3195d | |||
| 3ea05d30c1 | |||
| 40ebf2902e | |||
| 14253c3586 | |||
| 7b15274c84 | |||
| 6545d8b61d | |||
| 6f4eefe601 | |||
| 510c35f450 | |||
| 00addb0dd9 | |||
| db140a1e9b | |||
| db945e2fbd | |||
| 667fac15f4 | |||
| 29033a7828 | |||
| 2ffde55f8f | |||
| 6e5ed881f2 | |||
| d52c50fd9e | |||
| fa5fb927c1 | |||
| f7198c4c2f | |||
| 21e7d45b54 | |||
| db62a07fb8 | |||
| e3120c4028 | |||
| 7ae855e7c9 | |||
| b9307c6c9c | |||
| d30cdbf49a | |||
| cac00224db | |||
| b68f0a206c | |||
| 0bde51b91e | |||
| 280a22b57d | |||
| 315d852087 | |||
| 6a0d2e0a29 |
@@ -0,0 +1,4 @@
|
||||
XUI_DEBUG=true
|
||||
XUI_DB_FOLDER=x-ui
|
||||
XUI_LOG_FOLDER=x-ui
|
||||
XUI_BIN_FOLDER=x-ui
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
github: MHSanaei
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
@@ -11,4 +11,4 @@ issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: mhsanaei
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
custom: https://nowpayments.io/donation/hsanaei
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Version (please complete the following information):**
|
||||
- 3X-UI Version : [e.g. 2.3.5]
|
||||
- Xray Version : [e.g. 1.8.13]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,77 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
title: "Bug report"
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for reporting a bug! Please fill out the following information.
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: My problem is...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: how-repeat-problem
|
||||
attributes:
|
||||
label: How to repeat the problem?
|
||||
description: Sequence of actions that allow you to reproduce the bug
|
||||
placeholder: |
|
||||
1. Open `Inbounds` page
|
||||
2. ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-action
|
||||
attributes:
|
||||
label: Expected action
|
||||
description: What's going to happen
|
||||
placeholder: Must be...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: received-action
|
||||
attributes:
|
||||
label: Received action
|
||||
description: What's really happening
|
||||
placeholder: It's actually happening...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: xui-version
|
||||
attributes:
|
||||
label: 3x-ui Version
|
||||
description: Which version of 3x-ui are you using?
|
||||
placeholder: 2.X.X
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: xray-version
|
||||
attributes:
|
||||
label: Xray-core Version
|
||||
description: Which version of Xray-core are you using?
|
||||
placeholder: 2.X.X
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
options:
|
||||
- label: This bug report is written entirely in English.
|
||||
required: true
|
||||
- label: This bug report is new and no one has reported it before me.
|
||||
required: true
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
title: "Feature request"
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: is-related-problem
|
||||
attributes:
|
||||
label: Is your feature request related to a problem?
|
||||
description: A clear and concise description of what the problem is.
|
||||
placeholder: I'm always frustrated when...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
options:
|
||||
- label: This feature report is written entirely in English.
|
||||
required: true
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: 'Question '
|
||||
about: Describe this issue template's purpose here.
|
||||
title: ''
|
||||
labels: question
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Question
|
||||
description: Describe this issue template's purpose here.
|
||||
title: "Question"
|
||||
labels: ["question"]
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
placeholder: I have a question, ..., how can I solve it?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please check all the checkboxes
|
||||
options:
|
||||
- label: This question is written entirely in English.
|
||||
required: true
|
||||
@@ -0,0 +1,158 @@
|
||||
# 3X-UI Development Guide
|
||||
|
||||
## Project Overview
|
||||
3X-UI is a web-based control panel for managing Xray-core servers. It's a Go application using Gin web framework with embedded static assets and SQLite database. The panel manages VPN/proxy inbounds, monitors traffic, and provides Telegram bot integration.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
- **main.go**: Entry point that initializes database, web server, and subscription server. Handles graceful shutdown via SIGHUP/SIGTERM signals
|
||||
- **web/**: Primary web server with Gin router, HTML templates, and static assets embedded via `//go:embed`
|
||||
- **xray/**: Xray-core process management and API communication for traffic monitoring
|
||||
- **database/**: GORM-based SQLite database with models in `database/model/`
|
||||
- **sub/**: Subscription server running alongside main web server (separate port)
|
||||
- **web/service/**: Business logic layer containing InboundService, SettingService, TgBot, etc.
|
||||
- **web/controller/**: HTTP handlers using Gin context (`*gin.Context`)
|
||||
- **web/job/**: Cron-based background jobs for traffic monitoring, CPU checks, LDAP sync
|
||||
|
||||
### Key Architectural Patterns
|
||||
1. **Embedded Resources**: All web assets (HTML, CSS, JS, translations) are embedded at compile time using `embed.FS`:
|
||||
- `web/assets` → `assetsFS`
|
||||
- `web/html` → `htmlFS`
|
||||
- `web/translation` → `i18nFS`
|
||||
|
||||
2. **Dual Server Design**: Main web panel + subscription server run concurrently, managed by `web/global` package
|
||||
|
||||
3. **Xray Integration**: Panel generates `config.json` for Xray binary, communicates via gRPC API for real-time traffic stats
|
||||
|
||||
4. **Signal-Based Restart**: SIGHUP triggers graceful restart. **Critical**: Always call `service.StopBot()` before restart to prevent Telegram bot 409 conflicts
|
||||
|
||||
5. **Database Seeders**: Uses `HistoryOfSeeders` model to track one-time migrations (e.g., password bcrypt migration)
|
||||
|
||||
## Development Workflows
|
||||
|
||||
### Building & Running
|
||||
```bash
|
||||
# Build (creates bin/3x-ui.exe)
|
||||
go run tasks.json → "go: build" task
|
||||
|
||||
# Run with debug logging
|
||||
XUI_DEBUG=true go run ./main.go
|
||||
# Or use task: "go: run"
|
||||
|
||||
# Test
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Command-Line Operations
|
||||
The main.go accepts flags for admin tasks:
|
||||
- `-reset` - Reset all panel settings to defaults
|
||||
- `-show` - Display current settings (port, paths)
|
||||
- Use these by running the binary directly, not via web interface
|
||||
|
||||
### Database Management
|
||||
- DB path: Configured via `config.GetDBPath()`, typically `/etc/x-ui/x-ui.db`
|
||||
- Models: Located in `database/model/model.go` - Auto-migrated on startup
|
||||
- Seeders: Use `HistoryOfSeeders` to prevent re-running migrations
|
||||
- Default credentials: admin/admin (hashed with bcrypt)
|
||||
|
||||
### Telegram Bot Development
|
||||
- Bot instance in `web/service/tgbot.go` (3700+ lines)
|
||||
- Uses `telego` library with long polling
|
||||
- **Critical Pattern**: Must call `service.StopBot()` before any server restart to prevent 409 bot conflicts
|
||||
- Bot handlers use `telegohandler.BotHandler` for routing
|
||||
- i18n via embedded `i18nFS` passed to bot startup
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Service Layer Pattern
|
||||
Services inject dependencies (like xray.XrayAPI) and operate on GORM models:
|
||||
```go
|
||||
type InboundService struct {
|
||||
xrayApi xray.XrayAPI
|
||||
}
|
||||
|
||||
func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
|
||||
// Business logic here
|
||||
}
|
||||
```
|
||||
|
||||
### Controller Pattern
|
||||
Controllers use Gin context and inherit from BaseController:
|
||||
```go
|
||||
func (a *InboundController) getInbounds(c *gin.Context) {
|
||||
// Use I18nWeb(c, "key") for translations
|
||||
// Check auth via checkLogin middleware
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Management
|
||||
- Environment vars: `XUI_DEBUG`, `XUI_LOG_LEVEL`, `XUI_MAIN_FOLDER`
|
||||
- Config embedded files: `config/version`, `config/name`
|
||||
- Use `config.GetLogLevel()`, `config.GetDBPath()` helpers
|
||||
|
||||
### Internationalization
|
||||
- Translation files: `web/translation/<lang>.json` (one nested-namespace file per locale,
|
||||
e.g. `en-US.json`). Vue SPA imports these via `import.meta.glob` from `frontend/src/i18n/`,
|
||||
and the Go binary embeds the same files via `web/web.go`'s `//go:embed translation/*`.
|
||||
- Access from Go via `locale.I18n(locale.Web, "pages.login.loginAgain")` (see
|
||||
`web/locale/locale.go`); access from Vue via `useI18n()` and `t('pages.login.loginAgain')`.
|
||||
- Use `locale.I18nType` enum (Web, Bot).
|
||||
|
||||
## External Dependencies & Integration
|
||||
|
||||
### Xray-core
|
||||
- Binary management: Download platform-specific binary (`xray-{os}-{arch}`) to bin folder
|
||||
- Config generation: Panel creates `config.json` dynamically from inbound/outbound settings
|
||||
- Process control: Start/stop via `xray/process.go`
|
||||
- gRPC API: Real-time stats via `xray/api.go` using `google.golang.org/grpc`
|
||||
|
||||
### Critical External Paths
|
||||
- Xray binary: `{bin_folder}/xray-{os}-{arch}`
|
||||
- Xray config: `{bin_folder}/config.json`
|
||||
- GeoIP/GeoSite: `{bin_folder}/geoip.dat`, `geosite.dat`
|
||||
- Logs: `{log_folder}/3xipl.log`, `3xipl-banned.log`
|
||||
|
||||
### Job Scheduling
|
||||
Uses `robfig/cron/v3` for periodic tasks:
|
||||
- Traffic monitoring: `xray_traffic_job.go`
|
||||
- CPU alerts: `check_cpu_usage.go`
|
||||
- IP tracking: `check_client_ip_job.go`
|
||||
- LDAP sync: `ldap_sync_job.go`
|
||||
|
||||
Jobs registered in `web/web.go` during server initialization
|
||||
|
||||
## Deployment & Scripts
|
||||
|
||||
### Installation Script Pattern
|
||||
Both `install.sh` and `x-ui.sh` follow these patterns:
|
||||
- Multi-distro support via `$release` variable (ubuntu, debian, centos, arch, etc.)
|
||||
- Port detection with `is_port_in_use()` using ss/netstat/lsof
|
||||
- Systemd service management with distro-specific unit files (`.service.debian`, `.service.arch`, `.service.rhel`)
|
||||
|
||||
### Docker Build
|
||||
Multi-stage Dockerfile:
|
||||
1. **Builder**: CGO-enabled build, runs `DockerInit.sh` to download Xray binary
|
||||
2. **Final**: Alpine-based with fail2ban pre-configured
|
||||
|
||||
### Key File Locations (Production)
|
||||
- Binary: `/usr/local/x-ui/`
|
||||
- Database: `/etc/x-ui/x-ui.db`
|
||||
- Logs: `/var/log/x-ui/`
|
||||
- Service: `/etc/systemd/system/x-ui.service.*`
|
||||
|
||||
## Testing & Debugging
|
||||
- Set `XUI_DEBUG=true` for detailed logging
|
||||
- Check Xray process: `x-ui.sh` script provides menu for status/logs
|
||||
- Database inspection: Direct SQLite access to x-ui.db
|
||||
- Traffic debugging: Check `3xipl.log` for IP limit tracking
|
||||
- Telegram bot: Logs show bot initialization and command handling
|
||||
|
||||
## Common Gotchas
|
||||
1. **Bot Restart**: Always stop Telegram bot before server restart to avoid 409 conflict
|
||||
2. **Embedded Assets**: Changes to HTML/CSS require recompilation (not hot-reload)
|
||||
3. **Password Migration**: Seeder system tracks bcrypt migration - check `HistoryOfSeeders` table
|
||||
4. **Port Binding**: Subscription server uses different port from main panel
|
||||
5. **Xray Binary**: Must match OS/arch exactly - managed by installer scripts
|
||||
6. **Session Management**: Uses `gin-contrib/sessions` with cookie store
|
||||
7. **IP Limitation**: Implements "last IP wins" - when client exceeds LimitIP, oldest connections are automatically disconnected via Xray API to allow newest IPs
|
||||
@@ -0,0 +1,19 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -0,0 +1,20 @@
|
||||
## What is the pull request?
|
||||
|
||||
<!-- Briefly describe the changes introduced by this pull request -->
|
||||
|
||||
## Which part of the application is affected by the change?
|
||||
|
||||
- [ ] Frontend
|
||||
- [ ] Backend
|
||||
|
||||
## Type of Changes
|
||||
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Refactoring
|
||||
- [ ] Other
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- Add screenshots to illustrate the changes -->
|
||||
<!-- Remove this section if it is not applicable. -->
|
||||
@@ -0,0 +1,91 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "**.html"
|
||||
- "**.css"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- ".nvmrc"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "**.html"
|
||||
- "**.css"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- ".nvmrc"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
go-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stub web/dist for go:embed
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
- name: Test
|
||||
run: |
|
||||
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||
go test $(cat /tmp/go-packages.txt)
|
||||
|
||||
govulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- name: Stub web/dist for go:embed
|
||||
run: mkdir -p web/dist && touch web/dist/.gitkeep
|
||||
- name: Install govulncheck
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- name: Run govulncheck
|
||||
run: govulncheck ./...
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
- name: Audit
|
||||
run: npm audit --audit-level=high
|
||||
working-directory: frontend
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Cleanup Caches
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 3 * * *" # every day
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Delete caches older than 1 day
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
|
||||
echo "Deleting caches older than: $CUTOFF_DATE"
|
||||
|
||||
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
|
||||
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
|
||||
|
||||
if [ -z "$CACHE_IDS" ]; then
|
||||
echo "No old caches found to delete."
|
||||
else
|
||||
echo "$CACHE_IDS" | while read CACHE_ID; do
|
||||
echo "Deleting cache: $CACHE_ID"
|
||||
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
|
||||
done
|
||||
echo "Old caches deleted successfully."
|
||||
fi
|
||||
@@ -0,0 +1,84 @@
|
||||
name: "CodeQL Advanced"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags-ignore:
|
||||
- "v*"
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "frontend/package-lock.json"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "**.js"
|
||||
- "**.mjs"
|
||||
- "**.cjs"
|
||||
- "**.ts"
|
||||
- "**.vue"
|
||||
- "frontend/package-lock.json"
|
||||
schedule:
|
||||
- cron: "18 2 * * 2"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
env:
|
||||
CODEQL_ACTION_FILE_COVERAGE_ON_PRS: true
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: go
|
||||
build-mode: autobuild
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
if: matrix.language == 'go'
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -1,4 +1,9 @@
|
||||
name: Release 3X-UI for Docker
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -7,51 +12,49 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
hsanaeii/3x-ui
|
||||
ghcr.io/mhsanaei/3x-ui
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=pep440,pattern={{version}}
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
hsanaeii/3x-ui
|
||||
ghcr.io/mhsanaei/3x-ui
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
install: true
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
+196
-49
@@ -3,11 +3,38 @@ name: Release 3X-UI
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.html"
|
||||
- "**.sh"
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
pull_request:
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.html"
|
||||
- "**.sh"
|
||||
- "**.go"
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- "x-ui.service.debian"
|
||||
- "x-ui.service.arch"
|
||||
- "x-ui.service.rhel"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
@@ -18,72 +45,78 @@ jobs:
|
||||
- 386
|
||||
- armv5
|
||||
- s390x
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
- name: Install dependencies
|
||||
# Frontend dist must be built BEFORE go build — Go's //go:embed
|
||||
# all:dist directive in web/web.go requires web/dist/ to exist
|
||||
# at compile time. web/dist/ is .gitignored, so on a fresh CI
|
||||
# checkout it doesn't exist until vite emits it.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
run: |
|
||||
sudo apt-get update
|
||||
if [ "${{ matrix.platform }}" == "arm64" ]; then
|
||||
sudo apt install gcc-aarch64-linux-gnu
|
||||
elif [ "${{ matrix.platform }}" == "armv7" ]; then
|
||||
sudo apt install gcc-arm-linux-gnueabihf
|
||||
elif [ "${{ matrix.platform }}" == "armv6" ]; then
|
||||
sudo apt install gcc-arm-linux-gnueabihf
|
||||
elif [ "${{ matrix.platform }}" == "386" ]; then
|
||||
sudo apt install gcc-i686-linux-gnu
|
||||
elif [ "${{ matrix.platform }}" == "armv5" ]; then
|
||||
sudo apt install gcc-arm-linux-gnueabi
|
||||
elif [ "${{ matrix.platform }}" == "s390x" ]; then
|
||||
sudo apt install gcc-s390x-linux-gnu
|
||||
fi
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Build x-ui
|
||||
- name: Build 3X-UI
|
||||
run: |
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=linux
|
||||
export GOARCH=${{ matrix.platform }}
|
||||
if [ "${{ matrix.platform }}" == "arm64" ]; then
|
||||
export GOARCH=arm64
|
||||
export CC=aarch64-linux-gnu-gcc
|
||||
elif [ "${{ matrix.platform }}" == "armv7" ]; then
|
||||
export GOARCH=arm
|
||||
export GOARM=7
|
||||
export CC=arm-linux-gnueabihf-gcc
|
||||
elif [ "${{ matrix.platform }}" == "armv6" ]; then
|
||||
export GOARCH=arm
|
||||
export GOARM=6
|
||||
export CC=arm-linux-gnueabihf-gcc
|
||||
elif [ "${{ matrix.platform }}" == "386" ]; then
|
||||
export GOARCH=386
|
||||
export CC=i686-linux-gnu-gcc
|
||||
elif [ "${{ matrix.platform }}" == "armv5" ]; then
|
||||
export GOARCH=arm
|
||||
export GOARM=5
|
||||
export CC=arm-linux-gnueabi-gcc
|
||||
elif [ "${{ matrix.platform }}" == "s390x" ]; then
|
||||
export GOARCH=s390x
|
||||
export CC=s390x-linux-gnu-gcc
|
||||
fi
|
||||
go build -ldflags "-w -s" -o xui-release -v main.go
|
||||
|
||||
# Use Bootlin prebuilt cross-toolchains (musl 1.2.5 in stable series)
|
||||
case "${{ matrix.platform }}" in
|
||||
amd64) BOOTLIN_ARCH="x86-64" ;;
|
||||
arm64) BOOTLIN_ARCH="aarch64" ;;
|
||||
armv7) BOOTLIN_ARCH="armv7-eabihf"; export GOARCH=arm GOARM=7 ;;
|
||||
armv6) BOOTLIN_ARCH="armv6-eabihf"; export GOARCH=arm GOARM=6 ;;
|
||||
armv5) BOOTLIN_ARCH="armv5-eabi"; export GOARCH=arm GOARM=5 ;;
|
||||
386) BOOTLIN_ARCH="x86-i686" ;;
|
||||
s390x) BOOTLIN_ARCH="s390x-z13" ;;
|
||||
esac
|
||||
echo "Resolving Bootlin musl toolchain for arch=$BOOTLIN_ARCH (platform=${{ matrix.platform }})"
|
||||
TARBALL_BASE="https://toolchains.bootlin.com/downloads/releases/toolchains/$BOOTLIN_ARCH/tarballs/"
|
||||
TARBALL_URL=$(curl -fsSL "$TARBALL_BASE" | grep -oE "${BOOTLIN_ARCH}--musl--stable-[^\"]+\\.tar\\.xz" | sort -r | head -n1)
|
||||
[ -z "$TARBALL_URL" ] && { echo "Failed to locate Bootlin musl toolchain for arch=$BOOTLIN_ARCH" >&2; exit 1; }
|
||||
echo "Downloading: $TARBALL_URL"
|
||||
cd /tmp
|
||||
curl -fL -sS -o "$(basename "$TARBALL_URL")" "$TARBALL_BASE/$TARBALL_URL"
|
||||
tar -xf "$(basename "$TARBALL_URL")"
|
||||
TOOLCHAIN_DIR=$(find . -maxdepth 1 -type d -name "${BOOTLIN_ARCH}--musl--stable-*" | head -n1)
|
||||
export PATH="$(realpath "$TOOLCHAIN_DIR")/bin:$PATH"
|
||||
export CC=$(realpath "$(find "$TOOLCHAIN_DIR/bin" -name '*-gcc.br_real' -type f -executable | head -n1)")
|
||||
[ -z "$CC" ] && { echo "No gcc.br_real found in $TOOLCHAIN_DIR/bin" >&2; exit 1; }
|
||||
cd -
|
||||
go build -ldflags "-w -s -linkmode external -extldflags '-static'" -o xui-release -v main.go
|
||||
file xui-release
|
||||
ldd xui-release || echo "Static binary confirmed"
|
||||
|
||||
mkdir x-ui
|
||||
cp xui-release x-ui/
|
||||
cp x-ui.service x-ui/
|
||||
cp x-ui.service.debian x-ui/
|
||||
cp x-ui.service.arch x-ui/
|
||||
cp x-ui.service.rhel x-ui/
|
||||
cp x-ui.sh x-ui/
|
||||
mv x-ui/xui-release x-ui/x-ui
|
||||
mkdir x-ui/bin
|
||||
cd x-ui/bin
|
||||
|
||||
|
||||
# Download dependencies
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v25.3.6/"
|
||||
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
if [ "${{ matrix.platform }}" == "amd64" ]; then
|
||||
wget -q ${Xray_URL}Xray-linux-64.zip
|
||||
unzip Xray-linux-64.zip
|
||||
@@ -122,21 +155,135 @@ jobs:
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
mv xray xray-linux-${{ matrix.platform }}
|
||||
cd ../..
|
||||
|
||||
|
||||
- name: Package
|
||||
run: tar -zcvf x-ui-linux-${{ matrix.platform }}.tar.gz x-ui
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-linux-${{ matrix.platform }}
|
||||
path: ./x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
asset_name: x-ui-linux-${{ matrix.platform }}.tar.gz
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
|
||||
# =================================
|
||||
# Windows Build
|
||||
# =================================
|
||||
build-windows:
|
||||
name: Build for Windows
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
platform:
|
||||
- amd64
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
check-latest: true
|
||||
|
||||
# Frontend dist must be built BEFORE go build — see comment on the
|
||||
# Linux job above. This step is identical except npm runs on the
|
||||
# Windows runner here.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Build frontend bundle
|
||||
shell: pwsh
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
working-directory: frontend
|
||||
|
||||
- name: Install MSYS2
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MINGW64
|
||||
update: true
|
||||
install: >-
|
||||
mingw-w64-x86_64-gcc
|
||||
mingw-w64-x86_64-sqlite3
|
||||
mingw-w64-x86_64-pkg-config
|
||||
|
||||
- name: Build 3X-UI for Windows (CGO)
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
export PATH="/c/hostedtoolcache/windows/go/$(ls /c/hostedtoolcache/windows/go | sort -V | tail -n1)/x64/bin:$PATH"
|
||||
|
||||
export CGO_ENABLED=1
|
||||
export GOOS=windows
|
||||
export GOARCH=amd64
|
||||
export CC=x86_64-w64-mingw32-gcc
|
||||
|
||||
which go
|
||||
go version
|
||||
gcc --version
|
||||
|
||||
go build -ldflags "-w -s" -o xui-release.exe -v main.go
|
||||
|
||||
- name: Copy and download resources
|
||||
shell: pwsh
|
||||
run: |
|
||||
mkdir x-ui
|
||||
Copy-Item xui-release.exe x-ui\x-ui.exe
|
||||
mkdir x-ui\bin
|
||||
cd x-ui\bin
|
||||
|
||||
# Download Xray for Windows
|
||||
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
|
||||
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
|
||||
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
|
||||
Remove-Item "Xray-windows-64.zip"
|
||||
Remove-Item geoip.dat, geosite.dat -ErrorAction SilentlyContinue
|
||||
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat" -OutFile "geoip_IR.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat" -OutFile "geosite_IR.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
|
||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
|
||||
Rename-Item xray.exe xray-windows-amd64.exe
|
||||
cd ..
|
||||
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
|
||||
cd ..
|
||||
|
||||
- name: Package to Zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path .\x-ui -DestinationPath "x-ui-windows-amd64.zip"
|
||||
|
||||
- name: Upload files to Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: x-ui-windows-amd64
|
||||
path: ./x-ui-windows-amd64.zip
|
||||
|
||||
- name: Upload files to GH release
|
||||
uses: svenstaro/upload-release-action@v2
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: ${{ github.ref_name }}
|
||||
file: x-ui-windows-amd64.zip
|
||||
asset_name: x-ui-windows-amd64.zip
|
||||
overwrite: true
|
||||
prerelease: true
|
||||
|
||||
+35
-8
@@ -1,16 +1,43 @@
|
||||
.idea
|
||||
.vscode
|
||||
.cache
|
||||
# Ignore editor and IDE settings
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
.cache/
|
||||
.sync*
|
||||
*.tar.gz
|
||||
|
||||
# Ignore log files
|
||||
*.log
|
||||
access.log
|
||||
error.log
|
||||
tmp
|
||||
main
|
||||
|
||||
# Ignore temporary files
|
||||
tmp/
|
||||
*.tar.gz
|
||||
|
||||
# Ignore build and distribution directories
|
||||
backup/
|
||||
bin/
|
||||
dist/
|
||||
release/
|
||||
node_modules/
|
||||
|
||||
# Ignore compiled binaries
|
||||
main
|
||||
|
||||
# Ignore script and executable files
|
||||
/release.sh
|
||||
/x-ui
|
||||
|
||||
# Ignore OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Ignore Go build files
|
||||
*.exe
|
||||
x-ui.db
|
||||
x-ui.db-shm
|
||||
x-ui.db-wal
|
||||
|
||||
# Ignore Docker specific files
|
||||
docker-compose.override.yml
|
||||
|
||||
# Ignore .env (Environment Variables) file
|
||||
.env
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "vscode://schemas/launch",
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run 3x-ui (Debug)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Run 3x-ui (Debug, custom env)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
// Set to true to serve assets/templates directly from disk for development
|
||||
"XUI_DEBUG": "true",
|
||||
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
|
||||
// "XUI_DB_FOLDER": "${workspaceFolder}",
|
||||
// Example: override log level (debug|info|notice|warn|error)
|
||||
// "XUI_LOG_LEVEL": "debug"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+75
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "go: build",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"build",
|
||||
"-o",
|
||||
"bin/3x-ui.exe",
|
||||
"./main.go"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
],
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "go: run",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"run",
|
||||
"./main.go"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "go: test",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"test",
|
||||
"./..."
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
],
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "go: vet",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": [
|
||||
"vet",
|
||||
"./..."
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": [
|
||||
"$go"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
## Local Development Setup
|
||||
|
||||
- Create a directory named `x-ui` in the project root
|
||||
- Rename `.env.example` to `.env `
|
||||
- Run `main.go`
|
||||
+56
-2
@@ -1,7 +1,61 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Start fail2ban
|
||||
[ $X_UI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
|
||||
# Start fail2ban with the 3x-ipl jail
|
||||
if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
|
||||
LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
|
||||
mkdir -p "$LOG_FOLDER"
|
||||
touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
|
||||
|
||||
mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
|
||||
|
||||
cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
|
||||
[3x-ipl]
|
||||
enabled=true
|
||||
backend=auto
|
||||
filter=3x-ipl
|
||||
action=3x-ipl
|
||||
logpath=$LOG_FOLDER/3xipl.log
|
||||
maxretry=1
|
||||
findtime=32
|
||||
bantime=30m
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
|
||||
[Definition]
|
||||
datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
|
||||
failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
|
||||
ignoreregex =
|
||||
EOF
|
||||
|
||||
cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
|
||||
[INCLUDES]
|
||||
before = iptables-allports.conf
|
||||
|
||||
[Definition]
|
||||
actionstart = <iptables> -N f2b-<name>
|
||||
<iptables> -A f2b-<name> -j <returntype>
|
||||
<iptables> -I <chain> -p <protocol> -j f2b-<name>
|
||||
|
||||
actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
|
||||
<actionflush>
|
||||
<iptables> -X f2b-<name>
|
||||
|
||||
actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
|
||||
|
||||
actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
|
||||
echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
|
||||
|
||||
[Init]
|
||||
name = default
|
||||
protocol = tcp
|
||||
chain = INPUT
|
||||
EOF
|
||||
|
||||
fail2ban-client -x start
|
||||
fi
|
||||
|
||||
# Run x-ui
|
||||
exec /app/x-ui
|
||||
|
||||
+8
-8
@@ -27,14 +27,14 @@ case $1 in
|
||||
esac
|
||||
mkdir -p build/bin
|
||||
cd build/bin
|
||||
wget -q "https://github.com/XTLS/Xray-core/releases/download/v25.3.6/Xray-linux-${ARCH}.zip"
|
||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
|
||||
unzip "Xray-linux-${ARCH}.zip"
|
||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||
mv xray "xray-linux-${FNAME}"
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
cd ../../
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
|
||||
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||
cd ../../
|
||||
|
||||
+20
-4
@@ -1,17 +1,29 @@
|
||||
# ========================================================
|
||||
# Stage: Frontend (Vite)
|
||||
# ========================================================
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
COPY web/translation /src/web/translation
|
||||
RUN npm run build
|
||||
|
||||
# ========================================================
|
||||
# Stage: Builder
|
||||
# ========================================================
|
||||
FROM golang:1.24-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /app
|
||||
ARG TARGETARCH
|
||||
|
||||
RUN apk --no-cache --update add \
|
||||
build-base \
|
||||
gcc \
|
||||
wget \
|
||||
curl \
|
||||
unzip
|
||||
|
||||
COPY . .
|
||||
COPY --from=frontend /src/web/dist ./web/dist
|
||||
|
||||
ENV CGO_ENABLED=1
|
||||
ENV CGO_CFLAGS="-D_LARGEFILE64_SOURCE"
|
||||
@@ -29,11 +41,14 @@ RUN apk add --no-cache --update \
|
||||
ca-certificates \
|
||||
tzdata \
|
||||
fail2ban \
|
||||
bash
|
||||
bash \
|
||||
curl \
|
||||
openssl
|
||||
|
||||
COPY --from=builder /app/build/ /app/
|
||||
COPY --from=builder /app/DockerEntrypoint.sh /app/
|
||||
COPY --from=builder /app/x-ui.sh /usr/bin/x-ui
|
||||
COPY --from=builder /app/web/translation /app/web/translation
|
||||
|
||||
|
||||
# Configure fail2ban
|
||||
@@ -48,7 +63,8 @@ RUN chmod +x \
|
||||
/app/x-ui \
|
||||
/usr/bin/x-ui
|
||||
|
||||
ENV X_UI_ENABLE_FAIL2BAN="true"
|
||||
ENV XUI_ENABLE_FAIL2BAN="true"
|
||||
EXPOSE 2053
|
||||
VOLUME [ "/etc/x-ui" ]
|
||||
CMD [ "./x-ui" ]
|
||||
ENTRYPOINT [ "/app/DockerEntrypoint.sh" ]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
**3X-UI** — لوحة تحكم متقدمة مفتوحة المصدر تعتمد على الويب مصممة لإدارة خادم Xray-core. توفر واجهة سهلة الاستخدام لتكوين ومراقبة بروتوكولات VPN والوكيل المختلفة.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> هذا المشروع مخصص للاستخدام الشخصي والاتصال فقط، يرجى عدم استخدامه لأغراض غير قانونية، يرجى عدم استخدامه في بيئة الإنتاج.
|
||||
|
||||
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
|
||||
|
||||
## البدء السريع
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
للحصول على الوثائق الكاملة، يرجى زيارة [ويكي المشروع](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## شكر خاص إلى
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## الاعتراف
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
|
||||
|
||||
## دعم المشروع
|
||||
|
||||
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## النجوم عبر الزمن
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
+30
-560
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -7,581 +7,51 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
**Un Panel Web Avanzado • Construido sobre Xray Core**
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
> **Descargo de responsabilidad:** Este proyecto es solo para aprendizaje personal y comunicación, por favor no lo uses con fines ilegales, por favor no lo uses en un entorno de producción
|
||||
**3X-UI** — panel de control avanzado basado en web de código abierto diseñado para gestionar el servidor Xray-core. Ofrece una interfaz fácil de usar para configurar y monitorear varios protocolos VPN y proxy.
|
||||
|
||||
**Si este proyecto te es útil, podrías considerar darle una**:star2:
|
||||
> [!IMPORTANT]
|
||||
> Este proyecto es solo para uso personal y comunicación, por favor no lo use para fines ilegales, por favor no lo use en un entorno de producción.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
|
||||
<img src="./media/buymeacoffe.png" alt="Image">
|
||||
</a>
|
||||
</p>
|
||||
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
|
||||
|
||||
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
|
||||
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
|
||||
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
|
||||
|
||||
## Instalar y Actualizar
|
||||
## Inicio Rápido
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
## Instalar versión antigua (no recomendamos)
|
||||
Para documentación completa, visita la [Wiki del proyecto](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
Para instalar la versión deseada, utiliza el siguiente comando de instalación. Por ejemplo, ver `v1.7.9`:
|
||||
|
||||
```
|
||||
VERSION=v1.7.9 && <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
|
||||
```
|
||||
|
||||
## Certificado SSL
|
||||
|
||||
<details>
|
||||
<summary>Haga clic para ver los detalles del certificado SSL</summary>
|
||||
|
||||
### ACME
|
||||
|
||||
Para gestionar certificados SSL utilizando ACME:
|
||||
|
||||
1. Asegúrate de que tu dominio esté correctamente resuelto al servidor.
|
||||
2. Ejecuta el comando `x-ui` en la terminal y elige `Gestión de Certificados SSL`.
|
||||
3. Se te presentarán las siguientes opciones:
|
||||
|
||||
- **Get SSL:** Obtener certificados SSL.
|
||||
- **Revoke:** Revocar certificados SSL existentes.
|
||||
- **Force Renew:** Forzar la renovación de certificados SSL.
|
||||
- **Show Existing Domains:** Mostrar todos los certificados de dominio disponibles en el servidor.
|
||||
- **Set Certificate Paths for the Panel:** Especificar el certificado para tu dominio que será utilizado por el panel.
|
||||
|
||||
### Certbot
|
||||
|
||||
Para instalar y usar Certbot:
|
||||
|
||||
```sh
|
||||
apt-get install certbot -y
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
|
||||
certbot renew --dry-run
|
||||
```
|
||||
|
||||
### Cloudflare
|
||||
|
||||
El script de gestión incluye una aplicación de certificado SSL integrada para Cloudflare. Para usar este script para solicitar un certificado, necesitas lo siguiente:
|
||||
|
||||
- Correo electrónico registrado en Cloudflare
|
||||
- Clave API Global de Cloudflare
|
||||
- El nombre de dominio debe estar resuelto al servidor actual a través de Cloudflare
|
||||
|
||||
**Cómo obtener la Clave API Global de Cloudflare:**
|
||||
|
||||
1. Ejecuta el comando `x-ui` en la terminal y elige `Certificado SSL de Cloudflare`.
|
||||
2. Visita el enlace: [Tokens de API de Cloudflare](https://dash.cloudflare.com/profile/api-tokens).
|
||||
3. Haz clic en "Ver Clave API Global" (consulta la captura de pantalla a continuación):
|
||||

|
||||
4. Es posible que necesites volver a autenticar tu cuenta. Después de eso, se mostrará la Clave API (consulta la captura de pantalla a continuación):
|
||||

|
||||
|
||||
Al utilizarlo, simplemente ingresa tu `nombre de dominio`, `correo electrónico` y `CLAVE API`. El diagrama es el siguiente:
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Instalación y Actualización Manual
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para más detalles de la instalación manual</summary>
|
||||
|
||||
#### Uso
|
||||
|
||||
1. Para descargar la última versión del paquete comprimido directamente en tu servidor, ejecuta el siguiente comando:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
|
||||
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
```
|
||||
|
||||
2. Una vez que se haya descargado el paquete comprimido, ejecuta los siguientes comandos para instalar o actualizar x-ui:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
cd /root/
|
||||
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
|
||||
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
|
||||
cp x-ui/x-ui.sh /usr/bin/x-ui
|
||||
cp -f x-ui/x-ui.service /etc/systemd/system/
|
||||
mv x-ui/ /usr/local/
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Instalar con Docker
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para más detalles del Docker</summary>
|
||||
|
||||
#### Uso
|
||||
|
||||
1. Instala Docker:
|
||||
|
||||
```sh
|
||||
bash <(curl -sSL https://get.docker.com)
|
||||
```
|
||||
|
||||
2. Clona el Repositorio del Proyecto:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MHSanaei/3x-ui.git
|
||||
cd 3x-ui
|
||||
```
|
||||
|
||||
3. Inicia el Servicio
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
O tambien
|
||||
|
||||
```sh
|
||||
docker run -itd \
|
||||
-e XRAY_VMESS_AEAD_FORCED=false \
|
||||
-v $PWD/db/:/etc/x-ui/ \
|
||||
-v $PWD/cert/:/root/cert/ \
|
||||
--network=host \
|
||||
--restart=unless-stopped \
|
||||
--name 3x-ui \
|
||||
ghcr.io/mhsanaei/3x-ui:latest
|
||||
```
|
||||
|
||||
actualizar a la última versión
|
||||
|
||||
```sh
|
||||
cd 3x-ui
|
||||
docker compose down
|
||||
docker compose pull 3x-ui
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
eliminar 3x-ui de docker
|
||||
|
||||
```sh
|
||||
docker stop 3x-ui
|
||||
docker rm 3x-ui
|
||||
cd --
|
||||
rm -r 3x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Configuración de Nginx
|
||||
<details>
|
||||
<summary>Haga clic aquí para configurar el proxy inverso</summary>
|
||||
|
||||
#### Proxy inverso Nginx
|
||||
```nginx
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
|
||||
#### Nginx sub-path
|
||||
- EAsegúrese de que la "Ruta Raíz de la URL del Panel" en la configuración del panel `/sub` es la misma.
|
||||
- El `url` en la configuración del panel debe terminar con `/`.
|
||||
|
||||
```nginx
|
||||
location /sub {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## SO Recomendados
|
||||
|
||||
- Ubuntu 20.04+
|
||||
- Debian 11+
|
||||
- CentOS 8+
|
||||
- OpenEuler 22.03+
|
||||
- Fedora 36+
|
||||
- Arch Linux
|
||||
- Parch Linux
|
||||
- Manjaro
|
||||
- Armbian
|
||||
- AlmaLinux 8.0+
|
||||
- Rocky Linux 8+
|
||||
- Oracle Linux 8+
|
||||
- OpenSUSE Tubleweed
|
||||
- Amazon Linux 2023
|
||||
- Windows x64
|
||||
|
||||
## Arquitecturas y Dispositivos Compatibles
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para detalles de arquitecturas y dispositivos compatibles</summary>
|
||||
|
||||
Nuestra plataforma ofrece compatibilidad con una amplia gama de arquitecturas y dispositivos, garantizando flexibilidad en diversos entornos informáticos. A continuación se presentan las principales arquitecturas que admitimos:
|
||||
|
||||
- **amd64**: Esta arquitectura predominante es la estándar para computadoras personales y servidores, y admite la mayoría de los sistemas operativos modernos sin problemas.
|
||||
|
||||
- **x86 / i386**: Ampliamente adoptada en computadoras de escritorio y portátiles, esta arquitectura cuenta con un amplio soporte de numerosos sistemas operativos y aplicaciones, incluidos, entre otros, Windows, macOS y sistemas Linux.
|
||||
|
||||
- **armv8 / arm64 / aarch64**: Diseñada para dispositivos móviles y embebidos contemporáneos, como teléfonos inteligentes y tabletas, esta arquitectura está ejemplificada por dispositivos como Raspberry Pi 4, Raspberry Pi 3, Raspberry Pi Zero 2/Zero 2 W, Orange Pi 3 LTS, entre otros.
|
||||
|
||||
- **armv7 / arm / arm32**: Sirve como arquitectura para dispositivos móviles y embebidos más antiguos, y sigue siendo ampliamente utilizada en dispositivos como Orange Pi Zero LTS, Orange Pi PC Plus, Raspberry Pi 2, entre otros.
|
||||
|
||||
- **armv6 / arm / arm32**: Orientada a dispositivos embebidos muy antiguos, esta arquitectura, aunque menos común, todavía se utiliza. Dispositivos como Raspberry Pi 1, Raspberry Pi Zero/Zero W, dependen de esta arquitectura.
|
||||
|
||||
- **armv5 / arm / arm32**: Una arquitectura más antigua asociada principalmente con sistemas embebidos tempranos, es menos común hoy en día pero aún puede encontrarse en dispositivos heredados como versiones antiguas de Raspberry Pi y algunos teléfonos inteligentes más antiguos.
|
||||
</details>
|
||||
|
||||
## Idiomas
|
||||
|
||||
- English (inglés)
|
||||
- Persian (persa)
|
||||
- Traditional Chinese (chino tradicional)
|
||||
- Simplified Chinese (chino simplificado)
|
||||
- Japanese (japonés)
|
||||
- Russian (ruso)
|
||||
- Vietnamese (vietnamita)
|
||||
- Spanish (español)
|
||||
- Indonesian (indonesio)
|
||||
- Ukrainian (ucraniano)
|
||||
- Turkish (turco)
|
||||
- Português (Brazil) (portugués (Brasil))
|
||||
|
||||
|
||||
## Características
|
||||
|
||||
- Monitoreo del Estado del Sistema
|
||||
- Búsqueda dentro de todas las reglas de entrada y clientes
|
||||
- Tema Oscuro/Claro
|
||||
- Soporta multiusuario y multiprotocolo
|
||||
- Soporta protocolos, incluyendo VMess, VLESS, Trojan, Shadowsocks, Dokodemo-door, Socks, HTTP, wireguard
|
||||
- Soporta Protocolos nativos XTLS, incluyendo RPRX-Direct, Visión, REALITY
|
||||
- Estadísticas de tráfico, límite de tráfico, límite de tiempo de vencimiento
|
||||
- Plantillas de configuración de Xray personalizables
|
||||
- Soporta acceso HTTPS al panel (dominio proporcionado por uno mismo + certificado SSL)
|
||||
- Soporta la solicitud y renovación automática de certificados SSL con un clic
|
||||
- Para elementos de configuración más avanzados, consulta el panel
|
||||
- Corrige rutas de API (la configuración del usuario se creará con la API)
|
||||
- Soporta cambiar las configuraciones por diferentes elementos proporcionados en el panel.
|
||||
- Soporta exportar/importar base de datos desde el panel
|
||||
|
||||
|
||||
## Configuración Predeterminada del Panel
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para ver los detalles de la configuración predeterminada</summary>
|
||||
|
||||
### Nombre de usuario, Contraseña, Puerto y Ruta Base Web
|
||||
|
||||
Si elige no modificar estas configuraciones, se generarán aleatoriamente (esto no se aplica a Docker).
|
||||
|
||||
**Configuraciones predeterminadas para Docker:**
|
||||
- **Nombre de usuario:** admin
|
||||
- **Contraseña:** admin
|
||||
- **Puerto:** 2053
|
||||
|
||||
### Gestión de la Base de Datos:
|
||||
|
||||
Puedes realizar copias de seguridad y restauraciones de la base de datos directamente desde el panel.
|
||||
|
||||
- **Ruta de la Base de Datos:**
|
||||
- `/etc/x-ui/x-ui.db`
|
||||
|
||||
### Ruta Base Web
|
||||
|
||||
1. **Restablecer la Ruta Base Web:**
|
||||
- Abre tu terminal.
|
||||
- Ejecuta el comando `x-ui`.
|
||||
- Selecciona la opción `Restablecer la Ruta Base Web`.
|
||||
|
||||
2. **Generar o Personalizar la Ruta:**
|
||||
- La ruta se generará aleatoriamente, o puedes ingresar una ruta personalizada.
|
||||
|
||||
3. **Ver Configuración Actual:**
|
||||
- Para ver tu configuración actual, utiliza el comando `x-ui settings` en el terminal o selecciona `Ver Configuración Actual` en `x-ui`.
|
||||
|
||||
### Recomendación de Seguridad:
|
||||
- Para mayor seguridad, utiliza una palabra larga y aleatoria en la estructura de tu URL.
|
||||
|
||||
**Ejemplos:**
|
||||
- `http://ip:port/*webbasepath*/panel`
|
||||
- `http://domain:port/*webbasepath*/panel`
|
||||
|
||||
</details>
|
||||
|
||||
## Configuración de WARP
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para ver los detalles de la configuración de WARP</summary>
|
||||
|
||||
#### Uso
|
||||
|
||||
**Para versiones `v2.1.0` y posteriores:**
|
||||
|
||||
WARP está integrado, no se requiere instalación adicional. Simplemente habilita la configuración necesaria en el panel.
|
||||
|
||||
</details>
|
||||
|
||||
## Límite de IP
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para ver los detalles del límite de IP</summary>
|
||||
|
||||
#### Uso
|
||||
|
||||
**Nota:** El Límite de IP no funcionará correctamente cuando uses Túnel IP.
|
||||
|
||||
- **Para versiones hasta `v1.6.1`:**
|
||||
- El límite de IP está integrado en el panel.
|
||||
|
||||
**Para versiones `v1.7.0` y posteriores:**
|
||||
|
||||
Para habilitar la funcionalidad de límite de IP, necesitas instalar `fail2ban` y los archivos requeridos siguiendo estos pasos:
|
||||
|
||||
1. Ejecuta el comando `x-ui` en el terminal, luego elige `Gestión de Límite de IP`.
|
||||
2. Verás las siguientes opciones:
|
||||
|
||||
- **Cambiar la Duración del Bloqueo:** Ajustar la duración de los bloqueos.
|
||||
- **Desbloquear a Todos:** Levantar todos los bloqueos actuales.
|
||||
- **Revisar los Registros:** Revisar los registros.
|
||||
- **Estado de Fail2ban:** Verificar el estado de `fail2ban`.
|
||||
- **Reiniciar Fail2ban:** Reiniciar el servicio `fail2ban`.
|
||||
- **Desinstalar Fail2ban:** Desinstalar Fail2ban con la configuración.
|
||||
|
||||
3. Agrega una ruta para el registro de acceso en el panel configurando `Xray Configs/log/Access log` a `./access.log`, luego guarda y reinicia Xray.
|
||||
|
||||
- **Para versiones anteriores a `v2.1.3`:**
|
||||
- Necesitas configurar manualmente la ruta del registro de acceso en tu configuración de Xray:
|
||||
|
||||
```sh
|
||||
"log": {
|
||||
"access": "./access.log",
|
||||
"dnsLog": false,
|
||||
"loglevel": "warning"
|
||||
},
|
||||
```
|
||||
|
||||
- **Para versiones `v2.1.3` y posteriores:**
|
||||
- Hay una opción para configurar `access.log` directamente desde el panel.
|
||||
|
||||
</details>
|
||||
|
||||
## Bot de Telegram
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para más detalles del bot de Telegram</summary>
|
||||
|
||||
#### Uso
|
||||
|
||||
El panel web admite tráfico diario, inicio de sesión en el panel, copia de seguridad de la base de datos, estado del sistema, información del cliente y otras notificaciones y funciones a través del Bot de Telegram. Para usar el bot, debes establecer los parámetros relacionados con el bot en el panel, que incluyen:
|
||||
|
||||
- Token de Telegram
|
||||
- ID de chat de administrador(es)
|
||||
- Hora de Notificación (en sintaxis cron)
|
||||
- Notificación de Fecha de Caducidad
|
||||
- Notificación de Capacidad de Tráfico
|
||||
- Copia de seguridad de la base de datos
|
||||
- Notificación de Carga de CPU
|
||||
|
||||
|
||||
**Sintaxis de referencia:**
|
||||
|
||||
- `30 \* \* \* \* \*` - Notifica a los 30s de cada punto
|
||||
- `0 \*/10 \* \* \* \*` - Notifica en el primer segundo de cada 10 minutos
|
||||
- `@hourly` - Notificación por hora
|
||||
- `@daily` - Notificación diaria (00:00 de la mañana)
|
||||
- `@weekly` - Notificación semanal
|
||||
- `@every 8h` - Notifica cada 8 horas
|
||||
|
||||
### Funcionalidades del Bot de Telegram
|
||||
|
||||
- Reporte periódico
|
||||
- Notificación de inicio de sesión
|
||||
- Notificación de umbral de CPU
|
||||
- Umbral de Notificación para Fecha de Caducidad y Tráfico para informar con anticipación
|
||||
- Soporte para menú de reporte de cliente si el nombre de usuario de Telegram del cliente se agrega a las configuraciones de usuario
|
||||
- Soporte para reporte de tráfico de Telegram buscado con UUID (VMESS/VLESS) o Contraseña (TROJAN) - anónimamente
|
||||
- Bot basado en menú
|
||||
- Buscar cliente por correo electrónico (solo administrador)
|
||||
- Ver todas las Entradas
|
||||
- Ver estado del servidor
|
||||
- Ver clientes agotados
|
||||
- Recibir copia de seguridad bajo demanda y en informes periódicos
|
||||
- Bot multilingüe
|
||||
|
||||
### Configuración del Bot de Telegram
|
||||
|
||||
- Inicia [Botfather](https://t.me/BotFather) en tu cuenta de Telegram:
|
||||

|
||||
|
||||
- Crea un nuevo bot usando el comando /newbot: Te hará 2 preguntas, Un nombre y un nombre de usuario para tu bot. Ten en cuenta que el nombre de usuario debe terminar con la palabra "bot".
|
||||

|
||||
|
||||
- Inicia el bot que acabas de crear. Puedes encontrar el enlace a tu bot aquí.
|
||||

|
||||
|
||||
- Ingresa a tu panel y configura los ajustes del bot de Telegram como se muestra a continuación:
|
||||

|
||||
|
||||
Ingresa el token de tu bot en el campo de entrada número 3.
|
||||
Ingresa el ID de chat de usuario en el campo de entrada número 4. Las cuentas de Telegram con esta ID serán los administradores del bot. (Puedes ingresar más de uno, solo sepáralos con ,)
|
||||
|
||||
- ¿Cómo obtener el ID de chat de Telegram? Usa este [bot](https://t.me/useridinfobot), Inicia el bot y te dará el ID de chat del usuario de Telegram.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Rutas de API
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para más detalles de las rutas de API</summary>
|
||||
|
||||
#### Uso
|
||||
|
||||
- [Documentación de API](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
|
||||
- `/login` con `POST` datos de usuario: `{username: '', password: ''}` para iniciar sesión
|
||||
- `/panel/api/inbounds` base para las siguientes acciones:
|
||||
|
||||
| Método | Ruta | Acción |
|
||||
| :----: | ---------------------------------- | --------------------------------------------------------- |
|
||||
| `GET` | `"/list"` | Obtener todas los Entradas |
|
||||
| `GET` | `"/get/:id"` | Obtener Entrada con inbound.id |
|
||||
| `GET` | `"/getClientTraffics/:email"` | Obtener Tráficos del Cliente con email |
|
||||
| `GET` | `"/createbackup"` | El bot de Telegram envía copia de seguridad a los admins |
|
||||
| `POST` | `"/add"` | Agregar Entrada |
|
||||
| `POST` | `"/del/:id"` | Eliminar Entrada |
|
||||
| `POST` | `"/update/:id"` | Actualizar Entrada |
|
||||
| `POST` | `"/clientIps/:email"` | Dirección IP del Cliente |
|
||||
| `POST` | `"/clearClientIps/:email"` | Borrar Dirección IP del Cliente |
|
||||
| `POST` | `"/addClient"` | Agregar Cliente a la Entrada |
|
||||
| `POST` | `"/:id/delClient/:clientId"` | Eliminar Cliente por clientId\* |
|
||||
| `POST` | `"/updateClient/:clientId"` | Actualizar Cliente por clientId\* |
|
||||
| `POST` | `"/:id/resetClientTraffic/:email"` | Restablecer Tráfico del Cliente |
|
||||
| `POST` | `"/resetAllTraffics"` | Restablecer tráfico de todos las Entradas |
|
||||
| `POST` | `"/resetAllClientTraffics/:id"` | Restablecer tráfico de todos los clientes en una Entrada |
|
||||
| `POST` | `"/delDepletedClients/:id"` | Eliminar clientes agotados de la entrada (-1: todos) |
|
||||
| `POST` | `"/onlines"` | Obtener usuarios en línea (lista de correos electrónicos) |
|
||||
|
||||
\*- El campo `clientId` debe llenarse por:
|
||||
|
||||
- `client.id` para VMESS y VLESS
|
||||
- `client.password` para TROJAN
|
||||
- `client.email` para Shadowsocks
|
||||
|
||||
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
|
||||
</details>
|
||||
|
||||
## Variables de Entorno
|
||||
|
||||
<details>
|
||||
<summary>Haz clic para más detalles de las variables de entorno</summary>
|
||||
|
||||
#### Uso
|
||||
|
||||
| Variable | Tipo | Predeterminado |
|
||||
| -------------- | :--------------------------------------------: | :------------- |
|
||||
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
|
||||
| XUI_DEBUG | `boolean` | `false` |
|
||||
| XUI_BIN_FOLDER | `string` | `"bin"` |
|
||||
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
|
||||
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
|
||||
|
||||
Ejemplo:
|
||||
|
||||
```sh
|
||||
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Vista previa
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||
<img alt="3x-ui" src="./media/01-overview-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
|
||||
<img alt="3x-ui" src="./media/02-inbounds-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
|
||||
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
|
||||
<img alt="3x-ui" src="./media/04-add-client-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
|
||||
<img alt="3x-ui" src="./media/05-settings-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
|
||||
<img alt="3x-ui" src="./media/06-configs-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
|
||||
<img alt="3x-ui" src="./media/07-bot-light.png">
|
||||
</picture>
|
||||
|
||||
## Un agradecimiento especial a
|
||||
## Un Agradecimiento Especial a
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## Reconocimientos
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas de v2ray/xray y v2ray/xray-clients con dominios iraníes integrados y un enfoque en seguridad y bloqueo de anuncios._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento de V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueados en Rusia._
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
|
||||
|
||||
## Estrellas a lo largo del tiempo
|
||||
## Apoyar el Proyecto
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
**Si este proyecto te es útil, puedes darle una**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## Estrellas a lo Largo del Tiempo
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+31
-501
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -7,521 +7,51 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
**یک پنل وب پیشرفته • ساخته شده بر پایه Xray Core**
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
> **سلب مسئولیت:** این پروژه صرفاً برای اهداف آموزشی و تحقیقاتی است. استفاده از آن برای مقاصد غیرقانونی یا در محیطهای عملیاتی ممنوع است.
|
||||
**3X-UI** — یک پنل کنترل پیشرفته مبتنی بر وب با کد باز که برای مدیریت سرور Xray-core طراحی شده است. این پنل یک رابط کاربری آسان برای پیکربندی و نظارت بر پروتکلهای مختلف VPN و پراکسی ارائه میدهد.
|
||||
|
||||
**اگر این پروژه برای شما مفید بوده، میتوانید با دادن یک**:star2: از آن حمایت کنید.
|
||||
> [!IMPORTANT]
|
||||
> این پروژه فقط برای استفاده شخصی و ارتباطات است، لطفاً از آن برای اهداف غیرقانونی استفاده نکنید، لطفاً از آن در محیط تولید استفاده نکنید.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
|
||||
<img src="./media/buymeacoffe.png" alt="Image">
|
||||
</a>
|
||||
</p>
|
||||
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گستردهتر از پروتکلها و ویژگیهای اضافی را ارائه میدهد.
|
||||
|
||||
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
|
||||
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
|
||||
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
|
||||
|
||||
## نصب و ارتقا
|
||||
## شروع سریع
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
## نصب نسخههای قدیمی (توصیه نمیشود)
|
||||
برای مستندات کامل، لطفاً به [ویکی پروژه](https://github.com/MHSanaei/3x-ui/wiki) مراجعه کنید.
|
||||
|
||||
برای نصب نسخه خاصی از دستور زیر استفاده کنید. مثال برای نسخه `v1.7.9`:
|
||||
|
||||
```
|
||||
VERSION=v1.7.9 && bash <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
|
||||
```
|
||||
|
||||
## گواهی SSL
|
||||
|
||||
<details>
|
||||
<summary>جزئیات گواهی SSL</summary>
|
||||
|
||||
### ACME
|
||||
|
||||
برای مدیریت گواهیهای SSL با استفاده از ACME:
|
||||
|
||||
1. اطمینان حاصل کنید دامنه شما به درستی به سرور متصل است.
|
||||
2. دستور `x-ui` را در ترمینال اجرا کرده و گزینه `مدیریت گواهی SSL` را انتخاب کنید.
|
||||
3. گزینههای زیر نمایش داده میشوند:
|
||||
|
||||
- **دریافت SSL:** دریافت گواهی SSL
|
||||
- **لغو:** لغو گواهیهای موجود
|
||||
- **تمدید اجباری:** تمدید اجباری گواهیها
|
||||
- **نمایش دامنههای موجود:** نمایش تمام دامنههای دارای گواهی
|
||||
- **تنظیم مسیر گواهی برای پنل:** تنظیم مسیر گواهی برای دامنه شما
|
||||
|
||||
### Certbot
|
||||
|
||||
نصب و استفاده از Certbot:
|
||||
|
||||
```sh
|
||||
apt-get install certbot -y
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
|
||||
certbot renew --dry-run
|
||||
```
|
||||
|
||||
### Cloudflare
|
||||
|
||||
اسکریپت داخلی برای دریافت گواهی SSL از Cloudflare. نیازمند:
|
||||
|
||||
- ایمیل ثبتشده در Cloudflare
|
||||
- کلید API جهانی Cloudflare
|
||||
- دامنه باید از طریق Cloudflare به سرور متصل باشد
|
||||
|
||||
**دریافت کلید API جهانی Cloudflare:**
|
||||
|
||||
1. دستور `x-ui` را اجرا و گزینه `گواهی SSL کلادفلر` را انتخاب کنید.
|
||||
2. به لینک [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens) مراجعه کنید.
|
||||
3. روی "View Global API Key" کلیک کنید:
|
||||

|
||||
4. پس از احراز هویت، کلید API نمایش داده میشود:
|
||||

|
||||
|
||||
در هنگام استفاده، نام دامنه، ایمیل و کلید API را وارد کنید:
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## نصب دستی و ارتقا
|
||||
|
||||
<details>
|
||||
<summary>جزئیات نصب دستی</summary>
|
||||
|
||||
#### استفاده
|
||||
|
||||
1. دریافت آخرین نسخه از سرور:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
```
|
||||
|
||||
2. نصب یا ارتقا:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
cd /root/
|
||||
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
|
||||
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
|
||||
cp x-ui/x-ui.sh /usr/bin/x-ui
|
||||
cp -f x-ui/x-ui.service /etc/systemd/system/
|
||||
mv x-ui/ /usr/local/
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## نصب با Docker
|
||||
|
||||
<details>
|
||||
<summary>جزئیات Docker</summary>
|
||||
|
||||
#### استفاده
|
||||
|
||||
1. **نصب Docker:**
|
||||
|
||||
```sh
|
||||
bash <(curl -sSL https://get.docker.com)
|
||||
```
|
||||
|
||||
2. **کلون پروژه:**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MHSanaei/3x-ui.git
|
||||
cd 3x-ui
|
||||
```
|
||||
|
||||
3. **راهاندازی سرویس:**
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
یا
|
||||
|
||||
```sh
|
||||
docker run -itd \
|
||||
-e XRAY_VMESS_AEAD_FORCED=false \
|
||||
-v $PWD/db/:/etc/x-ui/ \
|
||||
-v $PWD/cert/:/root/cert/ \
|
||||
--network=host \
|
||||
--restart=unless-stopped \
|
||||
--name 3x-ui \
|
||||
ghcr.io/mhsanaei/3x-ui:latest
|
||||
```
|
||||
|
||||
4. **بهروزرسانی:**
|
||||
|
||||
```sh
|
||||
cd 3x-ui
|
||||
docker compose down
|
||||
docker compose pull 3x-ui
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **حذف:**
|
||||
|
||||
```sh
|
||||
docker stop 3x-ui
|
||||
docker rm 3x-ui
|
||||
cd --
|
||||
rm -r 3x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## تنظیمات Nginx
|
||||
<details>
|
||||
<summary>پیکربندی Reverse Proxy</summary>
|
||||
|
||||
#### Nginx Reverse Proxy
|
||||
```nginx
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
|
||||
#### مسیر فرعی در Nginx
|
||||
- اطمینان حاصل کنید "URI Path" در تنظیمات پنل یکسان باشد.
|
||||
- `url` در تنظیمات پنل باید با `/` پایان یابد.
|
||||
|
||||
```nginx
|
||||
location /sub {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## سیستمعاملهای توصیه شده
|
||||
|
||||
- Ubuntu 20.04+
|
||||
- Debian 11+
|
||||
- CentOS 8+
|
||||
- OpenEuler 22.03+
|
||||
- Fedora 36+
|
||||
- Arch Linux
|
||||
- Parch Linux
|
||||
- Manjaro
|
||||
- Armbian
|
||||
- AlmaLinux 8.0+
|
||||
- Rocky Linux 8+
|
||||
- Oracle Linux 8+
|
||||
- OpenSUSE Tubleweed
|
||||
- Amazon Linux 2023
|
||||
- Windows x64
|
||||
|
||||
## معماریها و دستگاههای پشتیبانی شده
|
||||
|
||||
<details>
|
||||
<summary>جزئیات معماریها و دستگاهها</summary>
|
||||
|
||||
- **amd64**: معماری استاندارد برای کامپیوترهای شخصی و سرورها
|
||||
- **x86 / i386**: سیستمهای دسکتاپ و لپتاپ
|
||||
- **armv8 / arm64 / aarch64**: دستگاههای موبایل و embedded مانند Raspberry Pi 4
|
||||
- **armv7 / arm / arm32**: دستگاههای قدیمی مانند Orange Pi Zero
|
||||
- **armv6 / arm / arm32**: دستگاههای بسیار قدیمی مانند Raspberry Pi 1
|
||||
- **armv5 / arm / arm32**: سیستمهای embedded قدیمی
|
||||
- **s390x**: کامپیوترهای IBM mainframe
|
||||
</details>
|
||||
|
||||
## زبانهای پشتیبانی شده
|
||||
|
||||
- انگلیسی
|
||||
- فارسی
|
||||
- چینی سنتی
|
||||
- چینی سادهشده
|
||||
- ژاپنی
|
||||
- روسی
|
||||
- ویتنامی
|
||||
- اسپانیایی
|
||||
- اندونزیایی
|
||||
- اوکراینی
|
||||
- ترکی
|
||||
- پرتغالی (برزیل)
|
||||
|
||||
## ویژگیها
|
||||
|
||||
- مانیتورینگ وضعیت سیستم
|
||||
- جستجو در بین inboundها و کلاینتها
|
||||
- تم تاریک/روشن
|
||||
- پشتیبانی از چند کاربر و پروتکل
|
||||
- پروتکلهای VMESS، VLESS، Trojan، Shadowsocks، Dokodemo-door، Socks، HTTP، WireGuard
|
||||
- پشتیبانی از XTLS شامل RPRX-Direct، Vision، REALITY
|
||||
- آمار ترافیک، محدودیت ترافیک، محدودیت زمانی
|
||||
- تنظیمات سفارشی Xray
|
||||
- پشتیبانی از HTTPS برای پنل
|
||||
- دریافت خودکار گواهی SSL
|
||||
- مسیرهای API اصلاح شده
|
||||
- پشتیبانی از تغییر تنظیمات از طریق پنل
|
||||
- امکان export/import دیتابیس
|
||||
|
||||
## تنظیمات پیشفرض پنل
|
||||
|
||||
<details>
|
||||
<summary>جزئیات تنظیمات پیشفرض</summary>
|
||||
|
||||
### نام کاربری، رمز عبور، پورت و مسیر وب
|
||||
|
||||
در صورت عدم تغییر، این موارد به صورت تصادفی ایجاد میشوند (به جز Docker).
|
||||
|
||||
**تنظیمات پیشفرض Docker:**
|
||||
- **نام کاربری:** admin
|
||||
- **رمز عبور:** admin
|
||||
- **پورت:** 2053
|
||||
|
||||
### مدیریت دیتابیس:
|
||||
|
||||
امکان Backup و Restore دیتابیس از طریق پنل.
|
||||
|
||||
- **مسیر دیتابیس:**
|
||||
- `/etc/x-ui/x-ui.db`
|
||||
|
||||
### مسیر پایه وب
|
||||
|
||||
1. **بازنشانی مسیر:**
|
||||
- اجرای دستور `x-ui`
|
||||
- انتخاب گزینه `Reset Web Base Path`
|
||||
|
||||
2. **ساخت یا تنظیم مسیر:**
|
||||
- مسیر به صورت تصادفی ساخته شده یا قابل تنظیم است
|
||||
|
||||
3. **مشاهده تنظیمات فعلی:**
|
||||
- استفاده از دستور `x-ui settings` یا `View Current Settings` در `x-ui`
|
||||
|
||||
**توصیه امنیتی:**
|
||||
- استفاده از مسیرهای طولانی و تصادفی برای افزایش امنیت
|
||||
|
||||
**مثال:**
|
||||
- `http://ip:port/*webbasepath*/panel`
|
||||
- `http://domain:port/*webbasepath*/panel`
|
||||
|
||||
</details>
|
||||
|
||||
## پیکربندی WARP
|
||||
|
||||
<details>
|
||||
<summary>جزئیات WARP</summary>
|
||||
|
||||
#### استفاده
|
||||
|
||||
**برای نسخههای `v2.1.0` و جدیدتر:**
|
||||
|
||||
WARP به صورت داخلی پشتیبانی میشود. تنها نیاز به فعالسازی در پنل است.
|
||||
|
||||
</details>
|
||||
|
||||
## محدودیت IP
|
||||
|
||||
<details>
|
||||
<summary>جزئیات محدودیت IP</summary>
|
||||
|
||||
#### استفاده
|
||||
|
||||
**توجه:** محدودیت IP در صورت استفاده از IP Tunnel کار نمیکند.
|
||||
|
||||
- **تا نسخه `v1.6.1`:**
|
||||
- محدودیت IP به صورت داخلی در پنل وجود دارد
|
||||
|
||||
**برای نسخههای `v1.7.0` و جدیدتر:**
|
||||
|
||||
برای فعالسازی نیاز به نصب `fail2ban` است:
|
||||
|
||||
1. اجرای دستور `x-ui` و انتخاب `مدیریت محدودیت IP`
|
||||
2. گزینههای موجود:
|
||||
|
||||
- **تغییر مدت زمان Ban**
|
||||
- **حذف تمام Banها**
|
||||
- **مشاهده لاگها**
|
||||
- **وضعیت Fail2ban**
|
||||
- **راهاندازی مجدد Fail2ban**
|
||||
- **حذف Fail2ban**
|
||||
|
||||
3. تنظیم مسیر `Access log` در پنل به `./access.log` و ذخیره و راهاندازی مجدد Xray
|
||||
|
||||
- **قبل از نسخه `v2.1.3`:**
|
||||
- تنظیم دستی `access.log` در تنظیمات Xray:
|
||||
|
||||
```sh
|
||||
"log": {
|
||||
"access": "./access.log",
|
||||
"dnsLog": false,
|
||||
"loglevel": "warning"
|
||||
},
|
||||
```
|
||||
|
||||
- **از نسخه `v2.1.3`:**
|
||||
- امکان تنظیم `access.log` از طریق پنل
|
||||
|
||||
</details>
|
||||
|
||||
## ربات تلگرام
|
||||
|
||||
<details>
|
||||
<summary>جزئیات ربات تلگرام</summary>
|
||||
|
||||
#### استفاده
|
||||
|
||||
ربات تلگرام برای اطلاعرسانی ترافیک، ورود به پنل، Backup دیتابیس و ... استفاده میشود. نیازمند تنظیم:
|
||||
|
||||
- توکن تلگرام
|
||||
- Chat ID ادمینها
|
||||
- زمان اطلاعرسانی (Cron syntax)
|
||||
- اطلاعرسانی انقضا
|
||||
- اطلاعرسانی ترافیک
|
||||
- Backup دیتابیس
|
||||
- اطلاعرسانی مصرف CPU
|
||||
|
||||
**سینتکس نمونه:**
|
||||
|
||||
- `30 \* \* \* \* \*` - اطلاع در ثانیه 30 هر دقیقه
|
||||
- `@hourly` - هر ساعت
|
||||
- `@daily` - هر روز
|
||||
|
||||
### ویژگیهای ربات
|
||||
|
||||
- گزارش دورهای
|
||||
- اطلاع ورود به پنل
|
||||
- اطلاع مصرف CPU
|
||||
- اطلاع پیشاز موعد انقضا و ترافیک
|
||||
- گزارش ترافیک کلاینتها
|
||||
- منوی مبتنی بر دستور
|
||||
- جستجوی کلاینت بر اساس ایمیل
|
||||
- بررسی inboundها
|
||||
- بررسی وضعیت سرور
|
||||
- دریافت Backup
|
||||
- چندزبانه
|
||||
|
||||
### راهاندازی ربات
|
||||
|
||||
- شروع [Botfather](https://t.me/BotFather) در تلگرام:
|
||||

|
||||
|
||||
- ساخت ربات جدید با دستور /newbot:
|
||||

|
||||
|
||||
- شروع ربات ساخته شده:
|
||||

|
||||
|
||||
- تنظیمات پنل:
|
||||

|
||||
|
||||
وارد کردن توکن و Chat ID (دریافت از [این ربات](https://t.me/useridinfobot)):
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## مسیرهای API
|
||||
|
||||
<details>
|
||||
<summary>جزئیات API</summary>
|
||||
|
||||
#### استفاده
|
||||
|
||||
- [مستندات API](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
|
||||
- `/login` با `POST` داده کاربر: `{username: '', password: ''}`
|
||||
|
||||
| Method | مسیر | عملکرد |
|
||||
| :----: | ---------------------------------- | ------------------------------------------- |
|
||||
| `GET` | `"/list"` | دریافت تمام inboundها |
|
||||
| `GET` | `"/get/:id"` | دریافت inbound بر اساس id |
|
||||
| `POST` | `"/add"` | افزودن inbound |
|
||||
| `POST` | `"/del/:id"` | حذف inbound |
|
||||
|
||||
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
|
||||
</details>
|
||||
|
||||
## متغیرهای محیطی
|
||||
|
||||
<details>
|
||||
<summary>جزئیات متغیرها</summary>
|
||||
|
||||
#### استفاده
|
||||
|
||||
| متغیر | نوع | پیشفرض |
|
||||
| ------------- | :--------------------------------------------: | :------------ |
|
||||
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
|
||||
| XUI_DEBUG | `boolean` | `false` |
|
||||
| XUI_BIN_FOLDER| `string` | `"bin"` |
|
||||
|
||||
مثال:
|
||||
|
||||
```sh
|
||||
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## پیشنمایش
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||
<img alt="3x-ui" src="./media/01-overview-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
|
||||
<img alt="3x-ui" src="./media/02-inbounds-light.png">
|
||||
</picture>
|
||||
|
||||
## قدردانی ویژه از
|
||||
## تشکر ویژه از
|
||||
|
||||
- [alireza0](https://github.com/alireza0/)
|
||||
|
||||
## تشکر و قدردانی
|
||||
## قدردانی
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**)
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**)
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنههای ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray بهروزرسانی شده خودکار بر اساس دادههای دامنهها و آدرسهای مسدود شده در روسیه است._
|
||||
|
||||
## Stargazers over Time
|
||||
## پشتیبانی از پروژه
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
**اگر این پروژه برای شما مفید است، میتوانید به آن یک**:star2: بدهید
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## ستارهها در طول زمان
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -7,580 +7,28 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
**An Advanced Web Panel • Built on Xray Core**
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
> **Disclaimer:** This project is only for personal learning and communication, please do not use it for illegal purposes, please do not use it in a production environment
|
||||
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
||||
|
||||
**If this project is helpful to you, you may wish to give it a**:star2:
|
||||
> [!IMPORTANT]
|
||||
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
|
||||
<img src="./media/buymeacoffe.png" alt="Image">
|
||||
</a>
|
||||
</p>
|
||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||
|
||||
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
|
||||
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
|
||||
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
|
||||
## Quick Start
|
||||
|
||||
## Install & Upgrade
|
||||
|
||||
```
|
||||
```bash
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
## Install legacy Version (we don't recommend)
|
||||
|
||||
To install your desired version, use following installation command. e.g., ver `v1.7.9`:
|
||||
|
||||
```
|
||||
VERSION=v1.7.9 && bash <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
|
||||
```
|
||||
|
||||
## SSL Certificate
|
||||
|
||||
<details>
|
||||
<summary>Click for SSL Certificate details</summary>
|
||||
|
||||
### ACME
|
||||
|
||||
To manage SSL certificates using ACME:
|
||||
|
||||
1. Ensure your domain is correctly resolved to the server.
|
||||
2. Run the `x-ui` command in the terminal, then choose `SSL Certificate Management`.
|
||||
3. You will be presented with the following options:
|
||||
|
||||
- **Get SSL:** Obtain SSL certificates.
|
||||
- **Revoke:** Revoke existing SSL certificates.
|
||||
- **Force Renew:** Force renewal of SSL certificates.
|
||||
- **Show Existing Domains:** Display all domain certificates available on the server.
|
||||
- **Set Certificate Paths for the Panel:** Specify the certificate for your domain to be used by the panel.
|
||||
|
||||
### Certbot
|
||||
|
||||
To install and use Certbot:
|
||||
|
||||
```sh
|
||||
apt-get install certbot -y
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
|
||||
certbot renew --dry-run
|
||||
```
|
||||
|
||||
### Cloudflare
|
||||
|
||||
The management script includes a built-in SSL certificate application for Cloudflare. To use this script to apply for a certificate, you need the following:
|
||||
|
||||
- Cloudflare registered email
|
||||
- Cloudflare Global API Key
|
||||
- The domain name must be resolved to the current server through Cloudflare
|
||||
|
||||
**How to get the Cloudflare Global API Key:**
|
||||
|
||||
1. Run the `x-ui` command in the terminal, then choose `Cloudflare SSL Certificate`.
|
||||
2. Visit the link: [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens).
|
||||
3. Click on "View Global API Key" (see the screenshot below):
|
||||

|
||||
4. You may need to re-authenticate your account. After that, the API Key will be shown (see the screenshot below):
|
||||

|
||||
|
||||
When using, just enter your `domain name`, `email`, and `API KEY`. The diagram is as follows:
|
||||

|
||||
|
||||
|
||||
</details>
|
||||
|
||||
## Manual Install & Upgrade
|
||||
|
||||
<details>
|
||||
<summary>Click for manual install details</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
1. To download the latest version of the compressed package directly to your server, run the following command:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
|
||||
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
```
|
||||
|
||||
2. Once the compressed package is downloaded, execute the following commands to install or upgrade x-ui:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
cd /root/
|
||||
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
|
||||
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
|
||||
cp x-ui/x-ui.sh /usr/bin/x-ui
|
||||
cp -f x-ui/x-ui.service /etc/systemd/system/
|
||||
mv x-ui/ /usr/local/
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Install with Docker
|
||||
|
||||
<details>
|
||||
<summary>Click for Docker details</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
1. **Install Docker:**
|
||||
|
||||
```sh
|
||||
bash <(curl -sSL https://get.docker.com)
|
||||
```
|
||||
|
||||
2. **Clone the Project Repository:**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MHSanaei/3x-ui.git
|
||||
cd 3x-ui
|
||||
```
|
||||
|
||||
3. **Start the Service:**
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Add ```--pull always``` flag to make docker automatically recreate container if a newer image is pulled. See https://docs.docker.com/reference/cli/docker/container/run/#pull for more info.
|
||||
|
||||
**OR**
|
||||
|
||||
```sh
|
||||
docker run -itd \
|
||||
-e XRAY_VMESS_AEAD_FORCED=false \
|
||||
-v $PWD/db/:/etc/x-ui/ \
|
||||
-v $PWD/cert/:/root/cert/ \
|
||||
--network=host \
|
||||
--restart=unless-stopped \
|
||||
--name 3x-ui \
|
||||
ghcr.io/mhsanaei/3x-ui:latest
|
||||
```
|
||||
|
||||
4. **Update to the Latest Version:**
|
||||
|
||||
```sh
|
||||
cd 3x-ui
|
||||
docker compose down
|
||||
docker compose pull 3x-ui
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Remove 3x-ui from Docker:**
|
||||
|
||||
```sh
|
||||
docker stop 3x-ui
|
||||
docker rm 3x-ui
|
||||
cd --
|
||||
rm -r 3x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Nginx Settings
|
||||
<details>
|
||||
<summary>Click for Reverse Proxy Configuration</summary>
|
||||
|
||||
#### Nginx Reverse Proxy
|
||||
```nginx
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
|
||||
#### Nginx sub-path
|
||||
- Ensure that the "URI Path" in the `/sub` panel settings is the same.
|
||||
- The `url` in the panel settings needs to end with `/`.
|
||||
|
||||
```nginx
|
||||
location /sub {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## Recommended OS
|
||||
|
||||
- Ubuntu 20.04+
|
||||
- Debian 11+
|
||||
- CentOS 8+
|
||||
- OpenEuler 22.03+
|
||||
- Fedora 36+
|
||||
- Arch Linux
|
||||
- Parch Linux
|
||||
- Manjaro
|
||||
- Armbian
|
||||
- AlmaLinux 8.0+
|
||||
- Rocky Linux 8+
|
||||
- Oracle Linux 8+
|
||||
- OpenSUSE Tubleweed
|
||||
- Amazon Linux 2023
|
||||
- Windows x64
|
||||
|
||||
## Supported Architectures and Devices
|
||||
|
||||
<details>
|
||||
<summary>Click for Supported Architectures and devices details</summary>
|
||||
|
||||
Our platform offers compatibility with a diverse range of architectures and devices, ensuring flexibility across various computing environments. The following are key architectures that we support:
|
||||
|
||||
- **amd64**: This prevalent architecture is the standard for personal computers and servers, accommodating most modern operating systems seamlessly.
|
||||
|
||||
- **x86 / i386**: Widely adopted in desktop and laptop computers, this architecture enjoys broad support from numerous operating systems and applications, including but not limited to Windows, macOS, and Linux systems.
|
||||
|
||||
- **armv8 / arm64 / aarch64**: Tailored for contemporary mobile and embedded devices, such as smartphones and tablets, this architecture is exemplified by devices like Raspberry Pi 4, Raspberry Pi 3, Raspberry Pi Zero 2/Zero 2 W, Orange Pi 3 LTS, and more.
|
||||
|
||||
- **armv7 / arm / arm32**: Serving as the architecture for older mobile and embedded devices, it remains widely utilized in devices like Orange Pi Zero LTS, Orange Pi PC Plus, Raspberry Pi 2, among others.
|
||||
|
||||
- **armv6 / arm / arm32**: Geared towards very old embedded devices, this architecture, while less prevalent, is still in use. Devices such as Raspberry Pi 1, Raspberry Pi Zero/Zero W, rely on this architecture.
|
||||
|
||||
- **armv5 / arm / arm32**: An older architecture primarily associated with early embedded systems, it is less common today but may still be found in legacy devices like early Raspberry Pi versions and some older smartphones.
|
||||
|
||||
- **s390x**: This architecture is commonly used in IBM mainframe computers and offers high performance and reliability for enterprise workloads.
|
||||
</details>
|
||||
|
||||
## Languages
|
||||
|
||||
- English
|
||||
- Persian
|
||||
- Traditional Chinese
|
||||
- Simplified Chinese
|
||||
- Japanese
|
||||
- Russian
|
||||
- Vietnamese
|
||||
- Spanish
|
||||
- Indonesian
|
||||
- Ukrainian
|
||||
- Turkish
|
||||
- Português (Brazil)
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- System Status Monitoring
|
||||
- Search within all inbounds and clients
|
||||
- Dark/Light theme
|
||||
- Supports multi-user and multi-protocol
|
||||
- Supports protocols, including VMESS, VLESS, Trojan, Shadowsocks, Dokodemo-door, Socks, HTTP, wireguard
|
||||
- Supports XTLS native Protocols, including RPRX-Direct, Vision, REALITY
|
||||
- Traffic statistics, traffic limit, expiration time limit
|
||||
- Customizable Xray configuration templates
|
||||
- Supports HTTPS access panel (self-provided domain name + SSL certificate)
|
||||
- Supports One-Click SSL certificate application and automatic renewal
|
||||
- For more advanced configuration items, please refer to the panel
|
||||
- Fixes API routes (user setting will be created with API)
|
||||
- Supports changing configs by different items provided in the panel.
|
||||
- Supports export/import database from the panel
|
||||
|
||||
|
||||
## Default Panel Settings
|
||||
|
||||
<details>
|
||||
<summary>Click for default settings details</summary>
|
||||
|
||||
### Username, Password, Port, and Web Base Path
|
||||
|
||||
If you choose not to modify these settings, they will be generated randomly (this does not apply to Docker).
|
||||
|
||||
**Default Settings for Docker:**
|
||||
- **Username:** admin
|
||||
- **Password:** admin
|
||||
- **Port:** 2053
|
||||
|
||||
### Database Management:
|
||||
|
||||
You can conveniently perform database Backups and Restores directly from the panel.
|
||||
|
||||
- **Database Path:**
|
||||
- `/etc/x-ui/x-ui.db`
|
||||
|
||||
|
||||
### Web Base Path
|
||||
|
||||
1. **Reset Web Base Path:**
|
||||
- Open your terminal.
|
||||
- Run the `x-ui` command.
|
||||
- Select the option to `Reset Web Base Path`.
|
||||
|
||||
2. **Generate or Customize Path:**
|
||||
- The path will be randomly generated, or you can enter a custom path.
|
||||
|
||||
3. **View Current Settings:**
|
||||
- To view your current settings, use the `x-ui settings` command in the terminal or `View Current Settings` in `x-ui`
|
||||
|
||||
### Security Recommendation:
|
||||
- For enhanced security, use a long, random word in your URL structure.
|
||||
|
||||
**Examples:**
|
||||
- `http://ip:port/*webbasepath*/panel`
|
||||
- `http://domain:port/*webbasepath*/panel`
|
||||
|
||||
</details>
|
||||
|
||||
## WARP Configuration
|
||||
|
||||
<details>
|
||||
<summary>Click for WARP configuration details</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
**For versions `v2.1.0` and later:**
|
||||
|
||||
WARP is built-in, and no additional installation is required. Simply turn on the necessary configuration in the panel.
|
||||
|
||||
</details>
|
||||
|
||||
## IP Limit
|
||||
|
||||
<details>
|
||||
<summary>Click for IP limit details</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
**Note:** IP Limit won't work correctly when using IP Tunnel.
|
||||
|
||||
- **For versions up to `v1.6.1`:**
|
||||
- The IP limit is built-in to the panel
|
||||
|
||||
**For versions `v1.7.0` and newer:**
|
||||
|
||||
To enable the IP Limit functionality, you need to install `fail2ban` and its required files by following these steps:
|
||||
|
||||
1. Run the `x-ui` command in the terminal, then choose `IP Limit Management`.
|
||||
2. You will see the following options:
|
||||
|
||||
- **Change Ban Duration:** Adjust the duration of bans.
|
||||
- **Unban Everyone:** Lift all current bans.
|
||||
- **Check Logs:** Review the logs.
|
||||
- **Fail2ban Status:** Check the status of `fail2ban`.
|
||||
- **Restart Fail2ban:** Restart the `fail2ban` service.
|
||||
- **Uninstall Fail2ban:** Uninstall Fail2ban with configuration.
|
||||
|
||||
3. Add a path for the access log on the panel by setting `Xray Configs/log/Access log` to `./access.log` then save and restart xray.
|
||||
|
||||
- **For versions before `v2.1.3`:**
|
||||
- You need to set the access log path manually in your Xray configuration:
|
||||
|
||||
```sh
|
||||
"log": {
|
||||
"access": "./access.log",
|
||||
"dnsLog": false,
|
||||
"loglevel": "warning"
|
||||
},
|
||||
```
|
||||
|
||||
- **For versions `v2.1.3` and newer:**
|
||||
- There is an option for configuring `access.log` directly from the panel.
|
||||
|
||||
</details>
|
||||
|
||||
## Telegram Bot
|
||||
|
||||
<details>
|
||||
<summary>Click for Telegram bot details</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
The web panel supports daily traffic, panel login, database backup, system status, client info, and other notification and functions through the Telegram Bot. To use the bot, you need to set the bot-related parameters in the panel, including:
|
||||
|
||||
- Telegram Token
|
||||
- Admin Chat ID(s)
|
||||
- Notification Time (in cron syntax)
|
||||
- Expiration Date Notification
|
||||
- Traffic Cap Notification
|
||||
- Database Backup
|
||||
- CPU Load Notification
|
||||
|
||||
|
||||
**Reference syntax:**
|
||||
|
||||
- `30 \* \* \* \* \*` - Notify at the 30s of each point
|
||||
- `0 \*/10 \* \* \* \*` - Notify at the first second of each 10 minutes
|
||||
- `@hourly` - Hourly notification
|
||||
- `@daily` - Daily notification (00:00 in the morning)
|
||||
- `@weekly` - weekly notification
|
||||
- `@every 8h` - Notify every 8 hours
|
||||
|
||||
### Telegram Bot Features
|
||||
|
||||
- Report periodic
|
||||
- Login notification
|
||||
- CPU threshold notification
|
||||
- Threshold for Expiration time and Traffic to report in advance
|
||||
- Support client report menu if client's telegram username added to the user's configurations
|
||||
- Support telegram traffic report searched with UUID (VMESS/VLESS) or Password (TROJAN) - anonymously
|
||||
- Menu-based bot
|
||||
- Search client by email (only admin)
|
||||
- Check all inbounds
|
||||
- Check server status
|
||||
- Check depleted users
|
||||
- Receive backup by request and in periodic reports
|
||||
- Multi-language bot
|
||||
|
||||
### Setting up Telegram bot
|
||||
|
||||
- Start [Botfather](https://t.me/BotFather) in your Telegram account:
|
||||

|
||||
|
||||
- Create a new Bot using /newbot command: It will ask you 2 questions, A name and a username for your bot. Note that the username has to end with the word "bot".
|
||||

|
||||
|
||||
- Start the bot you've just created. You can find the link to your bot here.
|
||||

|
||||
|
||||
- Enter your panel and config Telegram bot settings like below:
|
||||

|
||||
|
||||
Enter your bot token in input field number 3.
|
||||
Enter the user ID in input field number 4. The Telegram accounts with this id will be the bot admin. (You can enter more than one, Just separate them with ,)
|
||||
|
||||
- How to get Telegram user ID? Use this [bot](https://t.me/useridinfobot), Start the bot and it will give you the Telegram user ID.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## API Routes
|
||||
|
||||
<details>
|
||||
<summary>Click for API routes details</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
- [API Documentation](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
|
||||
- `/login` with `POST` user data: `{username: '', password: ''}` for login
|
||||
- `/panel/api/inbounds` base for following actions:
|
||||
|
||||
| Method | Path | Action |
|
||||
| :----: | ---------------------------------- | ------------------------------------------- |
|
||||
| `GET` | `"/list"` | Get all inbounds |
|
||||
| `GET` | `"/get/:id"` | Get inbound with inbound.id |
|
||||
| `GET` | `"/getClientTraffics/:email"` | Get Client Traffics with email |
|
||||
| `GET` | `"/getClientTrafficsById/:id"` | Get client's traffic By ID |
|
||||
| `GET` | `"/createbackup"` | Telegram bot sends backup to admins |
|
||||
| `POST` | `"/add"` | Add inbound |
|
||||
| `POST` | `"/del/:id"` | Delete Inbound |
|
||||
| `POST` | `"/update/:id"` | Update Inbound |
|
||||
| `POST` | `"/clientIps/:email"` | Client Ip address |
|
||||
| `POST` | `"/clearClientIps/:email"` | Clear Client Ip address |
|
||||
| `POST` | `"/addClient"` | Add Client to inbound |
|
||||
| `POST` | `"/:id/delClient/:clientId"` | Delete Client by clientId\* |
|
||||
| `POST` | `"/updateClient/:clientId"` | Update Client by clientId\* |
|
||||
| `POST` | `"/:id/resetClientTraffic/:email"` | Reset Client's Traffic |
|
||||
| `POST` | `"/resetAllTraffics"` | Reset traffics of all inbounds |
|
||||
| `POST` | `"/resetAllClientTraffics/:id"` | Reset traffics of all clients in an inbound |
|
||||
| `POST` | `"/delDepletedClients/:id"` | Delete inbound depleted clients (-1: all) |
|
||||
| `POST` | `"/onlines"` | Get Online users ( list of emails ) |
|
||||
|
||||
\*- The field `clientId` should be filled by:
|
||||
|
||||
- `client.id` for VMESS and VLESS
|
||||
- `client.password` for TROJAN
|
||||
- `client.email` for Shadowsocks
|
||||
|
||||
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
<details>
|
||||
<summary>Click for environment variables details</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
| Variable | Type | Default |
|
||||
| -------------- | :--------------------------------------------: | :------------ |
|
||||
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
|
||||
| XUI_DEBUG | `boolean` | `false` |
|
||||
| XUI_BIN_FOLDER | `string` | `"bin"` |
|
||||
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
|
||||
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Preview
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||
<img alt="3x-ui" src="./media/01-overview-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
|
||||
<img alt="3x-ui" src="./media/02-inbounds-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
|
||||
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
|
||||
<img alt="3x-ui" src="./media/04-add-client-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
|
||||
<img alt="3x-ui" src="./media/05-settings-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
|
||||
<img alt="3x-ui" src="./media/06-configs-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
|
||||
<img alt="3x-ui" src="./media/07-bot-light.png">
|
||||
</picture>
|
||||
For full documentation, please visit the [project Wiki](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## A Special Thanks to
|
||||
|
||||
@@ -591,6 +39,19 @@ XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
|
||||
|
||||
## Support project
|
||||
|
||||
**If this project is helpful to you, you may wish to give it a**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## Stargazers over Time
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+29
-566
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -7,578 +7,28 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
**Продвинутая веб-панель • Построена на основе Xray Core**
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
> **Отказ от ответственности:** Этот проект предназначен только для личного обучения и общения. Пожалуйста, не используйте его в незаконных целях и не применяйте в производственной среде.
|
||||
**3X-UI** — продвинутая панель управления с открытым исходным кодом на основе веб-интерфейса, разработанная для управления сервером Xray-core. Предоставляет удобный интерфейс для настройки и мониторинга различных VPN и прокси-протоколов.
|
||||
|
||||
**Если этот проект оказался полезным для вас, вы можете оценить его, поставив звёздочку** :star2:
|
||||
> [!IMPORTANT]
|
||||
> Этот проект предназначен только для личного использования, пожалуйста, не используйте его в незаконных целях и в производственной среде.
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
|
||||
<img src="./media/buymeacoffe.png" alt="Image">
|
||||
</a>
|
||||
</p>
|
||||
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
|
||||
|
||||
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
|
||||
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
|
||||
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
|
||||
|
||||
## Установка и обновление
|
||||
## Быстрый старт
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
## Установить старую версию (мы не рекомендуем)
|
||||
|
||||
Чтобы установить желаемую версию, используйте следующую команду установки. Например, ver `v1.7.9`:
|
||||
|
||||
```
|
||||
VERSION=v1.7.9 && <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
|
||||
```
|
||||
|
||||
## SSL Сертификат
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации об SSL сертификате</summary>
|
||||
|
||||
### ACME
|
||||
|
||||
Для управления SSL сертификатами с помощью ACME:
|
||||
|
||||
1. Убедитесь, что ваш домен правильно настроен и указывает на сервер.
|
||||
2. Выполните команду `x-ui` в терминале, затем выберите `SSL Certificate Management`.
|
||||
3. Вам будут предложены следующие опции:
|
||||
|
||||
- **Get SSL:** Получить SSL сертификаты.
|
||||
- **Revoke:** Отозвать существующие SSL сертификаты.
|
||||
- **Force Renew:** Принудительно перевыпустить SSL сертификаты.
|
||||
- **Show Existing Domains:** Отобразить все сертификаты доменов, доступные на сервере.
|
||||
- **Set Certificate Paths for the Panel:** Укажите сертификат для вашего домена, который будет использоваться панелью.
|
||||
|
||||
### Certbot
|
||||
|
||||
Для установки и использования Certbot:
|
||||
|
||||
```sh
|
||||
apt-get install certbot -y
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d вашдомен.com
|
||||
certbot renew --dry-run
|
||||
```
|
||||
|
||||
### Cloudflare
|
||||
|
||||
Скрипт управления включает встроенное приложение для получения SSL сертификата через Cloudflare. Чтобы использовать этот скрипт для запроса сертификата, вам потребуется следующее:
|
||||
|
||||
- Email, зарегистрированный в Cloudflare
|
||||
- Глобальный API-ключ Cloudflare
|
||||
- Доменное имя должно указывать на текущий сервер через Cloudflare
|
||||
|
||||
**Как получить глобальный API-ключ Cloudflare:**
|
||||
|
||||
1. Выполните команду `x-ui` в терминале, затем выберите `Cloudflare SSL Certificate`.
|
||||
2. Перейдите по ссылке: [Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens).
|
||||
3. Нажмите на "View Global API Key" (см. скриншот ниже):
|
||||

|
||||
4. Возможно, вам потребуется повторно пройти аутентификацию. После этого ключ API будет отображён (см. скриншот ниже):
|
||||

|
||||
|
||||
При использовании просто введите ваше `доменное имя`, `email` и `API-ключ`. Схема приведена ниже:
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Ручная установка и обновление
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о ручной установке</summary>
|
||||
|
||||
#### Использование
|
||||
|
||||
1. Чтобы скачать последнюю версию архива напрямую на ваш сервер, выполните следующую команду:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
|
||||
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
```
|
||||
|
||||
2. После загрузки архива выполните следующие команды для установки или обновления x-ui:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
s390x) echo 's390x' ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
cd /root/
|
||||
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
|
||||
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
|
||||
cp x-ui/x-ui.sh /usr/bin/x-ui
|
||||
cp -f x-ui/x-ui.service /etc/systemd/system/
|
||||
mv x-ui/ /usr/local/
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Установка с помощью Docker
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о Docker</summary>
|
||||
|
||||
#### Использование
|
||||
|
||||
1. **Установите Docker:**
|
||||
|
||||
```sh
|
||||
bash <(curl -sSL https://get.docker.com)
|
||||
```
|
||||
|
||||
2. **Склонируйте репозиторий проекта:**
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MHSanaei/3x-ui.git
|
||||
cd 3x-ui
|
||||
```
|
||||
|
||||
3. **Запустите сервис:**
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Добавьте параметр ```--pull always``` для автоматического обновления контейнера, когда публикуется новый образ. Подробности: https://docs.docker.com/reference/cli/docker/container/run/#pull
|
||||
|
||||
**ИЛИ**
|
||||
|
||||
```sh
|
||||
docker run -itd \
|
||||
-e XRAY_VMESS_AEAD_FORCED=false \
|
||||
-v $PWD/db/:/etc/x-ui/ \
|
||||
-v $PWD/cert/:/root/cert/ \
|
||||
--network=host \
|
||||
--restart=unless-stopped \
|
||||
--name 3x-ui \
|
||||
ghcr.io/mhsanaei/3x-ui:latest
|
||||
```
|
||||
|
||||
4. **Обновление до последней версии:**
|
||||
|
||||
```sh
|
||||
cd 3x-ui
|
||||
docker compose down
|
||||
docker compose pull 3x-ui
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Удаление 3x-ui из Docker:**
|
||||
|
||||
```sh
|
||||
docker stop 3x-ui
|
||||
docker rm 3x-ui
|
||||
cd --
|
||||
rm -r 3x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Настройки Nginx
|
||||
<details>
|
||||
<summary>Нажмите чтобы просмотреть конфигурацию обратного прокси-сервера</summary>
|
||||
|
||||
#### Обратный прокси-сервер Nginx
|
||||
```nginx
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
|
||||
#### Nginx sub-path
|
||||
- Убедитесь, что "корневой путь URL адреса панели" в настройках панели и `/sub` совпадают.
|
||||
- В настройках панели `url` должен заканчиваться на `/`.
|
||||
|
||||
```nginx
|
||||
location /sub {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## Рекомендуемые ОС
|
||||
|
||||
- Ubuntu 20.04+
|
||||
- Debian 11+
|
||||
- CentOS 8+
|
||||
- OpenEuler 22.03+
|
||||
- Fedora 36+
|
||||
- Arch Linux
|
||||
- Parch Linux
|
||||
- Manjaro
|
||||
- Armbian
|
||||
- AlmaLinux 8.0+
|
||||
- Rocky Linux 8+
|
||||
- Oracle Linux 8+
|
||||
- OpenSUSE Tubleweed
|
||||
- Amazon Linux 2023
|
||||
- Windows x64
|
||||
|
||||
## Поддерживаемые архитектуры и устройства
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о поддерживаемых архитектурах и устройствах</summary>
|
||||
|
||||
Наша платформа поддерживает разнообразные архитектуры и устройства, обеспечивая гибкость в различных вычислительных средах. Вот основные архитектуры, которые мы поддерживаем:
|
||||
|
||||
- **amd64**: Эта распространенная архитектура является стандартом для персональных компьютеров и серверов, обеспечивая беспроблемную работу большинства современных операционных систем.
|
||||
|
||||
- **x86 / i386**: Широко используется в настольных и портативных компьютерах. Эта архитектура имеет широкую поддержку со стороны множества операционных систем и приложений, включая, но не ограничиваясь, Windows, macOS и Linux.
|
||||
|
||||
- **armv8 / arm64 / aarch64**: Предназначена для современных мобильных и встроенных устройств, таких как смартфоны и планшеты. Эта архитектура представлена устройствами, такими как Raspberry Pi 4, Raspberry Pi 3, Raspberry Pi Zero 2/Zero 2 W, Orange Pi 3 LTS и другими.
|
||||
|
||||
- **armv7 / arm / arm32**: Служит архитектурой для старых мобильных и встроенных устройств, оставаясь широко используемой в таких устройствах, как Orange Pi Zero LTS, Orange Pi PC Plus, Raspberry Pi 2 и других.
|
||||
|
||||
- **armv6 / arm / arm32**: Ориентирована на очень старые встроенные устройства, эта архитектура, хотя и менее распространенная, всё ещё используется. Например, такие устройства, как Raspberry Pi 1, Raspberry Pi Zero/Zero W, полагаются на эту архитектуру.
|
||||
|
||||
- **armv5 / arm / arm32**: Более старая архитектура, ассоциируемая с ранними встроенными системами, сегодня менее распространена, но всё ещё может быть найдена в устаревших устройствах, таких как ранние версии Raspberry Pi и некоторые старые смартфоны.
|
||||
|
||||
- **s390x**: Эта архитектура обычно используется в мейнфреймах IBM и обеспечивает высокую производительность и надежность для корпоративных рабочих нагрузок.
|
||||
</details>
|
||||
|
||||
## Языки
|
||||
|
||||
- English (английский)
|
||||
- Persian (персидский)
|
||||
- Traditional Chinese (традиционный китайский)
|
||||
- Simplified Chinese (упрощенный китайский)
|
||||
- Japanese (японский)
|
||||
- Russian (русский)
|
||||
- Vietnamese (вьетнамский)
|
||||
- Spanish (испанский)
|
||||
- Indonesian (индонезийский)
|
||||
- Ukrainian (украинский)
|
||||
- Turkish (турецкий)
|
||||
- Português (Brazil) (португальский (Бразилия))
|
||||
|
||||
## Возможности
|
||||
|
||||
- Мониторинг состояния системы
|
||||
- Поиск по всем входящим подключениям и клиентам
|
||||
- Тёмная/светлая тема
|
||||
- Поддержка нескольких пользователей и протоколов
|
||||
- Поддержка протоколов, включая VMESS, VLESS, Trojan, Shadowsocks, Dokodemo-door, Socks, HTTP, WireGuard
|
||||
- Поддержка протоколов XTLS, включая RPRX-Direct, Vision, REALITY
|
||||
- Статистика трафика, ограничение трафика, ограничение по времени истечения
|
||||
- Настраиваемые шаблоны конфигурации Xray
|
||||
- Поддержка HTTPS доступа к панели (ваше доменное имя + SSL сертификат)
|
||||
- Поддержка установки SSL-сертификата в один клик и автоматического перевыпуска
|
||||
- Для получения более продвинутых настроек обращайтесь к панели
|
||||
- Исправляет маршруты API (настройка пользователя будет создана через API)
|
||||
- Поддержка изменения конфигураций по различным элементам, предоставленным в панели
|
||||
- Поддержка экспорта/импорта базы данных из панели
|
||||
|
||||
## Настройки панели по умолчанию
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о настройках по умолчанию</summary>
|
||||
|
||||
### Имя пользователя, Пароль, Порт и Web Base Path
|
||||
|
||||
Если вы не измените эти настройки, они будут сгенерированы случайным образом (это не относится к Docker).
|
||||
|
||||
**Настройки по умолчанию для Docker:**
|
||||
- **Имя пользователя:** admin
|
||||
- **Пароль:** admin
|
||||
- **Порт:** 2053
|
||||
|
||||
### Управление базой данных:
|
||||
|
||||
Вы можете удобно выполнять резервное копирование и восстановление базы данных прямо из панели.
|
||||
|
||||
- **Путь к базе данных:**
|
||||
- `/etc/x-ui/x-ui.db`
|
||||
|
||||
### Webbasepath
|
||||
|
||||
1. **Сбросить webbasepath:**
|
||||
- Откройте терминал.
|
||||
- Выполните команду `x-ui`.
|
||||
- Выберите опцию `Reset Web Base Path`.
|
||||
|
||||
2. **Генерация или настройка пути:**
|
||||
- Путь будет сгенерирован случайным образом, или вы можете ввести собственный путь.
|
||||
|
||||
3. **Просмотр текущих настроек:**
|
||||
- Чтобы просмотреть текущие настройки, используйте команду `x-ui settings` в терминале или опцию `View Current Settings` в `x-ui`.
|
||||
|
||||
### Рекомендации по безопасности:
|
||||
- Для повышения безопасности используйте длинное случайное слово в структуре вашего URL.
|
||||
|
||||
**Примеры:**
|
||||
- `http://ip_адрес:порт/*webbasepath*/panel`
|
||||
- `http://домен:порт/*webbasepath*/panel`
|
||||
|
||||
</details>
|
||||
|
||||
## Настройка WARP
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о настройке WARP</summary>
|
||||
|
||||
#### Использование
|
||||
|
||||
**Для версий `v2.1.0` и новее:**
|
||||
|
||||
WARP встроен, и дополнительная установка не требуется. Просто включите необходимую конфигурацию в панели.
|
||||
|
||||
</details>
|
||||
|
||||
## Ограничение IP
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации об ограничении IP</summary>
|
||||
|
||||
#### Использование
|
||||
|
||||
**Примечание:** Ограничение IP не будет работать корректно при использовании IP Tunnel.
|
||||
|
||||
- **Для версий до `v1.6.1`:**
|
||||
- Ограничение IP встроено в панель.
|
||||
|
||||
**Для версий `v1.7.0` и новее:**
|
||||
|
||||
Чтобы включить функциональность ограничения IP, вам нужно установить `fail2ban` и его необходимые файлы, выполнив следующие шаги:
|
||||
|
||||
1. Выполните команду `x-ui` в терминале, затем выберите `IP Limit Management`.
|
||||
2. Вам будут предложены следующие опции:
|
||||
|
||||
- **Change Ban Duration:** Отрегулировать длительность блокировок.
|
||||
- **Unban Everyone:** Снять все текущие блокировки.
|
||||
- **Check Logs:** Просмотреть логи.
|
||||
- **Fail2ban Status:** Проверить статус `fail2ban`.
|
||||
- **Restart Fail2ban:** Перезапустить службу `fail2ban`.
|
||||
- **Uninstall Fail2ban:** Удалить Fail2ban с его конфигурацией.
|
||||
|
||||
3. Добавьте путь к логам доступа в панели, установив `Xray Configs/log/Access log` в `./access.log`, затем сохраните и перезапустите xray.
|
||||
|
||||
- **Для версий до `v2.1.3`:**
|
||||
- Вам нужно вручную установить путь к логам доступа в вашей конфигурации Xray:
|
||||
|
||||
```sh
|
||||
"log": {
|
||||
"access": "./access.log",
|
||||
"dnsLog": false,
|
||||
"loglevel": "warning"
|
||||
},
|
||||
```
|
||||
|
||||
- **Для версий `v2.1.3` и новее:**
|
||||
- Есть возможность настройки `access.log` непосредственно из панели.
|
||||
|
||||
</details>
|
||||
|
||||
## Телеграм-бот
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о телеграм-боте</summary>
|
||||
|
||||
#### Использование
|
||||
|
||||
Веб-панель поддерживает уведомления и функции, такие как ежедневный трафик, вход в панель, резервное копирование базы данных, состояние системы, информация о клиентах и другие, через телеграм-бота. Чтобы использовать бота, вам нужно настроить параметры, связанные с ботом, в панели, включая:
|
||||
|
||||
- Токен Telegram
|
||||
- ID чата админа(-ов)
|
||||
- Время уведомлений (в синтаксисе cron)
|
||||
- Уведомления о дате истечения
|
||||
- Уведомления о лимите трафика
|
||||
- Резервное копирование базы данных
|
||||
- Уведомления о загрузке CPU
|
||||
|
||||
**Примеры синтаксиса:**
|
||||
|
||||
- `30 * * * * *` - Уведомлять на 30-й секунде каждого часа
|
||||
- `0 */10 * * * *` - Уведомлять на первой секунде каждых 10 минут
|
||||
- `@hourly` - Ежечасное уведомление
|
||||
- `@daily` - Ежедневное уведомление (в 00:00)
|
||||
- `@weekly` - Еженедельное уведомление
|
||||
- `@every 8h` - Уведомлять каждые 8 часов
|
||||
|
||||
### Возможности телеграм-бота
|
||||
|
||||
- Периодические отчеты
|
||||
- Уведомления о входе
|
||||
- Уведомления о пороге загруженности процессора
|
||||
- Уведомления о времени истечения и трафике заранее
|
||||
- Поддерживает меню отчетов клиента, если имя пользователя телеграм клиента добавлено в конфигурации пользователя
|
||||
- Поддержка отчета о трафике через Telegram, поиск по UUID (VMESS/VLESS) или паролю (TROJAN) - анонимно
|
||||
- Бот, основанный на меню
|
||||
- Поиск клиента по email (только администратор)
|
||||
- Проверка всех входящих соединений
|
||||
- Проверка состояния сервера
|
||||
- Проверка истекших пользователей
|
||||
- Получение резервных копий по запросу и в периодических отчётах
|
||||
- Многоязычный бот
|
||||
|
||||
### Настройка телеграм-бота
|
||||
|
||||
- Запустите [Botfather](https://t.me/BotFather) в вашем аккаунте Telegram:
|
||||

|
||||
|
||||
- Создайте нового бота с помощью команды /newbot: у вас спросят 2 вопроса: отображаемое имя и имя пользователя для вашего бота. Обратите внимание, что имя пользователя должно заканчиваться на слово "bot".
|
||||

|
||||
|
||||
- Запустите созданного бота. Ссылку на вашего бота можно найти здесь.
|
||||

|
||||
|
||||
- Перейдите в панель и настройте параметры телеграм-бота следующим образом:
|
||||

|
||||
|
||||
Введите токен вашего бота в поле ввода номер 3.
|
||||
Введите ID пользователя в поле ввода номер 4. Telegram-аккаунты с этим ID будут администраторами бота. (Вы можете ввести несколько ID, разделяя их запятой)
|
||||
|
||||
- Как получить ID пользователя Telegram? Используйте этот [бот](https://t.me/useridinfobot). Запустите бота, и он отобразит ваш ID пользователя Telegram.
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Маршруты API
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о маршрутах API</summary>
|
||||
|
||||
#### Использование
|
||||
|
||||
- [API документация](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
|
||||
- `/login` с `POST`-данными: `{username: '', password: ''}` для входа
|
||||
- `/panel/api/inbounds` это базовый путь для следующих действий:
|
||||
|
||||
| Метод | Путь | Действие
|
||||
| :----: | -----------------------------------| -------------------------------------------
|
||||
| `GET` | `"/list"` | Получить все входящие соединения
|
||||
| `GET` | `"/get/:id"` | Получить входящее соединение с inbound.id
|
||||
| `GET` | `"/getClientTraffics/:email"` | Получить трафик клиента по email
|
||||
| `GET` | `"/getClientTrafficsById/:id"` | Получить трафик клиента по ID
|
||||
| `GET` | `"/createbackup"` | Telegram-бот отправит резервную копию администраторам
|
||||
| `POST` | `"/add"` | Добавить входящее соединение
|
||||
| `POST` | `"/del/:id"` | Удалить входящее соединение
|
||||
| `POST` | `"/update/:id"` | Обновить входящее соединение
|
||||
| `POST` | `"/clientIps/:email"` | IP-адрес клиента
|
||||
| `POST` | `"/clearClientIps/:email"` | Очистить IP-адреса клиента
|
||||
| `POST` | `"/addClient"` | Добавить клиента к входящему соединению
|
||||
| `POST` | `"/:id/delClient/:clientId"` | Удалить клиента по clientId\*
|
||||
| `POST` | `"/updateClient/:clientId"` | Обновить клиента по clientId\*
|
||||
| `POST` | `"/:id/resetClientTraffic/:email"` | Сбросить трафик клиента
|
||||
| `POST` | `"/resetAllTraffics"` | Сбросить трафик всех входящих соединений
|
||||
| `POST` | `"/resetAllClientTraffics/:id"` | Сбросить трафик всех клиентов в входящем соединении
|
||||
| `POST` | `"/delDepletedClients/:id"` | Удалить истекших клиентов в входящем соединении (-1: всех)
|
||||
| `POST` | `"/onlines"` | Получить пользователей, которые находятся онлайн (список email'ов)
|
||||
|
||||
\*- Поле `clientId` должно быть заполнено следующим образом:
|
||||
|
||||
- `client.id` для VMESS и VLESS
|
||||
- `client.password` для TROJAN
|
||||
- `client.email` для Shadowsocks
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
|
||||
</details>
|
||||
|
||||
## Переменные среды
|
||||
|
||||
<details>
|
||||
<summary>Нажмите для получения информации о переменных среды</summary>
|
||||
|
||||
#### Использование
|
||||
|
||||
| Переменная | Тип | Значение по умолчанию |
|
||||
| ---------------- | :------------------------------------------: | :-------------------- |
|
||||
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
|
||||
| XUI_DEBUG | `boolean` | `false` |
|
||||
| XUI_BIN_FOLDER | `string` | `"bin"` |
|
||||
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
|
||||
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
|
||||
|
||||
Пример:
|
||||
|
||||
```sh
|
||||
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Предварительный Просмотр
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||
<img alt="3x-ui" src="./media/01-overview-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
|
||||
<img alt="3x-ui" src="./media/02-inbounds-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
|
||||
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
|
||||
<img alt="3x-ui" src="./media/04-add-client-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
|
||||
<img alt="3x-ui" src="./media/05-settings-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
|
||||
<img alt="3x-ui" src="./media/06-configs-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
|
||||
<img alt="3x-ui" src="./media/07-bot-light.png">
|
||||
</picture>
|
||||
Полную документацию смотрите в [вики проекта](https://github.com/MHSanaei/3x-ui/wiki).
|
||||
|
||||
## Особая благодарность
|
||||
|
||||
@@ -586,9 +36,22 @@ XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
|
||||
## Благодарности
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
|
||||
|
||||
## Число звёзд со временем
|
||||
## Поддержка проекта
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
**Если этот проект полезен для вас, вы можете поставить ему**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## Звезды с течением времени
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+29
-559
@@ -1,4 +1,4 @@
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
@@ -7,571 +7,28 @@
|
||||
</picture>
|
||||
</p>
|
||||
|
||||
**一个更好的面板 • 基于Xray Core构建**
|
||||
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases)
|
||||
[](https://github.com/MHSanaei/3x-ui/actions)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://github.com/MHSanaei/3x-ui/releases/latest)
|
||||
[](https://www.gnu.org/licenses/gpl-3.0.en.html)
|
||||
[](https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3)
|
||||
[](https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3)
|
||||
|
||||
> **Disclaimer:** 此项目仅供个人学习交流,请不要用于非法目的,请不要在生产环境中使用。
|
||||
**3X-UI** — 一个基于网页的高级开源控制面板,专为管理 Xray-core 服务器而设计。它提供了用户友好的界面,用于配置和监控各种 VPN 和代理协议。
|
||||
|
||||
**如果此项目对你有用,请给一个**:star2:
|
||||
> [!IMPORTANT]
|
||||
> 本项目仅用于个人使用和通信,请勿将其用于非法目的,请勿在生产环境中使用。
|
||||
|
||||
<p align="left">
|
||||
<a href="https://buymeacoffee.com/mhsanaei" target="_blank">
|
||||
<img src="./media/buymeacoffe.png" alt="Image">
|
||||
</a>
|
||||
</p>
|
||||
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
|
||||
|
||||
- USDT (TRC20): `TXncxkvhkDWGts487Pjqq1qT9JmwRUz8CC`
|
||||
- MATIC (polygon): `0x41C9548675D044c6Bfb425786C765bc37427256A`
|
||||
- LTC (Litecoin): `ltc1q2ach7x6d2zq0n4l0t4zl7d7xe2s6fs7a3vspwv`
|
||||
|
||||
## 安装 & 升级
|
||||
## 快速开始
|
||||
|
||||
```
|
||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||
```
|
||||
|
||||
## 安装旧版本 (我们不建议)
|
||||
|
||||
要安装您想要的版本,请使用以下安装命令。例如,ver `v1.7.9`:
|
||||
|
||||
```
|
||||
VERSION=v1.7.9 && <(curl -Ls "https://raw.githubusercontent.com/mhsanaei/3x-ui/$VERSION/install.sh") $VERSION
|
||||
```
|
||||
|
||||
### SSL证书
|
||||
|
||||
<details>
|
||||
<summary>点击查看SSL证书详情</summary>
|
||||
|
||||
### ACME
|
||||
|
||||
使用ACME管理SSL证书:
|
||||
|
||||
1. 确保您的域名正确解析到服务器。
|
||||
2. 在终端中运行 `x-ui` 命令,然后选择 `SSL证书管理`。
|
||||
3. 您将看到以下选项:
|
||||
|
||||
- **Get SSL:** 获取SSL证书。
|
||||
- **Revoke:** 吊销现有的SSL证书。
|
||||
- **Force Renew:** 强制更新SSL证书。
|
||||
- **Show Existing Domains:** 显示服务器上所有可用的域证书。
|
||||
- **Set Certificate Paths for the Panel:** 指定用于面板的域证书。
|
||||
|
||||
### Certbot
|
||||
|
||||
安装并使用Certbot:
|
||||
|
||||
```sh
|
||||
apt-get install certbot -y
|
||||
certbot certonly --standalone --agree-tos --register-unsafely-without-email -d yourdomain.com
|
||||
certbot renew --dry-run
|
||||
```
|
||||
|
||||
### Cloudflare
|
||||
|
||||
管理脚本内置了Cloudflare的SSL证书申请。要使用此脚本申请证书,您需要以下信息:
|
||||
|
||||
- Cloudflare注册的电子邮件
|
||||
- Cloudflare全局API密钥
|
||||
- 域名必须通过Cloudflare解析到当前服务器
|
||||
|
||||
**如何获取Cloudflare全局API密钥:**
|
||||
|
||||
1. 在终端中运行 `x-ui` 命令,然后选择 `Cloudflare SSL证书`。
|
||||
2. 访问链接:[Cloudflare API Tokens](https://dash.cloudflare.com/profile/api-tokens)。
|
||||
3. 点击“查看全局API密钥”(参见下图):
|
||||

|
||||
4. 您可能需要重新验证您的账户。之后将显示API密钥(参见下图):
|
||||

|
||||
|
||||
使用时,只需输入您的 `域名`、`电子邮件` 和 `API密钥`。如下图所示:
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## 手动安装 & 升级
|
||||
|
||||
<details>
|
||||
<summary>点击查看 手动安装 & 升级</summary>
|
||||
|
||||
#### 使用
|
||||
|
||||
1. 若要将最新版本的压缩包直接下载到服务器,请运行以下命令:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
|
||||
wget https://github.com/MHSanaei/3x-ui/releases/latest/download/x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
```
|
||||
|
||||
2. 下载压缩包后,执行以下命令安装或升级 x-ui:
|
||||
|
||||
```sh
|
||||
ARCH=$(uname -m)
|
||||
case "${ARCH}" in
|
||||
x86_64 | x64 | amd64) XUI_ARCH="amd64" ;;
|
||||
i*86 | x86) XUI_ARCH="386" ;;
|
||||
armv8* | armv8 | arm64 | aarch64) XUI_ARCH="arm64" ;;
|
||||
armv7* | armv7) XUI_ARCH="armv7" ;;
|
||||
armv6* | armv6) XUI_ARCH="armv6" ;;
|
||||
armv5* | armv5) XUI_ARCH="armv5" ;;
|
||||
*) XUI_ARCH="amd64" ;;
|
||||
esac
|
||||
|
||||
cd /root/
|
||||
rm -rf x-ui/ /usr/local/x-ui/ /usr/bin/x-ui
|
||||
tar zxvf x-ui-linux-${XUI_ARCH}.tar.gz
|
||||
chmod +x x-ui/x-ui x-ui/bin/xray-linux-* x-ui/x-ui.sh
|
||||
cp x-ui/x-ui.sh /usr/bin/x-ui
|
||||
cp -f x-ui/x-ui.service /etc/systemd/system/
|
||||
mv x-ui/ /usr/local/
|
||||
systemctl daemon-reload
|
||||
systemctl enable x-ui
|
||||
systemctl restart x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 通过Docker安装
|
||||
|
||||
<details>
|
||||
<summary>点击查看 通过Docker安装</summary>
|
||||
|
||||
#### 使用
|
||||
|
||||
1. 安装Docker:
|
||||
|
||||
```sh
|
||||
bash <(curl -sSL https://get.docker.com)
|
||||
```
|
||||
|
||||
2. 克隆仓库:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MHSanaei/3x-ui.git
|
||||
cd 3x-ui
|
||||
```
|
||||
|
||||
3. 运行服务:
|
||||
|
||||
```sh
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
或
|
||||
|
||||
```sh
|
||||
docker run -itd \
|
||||
-e XRAY_VMESS_AEAD_FORCED=false \
|
||||
-v $PWD/db/:/etc/x-ui/ \
|
||||
-v $PWD/cert/:/root/cert/ \
|
||||
--network=host \
|
||||
--restart=unless-stopped \
|
||||
--name 3x-ui \
|
||||
ghcr.io/mhsanaei/3x-ui:latest
|
||||
```
|
||||
|
||||
更新至最新版本
|
||||
|
||||
```sh
|
||||
cd 3x-ui
|
||||
docker compose down
|
||||
docker compose pull 3x-ui
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
从Docker中删除3x-ui
|
||||
|
||||
```sh
|
||||
docker stop 3x-ui
|
||||
docker rm 3x-ui
|
||||
cd --
|
||||
rm -r 3x-ui
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Nginx 设置
|
||||
<details>
|
||||
<summary>点击查看 反向代理配置</summary>
|
||||
|
||||
#### Nginx反向代理
|
||||
```nginx
|
||||
location / {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
|
||||
#### Nginx子路径
|
||||
- 确保 `/sub` 面板设置中的"面板url根路径"一致
|
||||
- 面板设置中的 `url` 需要以 `/` 结尾
|
||||
|
||||
```nginx
|
||||
location /sub {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Range $http_range;
|
||||
proxy_set_header If-Range $http_if_range;
|
||||
proxy_redirect off;
|
||||
proxy_pass http://127.0.0.1:2053;
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## 建议使用的操作系统
|
||||
|
||||
- Ubuntu 20.04+
|
||||
- Debian 11+
|
||||
- CentOS 8+
|
||||
- OpenEuler 22.03+
|
||||
- Fedora 36+
|
||||
- Arch Linux
|
||||
- Parch Linux
|
||||
- Manjaro
|
||||
- Armbian
|
||||
- AlmaLinux 8.0+
|
||||
- Rocky Linux 8+
|
||||
- Oracle Linux 8+
|
||||
- OpenSUSE Tubleweed
|
||||
- Amazon Linux 2023
|
||||
- Windows x64
|
||||
|
||||
## 支持的架构和设备
|
||||
<details>
|
||||
<summary>点击查看 支持的架构和设备</summary>
|
||||
|
||||
我们的平台提供与各种架构和设备的兼容性,确保在各种计算环境中的灵活性。以下是我们支持的关键架构:
|
||||
|
||||
- **amd64**: 这种流行的架构是个人计算机和服务器的标准,可以无缝地适应大多数现代操作系统。
|
||||
|
||||
- **x86 / i386**: 这种架构在台式机和笔记本电脑中被广泛采用,得到了众多操作系统和应用程序的广泛支持,包括但不限于 Windows、macOS 和 Linux 系统。
|
||||
|
||||
- **armv8 / arm64 / aarch64**: 这种架构专为智能手机和平板电脑等当代移动和嵌入式设备量身定制,以 Raspberry Pi 4、Raspberry Pi 3、Raspberry Pi Zero 2/Zero 2 W、Orange Pi 3 LTS 等设备为例。
|
||||
|
||||
- **armv7 / arm / arm32**: 作为较旧的移动和嵌入式设备的架构,它仍然广泛用于Orange Pi Zero LTS、Orange Pi PC Plus、Raspberry Pi 2等设备。
|
||||
|
||||
- **armv6 / arm / arm32**: 这种架构面向非常老旧的嵌入式设备,虽然不太普遍,但仍在使用中。Raspberry Pi 1、Raspberry Pi Zero/Zero W 等设备都依赖于这种架构。
|
||||
|
||||
- **armv5 / arm / arm32**: 它是一种主要与早期嵌入式系统相关的旧架构,目前不太常见,但仍可能出现在早期 Raspberry Pi 版本和一些旧智能手机等传统设备中。
|
||||
</details>
|
||||
|
||||
## Languages
|
||||
|
||||
- English(英语)
|
||||
- Persian(波斯语)
|
||||
- Traditional Chinese(繁体中文)
|
||||
- Simplified Chinese(简体中文)
|
||||
- Japanese(日语)
|
||||
- Russian(俄语)
|
||||
- Vietnamese(越南语)
|
||||
- Spanish(西班牙语)
|
||||
- Indonesian(印尼语)
|
||||
- Ukrainian(乌克兰语)
|
||||
- Turkish(土耳其语)
|
||||
- Português (Brazil)(葡萄牙语(巴西))
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- 系统状态监控
|
||||
- 在所有入站和客户端中搜索
|
||||
- 深色/浅色主题
|
||||
- 支持多用户和多协议
|
||||
- 支持多种协议,包括 VMess、VLESS、Trojan、Shadowsocks、Dokodemo-door、Socks、HTTP、wireguard
|
||||
- 支持 XTLS 原生协议,包括 RPRX-Direct、Vision、REALITY
|
||||
- 流量统计、流量限制、过期时间限制
|
||||
- 可自定义的 Xray配置模板
|
||||
- 支持HTTPS访问面板(自建域名+SSL证书)
|
||||
- 支持一键式SSL证书申请和自动续费
|
||||
- 更多高级配置项目请参考面板
|
||||
- 修复了 API 路由(用户设置将使用 API 创建)
|
||||
- 支持通过面板中提供的不同项目更改配置。
|
||||
- 支持从面板导出/导入数据库
|
||||
|
||||
|
||||
## 默认面板设置
|
||||
|
||||
<details>
|
||||
<summary>点击查看默认设置详情</summary>
|
||||
|
||||
### 用户名、密码、端口和 Web Base Path
|
||||
|
||||
如果您选择不修改这些设置,它们将随机生成(不适用于 Docker)。
|
||||
|
||||
**Docker 的默认设置:**
|
||||
- **用户名:** admin
|
||||
- **密码:** admin
|
||||
- **端口:** 2053
|
||||
|
||||
### 数据库管理:
|
||||
|
||||
您可以直接在面板中方便地进行数据库备份和还原。
|
||||
|
||||
- **数据库路径:**
|
||||
- `/etc/x-ui/x-ui.db`
|
||||
|
||||
### Web 基础路径
|
||||
|
||||
1. **重置 Web 基础路径:**
|
||||
- 打开终端。
|
||||
- 运行 `x-ui` 命令。
|
||||
- 选择 `重置 Web 基础路径` 选项。
|
||||
|
||||
2. **生成或自定义路径:**
|
||||
- 路径将会随机生成,或者您可以输入自定义路径。
|
||||
|
||||
3. **查看当前设置:**
|
||||
- 要查看当前设置,请在终端中使用 `x-ui settings` 命令,或在 `x-ui` 面板中点击 `查看当前设置`。
|
||||
|
||||
### 安全建议:
|
||||
- 为了提高安全性,建议在URL结构中使用一个长的随机词。
|
||||
|
||||
**示例:**
|
||||
- `http://ip:port/*webbasepath*/panel`
|
||||
- `http://domain:port/*webbasepath*/panel`
|
||||
|
||||
</details>
|
||||
|
||||
## WARP 配置
|
||||
|
||||
<details>
|
||||
<summary>点击查看 WARP 配置详情</summary>
|
||||
|
||||
#### 使用方法
|
||||
|
||||
**对于 `v2.1.0` 及之后的版本:**
|
||||
|
||||
WARP 已内置,无需额外安装。只需在面板中开启相关配置即可。
|
||||
|
||||
</details>
|
||||
|
||||
## IP 限制
|
||||
|
||||
<details>
|
||||
<summary>点击查看 IP 限制详情</summary>
|
||||
|
||||
#### 使用方法
|
||||
|
||||
**注意:** 当使用 IP 隧道时,IP 限制将无法正常工作。
|
||||
|
||||
- **对于 `v1.6.1` 及之前的版本:**
|
||||
- IP 限制功能已内置于面板中。
|
||||
|
||||
**对于 `v1.7.0` 及更新的版本:**
|
||||
|
||||
要启用 IP 限制功能,您需要安装 `fail2ban` 及其所需的文件,步骤如下:
|
||||
|
||||
1. 在终端中运行 `x-ui` 命令,然后选择 `IP 限制管理`。
|
||||
2. 您将看到以下选项:
|
||||
|
||||
- **更改封禁时长:** 调整封禁时长。
|
||||
- **解除所有封禁:** 解除当前的所有封禁。
|
||||
- **查看日志:** 查看日志。
|
||||
- **Fail2ban 状态:** 检查 `fail2ban` 的状态。
|
||||
- **重启 Fail2ban:** 重启 `fail2ban` 服务。
|
||||
- **卸载 Fail2ban:** 卸载带有配置的 Fail2ban。
|
||||
|
||||
3. 在面板中通过设置 `Xray 配置/log/访问日志` 为 `./access.log` 添加访问日志路径,然后保存并重启 Xray。
|
||||
|
||||
- **对于 `v2.1.3` 之前的版本:**
|
||||
- 您需要在 Xray 配置中手动设置访问日志路径:
|
||||
|
||||
```sh
|
||||
"log": {
|
||||
"access": "./access.log",
|
||||
"dnsLog": false,
|
||||
"loglevel": "warning"
|
||||
},
|
||||
```
|
||||
|
||||
- **对于 `v2.1.3` 及之后的版本:**
|
||||
- 面板中直接提供了配置 `access.log` 的选项。
|
||||
|
||||
</details>
|
||||
|
||||
## Telegram 机器人
|
||||
|
||||
<details>
|
||||
<summary>点击查看 Telegram 机器人</summary>
|
||||
|
||||
#### 使用
|
||||
|
||||
Web 面板通过 Telegram Bot 支持每日流量、面板登录、数据库备份、系统状态、客户端信息等通知和功能。要使用机器人,您需要在面板中设置机器人相关参数,包括:
|
||||
|
||||
- 电报令牌
|
||||
- 管理员聊天 ID
|
||||
- 通知时间(cron 语法)
|
||||
- 到期日期通知
|
||||
- 流量上限通知
|
||||
- 数据库备份
|
||||
- CPU 负载通知
|
||||
|
||||
|
||||
**参考:**
|
||||
|
||||
- `30 \* \* \* \* \*` - 在每个点的 30 秒处通知
|
||||
- `0 \*/10 \* \* \* \*` - 每 10 分钟的第一秒通知
|
||||
- `@hourly` - 每小时通知
|
||||
- `@daily` - 每天通知 (00:00)
|
||||
- `@weekly` - 每周通知
|
||||
- `@every 8h` - 每8小时通知
|
||||
|
||||
### Telegram Bot 功能
|
||||
|
||||
- 定期报告
|
||||
- 登录通知
|
||||
- CPU 阈值通知
|
||||
- 提前报告的过期时间和流量阈值
|
||||
- 如果将客户的电报用户名添加到用户的配置中,则支持客户端报告菜单
|
||||
- 支持使用UUID(VMESS/VLESS)或密码(TROJAN)搜索报文流量报告 - 匿名
|
||||
- 基于菜单的机器人
|
||||
- 通过电子邮件搜索客户端(仅限管理员)
|
||||
- 检查所有入库
|
||||
- 检查服务器状态
|
||||
- 检查耗尽的用户
|
||||
- 根据请求和定期报告接收备份
|
||||
- 多语言机器人
|
||||
|
||||
### 注册 Telegram bot
|
||||
|
||||
- 与 [Botfather](https://t.me/BotFather) 对话:
|
||||

|
||||
|
||||
- 使用 /newbot 创建新机器人:你需要提供机器人名称以及用户名,注意名称中末尾要包含“bot”
|
||||

|
||||
|
||||
- 启动您刚刚创建的机器人。可以在此处找到机器人的链接。
|
||||

|
||||
|
||||
- 输入您的面板并配置 Telegram 机器人设置,如下所示:
|
||||

|
||||
|
||||
在输入字段编号 3 中输入机器人令牌。
|
||||
在输入字段编号 4 中输入用户 ID。具有此 id 的 Telegram 帐户将是机器人管理员。 (您可以输入多个,只需将它们用“ ,”分开即可)
|
||||
|
||||
- 如何获取TG ID? 使用 [bot](https://t.me/useridinfobot), 启动机器人,它会给你 Telegram 用户 ID。
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## API 路由
|
||||
|
||||
<details>
|
||||
<summary>点击查看 API 路由</summary>
|
||||
|
||||
#### 使用
|
||||
|
||||
- [API 文档](https://www.postman.com/hsanaei/3x-ui/collection/q1l5l0u/3x-ui)
|
||||
- `/login` 使用 `POST` 用户名称 & 密码: `{username: '', password: ''}` 登录
|
||||
- `/panel/api/inbounds` 以下操作的基础:
|
||||
|
||||
| 方法 | 路径 | 操作 |
|
||||
| :----: | ---------------------------------- | --------------------------------- |
|
||||
| `GET` | `"/list"` | 获取所有入站 |
|
||||
| `GET` | `"/get/:id"` | 获取所有入站以及inbound.id |
|
||||
| `GET` | `"/getClientTraffics/:email"` | 通过电子邮件获取客户端流量 |
|
||||
| `GET` | `"/createbackup"` | Telegram 机器人向管理员发送备份 |
|
||||
| `POST` | `"/add"` | 添加入站 |
|
||||
| `POST` | `"/del/:id"` | 删除入站 |
|
||||
| `POST` | `"/update/:id"` | 更新入站 |
|
||||
| `POST` | `"/clientIps/:email"` | 客户端 IP 地址 |
|
||||
| `POST` | `"/clearClientIps/:email"` | 清除客户端 IP 地址 |
|
||||
| `POST` | `"/addClient"` | 将客户端添加到入站 |
|
||||
| `POST` | `"/:id/delClient/:clientId"` | 通过 clientId\* 删除客户端 |
|
||||
| `POST` | `"/updateClient/:clientId"` | 通过 clientId\* 更新客户端 |
|
||||
| `POST` | `"/:id/resetClientTraffic/:email"` | 重置客户端的流量 |
|
||||
| `POST` | `"/resetAllTraffics"` | 重置所有入站的流量 |
|
||||
| `POST` | `"/resetAllClientTraffics/:id"` | 重置入站中所有客户端的流量 |
|
||||
| `POST` | `"/delDepletedClients/:id"` | 删除入站耗尽的客户端 (-1: all) |
|
||||
| `POST` | `"/onlines"` | 获取在线用户 ( 电子邮件列表 ) |
|
||||
|
||||
\*- `clientId` 项应该使用下列数据
|
||||
|
||||
- `client.id` VMESS and VLESS
|
||||
- `client.password` TROJAN
|
||||
- `client.email` Shadowsocks
|
||||
|
||||
- [<img src="https://run.pstmn.io/button.svg" alt="Run In Postman" style="width: 128px; height: 32px;">](https://app.getpostman.com/run-collection/5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D5146551-dda3cab3-0e33-485f-96f9-d4262f437ac5%26entityType%3Dcollection%26workspaceId%3Dd64f609f-485a-4951-9b8f-876b3f917124)
|
||||
</details>
|
||||
|
||||
## 环境变量
|
||||
|
||||
<details>
|
||||
<summary>点击查看 环境变量</summary>
|
||||
|
||||
#### Usage
|
||||
|
||||
| 变量 | Type | 默认 |
|
||||
| -------------- | :--------------------------------------------: | :------------ |
|
||||
| XUI_LOG_LEVEL | `"debug"` \| `"info"` \| `"warn"` \| `"error"` | `"info"` |
|
||||
| XUI_DEBUG | `boolean` | `false` |
|
||||
| XUI_BIN_FOLDER | `string` | `"bin"` |
|
||||
| XUI_DB_FOLDER | `string` | `"/etc/x-ui"` |
|
||||
| XUI_LOG_FOLDER | `string` | `"/var/log"` |
|
||||
|
||||
例子:
|
||||
|
||||
```sh
|
||||
XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 预览
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||
<img alt="3x-ui" src="./media/01-overview-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/02-inbounds-dark.png">
|
||||
<img alt="3x-ui" src="./media/02-inbounds-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-inbound-dark.png">
|
||||
<img alt="3x-ui" src="./media/03-add-inbound-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/04-add-client-dark.png">
|
||||
<img alt="3x-ui" src="./media/04-add-client-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/05-settings-dark.png">
|
||||
<img alt="3x-ui" src="./media/05-settings-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/06-configs-dark.png">
|
||||
<img alt="3x-ui" src="./media/06-configs-light.png">
|
||||
</picture>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./media/07-bot-dark.png">
|
||||
<img alt="3x-ui" src="./media/07-bot-light.png">
|
||||
</picture>
|
||||
完整文档请参阅 [项目Wiki](https://github.com/MHSanaei/3x-ui/wiki)。
|
||||
|
||||
## 特别感谢
|
||||
|
||||
@@ -579,9 +36,22 @@ XUI_BIN_FOLDER="bin" XUI_DB_FOLDER="/etc/x-ui" go build main.go
|
||||
|
||||
## 致谢
|
||||
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
|
||||
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则,内置伊朗域名,专注于安全性和广告拦截。_
|
||||
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
|
||||
|
||||
## Star趋势
|
||||
## 支持项目
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
**如果这个项目对您有帮助,您可以给它一个**:star2:
|
||||
|
||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||
</a>
|
||||
|
||||
</br>
|
||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||
</a>
|
||||
|
||||
## 随时间变化的星标数
|
||||
|
||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||
|
||||
+94
-13
@@ -1,9 +1,14 @@
|
||||
// Package config provides configuration management utilities for the 3x-ui panel,
|
||||
// including version information, logging levels, database paths, and environment variable handling.
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -13,24 +18,29 @@ var version string
|
||||
//go:embed name
|
||||
var name string
|
||||
|
||||
// LogLevel represents the logging level for the application.
|
||||
type LogLevel string
|
||||
|
||||
// Logging level constants
|
||||
const (
|
||||
Debug LogLevel = "debug"
|
||||
Info LogLevel = "info"
|
||||
Notice LogLevel = "notice"
|
||||
Warn LogLevel = "warn"
|
||||
Error LogLevel = "error"
|
||||
Debug LogLevel = "debug"
|
||||
Info LogLevel = "info"
|
||||
Notice LogLevel = "notice"
|
||||
Warning LogLevel = "warning"
|
||||
Error LogLevel = "error"
|
||||
)
|
||||
|
||||
// GetVersion returns the version string of the 3x-ui application.
|
||||
func GetVersion() string {
|
||||
return strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
// GetName returns the name of the 3x-ui application.
|
||||
func GetName() string {
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
// GetLogLevel returns the current logging level based on environment variables or defaults to Info.
|
||||
func GetLogLevel() LogLevel {
|
||||
if IsDebug() {
|
||||
return Debug
|
||||
@@ -42,10 +52,12 @@ func GetLogLevel() LogLevel {
|
||||
return LogLevel(logLevel)
|
||||
}
|
||||
|
||||
// IsDebug returns true if debug mode is enabled via the XUI_DEBUG environment variable.
|
||||
func IsDebug() bool {
|
||||
return os.Getenv("XUI_DEBUG") == "true"
|
||||
}
|
||||
|
||||
// GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
|
||||
func GetBinFolderPath() string {
|
||||
binFolderPath := os.Getenv("XUI_BIN_FOLDER")
|
||||
if binFolderPath == "" {
|
||||
@@ -54,22 +66,91 @@ func GetBinFolderPath() string {
|
||||
return binFolderPath
|
||||
}
|
||||
|
||||
func GetDBFolderPath() string {
|
||||
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
|
||||
if dbFolderPath == "" {
|
||||
dbFolderPath = "/etc/x-ui"
|
||||
func getBaseDir() string {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return dbFolderPath
|
||||
exeDir := filepath.Dir(exePath)
|
||||
exeDirLower := strings.ToLower(filepath.ToSlash(exeDir))
|
||||
if strings.Contains(exeDirLower, "/appdata/local/temp/") || strings.Contains(exeDirLower, "/go-build") {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return wd
|
||||
}
|
||||
return exeDir
|
||||
}
|
||||
|
||||
// GetDBFolderPath returns the path to the database folder based on environment variables or platform defaults.
|
||||
func GetDBFolderPath() string {
|
||||
dbFolderPath := os.Getenv("XUI_DB_FOLDER")
|
||||
if dbFolderPath != "" {
|
||||
return dbFolderPath
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return getBaseDir()
|
||||
}
|
||||
return "/etc/x-ui"
|
||||
}
|
||||
|
||||
// GetDBPath returns the full path to the database file.
|
||||
func GetDBPath() string {
|
||||
return fmt.Sprintf("%s/%s.db", GetDBFolderPath(), GetName())
|
||||
}
|
||||
|
||||
// GetLogFolder returns the path to the log folder based on environment variables or platform defaults.
|
||||
func GetLogFolder() string {
|
||||
logFolderPath := os.Getenv("XUI_LOG_FOLDER")
|
||||
if logFolderPath == "" {
|
||||
logFolderPath = "/var/log"
|
||||
if logFolderPath != "" {
|
||||
return logFolderPath
|
||||
}
|
||||
return logFolderPath
|
||||
if runtime.GOOS == "windows" {
|
||||
return filepath.Join(".", "log")
|
||||
}
|
||||
return "/var/log/x-ui"
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
_, err = io.Copy(out, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return out.Sync()
|
||||
}
|
||||
|
||||
func init() {
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
if os.Getenv("XUI_DB_FOLDER") != "" {
|
||||
return
|
||||
}
|
||||
oldDBFolder := "/etc/x-ui"
|
||||
oldDBPath := fmt.Sprintf("%s/%s.db", oldDBFolder, GetName())
|
||||
newDBFolder := GetDBFolderPath()
|
||||
newDBPath := fmt.Sprintf("%s/%s.db", newDBFolder, GetName())
|
||||
_, err := os.Stat(newDBPath)
|
||||
if err == nil {
|
||||
return // new exists
|
||||
}
|
||||
_, err = os.Stat(oldDBPath)
|
||||
if os.IsNotExist(err) {
|
||||
return // old does not exist
|
||||
}
|
||||
_ = copyFile(oldDBPath, newDBPath) // ignore error
|
||||
}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.5.5
|
||||
3.0.2
|
||||
+198
-16
@@ -1,16 +1,22 @@
|
||||
// Package database provides database initialization, migration, and management utilities
|
||||
// for the 3x-ui panel using GORM with SQLite.
|
||||
package database
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"x-ui/config"
|
||||
"x-ui/database/model"
|
||||
"x-ui/xray"
|
||||
"github.com/mhsanaei/3x-ui/v3/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -22,20 +28,27 @@ var db *gorm.DB
|
||||
const (
|
||||
defaultUsername = "admin"
|
||||
defaultPassword = "admin"
|
||||
defaultSecret = ""
|
||||
)
|
||||
|
||||
func initModels() error {
|
||||
models := []interface{}{
|
||||
models := []any{
|
||||
&model.User{},
|
||||
&model.Inbound{},
|
||||
&model.OutboundTraffics{},
|
||||
&model.Setting{},
|
||||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.CustomGeoResource{},
|
||||
&model.Node{},
|
||||
&model.ApiToken{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
for _, mdl := range models {
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("Error auto migrating model: %v", err)
|
||||
return err
|
||||
}
|
||||
@@ -43,6 +56,28 @@ func initModels() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
const dupPrefix = "duplicate column name:"
|
||||
if !strings.Contains(errMsg, dupPrefix) {
|
||||
return false
|
||||
}
|
||||
idx := strings.Index(errMsg, dupPrefix)
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
|
||||
col = strings.Trim(col, "`\"[]")
|
||||
if col == "" {
|
||||
return false
|
||||
}
|
||||
return db != nil && db.Migrator().HasColumn(mdl, col)
|
||||
}
|
||||
|
||||
// initUser creates a default admin user if the users table is empty.
|
||||
func initUser() error {
|
||||
empty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
@@ -50,25 +85,119 @@ func initUser() error {
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(defaultPassword)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Error hashing default password: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: defaultUsername,
|
||||
Password: defaultPassword,
|
||||
LoginSecret: defaultSecret,
|
||||
Username: defaultUsername,
|
||||
Password: hashedPassword,
|
||||
}
|
||||
return db.Create(user).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runSeeders migrates user passwords to bcrypt and records seeder execution to prevent re-running.
|
||||
func runSeeders(isUsersEmpty bool) error {
|
||||
empty, err := isTableEmpty("history_of_seeders")
|
||||
if err != nil {
|
||||
log.Printf("Error checking if users table is empty: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if empty && isUsersEmpty {
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return seedApiTokens()
|
||||
}
|
||||
|
||||
var seedersHistory []string
|
||||
if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &seedersHistory).Error; err != nil {
|
||||
log.Printf("Error fetching seeder history: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "UserPasswordHash") && !isUsersEmpty {
|
||||
var users []model.User
|
||||
if err := db.Find(&users).Error; err != nil {
|
||||
log.Printf("Error fetching users for password migration: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(user.Password)
|
||||
if err != nil {
|
||||
log.Printf("Error hashing password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
if err := db.Model(&user).Update("password", hashedPassword).Error; err != nil {
|
||||
log.Printf("Error updating password for user '%s': %v", user.Username, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
hashSeeder := &model.HistoryOfSeeders{
|
||||
SeederName: "UserPasswordHash",
|
||||
}
|
||||
if err := db.Create(hashSeeder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(seedersHistory, "ApiTokensTable") {
|
||||
if err := seedApiTokens(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedApiTokens copies the legacy `apiToken` setting into the new
|
||||
// api_tokens table as a row named "default" so existing central panels
|
||||
// keep working after the upgrade. Idempotent — records itself in
|
||||
// history_of_seeders and only runs when api_tokens is empty.
|
||||
func seedApiTokens() error {
|
||||
empty, err := isTableEmpty("api_tokens")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if empty {
|
||||
var legacy model.Setting
|
||||
err := db.Model(model.Setting{}).Where("key = ?", "apiToken").First(&legacy).Error
|
||||
if err == nil && legacy.Value != "" {
|
||||
row := &model.ApiToken{
|
||||
Name: "default",
|
||||
Token: legacy.Value,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
log.Printf("Error migrating legacy apiToken: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
|
||||
}
|
||||
|
||||
// isTableEmpty returns true if the named table contains zero rows.
|
||||
func isTableEmpty(tableName string) (bool, error) {
|
||||
var count int64
|
||||
err := db.Table(tableName).Count(&count).Error
|
||||
return count == 0, err
|
||||
}
|
||||
|
||||
// InitDB sets up the database connection, migrates models, and runs seeders.
|
||||
func InitDB(dbPath string) error {
|
||||
dir := path.Dir(dbPath)
|
||||
err := os.MkdirAll(dir, fs.ModePerm)
|
||||
err := os.MkdirAll(dir, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -84,21 +213,45 @@ func InitDB(dbPath string) error {
|
||||
c := &gorm.Config{
|
||||
Logger: gormLogger,
|
||||
}
|
||||
db, err = gorm.Open(sqlite.Open(dbPath), c)
|
||||
dsn := dbPath + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
|
||||
db, err = gorm.Open(sqlite.Open(dsn), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA busy_timeout=10000"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sqlDB.Exec("PRAGMA synchronous=NORMAL"); err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(8)
|
||||
sqlDB.SetMaxIdleConns(4)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
if err := initModels(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := initUser(); err != nil {
|
||||
|
||||
isUsersEmpty, err := isTableEmpty("users")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
if err := initUser(); err != nil {
|
||||
return err
|
||||
}
|
||||
return runSeeders(isUsersEmpty)
|
||||
}
|
||||
|
||||
// CloseDB closes the database connection if it exists.
|
||||
func CloseDB() error {
|
||||
if db != nil {
|
||||
sqlDB, err := db.DB()
|
||||
@@ -110,14 +263,16 @@ func CloseDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB returns the global GORM database instance.
|
||||
func GetDB() *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool {
|
||||
return err == gorm.ErrRecordNotFound
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// IsSQLiteDB checks if the given file is a valid SQLite database by reading its signature.
|
||||
func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||
signature := []byte("SQLite format 3\x00")
|
||||
buf := make([]byte, len(signature))
|
||||
@@ -128,6 +283,7 @@ func IsSQLiteDB(file io.ReaderAt) (bool, error) {
|
||||
return bytes.Equal(buf, signature), nil
|
||||
}
|
||||
|
||||
// Checkpoint performs a WAL checkpoint on the SQLite database to ensure data consistency.
|
||||
func Checkpoint() error {
|
||||
// Update WAL
|
||||
err := db.Exec("PRAGMA wal_checkpoint;").Error
|
||||
@@ -136,3 +292,29 @@ func Checkpoint() error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateSQLiteDB opens the provided sqlite DB path with a throw-away connection
|
||||
// and runs a PRAGMA integrity_check to ensure the file is structurally sound.
|
||||
// It does not mutate global state or run migrations.
|
||||
func ValidateSQLiteDB(dbPath string) error {
|
||||
if _, err := os.Stat(dbPath); err != nil { // file must exist
|
||||
return err
|
||||
}
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := gdb.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
var res string
|
||||
if err := gdb.Raw("PRAGMA integrity_check;").Scan(&res).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if res != "ok" {
|
||||
return errors.New("sqlite integrity check failed: " + res)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+128
-38
@@ -1,44 +1,61 @@
|
||||
// Package model defines the database models and data structures used by the 3x-ui panel.
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"x-ui/util/json_util"
|
||||
"x-ui/xray"
|
||||
"github.com/mhsanaei/3x-ui/v3/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/xray"
|
||||
)
|
||||
|
||||
// Protocol represents the protocol type for Xray inbounds.
|
||||
type Protocol string
|
||||
|
||||
// Protocol constants for different Xray inbound protocols
|
||||
const (
|
||||
VMESS Protocol = "vmess"
|
||||
VLESS Protocol = "vless"
|
||||
DOKODEMO Protocol = "dokodemo-door"
|
||||
Tunnel Protocol = "tunnel"
|
||||
HTTP Protocol = "http"
|
||||
Trojan Protocol = "trojan"
|
||||
Shadowsocks Protocol = "shadowsocks"
|
||||
Socks Protocol = "socks"
|
||||
Mixed Protocol = "mixed"
|
||||
WireGuard Protocol = "wireguard"
|
||||
Hysteria Protocol = "hysteria"
|
||||
Hysteria2 Protocol = "hysteria2"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginSecret string `json:"loginSecret"`
|
||||
// IsHysteria returns true for both "hysteria" and "hysteria2".
|
||||
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
|
||||
// with the literal v2 string would otherwise fall through (#4081).
|
||||
func IsHysteria(p Protocol) bool {
|
||||
return p == Hysteria || p == Hysteria2
|
||||
}
|
||||
|
||||
type Inbound struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UserId int `json:"-"`
|
||||
Up int64 `json:"up" form:"up"`
|
||||
Down int64 `json:"down" form:"down"`
|
||||
Total int64 `json:"total" form:"total"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"`
|
||||
// User represents a user account in the 3x-ui panel.
|
||||
type User struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginEpoch int64 `json:"-" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// config part
|
||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||
type Inbound struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
||||
UserId int `json:"-"` // Associated user ID
|
||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||
AllTime int64 `json:"allTime" form:"allTime" gorm:"default:0"` // All-time traffic usage
|
||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2"` // Traffic reset schedule
|
||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||
ClientStats []xray.ClientTraffic `gorm:"foreignKey:InboundId;references:Id" json:"clientStats" form:"clientStats"` // Client traffic statistics
|
||||
|
||||
// Xray configuration fields
|
||||
Listen string `json:"listen" form:"listen"`
|
||||
Port int `json:"port" form:"port"`
|
||||
Protocol Protocol `json:"protocol" form:"protocol"`
|
||||
@@ -46,9 +63,10 @@ type Inbound struct {
|
||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||
Allocate string `json:"allocate" form:"allocate"`
|
||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||
}
|
||||
|
||||
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
|
||||
type OutboundTraffics struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
||||
@@ -57,17 +75,36 @@ type OutboundTraffics struct {
|
||||
Total int64 `json:"total" form:"total" gorm:"default:0"`
|
||||
}
|
||||
|
||||
// InboundClientIps stores IP addresses associated with inbound clients for access control.
|
||||
type InboundClientIps struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ClientEmail string `json:"clientEmail" form:"clientEmail" gorm:"unique"`
|
||||
Ips string `json:"ips" form:"ips"`
|
||||
}
|
||||
|
||||
// HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.
|
||||
type HistoryOfSeeders struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
SeederName string `json:"seederName"`
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
Token string `json:"token" gorm:"not null"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
// GenXrayInboundConfig generates an Xray inbound configuration from the Inbound model.
|
||||
func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
listen := i.Listen
|
||||
if listen != "" {
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
// Default to 0.0.0.0 (all interfaces) when listen is empty
|
||||
// This ensures proper dual-stack IPv4/IPv6 binding in systems where bindv6only=0
|
||||
if listen == "" {
|
||||
listen = "0.0.0.0"
|
||||
}
|
||||
listen = fmt.Sprintf("\"%v\"", listen)
|
||||
return &xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(listen),
|
||||
Port: i.Port,
|
||||
@@ -76,28 +113,81 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
|
||||
StreamSettings: json_util.RawMessage(i.StreamSettings),
|
||||
Tag: i.Tag,
|
||||
Sniffing: json_util.RawMessage(i.Sniffing),
|
||||
Allocate: json_util.RawMessage(i.Allocate),
|
||||
}
|
||||
}
|
||||
|
||||
// Setting stores key-value configuration settings for the 3x-ui panel.
|
||||
type Setting struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Key string `json:"key" form:"key"`
|
||||
Value string `json:"value" form:"value"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
ID string `json:"id"`
|
||||
Security string `json:"security"`
|
||||
Password string `json:"password"`
|
||||
Flow string `json:"flow"`
|
||||
Email string `json:"email"`
|
||||
LimitIP int `json:"limitIp"`
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"`
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
TgID int64 `json:"tgId" form:"tgId"`
|
||||
SubID string `json:"subId" form:"subId"`
|
||||
Comment string `json:"comment" form:"comment"`
|
||||
Reset int `json:"reset" form:"reset"`
|
||||
// Node represents a remote 3x-ui panel registered with the central panel.
|
||||
// The central panel polls each node's existing /panel/api/server/status
|
||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||
// status fields below.
|
||||
type Node struct {
|
||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" form:"name" gorm:"uniqueIndex"`
|
||||
Remark string `json:"remark" form:"remark"`
|
||||
Scheme string `json:"scheme" form:"scheme"`
|
||||
Address string `json:"address" form:"address"`
|
||||
Port int `json:"port" form:"port"`
|
||||
BasePath string `json:"basePath" form:"basePath"`
|
||||
ApiToken string `json:"apiToken" form:"apiToken"`
|
||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||
|
||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||
// truthful without us having to read LastHeartbeat separately.
|
||||
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
|
||||
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
||||
LatencyMs int `json:"latencyMs"`
|
||||
XrayVersion string `json:"xrayVersion"`
|
||||
CpuPct float64 `json:"cpuPct"`
|
||||
MemPct float64 `json:"memPct"`
|
||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||
LastError string `json:"lastError"`
|
||||
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
type CustomGeoResource struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Type string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
|
||||
Alias string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
|
||||
Url string `json:"url" gorm:"not null"`
|
||||
LocalPath string `json:"localPath" gorm:"column:local_path"`
|
||||
LastUpdatedAt int64 `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
|
||||
LastModified string `json:"lastModified" gorm:"column:last_modified"`
|
||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime;column:created_at"`
|
||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
|
||||
}
|
||||
|
||||
type ClientReverse struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
|
||||
type Client struct {
|
||||
ID string `json:"id,omitempty"` // Unique client identifier
|
||||
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
|
||||
Password string `json:"password,omitempty"` // Client password
|
||||
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
|
||||
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
|
||||
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
|
||||
Email string `json:"email"` // Client email identifier
|
||||
LimitIP int `json:"limitIp"` // IP limit for this client
|
||||
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
|
||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
|
||||
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
|
||||
SubID string `json:"subId" form:"subId"` // Subscription identifier
|
||||
Comment string `json:"comment" form:"comment"` // Client comment
|
||||
Reset int `json:"reset" form:"reset"` // Reset period in days
|
||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsHysteria(t *testing.T) {
|
||||
cases := []struct {
|
||||
in Protocol
|
||||
want bool
|
||||
}{
|
||||
{Hysteria, true},
|
||||
{Hysteria2, true},
|
||||
{VLESS, false},
|
||||
{Shadowsocks, false},
|
||||
{Protocol(""), false},
|
||||
{Protocol("hysteria3"), false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsHysteria(c.in); got != c.want {
|
||||
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -1,17 +1,17 @@
|
||||
---
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
3x-ui:
|
||||
image: ghcr.io/mhsanaei/3x-ui:latest
|
||||
container_name: 3x-ui
|
||||
hostname: yourhostname
|
||||
3xui:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
container_name: 3xui_app
|
||||
# hostname: yourhostname <- optional
|
||||
volumes:
|
||||
- $PWD/db/:/etc/x-ui/
|
||||
- $PWD/cert/:/root/cert/
|
||||
environment:
|
||||
XRAY_VMESS_AEAD_FORCED: "false"
|
||||
X_UI_ENABLE_FAIL2BAN: "true"
|
||||
XUI_ENABLE_FAIL2BAN: "true"
|
||||
tty: true
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "2053:2053"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.vite/
|
||||
*.log
|
||||
@@ -0,0 +1,75 @@
|
||||
# 3x-ui frontend
|
||||
|
||||
Vue 3 + Ant Design Vue 4 + Vite. Multi-page app — one HTML entry per
|
||||
panel route — built into `../web/dist/` and embedded into the Go binary
|
||||
via `embed.FS`.
|
||||
|
||||
## Dev
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite serves on `http://localhost:5173/`. API calls and `/panel/*` routes
|
||||
proxy to the Go panel at `http://localhost:2053/`, so start the Go panel
|
||||
first (`go run main.go`) and then Vite.
|
||||
|
||||
The proxy auto-rewrites `/panel`, `/panel/settings`, `/panel/inbounds`,
|
||||
`/panel/xray` to the matching Vite-served HTML in dev mode (see
|
||||
`MIGRATED_ROUTES` in `vite.config.js`), so the sidebar's
|
||||
production-style links work without round-tripping through Go.
|
||||
|
||||
## Production build
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
Outputs to `../web/dist/` (HTML at the root, hashed JS/CSS under
|
||||
`assets/`). The Go binary embeds this directory at compile time and
|
||||
`web/controller/dist.go` serves the per-page HTML.
|
||||
|
||||
## Lint
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
|
||||
ESLint 10 with `eslint.config.js` (flat config) — `vue3-recommended`
|
||||
plus a few rule overrides for the project's formatting style.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── *.html # Vite entry HTML, one per panel route
|
||||
├── eslint.config.js
|
||||
├── vite.config.js
|
||||
└── src/
|
||||
├── entries/ # Per-page bootstrap (createApp + mount)
|
||||
├── pages/ # One folder per route, each with the page
|
||||
│ ├── index/ # component + helpers + sub-components
|
||||
│ ├── login/
|
||||
│ ├── inbounds/
|
||||
│ ├── xray/
|
||||
│ ├── settings/
|
||||
│ └── sub/
|
||||
├── components/ # Cross-page Vue components
|
||||
├── composables/ # Reusable reactive logic (useTheme, …)
|
||||
├── api/ # Axios setup, CSRF interceptor
|
||||
├── i18n/ # vue-i18n init (locales live in web/translation/)
|
||||
├── models/ # Inbound, Outbound, Status, … domain classes
|
||||
└── utils/ # HttpUtil, ObjectUtil, LanguageManager, …
|
||||
```
|
||||
|
||||
## Adding a new page
|
||||
|
||||
1. Add `frontend/<page>.html` referencing `/src/entries/<page>.js`.
|
||||
2. Add `src/entries/<page>.js` that imports the page component and
|
||||
mounts it.
|
||||
3. Add the page component under `src/pages/<page>/`.
|
||||
4. Register the entry in `rollupOptions.input` in `vite.config.js`.
|
||||
5. If the page is reachable from the sidebar at `/panel/<route>`, add
|
||||
it to `MIGRATED_ROUTES` so the dev proxy serves the Vite HTML.
|
||||
6. Wire the Go controller to `serveDistPage(c, "<page>.html")`.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>API Docs</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/api-docs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
import js from '@eslint/js';
|
||||
import vue from 'eslint-plugin-vue';
|
||||
import vueParser from 'vue-eslint-parser';
|
||||
import globals from 'globals';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', '../web/dist/**'] },
|
||||
js.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.{js,vue}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: false },
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['warn', {
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
}],
|
||||
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||
'no-case-declarations': 'off',
|
||||
|
||||
// Stylistic rules from vue/recommended that don't match the
|
||||
// existing codebase formatting. Disable rather than churn the
|
||||
// whole tree to satisfy them.
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/html-self-closing': 'off',
|
||||
'vue/max-attributes-per-line': 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'vue/multiline-html-element-content-newline': 'off',
|
||||
'vue/html-indent': 'off',
|
||||
'vue/html-closing-bracket-newline': 'off',
|
||||
'vue/attributes-order': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
'vue/one-component-per-file': 'off',
|
||||
'vue/order-in-components': 'off',
|
||||
'vue/attribute-hyphenation': 'off',
|
||||
'vue/v-on-event-hyphenation': 'off',
|
||||
|
||||
// Pervasive in form components ported from the Vue 2 codebase
|
||||
// (parent passes a reactive object; child mutates it in place).
|
||||
// Properly fixing this means rewiring those components to emit
|
||||
// updates — a meaningful architectural change, separate task.
|
||||
'vue/no-mutating-props': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Inbounds</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/inbounds.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Overview</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>Sign in</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Nodes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/nodes.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3199
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "3x-ui-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.3",
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"otpauth": "^9.5.1",
|
||||
"qs": "^6.13.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.1.4",
|
||||
"vue3-persian-datetime-picker": "^1.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"globals": "^17.6.0",
|
||||
"vite": "^8.0.11",
|
||||
"vue-eslint-parser": "^10.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"moment-jalaali": "^0.10.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Settings</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message"></div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/entries/settings.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,111 @@
|
||||
import axios from 'axios';
|
||||
import qs from 'qs';
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
|
||||
const CSRF_TOKEN_PATH = '/csrf-token';
|
||||
|
||||
let csrfToken = null;
|
||||
let csrfFetchPromise = null;
|
||||
let sessionExpired = false;
|
||||
|
||||
function readMetaToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null;
|
||||
}
|
||||
|
||||
async function fetchCsrfToken() {
|
||||
try {
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
const url = (typeof basePath === 'string' && basePath !== '' && basePath !== '/'
|
||||
? basePath.replace(/\/$/, '') + CSRF_TOKEN_PATH
|
||||
: CSRF_TOKEN_PATH);
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json();
|
||||
return json?.success && typeof json.obj === 'string' ? json.obj : null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCsrfToken() {
|
||||
if (csrfToken) return csrfToken;
|
||||
const meta = readMetaToken();
|
||||
if (meta) {
|
||||
csrfToken = meta;
|
||||
return csrfToken;
|
||||
}
|
||||
if (!csrfFetchPromise) csrfFetchPromise = fetchCsrfToken();
|
||||
const fetched = await csrfFetchPromise;
|
||||
csrfFetchPromise = null;
|
||||
if (fetched) csrfToken = fetched;
|
||||
return csrfToken;
|
||||
}
|
||||
|
||||
// Apply the panel's axios defaults + interceptors. Call once at app
|
||||
// startup before any HTTP call goes out.
|
||||
export function setupAxios() {
|
||||
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
|
||||
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH;
|
||||
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
|
||||
axios.defaults.baseURL = basePath;
|
||||
}
|
||||
|
||||
// Seed the cache from the meta tag if a server-rendered page injected
|
||||
// one — saves a round trip on legacy templates that still embed it.
|
||||
csrfToken = readMetaToken();
|
||||
|
||||
axios.interceptors.request.use(
|
||||
async (config) => {
|
||||
config.headers = config.headers || {};
|
||||
const method = (config.method || 'get').toUpperCase();
|
||||
if (!SAFE_METHODS.has(method)) {
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) config.headers['X-CSRF-Token'] = token;
|
||||
}
|
||||
if (config.data instanceof FormData) {
|
||||
config.headers['Content-Type'] = 'multipart/form-data';
|
||||
} else {
|
||||
config.data = qs.stringify(config.data, { arrayFormat: 'repeat' });
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const status = error.response?.status;
|
||||
if (status === 401) {
|
||||
if (!sessionExpired) {
|
||||
sessionExpired = true;
|
||||
const basePath = window.X_UI_BASE_PATH || '/';
|
||||
window.location.replace(basePath);
|
||||
}
|
||||
return new Promise(() => { });
|
||||
}
|
||||
// 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once.
|
||||
const cfg = error.config;
|
||||
if (status === 403 && cfg && !cfg.__csrfRetried) {
|
||||
csrfToken = null;
|
||||
cfg.__csrfRetried = true;
|
||||
const token = await ensureCsrfToken();
|
||||
if (token) {
|
||||
cfg.headers = cfg.headers || {};
|
||||
cfg.headers['X-CSRF-Token'] = token;
|
||||
// axios re-stringifies on retry, so unwind our qs.stringify before
|
||||
// letting the same request flow through the interceptor again.
|
||||
if (typeof cfg.data === 'string') cfg.data = qs.parse(cfg.data);
|
||||
return axios(cfg);
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* WebSocket client for real-time panel updates.
|
||||
*
|
||||
* Public API (kept stable for index.html / inbounds.html / xray.html):
|
||||
* - connect() — open the connection (idempotent)
|
||||
* - disconnect() — close and stop reconnecting
|
||||
* - on(event, callback) — subscribe to event
|
||||
* - off(event, callback) — unsubscribe
|
||||
* - send(data) — send JSON to the server
|
||||
* - isConnected — boolean, current state
|
||||
* - reconnectAttempts — number, attempts since last success
|
||||
* - maxReconnectAttempts — number, give-up threshold
|
||||
*
|
||||
* Built-in events:
|
||||
* 'connected', 'disconnected', 'error', 'message',
|
||||
* plus any server-emitted message type (status, traffic, client_stats, ...).
|
||||
*/
|
||||
export class WebSocketClient {
|
||||
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
|
||||
static #BASE_RECONNECT_MS = 1000;
|
||||
static #MAX_RECONNECT_MS = 30_000;
|
||||
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
|
||||
// cadence rather than giving up forever — a panel that recovers an hour
|
||||
// later should reconnect without a manual page reload.
|
||||
static #SLOW_RETRY_MS = 60_000;
|
||||
|
||||
constructor(basePath = '') {
|
||||
this.basePath = basePath;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnected = false;
|
||||
|
||||
this.ws = null;
|
||||
this.shouldReconnect = true;
|
||||
this.reconnectTimer = null;
|
||||
this.listeners = new Map(); // event → Set<callback>
|
||||
}
|
||||
|
||||
// Open the connection. Safe to call repeatedly — no-op if already
|
||||
// open/connecting. Re-enables reconnects if previously disabled. Cancels
|
||||
// any pending reconnect timer so an external connect() can't race a
|
||||
// delayed retry into spawning a second socket.
|
||||
connect() {
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
this.shouldReconnect = true;
|
||||
this.#cancelReconnect();
|
||||
this.#openSocket();
|
||||
}
|
||||
|
||||
// Close the connection and stop any pending reconnect attempt. Resets the
|
||||
// attempt counter so a future connect() starts fresh from the small backoff.
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
this.#cancelReconnect();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.ws) {
|
||||
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this.isConnected = false;
|
||||
}
|
||||
|
||||
// Subscribe to an event. Re-subscribing the same callback is a no-op.
|
||||
on(event, callback) {
|
||||
if (typeof callback !== 'function') return;
|
||||
let set = this.listeners.get(event);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.listeners.set(event, set);
|
||||
}
|
||||
set.add(callback);
|
||||
}
|
||||
|
||||
// Unsubscribe from an event.
|
||||
off(event, callback) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
set.delete(callback);
|
||||
if (set.size === 0) this.listeners.delete(event);
|
||||
}
|
||||
|
||||
// Send JSON to the server. Drops silently if not connected — callers
|
||||
// should rely on connect()/server pushes rather than client-initiated sends.
|
||||
send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
// ───── internals ─────
|
||||
|
||||
#openSocket() {
|
||||
const url = this.#buildUrl();
|
||||
let socket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: failed to construct connection', err);
|
||||
this.#emit('error', err);
|
||||
this.#scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
this.ws = socket;
|
||||
|
||||
// Every handler must check `this.ws !== socket` first. A previous socket
|
||||
// can still fire events (especially `close`) after we've moved on to a
|
||||
// new one — e.g. connect() called while the old socket is in CLOSING
|
||||
// state. Without the guard, a stale close would null out the freshly
|
||||
// opened socket and silently break send().
|
||||
socket.addEventListener('open', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.#emit('connected');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
this.#onMessage(event);
|
||||
});
|
||||
|
||||
socket.addEventListener('error', (event) => {
|
||||
if (this.ws !== socket) return;
|
||||
// Browsers fire 'error' before 'close' on failure. We surface it for
|
||||
// consumers (so polling fallbacks can engage) but don't log every blip
|
||||
// — bad networks would flood the console otherwise.
|
||||
this.#emit('error', event);
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
if (this.ws !== socket) return;
|
||||
this.isConnected = false;
|
||||
this.ws = null;
|
||||
this.#emit('disconnected');
|
||||
if (this.shouldReconnect) this.#scheduleReconnect();
|
||||
});
|
||||
}
|
||||
|
||||
#buildUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
// basePath comes from window.X_UI_BASE_PATH which is only injected
|
||||
// by the Go binary in production. In dev (Vite serves directly) the
|
||||
// global is missing and basePath would be '' — without the fallback to
|
||||
// '/' we'd build `ws://host:portws` (no separator) and the WebSocket
|
||||
// constructor throws a SyntaxError.
|
||||
let basePath = this.basePath || '/';
|
||||
if (!basePath.startsWith('/')) basePath = '/' + basePath;
|
||||
if (!basePath.endsWith('/')) basePath += '/';
|
||||
return `${protocol}//${window.location.host}${basePath}ws`;
|
||||
}
|
||||
|
||||
#onMessage(event) {
|
||||
const data = event.data;
|
||||
// Reject oversized payloads up front. We compare actual UTF-8 byte
|
||||
// length (via Blob.size) against the limit — string.length counts
|
||||
// UTF-16 code units, which can undercount real bytes by up to 4× for
|
||||
// payloads with non-ASCII characters and bypass the cap.
|
||||
if (typeof data === 'string') {
|
||||
const byteLen = new Blob([data]).size;
|
||||
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
|
||||
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
|
||||
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
}
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.error('WebSocket: invalid JSON message', err);
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
|
||||
console.error('WebSocket: malformed message envelope');
|
||||
return;
|
||||
}
|
||||
this.#emit(message.type, message.payload, message.time);
|
||||
this.#emit('message', message);
|
||||
}
|
||||
|
||||
#emit(event, ...args) {
|
||||
const set = this.listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const callback of set) {
|
||||
try {
|
||||
callback(...args);
|
||||
} catch (err) {
|
||||
console.error(`WebSocket: handler for "${event}" threw`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleReconnect() {
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#cancelReconnect();
|
||||
|
||||
let base;
|
||||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.reconnectAttempts += 1;
|
||||
// Exponential backoff inside the active window.
|
||||
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
|
||||
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
|
||||
} else {
|
||||
// Active window exhausted — keep trying once a minute. The page-level
|
||||
// polling fallback runs in parallel; this just brings WS back when the
|
||||
// network recovers.
|
||||
base = WebSocketClient.#SLOW_RETRY_MS;
|
||||
}
|
||||
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
|
||||
const delay = base * (0.75 + Math.random() * 0.5);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
// clearTimeout doesn't cancel a callback that has already fired but
|
||||
// whose macrotask hasn't run yet — re-check shouldReconnect here so
|
||||
// disconnect() called in that window can't be overridden.
|
||||
if (!this.shouldReconnect) return;
|
||||
this.#openSocket();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
#cancelReconnect() {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
ClusterOutlined,
|
||||
LogoutOutlined,
|
||||
CloseOutlined,
|
||||
MenuOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
|
||||
|
||||
const props = defineProps({
|
||||
basePath: { type: String, default: '' },
|
||||
// Current request URI so the matching menu item highlights.
|
||||
requestUri: { type: String, default: '' },
|
||||
});
|
||||
|
||||
|
||||
const iconByName = {
|
||||
dashboard: DashboardOutlined,
|
||||
user: UserOutlined,
|
||||
setting: SettingOutlined,
|
||||
tool: ToolOutlined,
|
||||
cluster: ClusterOutlined,
|
||||
logout: LogoutOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
};
|
||||
|
||||
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
|
||||
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
|
||||
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
|
||||
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
|
||||
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
|
||||
{ key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: 'logout', icon: 'logout', title: t('logout') },
|
||||
]);
|
||||
|
||||
const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout'));
|
||||
const utilTabs = computed(() => tabs.value.filter((tab) => tab.icon === 'logout'));
|
||||
const activeTab = ref([props.requestUri]);
|
||||
const drawerOpen = ref(false);
|
||||
const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false'));
|
||||
const drawerWidth = 'min(82vw, 320px)';
|
||||
|
||||
async function openLink(key) {
|
||||
if (key === 'logout') {
|
||||
await HttpUtil.post('/logout');
|
||||
window.location.href = props.basePath || '/';
|
||||
return;
|
||||
}
|
||||
if (key.startsWith('http')) {
|
||||
window.open(key);
|
||||
} else {
|
||||
window.location.href = key;
|
||||
}
|
||||
}
|
||||
|
||||
function onCollapse(isCollapsed, type) {
|
||||
// Only persist explicit toggle clicks, not breakpoint-triggered collapses.
|
||||
if (type === 'clickTrigger') {
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, isCollapsed);
|
||||
collapsed.value = isCollapsed;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDrawer() {
|
||||
drawerOpen.value = !drawerOpen.value;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function cycleTheme() {
|
||||
pauseAnimationsUntilLeave('theme-cycle');
|
||||
if (!theme.isDark) {
|
||||
toggleTheme();
|
||||
if (theme.isUltra) toggleUltra();
|
||||
} else if (!theme.isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ant-sidebar">
|
||||
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
|
||||
<div class="sider-brand" :class="{ 'sider-brand-collapsed': collapsed }">
|
||||
<span class="brand-text">{{ collapsed ? '3X' : '3X-UI' }}</span>
|
||||
<button v-if="!collapsed" id="theme-cycle" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="sider-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
|
||||
:wrap-style="{ padding: 0 }" :width="drawerWidth"
|
||||
:body-style="{ padding: 0, display: 'flex', flexDirection: 'column', height: '100%' }"
|
||||
:header-style="{ display: 'none' }" @close="closeDrawer">
|
||||
<div class="drawer-header">
|
||||
<span class="drawer-brand">3X-UI</span>
|
||||
<div class="drawer-header-actions">
|
||||
<button id="theme-cycle-drawer" type="button" class="theme-cycle" :aria-label="t('menu.theme')"
|
||||
:title="t('menu.theme')" @click="cycleTheme">
|
||||
<svg v-if="!theme.isDark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
<svg v-else-if="!theme.isUltra" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
<path fill="none" d="M19 3l0.7 1.4 1.4 0.7-1.4 0.7L19 7.2l-0.7-1.4-1.4-0.7 1.4-0.7z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="drawer-close" type="button" :aria-label="t('close')" @click="closeDrawer">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-nav"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in navTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" class="drawer-menu drawer-utility"
|
||||
@click="({ key }) => openLink(key)">
|
||||
<a-menu-item v-for="tab in utilTabs" :key="tab.key">
|
||||
<component :is="iconByName[tab.icon]" />
|
||||
<span>{{ tab.title }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-drawer>
|
||||
|
||||
<button v-show="!drawerOpen" class="drawer-handle" type="button" :aria-label="t('menu.dashboard')"
|
||||
@click="toggleDrawer">
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.sider-brand,
|
||||
.drawer-brand {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Collapsed sider only has room for the '3X' brand — center it and
|
||||
* hide the theme cycle button (which is `v-if`-ed out in template). */
|
||||
.sider-brand-collapsed {
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
padding: 14px 4px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.sider-brand-collapsed .brand-text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.theme-cycle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s, transform 0.15s, color 0.2s;
|
||||
}
|
||||
|
||||
.theme-cycle:hover,
|
||||
.theme-cycle:focus-visible {
|
||||
background-color: rgba(64, 150, 255, 0.1);
|
||||
color: #4096ff;
|
||||
transform: scale(1.08);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.theme-cycle svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.drawer-header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.drawer-handle {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
border: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.drawer-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.drawer-close:hover,
|
||||
.drawer-close:focus-visible {
|
||||
background: rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.drawer-menu :deep(.ant-menu-item .anticon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.drawer-utility {
|
||||
margin-top: auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.sider-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider-utility {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.drawer-handle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
|
||||
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ant-sidebar>.ant-layout-sider {
|
||||
flex: 0 0 0 !important;
|
||||
max-width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .drawer-brand,
|
||||
body.dark .sider-brand {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-brand,
|
||||
html[data-theme='ultra-dark'] .sider-brand {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
body.dark .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .drawer-close {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .theme-cycle {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .ant-drawer .ant-drawer-content,
|
||||
body.dark .ant-drawer .ant-drawer-body {
|
||||
background: #252526 !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-content,
|
||||
html[data-theme='ultra-dark'] .ant-drawer .ant-drawer-body {
|
||||
background: #0a0a0a !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-selected,
|
||||
.sider-utility .ant-menu-item-selected,
|
||||
.drawer-menu .ant-menu-item-selected {
|
||||
background-color: rgba(64, 150, 255, 0.2) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
|
||||
.sider-nav .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-utility .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.drawer-menu .ant-menu-item-active:not(.ant-menu-item-selected),
|
||||
.sider-nav .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.sider-utility .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover,
|
||||
.drawer-menu .ant-menu-item:not(.ant-menu-item-selected):not(.ant-menu-item-disabled):hover {
|
||||
background-color: rgba(64, 150, 255, 0.1) !important;
|
||||
color: #4096ff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
value: { type: [String, Number], default: '' },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-statistic :title="title" :value="value">
|
||||
<template #prefix>
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix" />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-content) {
|
||||
color: var(--dark-color-text-primary);
|
||||
}
|
||||
|
||||
:global(body.dark .ant-statistic-title) {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import PersianDatePicker from 'vue3-persian-datetime-picker';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
// Drop-in replacement for <a-date-picker> that swaps to a real Jalali
|
||||
// calendar (vue3-persian-datetime-picker, backed by moment-jalaali)
|
||||
// when the panel's "Calendar Type" setting is `jalalian`.
|
||||
//
|
||||
// The v-model contract matches AD-Vue: the parent works with a dayjs
|
||||
// object (or null). For the persian picker we serialize to/from the
|
||||
// `YYYY-MM-DD HH:mm:ss` string it expects so callers don't need to
|
||||
// know which renderer is active.
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [Object, null], default: null },
|
||||
showTime: { type: Boolean, default: true },
|
||||
format: { type: String, default: 'YYYY-MM-DD HH:mm:ss' },
|
||||
placeholder: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value']);
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
const isJalali = computed(() => datepicker.value === 'jalalian');
|
||||
|
||||
const ISO_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
|
||||
// Persian picker's display format — `j…` tokens come from moment-jalaali
|
||||
// and render Jalali year/month/day.
|
||||
const persianDisplayFormat = computed(() =>
|
||||
props.showTime ? 'jYYYY/jMM/jDD HH:mm:ss' : 'jYYYY/jMM/jDD',
|
||||
);
|
||||
|
||||
// Persian picker stores the date as a Gregorian string in the format
|
||||
// it was given via `format`. We normalize on `YYYY-MM-DD HH:mm:ss` so
|
||||
// dayjs(...) round-trips cleanly.
|
||||
const stringValue = computed({
|
||||
get() {
|
||||
const v = props.value;
|
||||
if (!v) return '';
|
||||
return dayjs.isDayjs(v) ? v.format(ISO_FORMAT) : dayjs(v).format(ISO_FORMAT);
|
||||
},
|
||||
set(next) {
|
||||
if (!next) {
|
||||
emit('update:value', null);
|
||||
return;
|
||||
}
|
||||
const parsed = dayjs(next, ISO_FORMAT);
|
||||
emit('update:value', parsed.isValid() ? parsed : null);
|
||||
},
|
||||
});
|
||||
|
||||
function onAntChange(next) {
|
||||
emit('update:value', next || null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
|
||||
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
|
||||
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
|
||||
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
|
||||
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.jalali-datepicker {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Theme overrides for the picker. AD-Vue 4 doesn't expose CSS variables
|
||||
by default (its tokens live in JS), so we hardcode hexes per theme
|
||||
class — `body.dark` for the navy theme, `[data-theme="ultra-dark"]`
|
||||
for the neutral ultra-dark variant. The popup stays inside the
|
||||
wrapper's subtree (no teleport) so global selectors reach it cleanly. -->
|
||||
<style>
|
||||
/* ===== Light (default) =================================================== */
|
||||
|
||||
.persian-datepicker-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 11px;
|
||||
font-size: 14px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
.persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Light theme keeps the picker's brand-blue calendar button (set via
|
||||
* inline style on .vpd-icon-btn) — only its border + corner radius are
|
||||
* normalized so it sits flush with the input. Dark/ultra-dark themes
|
||||
* below override the inline blue so the control matches the form. */
|
||||
.vpd-main .vpd-icon-btn {
|
||||
color: #fff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
/* Match the input's left edge (no rounded left, no double border at the
|
||||
* seam) so it sits flush against the icon-btn. */
|
||||
.persian-datepicker-input {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.vpd-main .vpd-clear-btn {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Width is exactly 316px so the 7-day grid (7 × 40px + 36px padding)
|
||||
* fits flush. Don't add `border` here — box-sizing: border-box would
|
||||
* eat 2px from the content width and the 7th day-cell of each row
|
||||
* wraps. Use box-shadow + a wider radius for the visual edge instead. */
|
||||
.vpd-wrapper .vpd-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-header .vpd-year-label,
|
||||
.vpd-wrapper .vpd-header .vpd-date,
|
||||
.vpd-wrapper .vpd-header .vpd-locales li {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-month-label,
|
||||
.vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-week,
|
||||
.vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
/* The picker's <arrow> component renders an inline SVG with a hardcoded
|
||||
* `fill="#000"` attribute. Override the path fill via CSS so the arrow
|
||||
* is visible in every theme. */
|
||||
.vpd-wrapper .vpd-next svg path,
|
||||
.vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
.vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #1677ff;
|
||||
}
|
||||
|
||||
/* The picker paints disabled days as `darken(#fff, 20%)` (~#cccccc) which
|
||||
* is invisible on white and dark themes alike. Reset the day text color
|
||||
* across all states so days are always readable. */
|
||||
.vpd-wrapper .vpd-day,
|
||||
.vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.88) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day[disabled='true'],
|
||||
.vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(0, 0, 0, 0.25) !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-day:not([disabled='true']):hover .vpd-day-text,
|
||||
.vpd-wrapper .vpd-day.vpd-selected .vpd-day-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list,
|
||||
.vpd-wrapper .vpd-addon-list-content {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
.vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ===== Dark (navy) ======================================================= */
|
||||
|
||||
body.dark .persian-datepicker-input {
|
||||
background: #252526;
|
||||
border-color: #3c3c3c;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:hover {
|
||||
border-color: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .persian-datepicker-input:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
body.dark .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #3c3c3c !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-content {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.48),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-week,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-weekday {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-next svg path,
|
||||
body.dark .vpd-wrapper .vpd-prev svg path {
|
||||
fill: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-next:hover svg path,
|
||||
body.dark .vpd-wrapper .vpd-body .vpd-controls .vpd-prev:hover svg path {
|
||||
fill: #4096ff;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day,
|
||||
body.dark .vpd-wrapper .vpd-day .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'],
|
||||
body.dark .vpd-wrapper .vpd-day[disabled='true'] .vpd-day-text {
|
||||
color: rgba(255, 255, 255, 0.25) !important;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-actions button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #2d2d30;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item {
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item.vpd-selected,
|
||||
body.dark .vpd-wrapper .vpd-addon-list-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .vpd-wrapper .vpd-close-addon {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* ===== Ultra-dark (neutral black) ======================================= */
|
||||
|
||||
html[data-theme='ultra-dark'] .persian-datepicker-input {
|
||||
background: #0a0a0a;
|
||||
border-color: #303030;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-main .vpd-icon-btn {
|
||||
background: rgba(255, 255, 255, 0.04) !important;
|
||||
border: 1px solid #303030 !important;
|
||||
border-right: none !important;
|
||||
border-radius: 6px 0 0 6px !important;
|
||||
color: rgba(255, 255, 255, 0.75) !important;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-content {
|
||||
background: #141414;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-body {
|
||||
background: #141414;
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list,
|
||||
html[data-theme='ultra-dark'] .vpd-wrapper .vpd-addon-list-content {
|
||||
background: #141414;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,510 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { RandomUtil } from '@/utils';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
|
||||
// Mirrors web/html/form/stream/stream_finalmask.html. Used by both the
|
||||
// inbound and outbound modals — they share the same StreamSettings
|
||||
// shape (`stream.finalmask`, `stream.addTcpMask()`, etc.) so a single
|
||||
// component handles both. The host modal passes its protocol through
|
||||
// so we know whether to show only the Hysteria-specific UDP types.
|
||||
const props = defineProps({
|
||||
stream: { type: Object, required: true },
|
||||
protocol: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const isHysteria = computed(() => props.protocol === Protocols.HYSTERIA);
|
||||
const network = computed(() => props.stream?.network || '');
|
||||
|
||||
const showTcp = computed(() => ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(network.value));
|
||||
const showUdp = computed(() => isHysteria.value || network.value === 'kcp');
|
||||
const showQuic = computed(() => isHysteria.value || network.value === 'xhttp');
|
||||
|
||||
// Reset the per-row settings shape when the user picks a different
|
||||
// type — mirrors the legacy `mask._getDefaultSettings(type, {})` call.
|
||||
function changeMaskType(mask, type) {
|
||||
mask.type = type;
|
||||
mask.settings = mask._getDefaultSettings(type, {});
|
||||
}
|
||||
|
||||
// Special case from the legacy form: switching a UDP mask to xdns
|
||||
// shrinks the kcp MTU; everything else needs the default 1350.
|
||||
function changeUdpMaskType(mask, type) {
|
||||
changeMaskType(mask, type);
|
||||
if (network.value === 'kcp' && props.stream.kcp) {
|
||||
props.stream.kcp.mtu = type === 'xdns' ? 900 : 1350;
|
||||
}
|
||||
}
|
||||
|
||||
// header-custom and noise rows share the same per-item shape — the
|
||||
// type select rewires the packet field. Pulled out so the click
|
||||
// handlers in the template stay readable.
|
||||
function changeItemType(item, type) {
|
||||
item.type = type;
|
||||
if (type === 'base64') item.packet = RandomUtil.randomBase64();
|
||||
else if (type === 'array') { item.rand = 0; item.packet = []; }
|
||||
else item.packet = '';
|
||||
}
|
||||
|
||||
function addUdpMaskWithDefault() {
|
||||
const def = isHysteria.value ? 'salamander' : 'mkcp-aes128gcm';
|
||||
props.stream.addUdpMask(def);
|
||||
}
|
||||
|
||||
function newClientServerItem() {
|
||||
return { delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newUdpClientServerItem() {
|
||||
return { rand: 0, randRange: '0-255', type: 'array', packet: [] };
|
||||
}
|
||||
|
||||
function newNoiseItem() {
|
||||
return { rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
|
||||
:wrapper-col="{ md: { span: 14 } }">
|
||||
<!-- ============================== TCP MASKS ============================== -->
|
||||
<template v-if="showTcp">
|
||||
<a-form-item label="TCP Masks">
|
||||
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
TCP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delTcpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeMaskType(mask, t)">
|
||||
<a-select-option value="fragment">Fragment</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="sudoku">Sudoku</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<!-- Fragment -->
|
||||
<template v-if="mask.type === 'fragment'">
|
||||
<a-form-item label="Packets">
|
||||
<a-select v-model:value="mask.settings.packets">
|
||||
<a-select-option value="tlshello">tlshello</a-select-option>
|
||||
<a-select-option value="1-3">1-3</a-select-option>
|
||||
<a-select-option value="1-5">1-5</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Length">
|
||||
<a-input v-model:value="mask.settings.length" placeholder="e.g. 100-200" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="mask.settings.delay" placeholder="e.g. 10-20" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Split">
|
||||
<a-input v-model:value="mask.settings.maxSplit" placeholder="e.g. 3-6" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Sudoku -->
|
||||
<template v-if="mask.type === 'sudoku'">
|
||||
<a-form-item label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ASCII">
|
||||
<a-input v-model:value="mask.settings.ascii" placeholder="ASCII" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Table">
|
||||
<a-input v-model:value="mask.settings.customTable" placeholder="Custom Table" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Custom Tables">
|
||||
<a-input v-model:value="mask.settings.customTables" placeholder="Custom Tables" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Min">
|
||||
<a-input-number v-model:value="mask.settings.paddingMin" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Padding Max">
|
||||
<a-input-number v-model:value="mask.settings.paddingMax" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom — clients/servers as 2D groups -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<!-- Clients -->
|
||||
<a-form-item label="Clients">
|
||||
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Clients Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.clients.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Servers -->
|
||||
<a-form-item label="Servers">
|
||||
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Servers Group {{ gi + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.servers.splice(gi, 1)" />
|
||||
</a-divider>
|
||||
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="item.type" @change="(t) => changeItemType(item, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay (ms)">
|
||||
<a-input-number v-model:value="item.delay" :min="0" />
|
||||
</a-form-item>
|
||||
<template v-if="item.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="item.rand" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="item.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="item.type === 'base64'" compact>
|
||||
<a-input v-model:value="item.packet" placeholder="binary data"
|
||||
:style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="item.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== UDP MASKS ============================== -->
|
||||
<template v-if="showUdp">
|
||||
<a-form-item label="UDP Masks">
|
||||
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
UDP Mask {{ mIdx + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="stream.delUdpMask(mIdx)" />
|
||||
</a-divider>
|
||||
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="mask.type" @change="(t) => changeUdpMaskType(mask, t)">
|
||||
<template v-if="isHysteria">
|
||||
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
|
||||
<a-select-option value="header-dns">Header DNS</a-select-option>
|
||||
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
|
||||
<a-select-option value="header-srtp">Header SRTP</a-select-option>
|
||||
<a-select-option value="header-utp">Header uTP</a-select-option>
|
||||
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
|
||||
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
|
||||
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
|
||||
<a-select-option value="xdns">xDNS</a-select-option>
|
||||
<a-select-option value="xicmp">xICMP</a-select-option>
|
||||
<a-select-option value="header-custom">Header Custom</a-select-option>
|
||||
<a-select-option value="noise">Noise</a-select-option>
|
||||
</template>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)" label="Password">
|
||||
<a-input v-model:value="mask.settings.password" placeholder="Obfuscation password" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'header-dns'" label="Domain">
|
||||
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
|
||||
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
|
||||
:token-separators="[',']" placeholder="e.g., www.example.com" />
|
||||
</a-form-item>
|
||||
|
||||
<!-- Noise -->
|
||||
<template v-if="mask.type === 'noise'">
|
||||
<a-form-item label="Reset">
|
||||
<a-input-number v-model:value="mask.settings.reset" :min="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Noise">
|
||||
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Noise {{ ni + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.noise.splice(ni, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="n.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input v-model:value="n.rand" placeholder="0 or 1-8192" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="n.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="n.type === 'base64'" compact>
|
||||
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="n.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Delay">
|
||||
<a-input v-model:value="n.delay" placeholder="10-20" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Header Custom (UDP) — flat client/server lists -->
|
||||
<template v-if="mask.type === 'header-custom'">
|
||||
<a-form-item label="Client">
|
||||
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Client {{ ci + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.client.splice(ci, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="c.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="c.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="c.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="c.type === 'base64'" compact>
|
||||
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="c.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-divider :style="{ margin: '0' }" />
|
||||
<a-form-item label="Server">
|
||||
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
|
||||
<a-divider :style="{ margin: '0' }">
|
||||
Server {{ si + 1 }}
|
||||
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
|
||||
@click="mask.settings.server.splice(si, 1)" />
|
||||
</a-divider>
|
||||
<a-form-item label="Type">
|
||||
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
|
||||
<a-select-option value="array">Array</a-select-option>
|
||||
<a-select-option value="str">String</a-select-option>
|
||||
<a-select-option value="hex">Hex</a-select-option>
|
||||
<a-select-option value="base64">Base64</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<template v-if="s.type === 'array'">
|
||||
<a-form-item label="Rand">
|
||||
<a-input-number v-model:value="s.rand" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rand Range">
|
||||
<a-input v-model:value="s.randRange" placeholder="0-255" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item v-else label="Packet">
|
||||
<a-input-group v-if="s.type === 'base64'" compact>
|
||||
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
|
||||
<a-button @click="s.packet = RandomUtil.randomBase64()">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- xICMP -->
|
||||
<template v-if="mask.type === 'xicmp'">
|
||||
<a-form-item label="IP">
|
||||
<a-input v-model:value="mask.settings.ip" placeholder="0.0.0.0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="ID">
|
||||
<a-input-number v-model:value="mask.settings.id" :min="0" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============================== QUIC PARAMS ============================== -->
|
||||
<template v-if="showQuic">
|
||||
<a-form-item label="QUIC Params">
|
||||
<a-switch v-model:checked="stream.finalmask.enableQuicParams" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.enableQuicParams && stream.finalmask.quicParams">
|
||||
<a-form-item label="Congestion">
|
||||
<a-select v-model:value="stream.finalmask.quicParams.congestion">
|
||||
<a-select-option value="reno">Reno</a-select-option>
|
||||
<a-select-option value="bbr">BBR</a-select-option>
|
||||
<a-select-option value="brutal">Brutal</a-select-option>
|
||||
<a-select-option value="force-brutal">Force Brutal</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Debug">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.debug" />
|
||||
</a-form-item>
|
||||
<template v-if="['brutal', 'force-brutal'].includes(stream.finalmask.quicParams.congestion)">
|
||||
<a-form-item label="Brutal Up">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalUp" placeholder="65537" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Brutal Down">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.brutalDown" placeholder="65537" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="UDP Hop">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.hasUdpHop" />
|
||||
</a-form-item>
|
||||
<template v-if="stream.finalmask.quicParams.hasUdpHop && stream.finalmask.quicParams.udpHop">
|
||||
<a-form-item label="Hop Ports">
|
||||
<a-input v-model:value="stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Hop Interval (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.udpHop.interval" :min="5" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<a-form-item label="Max Idle Timeout (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Keep Alive Period (s)">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Disable Path MTU Dis">
|
||||
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Incoming Streams">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
|
||||
placeholder="1024 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Stream Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
|
||||
placeholder="8388608 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Init Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Max Conn Window">
|
||||
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
|
||||
placeholder="20971520 = default" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-form>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
// Inline ∞ SVG. The Unicode infinity character (U+221E) renders as an
|
||||
// "m"-shaped glyph in some system fonts (Windows Segoe UI in particular),
|
||||
// so the inbound list and client row table use this SVG instead. The
|
||||
// path matches what the legacy panel embedded.
|
||||
defineProps({
|
||||
width: { type: [String, Number], default: 14 },
|
||||
height: { type: [String, Number], default: 10 },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
|
||||
style="vertical-align: -1px; display: inline-block;">
|
||||
<path
|
||||
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { EditorView, basicSetup } from 'codemirror';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json';
|
||||
import { lintGutter, linter } from '@codemirror/lint';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import { syntaxHighlighting } from '@codemirror/language';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
|
||||
import { theme as themeState } from '@/composables/useTheme.js';
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, default: '' },
|
||||
minHeight: { type: String, default: '320px' },
|
||||
maxHeight: { type: String, default: '600px' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:value', 'change']);
|
||||
|
||||
const host = ref(null);
|
||||
let view = null;
|
||||
const themeCompartment = new Compartment();
|
||||
const readonlyCompartment = new Compartment();
|
||||
|
||||
function buildDarkTheme({ bg, panelBg, activeBg, border, selection }) {
|
||||
return EditorView.theme(
|
||||
{
|
||||
'&': { color: '#dcdcdc', backgroundColor: bg },
|
||||
'.cm-content': { caretColor: '#dcdcdc' },
|
||||
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#dcdcdc' },
|
||||
'.cm-gutters': {
|
||||
backgroundColor: bg,
|
||||
borderRight: `1px solid ${border}`,
|
||||
color: '#6a6a6a',
|
||||
},
|
||||
'.cm-activeLine': { backgroundColor: activeBg },
|
||||
'.cm-activeLineGutter': { backgroundColor: activeBg, color: '#dcdcdc' },
|
||||
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
|
||||
{ backgroundColor: selection },
|
||||
'.cm-panels': { backgroundColor: panelBg, color: '#dcdcdc' },
|
||||
'.cm-panels.cm-panels-top': { borderBottom: `1px solid ${border}` },
|
||||
'.cm-panels.cm-panels-bottom': { borderTop: `1px solid ${border}` },
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: panelBg,
|
||||
border: `1px solid ${border}`,
|
||||
color: '#dcdcdc',
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
}
|
||||
|
||||
const darkTheme = buildDarkTheme({
|
||||
bg: '#1e1e1e',
|
||||
panelBg: '#2d2d30',
|
||||
activeBg: '#252526',
|
||||
border: '#3a3a3c',
|
||||
selection: '#3a3a3c',
|
||||
});
|
||||
|
||||
const ultraDarkTheme = buildDarkTheme({
|
||||
bg: '#0a0a0a',
|
||||
panelBg: '#141414',
|
||||
activeBg: '#141414',
|
||||
border: '#1f1f1f',
|
||||
selection: '#2a2a2a',
|
||||
});
|
||||
|
||||
function themeExtension() {
|
||||
if (!themeState.isDark) return [];
|
||||
const chrome = themeState.isUltra ? ultraDarkTheme : darkTheme;
|
||||
return [chrome, syntaxHighlighting(oneDarkHighlightStyle)];
|
||||
}
|
||||
|
||||
function readonlyExtension() {
|
||||
return EditorState.readOnly.of(props.readonly);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const updateListener = EditorView.updateListener.of((u) => {
|
||||
if (!u.docChanged) return;
|
||||
const next = u.state.doc.toString();
|
||||
if (next === props.value) return;
|
||||
emit('update:value', next);
|
||||
emit('change', next);
|
||||
});
|
||||
|
||||
view = new EditorView({
|
||||
parent: host.value,
|
||||
state: EditorState.create({
|
||||
doc: props.value || '',
|
||||
extensions: [
|
||||
basicSetup,
|
||||
keymap.of([indentWithTab]),
|
||||
json(),
|
||||
linter(jsonParseLinter()),
|
||||
lintGutter(),
|
||||
EditorView.lineWrapping,
|
||||
updateListener,
|
||||
themeCompartment.of(themeExtension()),
|
||||
readonlyCompartment.of(readonlyExtension()),
|
||||
EditorView.theme({
|
||||
'&': { height: '100%' },
|
||||
'.cm-scroller': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: '12px',
|
||||
minHeight: props.minHeight,
|
||||
maxHeight: props.maxHeight,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
watch(() => props.value, (next) => {
|
||||
if (!view) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (next === current) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: current.length, insert: next || '' },
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => themeState.isDark, () => themeState.isUltra],
|
||||
() => {
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: themeCompartment.reconfigure(themeExtension()) });
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.readonly,
|
||||
() => {
|
||||
if (!view) return;
|
||||
view.dispatch({ effects: readonlyCompartment.reconfigure(readonlyExtension()) });
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
focus: () => view?.focus(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="json-editor-host" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.json-editor-host {
|
||||
border: 1px solid var(--ant-color-border, #d9d9d9);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
}
|
||||
|
||||
.json-editor-host :deep(.cm-editor),
|
||||
.json-editor-host :deep(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.json-editor-host:focus-within {
|
||||
border-color: var(--ant-color-primary, #1677ff);
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(body.dark) .json-editor-host {
|
||||
border-color: #3a3a3c;
|
||||
background: #1e1e1e;
|
||||
}
|
||||
|
||||
:global(html[data-theme="ultra-dark"]) .json-editor-host {
|
||||
border-color: #1f1f1f;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
// Generic prompt modal — used by features like "import inbound" that
|
||||
// need a free-form text/textarea input and a confirm callback. The
|
||||
// parent owns the action; this component only surfaces the value via
|
||||
// the `confirm` event when the user clicks OK.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
okText: { type: String, default: 'OK' },
|
||||
// 'text' = single-line input; 'textarea' = multi-line.
|
||||
type: { type: String, default: 'text', validator: (v) => ['text', 'textarea'].includes(v) },
|
||||
initialValue: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm']);
|
||||
|
||||
const value = ref('');
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (next) value.value = props.initialValue;
|
||||
});
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
function ok() { emit('confirm', value.value); }
|
||||
|
||||
// Enter submits when single-line; ctrl+S submits in textarea mode
|
||||
// (matches legacy keybindings).
|
||||
function onKeydown(e) {
|
||||
if (props.type !== 'textarea' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
return;
|
||||
}
|
||||
if (props.type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault();
|
||||
ok();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
|
||||
:confirm-loading="loading" @ok="ok" @cancel="close">
|
||||
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
|
||||
@keydown="onKeydown" />
|
||||
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
paddings: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (value) => ['small', 'default'].includes(value),
|
||||
},
|
||||
});
|
||||
|
||||
const padding = computed(() =>
|
||||
props.paddings === 'small' ? '10px 20px !important' : '20px !important',
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-list-item :style="{ padding }">
|
||||
<a-row :gutter="[8, 16]">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
<template #description>
|
||||
<slot name="description" />
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<slot name="control" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-list-item>
|
||||
</template>
|
||||
@@ -0,0 +1,297 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Array, required: true },
|
||||
labels: { type: Array, default: () => [] },
|
||||
vbWidth: { type: Number, default: 320 },
|
||||
height: { type: Number, default: 80 },
|
||||
stroke: { type: String, default: '#008771' },
|
||||
strokeWidth: { type: Number, default: 2 },
|
||||
maxPoints: { type: Number, default: 120 },
|
||||
showGrid: { type: Boolean, default: true },
|
||||
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
|
||||
fillOpacity: { type: Number, default: 0.15 },
|
||||
showMarker: { type: Boolean, default: true },
|
||||
markerRadius: { type: Number, default: 2.8 },
|
||||
showAxes: { type: Boolean, default: false },
|
||||
yTickStep: { type: Number, default: 25 },
|
||||
tickCountX: { type: Number, default: 4 },
|
||||
paddingLeft: { type: Number, default: 56 },
|
||||
paddingRight: { type: Number, default: 6 },
|
||||
paddingTop: { type: Number, default: 6 },
|
||||
paddingBottom: { type: Number, default: 20 },
|
||||
showTooltip: { type: Boolean, default: false },
|
||||
// Value-range customization. When valueMax is null the chart auto-scales
|
||||
// to the running max of the data (useful for unbounded series like
|
||||
// network throughput or online clients). Defaults preserve the legacy
|
||||
// 0..100 percent behavior so existing callers don't need to change.
|
||||
valueMin: { type: Number, default: 0 },
|
||||
valueMax: { type: [Number, null], default: 100 },
|
||||
// Y-axis tick formatter. Receives the raw value, returns the label.
|
||||
// tooltipFormatter formats the hover-readout; falls back to yFormatter.
|
||||
yFormatter: { type: Function, default: (v) => `${Math.round(v)}%` },
|
||||
tooltipFormatter: { type: Function, default: null },
|
||||
});
|
||||
|
||||
const hoverIdx = ref(-1);
|
||||
|
||||
// Measured CSS width of the SVG. Drives the viewBox so SVG units stay
|
||||
// 1:1 with rendered pixels — otherwise `preserveAspectRatio="none"`
|
||||
// stretches the X axis and squashes axis text horizontally on narrow
|
||||
// containers (mobile). Falls back to the prop until the first measure.
|
||||
const svgRef = ref(null);
|
||||
const measuredWidth = ref(0);
|
||||
const effectiveVbWidth = computed(() => measuredWidth.value > 0 ? measuredWidth.value : props.vbWidth);
|
||||
|
||||
let resizeObserver = null;
|
||||
function measure() {
|
||||
const el = svgRef.value;
|
||||
if (!el) return;
|
||||
const w = el.getBoundingClientRect?.().width || 0;
|
||||
if (w > 0) measuredWidth.value = Math.round(w);
|
||||
}
|
||||
onMounted(() => {
|
||||
measure();
|
||||
if (typeof ResizeObserver !== 'undefined' && svgRef.value) {
|
||||
resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(svgRef.value);
|
||||
} else {
|
||||
window.addEventListener('resize', measure);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
else window.removeEventListener('resize', measure);
|
||||
});
|
||||
|
||||
const viewBoxAttr = computed(() => `0 0 ${effectiveVbWidth.value} ${props.height}`);
|
||||
const drawWidth = computed(() => Math.max(1, effectiveVbWidth.value - props.paddingLeft - props.paddingRight));
|
||||
const drawHeight = computed(() => Math.max(1, props.height - props.paddingTop - props.paddingBottom));
|
||||
const nPoints = computed(() => Math.min(props.data.length, props.maxPoints));
|
||||
|
||||
const dataSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
return props.data.slice(props.data.length - n);
|
||||
});
|
||||
|
||||
const labelsSlice = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (!props.labels?.length || n === 0) return [];
|
||||
const start = Math.max(0, props.labels.length - n);
|
||||
return props.labels.slice(start);
|
||||
});
|
||||
|
||||
// Resolved domain. When valueMax is null we auto-scale; pad the upper
|
||||
// bound by 10% so the line never touches the top edge — looks more
|
||||
// natural and gives the axis a sane ceiling. Floor the dynamic range
|
||||
// at 1 to avoid divide-by-zero on flat-line data (e.g. all zeros).
|
||||
const yDomain = computed(() => {
|
||||
const min = props.valueMin;
|
||||
if (props.valueMax != null) return { min, max: props.valueMax };
|
||||
let max = min;
|
||||
for (const v of dataSlice.value) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n > max) max = n;
|
||||
}
|
||||
if (max <= min) max = min + 1;
|
||||
return { min, max: max * 1.1 };
|
||||
});
|
||||
|
||||
function project(v) {
|
||||
const { min, max } = yDomain.value;
|
||||
const span = max - min;
|
||||
if (span <= 0) return props.paddingTop + drawHeight.value;
|
||||
const clipped = Math.max(min, Math.min(max, Number(v) || 0));
|
||||
const ratio = (clipped - min) / span;
|
||||
return Math.round(props.paddingTop + (drawHeight.value - ratio * drawHeight.value));
|
||||
}
|
||||
|
||||
const pointsArr = computed(() => {
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const slice = dataSlice.value;
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
return slice.map((v, i) => {
|
||||
const x = Math.round(props.paddingLeft + i * dx);
|
||||
return [x, project(v)];
|
||||
});
|
||||
});
|
||||
|
||||
const pointsStr = computed(() => pointsArr.value.map((p) => `${p[0]},${p[1]}`).join(' '));
|
||||
|
||||
const areaPath = computed(() => {
|
||||
if (pointsArr.value.length === 0) return '';
|
||||
const first = pointsArr.value[0];
|
||||
const last = pointsArr.value[pointsArr.value.length - 1];
|
||||
const baseY = props.paddingTop + drawHeight.value;
|
||||
const line = pointsStr.value.replace(/ /g, ' L ');
|
||||
return `M ${first[0]},${baseY} L ${line} L ${last[0]},${baseY} Z`;
|
||||
});
|
||||
|
||||
const gridLines = computed(() => {
|
||||
if (!props.showGrid) return [];
|
||||
const h = drawHeight.value;
|
||||
const w = drawWidth.value;
|
||||
return [0, 0.25, 0.5, 0.75, 1].map((r) => {
|
||||
const y = Math.round(props.paddingTop + h * r);
|
||||
return { x1: props.paddingLeft, y1: y, x2: props.paddingLeft + w, y2: y };
|
||||
});
|
||||
});
|
||||
|
||||
const lastPoint = computed(() => {
|
||||
if (pointsArr.value.length === 0) return null;
|
||||
return pointsArr.value[pointsArr.value.length - 1];
|
||||
});
|
||||
|
||||
// Y-axis tick rendering. We pick a small number of evenly spaced values
|
||||
// inside the resolved domain and run them through yFormatter — that's
|
||||
// what makes "MB/s" / "clients" / "%" all render correctly without the
|
||||
// caller having to subclass the component.
|
||||
const yTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const { min, max } = yDomain.value;
|
||||
const out = [];
|
||||
// For percent-style domains keep the legacy fixed step; otherwise
|
||||
// default to 4 evenly spaced ticks (5 lines including the bottom).
|
||||
if (props.valueMax === 100 && props.valueMin === 0 && props.yTickStep > 0) {
|
||||
for (let p = min; p <= max; p += props.yTickStep) {
|
||||
const y = project(p);
|
||||
out.push({ y, label: props.yFormatter(p) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const ticks = 5;
|
||||
for (let i = 0; i < ticks; i++) {
|
||||
const v = min + ((max - min) * i) / (ticks - 1);
|
||||
out.push({ y: project(v), label: props.yFormatter(v) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const xTicks = computed(() => {
|
||||
if (!props.showAxes) return [];
|
||||
const labels = labelsSlice.value;
|
||||
const n = nPoints.value;
|
||||
if (n === 0) return [];
|
||||
const m = Math.max(2, props.tickCountX);
|
||||
const w = drawWidth.value;
|
||||
const dx = n > 1 ? w / (n - 1) : 0;
|
||||
const out = [];
|
||||
for (let i = 0; i < m; i++) {
|
||||
const idx = Math.round((i * (n - 1)) / (m - 1));
|
||||
const label = labels[idx] != null ? String(labels[idx]) : String(idx);
|
||||
const x = Math.round(props.paddingLeft + idx * dx);
|
||||
out.push({ x, label });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onMouseMove(evt) {
|
||||
if (!props.showTooltip || pointsArr.value.length === 0) return;
|
||||
const rect = evt.currentTarget.getBoundingClientRect();
|
||||
const px = evt.clientX - rect.left;
|
||||
const x = (px / rect.width) * effectiveVbWidth.value;
|
||||
const n = nPoints.value;
|
||||
const dx = n > 1 ? drawWidth.value / (n - 1) : 0;
|
||||
const idx = Math.max(0, Math.min(n - 1, Math.round((x - props.paddingLeft) / (dx || 1))));
|
||||
hoverIdx.value = idx;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
hoverIdx.value = -1;
|
||||
}
|
||||
|
||||
function fmtHoverText() {
|
||||
const idx = hoverIdx.value;
|
||||
if (idx < 0 || idx >= dataSlice.value.length) return '';
|
||||
const raw = Number(dataSlice.value[idx] || 0);
|
||||
const fmt = props.tooltipFormatter || props.yFormatter;
|
||||
const val = fmt(Number.isFinite(raw) ? raw : 0);
|
||||
const lab = labelsSlice.value[idx] != null ? labelsSlice.value[idx] : '';
|
||||
return `${val}${lab ? ' • ' + lab : ''}`;
|
||||
}
|
||||
|
||||
// Stable per-instance gradient id so multiple sparklines on a page
|
||||
// don't clobber each other's <defs id="spkGrad">.
|
||||
const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
|
||||
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
|
||||
<defs>
|
||||
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
|
||||
<stop offset="100%" :stop-color="stroke" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<g v-if="showGrid">
|
||||
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
|
||||
stroke-width="1" class="cpu-grid-line" />
|
||||
</g>
|
||||
|
||||
<g v-if="showAxes">
|
||||
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
|
||||
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
|
||||
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
|
||||
text-anchor="middle" font-size="10">{{ t.label }}</text>
|
||||
</g>
|
||||
|
||||
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
|
||||
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
|
||||
|
||||
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
|
||||
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
|
||||
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
|
||||
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
|
||||
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
|
||||
font-size="11">{{ fmtHoverText() }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sparkline-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Axis labels live on SVG <text> elements; Vue's scoped CSS doesn't
|
||||
reliably hash-attribute SVG descendants, so the dark-mode overrides
|
||||
have to live in a non-scoped block to actually take effect. The
|
||||
numbers are also small, so the dark-theme fills run at ~85% opacity
|
||||
for legibility (the previous 55% was washed out on navy backgrounds). -->
|
||||
<style>
|
||||
.sparkline-svg .cpu-grid-y-text,
|
||||
.sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-y-text,
|
||||
body.dark .sparkline-svg .cpu-grid-x-text {
|
||||
fill: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-text {
|
||||
fill: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-line {
|
||||
stroke: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .sparkline-svg .cpu-grid-h-line {
|
||||
stroke: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script>
|
||||
// Use defineComponent so we can keep the parent + child components in
|
||||
// the same file with the provide() <-> inject relationship intact.
|
||||
import { defineComponent, h, computed, ref, resolveComponent, inject } from 'vue';
|
||||
import { DragOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const ROW_CLASS = 'sortable-row';
|
||||
|
||||
// Sortable a-table — drag-to-reorder rows using Pointer Events.
|
||||
//
|
||||
// Why a custom component:
|
||||
// - Old impl set draggable: true on every row, which broke text selection
|
||||
// in cells and let HTML5 start drags from anywhere on the row. This
|
||||
// version only initiates drag from an explicit handle, via Pointer
|
||||
// Events (one API for mouse + touch + pen).
|
||||
// - During drag, data-source is reordered live; the source row visually
|
||||
// slides into the target slot. The live reorder IS the visual feedback.
|
||||
// - On commit, emits onsort(sourceIndex, targetIndex) — same signature as
|
||||
// before so existing call sites stay unchanged.
|
||||
// - Keyboard support: ArrowUp/ArrowDown move the focused handle's row by
|
||||
// one; Escape cancels an in-flight drag.
|
||||
|
||||
export const TableSortableTrigger = defineComponent({
|
||||
name: 'TableSortableTrigger',
|
||||
props: {
|
||||
itemIndex: { type: Number, required: true },
|
||||
},
|
||||
setup(props) {
|
||||
const sortable = inject('sortable', null);
|
||||
const ariaLabel = computed(() => `Drag to reorder row ${(props.itemIndex ?? 0) + 1}`);
|
||||
|
||||
function onPointerDown(e) {
|
||||
sortable?.startDrag?.(e, props.itemIndex);
|
||||
}
|
||||
|
||||
function onKeyDown(e) {
|
||||
const move = sortable?.moveByKeyboard;
|
||||
if (!move) return;
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1, props.itemIndex);
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(+1, props.itemIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return () => h(DragOutlined, {
|
||||
class: 'sortable-icon',
|
||||
role: 'button',
|
||||
tabindex: 0,
|
||||
'aria-label': ariaLabel.value,
|
||||
onPointerdown: onPointerDown,
|
||||
onKeydown: onKeyDown,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TableSortable',
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
customRow: { type: Function, default: null },
|
||||
rowKey: { type: [String, Function], default: null },
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => ({ filterConfirm: 'OK', filterReset: 'Reset', emptyText: 'No data' }),
|
||||
},
|
||||
},
|
||||
emits: ['onsort'],
|
||||
setup(props, { emit, slots, attrs, expose }) {
|
||||
// null when idle; while dragging:
|
||||
// { sourceIndex, targetIndex, pointerId, sourceKey }
|
||||
const drag = ref(null);
|
||||
const rootRef = ref(null);
|
||||
|
||||
const isDragging = computed(() => drag.value !== null);
|
||||
|
||||
// Resolve the row key for a record. Used to identify the source row
|
||||
// even after data-source is reordered live during drag.
|
||||
function keyOf(record, fallback) {
|
||||
const rk = props.rowKey;
|
||||
if (typeof rk === 'function') return rk(record);
|
||||
if (typeof rk === 'string') return record?.[rk];
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function attachListeners() {
|
||||
document.addEventListener('pointermove', onPointerMove, true);
|
||||
document.addEventListener('pointerup', onPointerUp, true);
|
||||
document.addEventListener('pointercancel', cancelDrag, true);
|
||||
document.addEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function detachListeners() {
|
||||
document.removeEventListener('pointermove', onPointerMove, true);
|
||||
document.removeEventListener('pointerup', onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', cancelDrag, true);
|
||||
document.removeEventListener('keydown', cancelDrag, true);
|
||||
}
|
||||
|
||||
function startDrag(e, sourceIndex) {
|
||||
// Primary button only (mouse left / first touch).
|
||||
if (e.button != null && e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const record = props.dataSource?.[sourceIndex];
|
||||
drag.value = {
|
||||
sourceIndex,
|
||||
targetIndex: sourceIndex,
|
||||
pointerId: e.pointerId,
|
||||
sourceKey: keyOf(record, sourceIndex),
|
||||
};
|
||||
// Capture the pointer so move/up keep firing even if the cursor
|
||||
// leaves the icon. Try/catch — some older browsers throw on capture.
|
||||
if (e.target?.setPointerCapture && e.pointerId != null) {
|
||||
try { e.target.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ }
|
||||
}
|
||||
attachListeners();
|
||||
}
|
||||
|
||||
function onPointerMove(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
const root = rootRef.value;
|
||||
if (!root) return;
|
||||
const rows = root.querySelectorAll(`tr.${ROW_CLASS}`);
|
||||
if (!rows.length) return;
|
||||
const y = e.clientY;
|
||||
const firstRect = rows[0].getBoundingClientRect();
|
||||
const lastRect = rows[rows.length - 1].getBoundingClientRect();
|
||||
let target = d.targetIndex;
|
||||
if (y < firstRect.top) {
|
||||
target = 0;
|
||||
} else if (y > lastRect.bottom) {
|
||||
target = rows.length - 1;
|
||||
} else {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rect = rows[i].getBoundingClientRect();
|
||||
if (y >= rect.top && y <= rect.bottom) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (target !== d.targetIndex) {
|
||||
drag.value = { ...d, targetIndex: target };
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
const d = drag.value;
|
||||
if (!d) return;
|
||||
if (d.pointerId != null && e.pointerId !== d.pointerId) return;
|
||||
detachListeners();
|
||||
const captured = d;
|
||||
drag.value = null;
|
||||
if (captured.sourceIndex !== captured.targetIndex) {
|
||||
emit('onsort', captured.sourceIndex, captured.targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDrag(e) {
|
||||
// Triggered by pointercancel and keydown. For keydown only act on
|
||||
// Escape; otherwise let the event propagate.
|
||||
if (e?.type === 'keydown' && e.key !== 'Escape') return;
|
||||
detachListeners();
|
||||
drag.value = null;
|
||||
}
|
||||
|
||||
function moveByKeyboard(direction, sourceIndex) {
|
||||
const target = sourceIndex + direction;
|
||||
if (target < 0 || target >= (props.dataSource?.length ?? 0)) return;
|
||||
emit('onsort', sourceIndex, target);
|
||||
}
|
||||
|
||||
function customRowRender(record, index) {
|
||||
const parent = typeof props.customRow === 'function' ? props.customRow(record, index) || {} : {};
|
||||
const d = drag.value;
|
||||
const isSource = d && keyOf(record, index) === d.sourceKey;
|
||||
// Vue 3 customRow shape: a flat object of attrs/listeners/class —
|
||||
// no nested props/on like Vue 2.
|
||||
return {
|
||||
...parent,
|
||||
class: { [ROW_CLASS]: true, 'sortable-source-row': !!isSource, ...(parent.class || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
// Render-data: dataSource with the source row spliced into targetIndex.
|
||||
// When idle the original list is returned unchanged so a-table can
|
||||
// diff against a stable reference.
|
||||
const records = computed(() => {
|
||||
const d = drag.value;
|
||||
const src = props.dataSource ?? [];
|
||||
if (!d || d.sourceIndex === d.targetIndex) return src;
|
||||
const list = src.slice();
|
||||
const [item] = list.splice(d.sourceIndex, 1);
|
||||
list.splice(d.targetIndex, 0, item);
|
||||
return list;
|
||||
});
|
||||
|
||||
expose({ startDrag, moveByKeyboard });
|
||||
|
||||
return {
|
||||
rootRef, drag, isDragging, records, slots, attrs,
|
||||
startDrag, moveByKeyboard, customRowRender,
|
||||
};
|
||||
},
|
||||
// provide() needs to live at the options level so child components in
|
||||
// the rendered subtree resolve the same instance methods.
|
||||
provide() {
|
||||
return {
|
||||
sortable: {
|
||||
startDrag: (...a) => this.startDrag(...a),
|
||||
moveByKeyboard: (...a) => this.moveByKeyboard(...a),
|
||||
},
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('pointermove', this.onPointerMove, true);
|
||||
document.removeEventListener('pointerup', this.onPointerUp, true);
|
||||
document.removeEventListener('pointercancel', this.cancelDrag, true);
|
||||
document.removeEventListener('keydown', this.cancelDrag, true);
|
||||
},
|
||||
render() {
|
||||
// Forward every passed slot to a-table by reusing the slot fn
|
||||
// directly. Vue 3 slots are scoped by default so no $scopedSlots dance.
|
||||
const tableSlots = {};
|
||||
for (const name of Object.keys(this.slots)) {
|
||||
tableSlots[name] = this.slots[name];
|
||||
}
|
||||
// Resolved at runtime so the user's app.use(Antd) registration wins;
|
||||
// avoids importing Table directly here.
|
||||
const ATable = resolveComponent('a-table');
|
||||
return h(
|
||||
'div',
|
||||
{ ref: 'rootRef' },
|
||||
[h(
|
||||
ATable,
|
||||
{
|
||||
...this.attrs,
|
||||
'data-source': this.records,
|
||||
'row-key': this.rowKey,
|
||||
customRow: this.customRowRender,
|
||||
locale: this.locale,
|
||||
class: ['sortable-table', { 'sortable-table-dragging': this.isDragging }],
|
||||
},
|
||||
tableSlots,
|
||||
)],
|
||||
);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.sortable-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: grab;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.sortable-icon:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sortable-icon:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.sortable-icon:focus-visible {
|
||||
outline: 2px solid #008771;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.light .sortable-icon {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.light .sortable-icon:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row>td {
|
||||
background: rgba(0, 135, 113, 0.10) !important;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-source-row .routing-index,
|
||||
.sortable-table-dragging .sortable-source-row .outbound-index {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.sortable-table-dragging .sortable-row>td {
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.sortable-table-dragging,
|
||||
.sortable-table-dragging * {
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
// Read-only text modal — used to surface multi-line export blobs
|
||||
// (subscription URLs, raw inbound JSON, generated share links) the
|
||||
// way the legacy txtModal did.
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
content: { type: String, default: '' },
|
||||
// When set, surfaces a download button that writes `content` to a
|
||||
// text file with this name.
|
||||
fileName: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
async function copy(value) {
|
||||
const ok = await ClipboardManager.copyText(value || '');
|
||||
if (ok) {
|
||||
message.success('Copied');
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function download(content, name) {
|
||||
if (!name) return;
|
||||
FileManager.downloadTextFile(content, name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
|
||||
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
|
||||
<template #footer>
|
||||
<a-button v-if="fileName" @click="download(content, fileName)">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
{{ fileName }}
|
||||
</a-button>
|
||||
<a-button type="primary" @click="copy(content)">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
Copy
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-modal-content {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
// Module-scoped reactive ref for the panel's "Calendar Type" setting.
|
||||
// Loaded from /panel/setting/defaultSettings on first use, so any
|
||||
// component (modals, inbound forms, future pages) can read the same
|
||||
// value without prop-drilling and without re-fetching.
|
||||
//
|
||||
// useInbounds (which already reads defaultSettings for its own state)
|
||||
// calls setDatepicker() after its fetch so we don't issue a second
|
||||
// HTTP round-trip on the inbounds page.
|
||||
|
||||
import { readonly, ref } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const datepicker = ref('gregorian');
|
||||
let fetched = false;
|
||||
let pending = null;
|
||||
|
||||
async function loadOnce() {
|
||||
if (fetched) return;
|
||||
if (pending) {
|
||||
await pending;
|
||||
return;
|
||||
}
|
||||
pending = (async () => {
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (msg?.success) {
|
||||
datepicker.value = msg.obj?.datepicker || 'gregorian';
|
||||
}
|
||||
} finally {
|
||||
fetched = true;
|
||||
pending = null;
|
||||
}
|
||||
})();
|
||||
await pending;
|
||||
}
|
||||
|
||||
export function setDatepicker(value) {
|
||||
fetched = true;
|
||||
datepicker.value = value || 'gregorian';
|
||||
}
|
||||
|
||||
export function useDatepicker() {
|
||||
loadOnce();
|
||||
return { datepicker: readonly(datepicker) };
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ref, onBeforeUnmount, onMounted } from 'vue';
|
||||
|
||||
const MOBILE_BREAKPOINT_PX = 768;
|
||||
|
||||
// Vue 3 replacement for the legacy MediaQueryMixin. Returns a reactive
|
||||
// `isMobile` ref that updates on window resize. Use inside <script setup>:
|
||||
//
|
||||
// const { isMobile } = useMediaQuery();
|
||||
export function useMediaQuery(breakpoint = MOBILE_BREAKPOINT_PX) {
|
||||
const compute = () => window.innerWidth <= breakpoint;
|
||||
const isMobile = ref(compute());
|
||||
|
||||
const onResize = () => {
|
||||
isMobile.value = compute();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
return { isMobile };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Lightweight composable that fetches the node list once on mount and
|
||||
// exposes id→name + id→online lookups. Used by the Inbounds page so it
|
||||
// can render a Node selector and a Node column without pulling the
|
||||
// full pages/nodes/useNodes.js (which polls and owns CRUD state).
|
||||
|
||||
import { onMounted, ref, computed } from 'vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
export function useNodeList() {
|
||||
const nodes = ref([]);
|
||||
const fetched = ref(false);
|
||||
|
||||
async function refresh() {
|
||||
const msg = await HttpUtil.get('/panel/api/nodes/list');
|
||||
if (msg?.success) {
|
||||
nodes.value = Array.isArray(msg.obj) ? msg.obj : [];
|
||||
}
|
||||
fetched.value = true;
|
||||
}
|
||||
|
||||
// Indexed by id for O(1) UI lookups (Node column on N-row tables).
|
||||
const byId = computed(() => {
|
||||
const m = new Map();
|
||||
for (const n of nodes.value) m.set(n.id, n);
|
||||
return m;
|
||||
});
|
||||
|
||||
function nameFor(id) {
|
||||
if (id == null) return null;
|
||||
return byId.value.get(id)?.name || null;
|
||||
}
|
||||
|
||||
function isOnline(id) {
|
||||
if (id == null) return true;
|
||||
const n = byId.value.get(id);
|
||||
return n != null && n.enable && n.status === 'online';
|
||||
}
|
||||
|
||||
const hasActive = computed(() => nodes.value.some((n) => n.enable));
|
||||
|
||||
onMounted(refresh);
|
||||
|
||||
return { nodes, fetched, refresh, byId, nameFor, isOnline, hasActive };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { Status } from '@/models/status.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
// Polls /panel/api/server/status and exposes a reactive Status object
|
||||
// + a `fetched` flag so consumers can show a spinner before the first
|
||||
// successful fetch.
|
||||
//
|
||||
// WebSocket integration is intentionally deferred to a later sub-phase.
|
||||
// Polling at 2s is the same fallback the legacy panel falls back to
|
||||
// when its websocket link drops, so we're shipping the proven path
|
||||
// first and adding the websocket on top later.
|
||||
export function useStatus() {
|
||||
const status = shallowRef(new Status());
|
||||
const fetched = ref(false);
|
||||
let timer = null;
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/server/status');
|
||||
if (msg?.success) {
|
||||
status.value = new Status(msg.obj);
|
||||
if (!fetched.value) fetched.value = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get status:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
timer = window.setInterval(refresh, POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer != null) window.clearInterval(timer);
|
||||
});
|
||||
|
||||
return { status, fetched, refresh };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { reactive, computed, watchEffect } from 'vue';
|
||||
import { theme as antdTheme } from 'ant-design-vue';
|
||||
|
||||
// Single shared theme state. `import { theme } from '@/composables/useTheme.js'`
|
||||
// from any component to read/toggle. Boot side-effects (apply current
|
||||
// theme to <body>/<html>) run once at module load so the page is in the
|
||||
// right theme before Vue mounts.
|
||||
|
||||
const STORAGE_DARK = 'dark-mode';
|
||||
const STORAGE_ULTRA = 'isUltraDarkThemeEnabled';
|
||||
|
||||
function readBool(key, fallback) {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) return fallback;
|
||||
return raw === 'true';
|
||||
}
|
||||
|
||||
const isDark = readBool(STORAGE_DARK, true);
|
||||
const isUltra = readBool(STORAGE_ULTRA, false);
|
||||
|
||||
export const theme = reactive({
|
||||
isDark,
|
||||
isUltra,
|
||||
});
|
||||
|
||||
export const currentTheme = computed(() => (theme.isDark ? 'dark' : 'light'));
|
||||
|
||||
// AD-Vue 4 theme config consumed by every page's <a-config-provider>.
|
||||
// Three modes — light / dark / ultra-dark — all share AD-Vue's vanilla
|
||||
// blue primary. Dark uses a neutral grey palette modelled on VS Code's
|
||||
// Dark+ chrome (`#1e1e1e` editor, `#252526` sidebar, `#2d2d30` panel),
|
||||
// so the panel reads as a familiar modern IDE rather than the older
|
||||
// navy shade. Ultra-dark stays pure-black on darkAlgorithm.
|
||||
const DARK_TOKENS = {
|
||||
colorBgBase: '#1e1e1e',
|
||||
colorBgLayout: '#1e1e1e',
|
||||
colorBgContainer: '#252526',
|
||||
colorBgElevated: '#2d2d30',
|
||||
};
|
||||
const ULTRA_DARK_TOKENS = {
|
||||
colorBgBase: '#000',
|
||||
colorBgLayout: '#000',
|
||||
colorBgContainer: '#0a0a0a',
|
||||
colorBgElevated: '#141414',
|
||||
};
|
||||
|
||||
// AD-Vue 4 hardcodes navy `#001529` / `#002140` as the Layout sider
|
||||
// + trigger backgrounds and `#001529` / `#000c17` as the dark Menu item
|
||||
// backgrounds (see node_modules/ant-design-vue/es/{layout,menu}/style/
|
||||
// index.js). Override at the component-token level so the sider blends
|
||||
// with darkAlgorithm's neutral surfaces. Sider/trigger use the same
|
||||
// `#252526` / `#333333` tones VS Code does for its activity bar.
|
||||
const DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#252526',
|
||||
colorBgTrigger: '#333333',
|
||||
colorBgBody: '#1e1e1e',
|
||||
};
|
||||
const ULTRA_DARK_LAYOUT_TOKENS = {
|
||||
colorBgHeader: '#0a0a0a',
|
||||
colorBgTrigger: '#141414',
|
||||
colorBgBody: '#000',
|
||||
};
|
||||
const DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#252526',
|
||||
colorSubItemBg: '#1e1e1e',
|
||||
menuSubMenuBg: '#252526',
|
||||
};
|
||||
const ULTRA_DARK_MENU_TOKENS = {
|
||||
colorItemBg: '#0a0a0a',
|
||||
colorSubItemBg: '#000',
|
||||
menuSubMenuBg: '#0a0a0a',
|
||||
};
|
||||
|
||||
export const antdThemeConfig = computed(() => {
|
||||
if (!theme.isDark) {
|
||||
return { algorithm: antdTheme.defaultAlgorithm };
|
||||
}
|
||||
return {
|
||||
algorithm: antdTheme.darkAlgorithm,
|
||||
token: theme.isUltra ? ULTRA_DARK_TOKENS : DARK_TOKENS,
|
||||
components: {
|
||||
Layout: theme.isUltra ? ULTRA_DARK_LAYOUT_TOKENS : DARK_LAYOUT_TOKENS,
|
||||
Menu: theme.isUltra ? ULTRA_DARK_MENU_TOKENS : DARK_MENU_TOKENS,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export function toggleTheme() {
|
||||
theme.isDark = !theme.isDark;
|
||||
}
|
||||
|
||||
export function toggleUltra() {
|
||||
theme.isUltra = !theme.isUltra;
|
||||
}
|
||||
|
||||
// Briefly disable theme transition animations while a toggle is in
|
||||
// flight, then re-enable on mouseleave. Mirrors the legacy panel's
|
||||
// behavior of preventing flicker when hovering the theme menu.
|
||||
export function pauseAnimationsUntilLeave(elementId) {
|
||||
document.documentElement.setAttribute('data-theme-animations', 'off');
|
||||
const el = document.getElementById(elementId);
|
||||
if (!el) return;
|
||||
const restore = () => {
|
||||
document.documentElement.removeAttribute('data-theme-animations');
|
||||
el.removeEventListener('mouseleave', restore);
|
||||
el.removeEventListener('touchend', restore);
|
||||
};
|
||||
el.addEventListener('mouseleave', restore);
|
||||
el.addEventListener('touchend', restore);
|
||||
}
|
||||
|
||||
// Apply theme to DOM and persist whenever it changes.
|
||||
watchEffect(() => {
|
||||
document.body.setAttribute('class', theme.isDark ? 'dark' : 'light');
|
||||
localStorage.setItem(STORAGE_DARK, String(theme.isDark));
|
||||
|
||||
if (theme.isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}
|
||||
localStorage.setItem(STORAGE_ULTRA, String(theme.isUltra));
|
||||
|
||||
// Keep the global #message container's class in sync so AD-Vue toasts
|
||||
// pick up the right styling.
|
||||
const msg = document.getElementById('message');
|
||||
if (msg) msg.className = theme.isDark ? 'dark' : 'light';
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { WebSocketClient } from '@/api/websocket.js';
|
||||
|
||||
// One client per browser tab (= per multi-page entry). WebSocketClient is
|
||||
// idempotent: repeated connect() calls while the socket is already open
|
||||
// are no-ops, so multiple components on the same page can share a single
|
||||
// underlying connection without each spawning their own.
|
||||
let sharedClient = null;
|
||||
|
||||
function getSharedClient() {
|
||||
if (sharedClient) return sharedClient;
|
||||
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
|
||||
sharedClient = new WebSocketClient(basePath);
|
||||
return sharedClient;
|
||||
}
|
||||
|
||||
// useWebSocket lets a Vue component subscribe to live server-pushed
|
||||
// events. Pass a map of { eventName: handler } and the composable wires
|
||||
// connect()/disconnect() into the component lifecycle and unsubscribes
|
||||
// every handler on unmount so a stale closure can't fire after the
|
||||
// page has moved on.
|
||||
//
|
||||
// Example:
|
||||
// useWebSocket({
|
||||
// traffic: (payload) => applyTrafficEvent(payload),
|
||||
// client_stats: (payload) => applyClientStatsEvent(payload),
|
||||
// invalidate: ({ type }) => { if (type === 'inbounds') refresh(); },
|
||||
// });
|
||||
//
|
||||
// Built-in lifecycle events ('connected' / 'disconnected' / 'error')
|
||||
// can be subscribed to alongside server-emitted types.
|
||||
export function useWebSocket(handlers) {
|
||||
const client = getSharedClient();
|
||||
const entries = Object.entries(handlers || {});
|
||||
|
||||
onMounted(() => {
|
||||
for (const [event, fn] of entries) client.on(event, fn);
|
||||
client.connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const [event, fn] of entries) client.off(event, fn);
|
||||
// Don't disconnect — another mounted component on the same page may
|
||||
// still be subscribed. The client closes naturally on page unload.
|
||||
});
|
||||
|
||||
return { client };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import IndexPage from '@/pages/index/IndexPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing this module triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue renders anything.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import LoginPage from '@/pages/login/LoginPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
// Toasts attach to a #message div the page provides — keeps theme
|
||||
// styling in sync with the rest of the panel.
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import NodesPage from '@/pages/nodes/NodesPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
// Importing useTheme triggers the boot side-effect that applies the
|
||||
// stored theme to <body>/<html> before Vue mounts.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import SettingsPage from '@/pages/settings/SettingsPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
// The sub page is served by the subscription HTTP server (sub/sub.go)
|
||||
// at /<linksPath>/<subId>?html=1. Go injects window.__SUB_PAGE_DATA__
|
||||
// with the parsed traffic/quota/expiry view-model and the rendered
|
||||
// share links — the SPA reads those at mount.
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import SubPage from '@/pages/sub/SubPage.vue';
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(SubPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createApp } from 'vue';
|
||||
import Antd, { message } from 'ant-design-vue';
|
||||
import 'ant-design-vue/dist/reset.css';
|
||||
|
||||
import { setupAxios } from '@/api/axios-init.js';
|
||||
import '@/composables/useTheme.js';
|
||||
import { i18n } from '@/i18n/index.js';
|
||||
import { applyDocumentTitle } from '@/utils';
|
||||
import XrayPage from '@/pages/xray/XrayPage.vue';
|
||||
|
||||
setupAxios();
|
||||
applyDocumentTitle();
|
||||
|
||||
const messageContainer = document.getElementById('message');
|
||||
if (messageContainer) {
|
||||
message.config({ getContainer: () => messageContainer });
|
||||
}
|
||||
|
||||
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
|
||||
@@ -0,0 +1,93 @@
|
||||
// vue-i18n setup. Locale files live in web/translation/*.json — the same
|
||||
// directory the Go binary embeds, so SPA + Telegram bot + subscription
|
||||
// page all read from a single source.
|
||||
//
|
||||
// Usage in a component:
|
||||
// import { useI18n } from 'vue-i18n';
|
||||
// const { t } = useI18n();
|
||||
// ...
|
||||
// <span>{{ t('pages.inbounds.email') }}</span>
|
||||
//
|
||||
// Or via the global helper exposed on the app:
|
||||
// <span>{{ $t('pages.inbounds.email') }}</span>
|
||||
//
|
||||
// The locale follows the `lang` cookie that LanguageManager already
|
||||
// reads/writes — switching language anywhere in the app continues to
|
||||
// trigger a full page reload (matches legacy ergonomics), so we don't
|
||||
// need a runtime locale switcher here.
|
||||
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
|
||||
// Lazy-loaded locales — Vite splits each one into its own chunk. We
|
||||
// eager-load only the active language plus the en-US fallback so the
|
||||
// initial page payload stays small (the inbounds bundle was sitting
|
||||
// at ~700kB gzipped with all 13 locales eager; now ~480kB).
|
||||
//
|
||||
// LanguageManager.setLanguage() does a full reload on change, so
|
||||
// "lazy" here effectively means "load only what this page needs for
|
||||
// its lifetime."
|
||||
const FALLBACK = 'en-US';
|
||||
const lazyModules = import.meta.glob('../../../web/translation/*.json');
|
||||
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
|
||||
|
||||
function moduleKeyFor(code) {
|
||||
return `../../../web/translation/${code}.json`;
|
||||
}
|
||||
|
||||
// Resolve the active locale via LanguageManager so the cookie set on
|
||||
// the legacy panel keeps working after a user upgrades. Falls back
|
||||
// to en-US when the cookie names a language we don't have.
|
||||
let active = LanguageManager.getLanguage();
|
||||
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
|
||||
active = FALLBACK;
|
||||
}
|
||||
|
||||
const messages = {};
|
||||
// Eagerly include the active locale + the fallback (when distinct)
|
||||
// so the very first render has strings ready. Vite still emits these
|
||||
// as their own chunks so the user pays for at most two locales.
|
||||
for (const code of new Set([active, FALLBACK])) {
|
||||
const mod = eagerModules[moduleKeyFor(code)];
|
||||
if (mod) messages[code] = mod.default || mod;
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
// `composition` mode (legacy: false) so `useI18n()` works in
|
||||
// <script setup> blocks.
|
||||
globalInjection: true,
|
||||
locale: active,
|
||||
fallbackLocale: FALLBACK,
|
||||
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
|
||||
// so vue-i18n's default `.`-delimited lookups walk straight into it.
|
||||
messages,
|
||||
// The Go side sometimes interpolates `#variable#` into translated
|
||||
// strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
|
||||
// expects `{var}` — disable warnings about strings that look like
|
||||
// they don't use the new syntax.
|
||||
warnHtmlMessage: false,
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
});
|
||||
|
||||
// Convenience export for non-component contexts (HTTP error toasts,
|
||||
// stores, etc.) that need to look up a translation outside a setup
|
||||
// scope.
|
||||
export function t(key, params) {
|
||||
return i18n.global.t(key, params || {});
|
||||
}
|
||||
|
||||
// loadLocale fetches a locale module on demand and registers it with
|
||||
// vue-i18n. Pages that switch language at runtime (rather than via
|
||||
// LanguageManager's reload) can call this to swap strings live.
|
||||
export async function loadLocale(code) {
|
||||
const key = moduleKeyFor(code);
|
||||
const loader = lazyModules[key];
|
||||
if (!loader) return false;
|
||||
const mod = await loader();
|
||||
i18n.global.setLocaleMessage(code, mod.default || mod);
|
||||
i18n.global.locale.value = code;
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
class DBInbound {
|
||||
import dayjs from 'dayjs';
|
||||
import { ObjectUtil, NumberFormatter, SizeFormatter } from '@/utils';
|
||||
import { Inbound, Protocols } from './inbound.js';
|
||||
|
||||
export class DBInbound {
|
||||
|
||||
constructor(data) {
|
||||
this.id = 0;
|
||||
@@ -6,9 +10,12 @@ class DBInbound {
|
||||
this.up = 0;
|
||||
this.down = 0;
|
||||
this.total = 0;
|
||||
this.allTime = 0;
|
||||
this.remark = "";
|
||||
this.enable = true;
|
||||
this.expiryTime = 0;
|
||||
this.trafficReset = "never";
|
||||
this.lastTrafficResetTime = 0;
|
||||
|
||||
this.listen = "";
|
||||
this.port = 0;
|
||||
@@ -18,6 +25,9 @@ class DBInbound {
|
||||
this.tag = "";
|
||||
this.sniffing = "";
|
||||
this.clientStats = ""
|
||||
// Optional FK to web/runtime registered Node. null/undefined =
|
||||
// local panel; otherwise the inbound lives on the named node.
|
||||
this.nodeId = null;
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -48,8 +58,8 @@ class DBInbound {
|
||||
return this.protocol === Protocols.SHADOWSOCKS;
|
||||
}
|
||||
|
||||
get isSocks() {
|
||||
return this.protocol === Protocols.SOCKS;
|
||||
get isMixed() {
|
||||
return this.protocol === Protocols.MIXED;
|
||||
}
|
||||
|
||||
get isHTTP() {
|
||||
@@ -60,6 +70,10 @@ class DBInbound {
|
||||
return this.protocol === Protocols.WIREGUARD;
|
||||
}
|
||||
|
||||
get isHysteria() {
|
||||
return this.protocol === Protocols.HYSTERIA;
|
||||
}
|
||||
|
||||
get address() {
|
||||
let address = location.hostname;
|
||||
if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") {
|
||||
@@ -72,7 +86,7 @@ class DBInbound {
|
||||
if (this.expiryTime === 0) {
|
||||
return null;
|
||||
}
|
||||
return moment(this.expiryTime);
|
||||
return dayjs(this.expiryTime);
|
||||
}
|
||||
|
||||
set _expiryTime(t) {
|
||||
@@ -87,7 +101,16 @@ class DBInbound {
|
||||
return this.expiryTime < new Date().getTime();
|
||||
}
|
||||
|
||||
invalidateCache() {
|
||||
this._cachedInbound = null;
|
||||
this._clientStatsMap = null;
|
||||
}
|
||||
|
||||
toInbound() {
|
||||
if (this._cachedInbound) {
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
let settings = {};
|
||||
if (!ObjectUtil.isEmpty(this.settings)) {
|
||||
settings = JSON.parse(this.settings);
|
||||
@@ -113,7 +136,21 @@ class DBInbound {
|
||||
sniffing: sniffing,
|
||||
clientStats: this.clientStats,
|
||||
};
|
||||
return Inbound.fromJson(config);
|
||||
|
||||
this._cachedInbound = Inbound.fromJson(config);
|
||||
return this._cachedInbound;
|
||||
}
|
||||
|
||||
getClientStats(email) {
|
||||
if (!this._clientStatsMap) {
|
||||
this._clientStatsMap = new Map();
|
||||
if (this.clientStats && Array.isArray(this.clientStats)) {
|
||||
for (const stats of this.clientStats) {
|
||||
this._clientStatsMap.set(stats.email, stats);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this._clientStatsMap.get(email);
|
||||
}
|
||||
|
||||
isMultiUser() {
|
||||
@@ -121,6 +158,7 @@ class DBInbound {
|
||||
case Protocols.VMESS:
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
case Protocols.SHADOWSOCKS:
|
||||
return this.toInbound().isSSMultiUser;
|
||||
@@ -135,14 +173,15 @@ class DBInbound {
|
||||
case Protocols.VLESS:
|
||||
case Protocols.TROJAN:
|
||||
case Protocols.SHADOWSOCKS:
|
||||
case Protocols.HYSTERIA:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
genInboundLinks(remarkModel) {
|
||||
genInboundLinks(remarkModel, hostOverride = '') {
|
||||
const inbound = this.toInbound();
|
||||
return inbound.genInboundLinks(this.remark, remarkModel);
|
||||
return inbound.genInboundLinks(this.remark, remarkModel, hostOverride);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
// List of popular services for VLESS Reality Target/SNI randomization
|
||||
export const REALITY_TARGETS = [
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
|
||||
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
|
||||
{ target: 'www.sony.com:443', sni: 'www.sony.com' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random Reality target configuration from the predefined list
|
||||
* @returns {Object} Object with target and sni properties
|
||||
*/
|
||||
export function getRandomRealityTarget() {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
// Return a copy to avoid reference issues
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Mirrors web/assets/js/model/setting.js — every field on this class is
|
||||
// round-tripped through `/panel/setting/all` and `/panel/setting/update`,
|
||||
// so adding a field here without a matching Go-side change will silently
|
||||
// drop it on save. Defaults match the legacy panel.
|
||||
|
||||
import { ObjectUtil } from '@/utils';
|
||||
|
||||
export class AllSetting {
|
||||
|
||||
constructor(data) {
|
||||
this.webListen = "";
|
||||
this.webDomain = "";
|
||||
this.webPort = 2053;
|
||||
this.webCertFile = "";
|
||||
this.webKeyFile = "";
|
||||
this.webBasePath = "/";
|
||||
this.sessionMaxAge = 360;
|
||||
this.trustedProxyCIDRs = "127.0.0.1/32,::1/128";
|
||||
this.pageSize = 25;
|
||||
this.expireDiff = 0;
|
||||
this.trafficDiff = 0;
|
||||
this.remarkModel = "-ieo";
|
||||
this.datepicker = "gregorian";
|
||||
this.tgBotEnable = false;
|
||||
this.tgBotToken = "";
|
||||
this.tgBotProxy = "";
|
||||
this.tgBotAPIServer = "";
|
||||
this.tgBotChatId = "";
|
||||
this.tgRunTime = "@daily";
|
||||
this.tgBotBackup = false;
|
||||
this.tgBotLoginNotify = true;
|
||||
this.tgCpu = 80;
|
||||
this.tgLang = "en-US";
|
||||
this.twoFactorEnable = false;
|
||||
this.twoFactorToken = "";
|
||||
this.xrayTemplateConfig = "";
|
||||
this.subEnable = true;
|
||||
this.subJsonEnable = false;
|
||||
this.subTitle = "";
|
||||
this.subSupportUrl = "";
|
||||
this.subProfileUrl = "";
|
||||
this.subAnnounce = "";
|
||||
this.subEnableRouting = true;
|
||||
this.subRoutingRules = "";
|
||||
this.subListen = "";
|
||||
this.subPort = 2096;
|
||||
this.subPath = "/sub/";
|
||||
this.subJsonPath = "/json/";
|
||||
this.subClashEnable = true;
|
||||
this.subClashPath = "/clash/";
|
||||
this.subDomain = "";
|
||||
this.externalTrafficInformEnable = false;
|
||||
this.externalTrafficInformURI = "";
|
||||
this.restartXrayOnClientDisable = true;
|
||||
this.subCertFile = "";
|
||||
this.subKeyFile = "";
|
||||
this.subUpdates = 12;
|
||||
this.subEncrypt = true;
|
||||
this.subShowInfo = true;
|
||||
this.subEmailInRemark = true;
|
||||
this.subURI = "";
|
||||
this.subJsonURI = "";
|
||||
this.subClashURI = "";
|
||||
this.subJsonFragment = "";
|
||||
this.subJsonNoises = "";
|
||||
this.subJsonMux = "";
|
||||
this.subJsonRules = "";
|
||||
|
||||
this.timeLocation = "Local";
|
||||
|
||||
// LDAP settings
|
||||
this.ldapEnable = false;
|
||||
this.ldapHost = "";
|
||||
this.ldapPort = 389;
|
||||
this.ldapUseTLS = false;
|
||||
this.ldapBindDN = "";
|
||||
this.ldapPassword = "";
|
||||
this.ldapBaseDN = "";
|
||||
this.ldapUserFilter = "(objectClass=person)";
|
||||
this.ldapUserAttr = "mail";
|
||||
this.ldapVlessField = "vless_enabled";
|
||||
this.ldapSyncCron = "@every 1m";
|
||||
this.ldapFlagField = "";
|
||||
this.ldapTruthyValues = "true,1,yes,on";
|
||||
this.ldapInvertFlag = false;
|
||||
this.ldapInboundTags = "";
|
||||
this.ldapAutoCreate = false;
|
||||
this.ldapAutoDelete = false;
|
||||
this.ldapDefaultTotalGB = 0;
|
||||
this.ldapDefaultExpiryDays = 0;
|
||||
this.ldapDefaultLimitIP = 0;
|
||||
this.hasTgBotToken = false;
|
||||
this.hasTwoFactorToken = false;
|
||||
this.hasLdapPassword = false;
|
||||
this.hasApiToken = false;
|
||||
this.hasWarpSecret = false;
|
||||
this.hasNordSecret = false;
|
||||
|
||||
if (data == null) {
|
||||
return
|
||||
}
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
|
||||
equals(other) {
|
||||
return ObjectUtil.equals(this, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NumberFormatter } from '@/utils';
|
||||
|
||||
export class CurTotal {
|
||||
constructor(current, total) {
|
||||
this.current = current;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
get percent() {
|
||||
if (this.total === 0) return 0;
|
||||
return NumberFormatter.toFixed((this.current / this.total) * 100, 2);
|
||||
}
|
||||
|
||||
get color() {
|
||||
// Match AD-Vue 4's semantic palette so the gauges fit the
|
||||
// global blue/gold/red theme instead of the legacy teal/orange.
|
||||
const p = this.percent;
|
||||
if (p < 80) return '#1677ff'; // primary
|
||||
if (p < 90) return '#faad14'; // warning
|
||||
return '#ff4d4f'; // danger
|
||||
}
|
||||
}
|
||||
|
||||
const XRAY_STATE_COLORS = {
|
||||
running: 'green',
|
||||
stop: 'orange',
|
||||
error: 'red',
|
||||
};
|
||||
|
||||
export class Status {
|
||||
constructor(data) {
|
||||
this.cpu = new CurTotal(0, 0);
|
||||
this.cpuCores = 0;
|
||||
this.logicalPro = 0;
|
||||
this.cpuSpeedMhz = 0;
|
||||
this.disk = new CurTotal(0, 0);
|
||||
this.loads = [0, 0, 0];
|
||||
this.mem = new CurTotal(0, 0);
|
||||
this.netIO = { up: 0, down: 0 };
|
||||
this.netTraffic = { sent: 0, recv: 0 };
|
||||
this.publicIP = { ipv4: 0, ipv6: 0 };
|
||||
this.swap = new CurTotal(0, 0);
|
||||
this.tcpCount = 0;
|
||||
this.udpCount = 0;
|
||||
this.uptime = 0;
|
||||
this.appUptime = 0;
|
||||
this.appStats = { threads: 0, mem: 0, uptime: 0 };
|
||||
this.xray = { state: 'stop', errorMsg: '', version: '', color: '' };
|
||||
|
||||
if (data == null) return;
|
||||
|
||||
this.cpu = new CurTotal(data.cpu, 100);
|
||||
this.cpuCores = data.cpuCores;
|
||||
this.logicalPro = data.logicalPro;
|
||||
this.cpuSpeedMhz = data.cpuSpeedMhz;
|
||||
this.disk = new CurTotal(data.disk?.current ?? 0, data.disk?.total ?? 0);
|
||||
this.loads = (data.loads || [0, 0, 0]).map((v) => NumberFormatter.toFixed(v, 2));
|
||||
this.mem = new CurTotal(data.mem?.current ?? 0, data.mem?.total ?? 0);
|
||||
this.netIO = data.netIO ?? this.netIO;
|
||||
this.netTraffic = data.netTraffic ?? this.netTraffic;
|
||||
this.publicIP = data.publicIP ?? this.publicIP;
|
||||
this.swap = new CurTotal(data.swap?.current ?? 0, data.swap?.total ?? 0);
|
||||
this.tcpCount = data.tcpCount ?? 0;
|
||||
this.udpCount = data.udpCount ?? 0;
|
||||
this.uptime = data.uptime ?? 0;
|
||||
this.appUptime = data.appUptime ?? 0;
|
||||
this.appStats = data.appStats ?? this.appStats;
|
||||
this.xray = { ...this.xray, ...(data.xray || {}) };
|
||||
this.xray.color = XRAY_STATE_COLORS[this.xray.state] ?? 'gray';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import {
|
||||
KeyOutlined,
|
||||
SearchOutlined,
|
||||
ExpandOutlined,
|
||||
CompressOutlined,
|
||||
ApiOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
GlobalOutlined,
|
||||
SaveOutlined,
|
||||
SettingOutlined,
|
||||
WifiOutlined,
|
||||
LinkOutlined,
|
||||
NodeIndexOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import { sections as allSections } from './endpoints.js';
|
||||
import EndpointSection from './EndpointSection.vue';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
const settingsHref = `${basePath}panel/settings#security`;
|
||||
|
||||
const searchQuery = ref('');
|
||||
const collapsedSections = ref(new Set());
|
||||
const activeSection = ref('');
|
||||
|
||||
const sectionIcons = {
|
||||
authentication: SafetyCertificateOutlined,
|
||||
inbounds: NodeIndexOutlined,
|
||||
server: CloudServerOutlined,
|
||||
nodes: ClusterOutlined,
|
||||
'custom-geo': GlobalOutlined,
|
||||
backup: SaveOutlined,
|
||||
settings: SettingOutlined,
|
||||
'api-tokens': KeyOutlined,
|
||||
'xray-settings': WifiOutlined,
|
||||
subscription: LinkOutlined,
|
||||
websocket: ApiOutlined,
|
||||
};
|
||||
|
||||
const curlExample = `curl -X GET \\
|
||||
-H "Authorization: Bearer YOUR_API_TOKEN" \\
|
||||
-H "Accept: application/json" \\
|
||||
https://your-panel.example.com/panel/api/inbounds/list`;
|
||||
|
||||
const sections = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase().trim();
|
||||
if (!q) return allSections;
|
||||
return allSections
|
||||
.map(s => {
|
||||
const matching = s.endpoints.filter(e =>
|
||||
e.path.toLowerCase().includes(q) ||
|
||||
e.summary?.toLowerCase().includes(q) ||
|
||||
e.method.toLowerCase().includes(q)
|
||||
);
|
||||
return { ...s, endpoints: matching };
|
||||
})
|
||||
.filter(s => s.endpoints.length > 0);
|
||||
});
|
||||
|
||||
const endpointCount = computed(() =>
|
||||
allSections.reduce((sum, s) => sum + s.endpoints.length, 0)
|
||||
);
|
||||
|
||||
const visibleEndpoints = computed(() =>
|
||||
sections.value.reduce((sum, s) => sum + s.endpoints.length, 0)
|
||||
);
|
||||
|
||||
function isCollapsed(id) {
|
||||
return collapsedSections.value.has(id);
|
||||
}
|
||||
|
||||
function toggleSection(id) {
|
||||
const s = new Set(collapsedSections.value);
|
||||
if (s.has(id)) s.delete(id); else s.add(id);
|
||||
collapsedSections.value = s;
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
collapsedSections.value = new Set();
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
collapsedSections.value = new Set(allSections.map(s => s.id));
|
||||
}
|
||||
|
||||
function scrollToSection(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
if (window.location.hash !== `#${id}`) {
|
||||
history.replaceState(null, '', `#${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToHash() {
|
||||
const id = window.location.hash.slice(1);
|
||||
if (!id) return;
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||
}
|
||||
|
||||
let scrollObserver = null;
|
||||
function onScroll() {
|
||||
const toc = document.querySelector('.toc-nav');
|
||||
const tocHeight = toc ? toc.offsetHeight : 56;
|
||||
let current = '';
|
||||
for (const s of sections.value) {
|
||||
const el = document.getElementById(s.id);
|
||||
if (!el) continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= tocHeight + 20) {
|
||||
current = s.id;
|
||||
}
|
||||
}
|
||||
activeSection.value = current;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
scrollObserver = onScroll;
|
||||
window.addEventListener('scroll', scrollObserver, { passive: true });
|
||||
window.addEventListener('hashchange', scrollToHash);
|
||||
requestAnimationFrame(() => {
|
||||
scrollToHash();
|
||||
onScroll();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollObserver) {
|
||||
window.removeEventListener('scroll', scrollObserver);
|
||||
}
|
||||
window.removeEventListener('hashchange', scrollToHash);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="api-docs-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content class="content-area">
|
||||
<div class="docs-wrapper">
|
||||
<header class="docs-header">
|
||||
<h1 class="docs-title">API Documentation</h1>
|
||||
<p class="docs-lead">
|
||||
The 3x-ui panel exposes a REST API under <code>/panel/api/</code>. Authenticate with the panel session
|
||||
cookie, or with the <code>Authorization: Bearer <token></code> header below. Every endpoint
|
||||
returns a uniform <code>{ success, msg, obj }</code> envelope unless otherwise noted.
|
||||
</p>
|
||||
|
||||
</header>
|
||||
|
||||
<a-card class="token-card" size="small">
|
||||
<div class="token-card-head">
|
||||
<div class="token-card-title">
|
||||
<KeyOutlined />
|
||||
<span>API Tokens</span>
|
||||
</div>
|
||||
<a-button type="primary" size="small" :href="settingsHref">
|
||||
Manage tokens
|
||||
</a-button>
|
||||
</div>
|
||||
<p class="token-hint">
|
||||
Create, enable, or revoke named Bearer tokens in
|
||||
<a :href="settingsHref">Settings → Security</a>. Send each request as
|
||||
<code>Authorization: Bearer <token></code>. Token-authenticated callers skip CSRF and don't
|
||||
need a session cookie. Deleting a token revokes it immediately — running bots will need a new one.
|
||||
</p>
|
||||
</a-card>
|
||||
|
||||
<a-card class="curl-card" size="small" title="Quick example">
|
||||
<CodeBlock :code="curlExample" lang="text" />
|
||||
</a-card>
|
||||
|
||||
<div class="toolbar">
|
||||
<a-input-search
|
||||
v-model:value="searchQuery"
|
||||
placeholder="Search endpoints by path, method, or description…"
|
||||
allow-clear
|
||||
class="search-bar"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input-search>
|
||||
<span class="match-count" v-if="searchQuery">
|
||||
{{ visibleEndpoints }} / {{ endpointCount }} endpoints
|
||||
</span>
|
||||
<a-space size="small">
|
||||
<a-button size="small" @click="expandAll">
|
||||
<template #icon><ExpandOutlined /></template>
|
||||
Expand all
|
||||
</a-button>
|
||||
<a-button size="small" @click="collapseAll">
|
||||
<template #icon><CompressOutlined /></template>
|
||||
Collapse all
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<nav class="toc-nav">
|
||||
<span class="toc-label">On this page:</span>
|
||||
<div class="toc-links">
|
||||
<a
|
||||
v-for="s in sections"
|
||||
:key="s.id"
|
||||
class="toc-link"
|
||||
:class="{ active: activeSection === s.id }"
|
||||
:href="`#${s.id}`"
|
||||
@click.prevent="scrollToSection(s.id)"
|
||||
>
|
||||
<component :is="sectionIcons[s.id]" class="toc-icon" />
|
||||
<span class="toc-text">{{ s.title }}</span>
|
||||
<span class="toc-badge">{{ s.endpoints.length }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<EndpointSection
|
||||
v-for="s in sections"
|
||||
:key="s.id"
|
||||
:section="s"
|
||||
:icon="sectionIcons[s.id]"
|
||||
:collapsed="isCollapsed(s.id)"
|
||||
@toggle="toggleSection(s.id)"
|
||||
/>
|
||||
</div>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-docs-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #0a0a0a;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 16px 12px 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
.docs-wrapper {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.docs-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 24px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.docs-title {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 8px;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.docs-lead {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.65;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.docs-lead code,
|
||||
.token-hint code {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.token-card,
|
||||
.curl-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.token-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.token-card-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.token-hint {
|
||||
margin: 10px 0 0;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.match-count {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 8px 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toc-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
padding-top: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toc-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toc-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12.5px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toc-link:hover {
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.toc-link.active {
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
border-color: rgba(22, 119, 255, 0.3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toc-icon {
|
||||
font-size: 13px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.toc-text {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.toc-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
background: rgba(22, 119, 255, 0.12);
|
||||
color: #1677ff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toc-link.active .toc-badge {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .docs-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-title {
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
|
||||
body.dark .docs-header {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-header {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .docs-lead,
|
||||
body.dark .token-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead,
|
||||
html[data-theme='ultra-dark'] .token-hint {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
body.dark .docs-lead code,
|
||||
body.dark .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .docs-lead code,
|
||||
html[data-theme='ultra-dark'] .token-hint code {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .code-block {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .toc-nav {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-nav {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .toc-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-label {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
body.dark .toc-link {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .toc-link {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
body.dark .toc-link:hover {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
body.dark .toc-link.active {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
}
|
||||
|
||||
body.dark .toc-badge {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
color: #58a6ff;
|
||||
}
|
||||
|
||||
body.dark .toc-link.active .toc-badge {
|
||||
background: #58a6ff;
|
||||
color: #0d1117;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { CopyOutlined, CheckOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const props = defineProps({
|
||||
code: { type: String, default: '' },
|
||||
lang: { type: String, default: 'json' },
|
||||
});
|
||||
|
||||
const copied = ref(false);
|
||||
|
||||
function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function highlightJson(str) {
|
||||
const escaped = escapeHtml(str);
|
||||
return escaped.replace(
|
||||
/("(?:[^"\\]|\\.)*")\s*(:)|("(?:[^"\\]|\\.)*")|(-?\d+\.?\d*(?:[eE][+-]?\d+)?)\b|(true|false)|(null)|([{}[\]])/g,
|
||||
(_m, key, colon, string, number, bool, nil) => {
|
||||
if (colon) return `<span class="json-key">${key}</span>${colon}`;
|
||||
if (string) return `<span class="json-string">${string}</span>`;
|
||||
if (number) return `<span class="json-number">${number}</span>`;
|
||||
if (bool) return `<span class="json-boolean">${bool}</span>`;
|
||||
if (nil) return `<span class="json-null">${nil}</span>`;
|
||||
return _m;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const highlighted = computed(() => {
|
||||
if (props.lang === 'json') {
|
||||
return highlightJson(props.code);
|
||||
}
|
||||
return escapeHtml(props.code);
|
||||
});
|
||||
|
||||
async function copyCode() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.code);
|
||||
copied.value = true;
|
||||
message.success('Copied');
|
||||
setTimeout(() => { copied.value = false; }, 2000);
|
||||
} catch {
|
||||
message.error('Copy failed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-block-wrapper">
|
||||
<div class="code-toolbar">
|
||||
<span class="lang-badge">{{ lang.toUpperCase() }}</span>
|
||||
<button class="copy-btn" :class="{ copied }" @click="copyCode" :title="copied ? 'Copied' : 'Copy'">
|
||||
<CheckOutlined v-if="copied" />
|
||||
<CopyOutlined v-else />
|
||||
</button>
|
||||
</div>
|
||||
<pre class="code-block" :class="`lang-${lang}`"><code v-html="highlighted"></code></pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.code-block-wrapper {
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.code-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.lang-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
text-transform: uppercase;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: #fff;
|
||||
color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
.copy-btn.copied {
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
padding: 10px 12px;
|
||||
margin: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.json-key { color: #0550ae; }
|
||||
.json-string { color: #116329; }
|
||||
.json-number { color: #9a6700; }
|
||||
.json-boolean { color: #cf222e; }
|
||||
.json-null { color: #8250df; }
|
||||
|
||||
body.dark .code-block-wrapper {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body.dark .code-toolbar {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
body.dark .lang-badge {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
|
||||
body.dark .json-key { color: #79c0ff; }
|
||||
body.dark .json-string { color: #7ee787; }
|
||||
body.dark .json-number { color: #d29922; }
|
||||
body.dark .json-boolean { color: #ff7b72; }
|
||||
body.dark .json-null { color: #d2a8ff; }
|
||||
|
||||
body.dark .copy-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
body.dark .copy-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #58a6ff;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { methodColors, safeInlineHtml } from './endpoints.js';
|
||||
import CodeBlock from './CodeBlock.vue';
|
||||
|
||||
const props = defineProps({
|
||||
endpoint: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const tagColor = computed(() => methodColors[props.endpoint.method] || 'default');
|
||||
const hasParams = computed(() => Array.isArray(props.endpoint.params) && props.endpoint.params.length > 0);
|
||||
|
||||
const paramColumns = [
|
||||
{ title: 'Name', dataIndex: 'name', key: 'name', width: 180 },
|
||||
{ title: 'In', dataIndex: 'in', key: 'in', width: 100 },
|
||||
{ title: 'Type', dataIndex: 'type', key: 'type', width: 120 },
|
||||
{ title: 'Description', dataIndex: 'desc', key: 'desc' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="endpoint-row">
|
||||
<div class="endpoint-header">
|
||||
<a-tag :color="tagColor" class="method-tag">{{ endpoint.method }}</a-tag>
|
||||
<code class="endpoint-path">{{ endpoint.path }}</code>
|
||||
</div>
|
||||
|
||||
<p v-if="endpoint.summary" class="endpoint-summary" v-html="safeInlineHtml(endpoint.summary)"></p>
|
||||
|
||||
<div v-if="hasParams" class="endpoint-block">
|
||||
<div class="block-label">Parameters</div>
|
||||
<a-table :columns="paramColumns" :data-source="endpoint.params" :pagination="false" size="small" row-key="name" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.body" class="endpoint-block">
|
||||
<div class="block-label">Request body</div>
|
||||
<CodeBlock :code="endpoint.body" lang="json" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.response" class="endpoint-block">
|
||||
<div class="block-label">Response</div>
|
||||
<CodeBlock :code="endpoint.response" lang="json" />
|
||||
</div>
|
||||
|
||||
<div v-if="endpoint.errorResponse" class="endpoint-block">
|
||||
<div class="block-label error-label">Error response</div>
|
||||
<CodeBlock :code="endpoint.errorResponse" lang="json" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.endpoint-row {
|
||||
padding: 14px 0;
|
||||
margin: 0;
|
||||
transition: background 0.15s;
|
||||
border-radius: 6px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
margin-left: -8px;
|
||||
margin-right: -8px;
|
||||
}
|
||||
|
||||
.endpoint-row:hover {
|
||||
background: rgba(128, 128, 128, 0.03);
|
||||
}
|
||||
|
||||
.endpoint-row + .endpoint-row {
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.method-tag {
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 56px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.endpoint-path {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13.5px;
|
||||
word-break: break-all;
|
||||
color: rgba(0, 0, 0, 0.8);
|
||||
background: rgba(128, 128, 128, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.endpoint-summary {
|
||||
margin: 8px 0 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
line-height: 1.6;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.endpoint-block {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.error-label {
|
||||
color: #cf222e;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
border: 1px solid rgba(128, 128, 128, 0.15);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .endpoint-row:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.dark .endpoint-row + .endpoint-row {
|
||||
border-top-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .endpoint-path {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
body.dark .endpoint-summary {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
body.dark .error-label {
|
||||
color: #ff7b72;
|
||||
}
|
||||
|
||||
body.dark .code-block {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import EndpointRow from './EndpointRow.vue';
|
||||
import { safeInlineHtml } from './endpoints.js';
|
||||
|
||||
const props = defineProps({
|
||||
section: { type: Object, required: true },
|
||||
icon: { type: Object, default: null },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle']);
|
||||
|
||||
const endpointLabel = computed(() =>
|
||||
props.section.endpoints.length === 1
|
||||
? '1 endpoint'
|
||||
: `${props.section.endpoints.length} endpoints`
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :id="section.id" class="api-section">
|
||||
<div class="section-header" @click="emit('toggle')">
|
||||
<div class="section-header-left">
|
||||
<DownOutlined v-if="!collapsed" class="collapse-icon" />
|
||||
<RightOutlined v-else class="collapse-icon" />
|
||||
<component v-if="icon" :is="icon" class="section-icon" />
|
||||
<h2 class="section-title">{{ section.title }}</h2>
|
||||
</div>
|
||||
<span class="endpoint-count">{{ endpointLabel }}</span>
|
||||
</div>
|
||||
<p v-if="section.description && !collapsed" class="section-description" v-html="safeInlineHtml(section.description)"></p>
|
||||
|
||||
<div v-if="section.subHeader && !collapsed" class="sub-header-block">
|
||||
<div class="block-label">Response headers</div>
|
||||
<a-table
|
||||
:columns="[{ title: 'Header', dataIndex: 'name', key: 'name', width: 240 }, { title: 'Description', dataIndex: 'desc', key: 'desc' }]"
|
||||
:data-source="section.subHeader"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
row-key="name"
|
||||
>
|
||||
<template #bodyCell="{ column, text }">
|
||||
<span v-if="column.dataIndex === 'desc'" v-html="safeInlineHtml(text)"></span>
|
||||
<template v-else>{{ text }}</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<div v-show="!collapsed" class="endpoints">
|
||||
<EndpointRow v-for="(endpoint, idx) in section.endpoints" :key="idx" :endpoint="endpoint" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-section {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(128, 128, 128, 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 16px;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.api-section:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.section-header:hover .collapse-icon,
|
||||
.section-header:hover .section-icon {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.section-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 18px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
.endpoint-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
white-space: nowrap;
|
||||
background: rgba(128, 128, 128, 0.08);
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin: 12px 0 14px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sub-header-block {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.block-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.endpoints {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.1);
|
||||
}
|
||||
|
||||
.endpoints > :first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
body.dark .api-section {
|
||||
background: #252526;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
body.dark .api-section:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .api-section {
|
||||
background: #0a0a0a;
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
html[data-theme='ultra-dark'] .api-section:hover {
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body.dark .section-title {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
body.dark .section-icon {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
body.dark .section-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
body.dark .block-label {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
body.dark .endpoint-count {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,910 @@
|
||||
export function safeInlineHtml(input) {
|
||||
if (!input) return '';
|
||||
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const open = '<code>';
|
||||
const close = '</code>';
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < input.length) {
|
||||
const oIdx = input.indexOf(open, i);
|
||||
if (oIdx === -1) {
|
||||
out += escape(input.slice(i));
|
||||
break;
|
||||
}
|
||||
out += escape(input.slice(i, oIdx));
|
||||
const cIdx = input.indexOf(close, oIdx + open.length);
|
||||
if (cIdx === -1) {
|
||||
out += escape(input.slice(oIdx));
|
||||
break;
|
||||
}
|
||||
out += '<code>' + escape(input.slice(oIdx + open.length, cIdx)) + '</code>';
|
||||
i = cIdx + close.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const sections = [
|
||||
{
|
||||
id: 'authentication',
|
||||
title: 'Authentication',
|
||||
description:
|
||||
'Two authentication modes are supported. UI sessions use a cookie set by the login endpoint. Programmatic clients (bots, scripts, remote panels) authenticate with a Bearer token taken from Settings → Security → API Token. Both work for every endpoint under /panel/api/*.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/login',
|
||||
summary: 'Authenticate with username + password and receive a session cookie. Required before any cookie-based API call.',
|
||||
params: [
|
||||
{ name: 'username', in: 'body', type: 'string', desc: 'Panel admin username.' },
|
||||
{ name: 'password', in: 'body', type: 'string', desc: 'Panel admin password.' },
|
||||
{ name: 'twoFactorCode', in: 'body', type: 'string', desc: 'OTP code when 2FA is enabled. Omit otherwise.' },
|
||||
],
|
||||
body: '{\n "username": "admin",\n "password": "admin",\n "twoFactorCode": "123456"\n}',
|
||||
response:
|
||||
'{\n "success": true,\n "msg": "Logged in successfully"\n}',
|
||||
errorResponse:
|
||||
'{\n "success": false,\n "msg": "Wrong username or password"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/logout',
|
||||
summary: 'Clear the session cookie. Requires the CSRF header for browser sessions.',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/csrf-token',
|
||||
summary: 'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": "csrf-token-string"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/getTwoFactorEnable',
|
||||
summary: 'Returns whether 2FA is enabled on the panel — used by the login page to decide whether to show the OTP field.',
|
||||
response: '{\n "success": true,\n "obj": false\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'inbounds',
|
||||
title: 'Inbounds',
|
||||
description:
|
||||
'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour forwarded headers only when the request comes from a configured trusted proxy.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/list',
|
||||
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters.',
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": "{\\"clients\\":[...]}",\n "streamSettings": "{...}",\n "tag": "inbound-443",\n "sniffing": "{...}",\n "clientStats": [...]\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/get/:id',
|
||||
summary: 'Fetch a single inbound by numeric ID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTraffics/:email',
|
||||
summary: 'Traffic counters for a client identified by email.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientTrafficsById/:id',
|
||||
summary: 'Traffic counters for a client identified by its UUID/password.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'string', desc: 'Client subId / UUID.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/add',
|
||||
summary: 'Create a new inbound. Send the full inbound payload (protocol, port, settings JSON, streamSettings JSON, sniffing JSON, remark, expiryTime, total, enable).',
|
||||
body:
|
||||
'{\n "enable": true,\n "remark": "VLESS-443",\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "expiryTime": 0,\n "total": 0,\n "settings": "{\\"clients\\":[{\\"id\\":\\"...\\",\\"email\\":\\"user1\\"}],\\"decryption\\":\\"none\\",\\"fallbacks\\":[]}",\n "streamSettings": "{\\"network\\":\\"tcp\\",\\"security\\":\\"reality\\",\\"realitySettings\\":{...}}",\n "sniffing": "{\\"enabled\\":true,\\"destOverride\\":[\\"http\\",\\"tls\\"]}"\n}',
|
||||
errorResponse:
|
||||
'{\n "success": false,\n "msg": "Port 443 is already in use"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/del/:id',
|
||||
summary: 'Delete an inbound by ID. Also removes its associated client stats rows.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/update/:id',
|
||||
summary: 'Replace an inbound’s configuration. Body shape mirrors /add. Heavy on inbounds with thousands of clients — prefer /setEnable for enable-only flips.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/setEnable/:id',
|
||||
summary: 'Toggle only the enable flag without serialising the whole settings JSON. Recommended for UI switches on large inbounds.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
body: '{\n "enable": false\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clientIps/:email',
|
||||
summary: 'List source IPs that have connected with the given client’s credentials. Returns an array of "ip (timestamp)" strings.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/clearClientIps/:email',
|
||||
summary: 'Reset the recorded IP list for a client.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/addClient',
|
||||
summary: 'Add one or more clients to an existing inbound. The settings field is the JSON-encoded settings.clients array of the target inbound.',
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"newuser\\",\\"limitIp\\":0,\\"totalGB\\":0,\\"expiryTime\\":0,\\"enable\\":true,\\"flow\\":\\"\\"}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/copyClients',
|
||||
summary: 'Copy selected clients from one inbound into another. Useful for duplicating user lists across protocols.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Target inbound ID.' },
|
||||
{ name: 'sourceInboundId', in: 'body', type: 'number', desc: 'Inbound ID to read clients from.' },
|
||||
{ name: 'clientEmails', in: 'body', type: 'string[]', desc: 'Emails of clients to copy. Empty means all clients.' },
|
||||
{ name: 'flow', in: 'body', type: 'string', desc: 'Override the flow field on copied clients (e.g. "xtls-rprx-vision"). Empty to keep source flow.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClient/:clientId',
|
||||
summary: 'Delete a client by its UUID/password from a specific inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClient/:clientId',
|
||||
summary: 'Update a single client without rewriting the whole settings JSON. Send the target inbound payload with the new client values.',
|
||||
params: [
|
||||
{ name: 'clientId', in: 'path', type: 'string', desc: 'Client UUID / password.' },
|
||||
],
|
||||
body:
|
||||
'{\n "id": 1,\n "settings": "{\\"clients\\":[{\\"id\\":\\"uuid-here\\",\\"email\\":\\"user1\\",\\"limitIp\\":2,\\"totalGB\\":10737418240,\\"expiryTime\\":1735689600000,\\"enable\\":true}]}"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/resetTraffic',
|
||||
summary: 'Zero out upload + download counters for a single inbound. Does not touch per-client counters.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/resetClientTraffic/:email',
|
||||
summary: 'Zero out upload + download counters for one client.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllTraffics',
|
||||
summary: 'Reset upload + download counters on every inbound. Destructive — accounting history is lost.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/resetAllClientTraffics/:id',
|
||||
summary: 'Reset traffic for every client in one inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/delDepletedClients/:id',
|
||||
summary: 'Delete clients in this inbound whose traffic cap or expiry has elapsed. Pass id=-1 to sweep every inbound.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID, or -1 for all inbounds.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/import',
|
||||
summary: 'Bulk-import an inbound from a JSON blob (e.g. one exported via the UI). The body uses form encoding with a single "data" field.',
|
||||
params: [
|
||||
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/onlines',
|
||||
summary: 'List the emails of currently connected clients (last seen within the heartbeat window).',
|
||||
response: '{\n "success": true,\n "obj": ["user1", "user2"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/lastOnline',
|
||||
summary: 'Map of client email → last-seen unix timestamp.',
|
||||
response: '{\n "success": true,\n "obj": [\n { "email": "user1", "lastOnline": 1700000000 },\n { "email": "user2", "lastOnline": 1699999000 }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getSubLinks/:subId',
|
||||
summary:
|
||||
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
|
||||
params: [
|
||||
{ name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#user1",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/inbounds/getClientLinks/:id/:email',
|
||||
summary:
|
||||
"Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
response:
|
||||
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?...#user1"\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
||||
summary: 'Manually adjust a client’s upload + download counters. Useful for migrations from external accounting systems.',
|
||||
params: [
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
body: '{\n "upload": 1073741824,\n "download": 5368709120\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/inbounds/:id/delClientByEmail/:email',
|
||||
summary: 'Delete a client identified by email rather than UUID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
|
||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'server',
|
||||
title: 'Server',
|
||||
description:
|
||||
'System status, log retrieval, certificate generators, Xray binary management, and backup/restore. All under /panel/api/server.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/status',
|
||||
summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
|
||||
response: '{\n "success": true,\n "obj": {\n "cpu": 12.5,\n "mem": { "current": 2147483648, "total": 8589934592 },\n "swap": { "current": 0, "total": 4294967296 },\n "disk": { "current": 53687091200, "total": 268435456000 },\n "netIO": { "up": 1073741824, "down": 2147483648 },\n "xray": { "state": "running", "version": "v25.10.31" },\n "tcpCount": 42,\n "load": { "load1": 0.5, "load5": 0.3, "load15": 0.2 }\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/cpuHistory/:bucket',
|
||||
summary: 'Legacy: aggregated CPU history. Use /history/cpu/:bucket instead — same data with a uniform {t, v} shape.',
|
||||
params: [
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/history/:metric/:bucket',
|
||||
summary: 'Aggregated time-series for one metric. Returns an array of {t, v} samples covering the last ~6 hours.',
|
||||
params: [
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem | netUp | netDown | online | load1 | load5 | load15.' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
response: '{\n "success": true,\n "obj": [\n { "t": 1700000000, "v": 12.5 },\n { "t": 1700000002, "v": 13.1 }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/xrayMetricsState',
|
||||
summary: 'Xray runtime metrics state — whether the xray config has a `metrics` block, which expvar keys are flowing, and the current snapshot values for each. Returns an empty state when metrics are not configured.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/xrayMetricsHistory/:metric/:bucket',
|
||||
summary: 'Time-series history for one Xray runtime metric over the last ~6 hours. Same {t, v} shape as /history/:metric/:bucket.',
|
||||
params: [
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'xrAlloc | xrSys | xrHeapObjects | xrNumGC | xrPauseNs.' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/xrayObservatory',
|
||||
summary: 'Latest snapshot from the Xray observatory — per-outbound latency, health status, and last-probe time. Only populated when the Xray config has an observatory configured.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/xrayObservatoryHistory/:tag/:bucket',
|
||||
summary: 'Time-series of observatory probe results for one outbound tag. Same {t, v} shape as the other history endpoints.',
|
||||
params: [
|
||||
{ name: 'tag', in: 'path', type: 'string', desc: 'Outbound tag from the observatory config.' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getXrayVersion',
|
||||
summary: 'List Xray binary versions available for install on this host.',
|
||||
response: '{\n "success": true,\n "obj": ["v25.10.31", "v25.9.15", "v25.8.1"]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getPanelUpdateInfo',
|
||||
summary: 'Check whether a newer 3x-ui release is available on GitHub.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getConfigJson',
|
||||
summary: 'Return the assembled Xray config that\u2019s currently running on this host.',
|
||||
response: '{\n "success": true,\n "obj": {\n "log": { "loglevel": "warning" },\n "inbounds": [...],\n "outbounds": [...],\n "routing": { "rules": [...] }\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getDb',
|
||||
summary: 'Stream the SQLite database file as an attachment. Use as a manual backup.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewUUID',
|
||||
summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
|
||||
response: '{\n "success": true,\n "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewX25519Cert',
|
||||
summary: 'Generate a new X25519 keypair for Reality.',
|
||||
response: '{\n "success": true,\n "obj": {\n "privateKey": "uN9qLfV3zH8w...",\n "publicKey": "5v8xPqR2sM7k..."\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmldsa65',
|
||||
summary: 'Generate a new ML-DSA-65 keypair (post-quantum signature). Returns {privateKey, publicKey, seed}.',
|
||||
response: '{\n "success": true,\n "obj": {\n "privateKey": "mdsa65priv...",\n "publicKey": "mdsa65pub...",\n "seed": "random-seed..."\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewmlkem768',
|
||||
summary: 'Generate a new ML-KEM-768 keypair (post-quantum KEM). Returns {clientKey, serverKey}.',
|
||||
response: '{\n "success": true,\n "obj": {\n "clientKey": "mlkem768-client...",\n "serverKey": "mlkem768-server..."\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/server/getNewVlessEnc',
|
||||
summary: 'Generate VLESS encryption auth options. Returns an auths array each with id, label, encryption, and decryption fields.',
|
||||
response: '{\n "success": true,\n "obj": {\n "auths": [\n { "id": 0, "label": "Auth #0", "encryption": "aes-256-gcm", "decryption": "" }\n ]\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/stopXrayService',
|
||||
summary: 'Stop the Xray binary. All proxies go offline immediately.',
|
||||
errorResponse:
|
||||
'{\n "success": false,\n "msg": "Xray is not running"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/restartXrayService',
|
||||
summary: 'Reload Xray with the current config. Typically required after structural inbound or routing changes.',
|
||||
errorResponse:
|
||||
'{\n "success": false,\n "msg": "Xray config is invalid: ..."\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/installXray/:version',
|
||||
summary: 'Download and install the specified Xray version. Pass "latest" for the newest release.',
|
||||
params: [
|
||||
{ name: 'version', in: 'path', type: 'string', desc: 'Xray tag (e.g. v25.10.31) or "latest".' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updatePanel',
|
||||
summary: 'Self-update the panel to the latest version. The server restarts on success.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile',
|
||||
summary: 'Refresh the default GeoIP / GeoSite data files. Body can include a fileName, or use the /:fileName variant.',
|
||||
params: [
|
||||
{ name: 'fileName', in: 'body (form)', type: 'string', desc: 'Filename to update (e.g. geoip.dat, geosite.dat). Omit to update all defaults.' },
|
||||
],
|
||||
body: 'fileName=geoip.dat',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/updateGeofile/:fileName',
|
||||
summary: 'Refresh a single Geo file by filename (e.g. geoip.dat, geosite.dat).',
|
||||
params: [
|
||||
{ name: 'fileName', in: 'path', type: 'string', desc: 'Filename of the data file to refresh.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/logs/:count',
|
||||
summary: 'Return the last N lines of the panel\u2019s own log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
],
|
||||
body: '{\n "level": "info",\n "syslog": false\n}',
|
||||
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 [INFO] Server started\\n2025/01/01 12:00:01 [INFO] Xray is running"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/xraylogs/:count',
|
||||
summary: 'Return the last N lines of the Xray process log.',
|
||||
params: [
|
||||
{ name: 'count', in: 'path', type: 'number', desc: 'Number of trailing log lines.' },
|
||||
{ name: 'filter', in: 'body (form)', type: 'string', desc: 'Keyword filter — only lines containing this string.' },
|
||||
{ name: 'showDirect', in: 'body (form)', type: 'string', desc: '"true" to include direct (freedom) traffic lines.' },
|
||||
{ name: 'showBlocked', in: 'body (form)', type: 'string', desc: '"true" to include blocked (blackhole) traffic lines.' },
|
||||
{ name: 'showProxy', in: 'body (form)', type: 'string', desc: '"true" to include proxy traffic lines.' },
|
||||
],
|
||||
body: 'filter=error&showDirect=false&showBlocked=true&showProxy=true',
|
||||
response: '{\n "success": true,\n "obj": "2025/01/01 12:00:00 rejected vless proxy example.com reason: no valid user\\n2025/01/01 12:00:01 direct freedom ok"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/importDB',
|
||||
summary: 'Restore the panel DB from an uploaded SQLite file (multipart form, field name "db"). The panel restarts after restore. Destructive.',
|
||||
params: [
|
||||
{ name: 'db', in: 'body (multipart)', type: 'file', desc: 'SQLite database file to upload.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/server/getNewEchCert',
|
||||
summary: 'Generate a new ECH (Encrypted Client Hello) keypair and config list for the given SNI.',
|
||||
params: [
|
||||
{ name: 'sni', in: 'body (form)', type: 'string', desc: 'Server Name Indication to generate the ECH config for.' },
|
||||
],
|
||||
body: 'sni=example.com',
|
||||
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'nodes',
|
||||
title: 'Nodes',
|
||||
description:
|
||||
'Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/list',
|
||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "status": "online",\n "cpu": 23.5,\n "mem": 45.1\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/get/:id',
|
||||
summary: 'Fetch a single node by ID.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/add',
|
||||
summary: 'Register a new remote node. Provide its URL, apiToken, and optional label/notes.',
|
||||
body:
|
||||
'{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/update/:id',
|
||||
summary: 'Replace a node\u2019s connection details. Same body shape as /add.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
body: '{\n "name": "de-fra-1",\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/del/:id',
|
||||
summary: 'Delete a node. Inbounds bound to it are not auto-migrated.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/setEnable/:id',
|
||||
summary: 'Pause or resume traffic sync with this node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
body: '{\n "enable": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/test',
|
||||
summary: 'Probe a node without saving it. Uses the body as connection details and returns whether the handshake succeeds.',
|
||||
body: '{\n "scheme": "https",\n "host": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "cpu": 12.5,\n "mem": 45.2\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/nodes/probe/:id',
|
||||
summary: 'Probe an existing node, updating its cached health state.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/nodes/history/:id/:metric/:bucket',
|
||||
summary: 'Aggregated metric history for a node — same shape as /server/history, scoped to one node.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
|
||||
{ name: 'metric', in: 'path', type: 'string', desc: 'cpu | mem.' },
|
||||
{ name: 'bucket', in: 'path', type: 'number', desc: 'Bucket size in seconds. Allowed: 2, 30, 60, 120, 180, 300.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'custom-geo',
|
||||
title: 'Custom Geo',
|
||||
description:
|
||||
'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/list',
|
||||
summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/custom-geo/aliases',
|
||||
summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/add',
|
||||
summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
|
||||
body:
|
||||
'{\n "type": "geoip",\n "alias": "myips",\n "url": "https://example.com/geo/my.dat"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update/:id',
|
||||
summary: 'Replace a custom geo source. Same body shape as /add.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/delete/:id',
|
||||
summary: 'Remove a custom geo source and its cached file.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/download/:id',
|
||||
summary: 'Re-download one custom geo source on demand.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/custom-geo/update-all',
|
||||
summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'backup',
|
||||
title: 'Backup',
|
||||
description: 'Operations that interact with the configured Telegram bot.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/backuptotgbot',
|
||||
summary: 'Send a fresh DB backup to every Telegram chat configured as an admin recipient. No body, no params.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
description:
|
||||
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/all',
|
||||
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
||||
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/defaultSettings',
|
||||
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/update',
|
||||
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
||||
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/updateUser',
|
||||
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
|
||||
params: [
|
||||
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
|
||||
{ name: 'oldPassword', in: 'body', type: 'string', desc: 'Current admin password.' },
|
||||
{ name: 'newUsername', in: 'body', type: 'string', desc: 'Desired new username.' },
|
||||
{ name: 'newPassword', in: 'body', type: 'string', desc: 'Desired new password.' },
|
||||
],
|
||||
body: '{\n "oldUsername": "admin",\n "oldPassword": "admin",\n "newUsername": "newadmin",\n "newPassword": "newpass"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/restartPanel',
|
||||
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/setting/getDefaultJsonConfig',
|
||||
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'api-tokens',
|
||||
title: 'API Tokens',
|
||||
description:
|
||||
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/setting/apiTokens',
|
||||
summary: 'List every API token, enabled or not.',
|
||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "token": "abcdef-12345-...",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/apiTokens/create',
|
||||
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
|
||||
params: [
|
||||
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
||||
],
|
||||
body: '{\n "name": "central-panel-a"\n}',
|
||||
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
|
||||
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/apiTokens/delete/:id',
|
||||
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||
],
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/setting/apiTokens/setEnabled/:id',
|
||||
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
|
||||
params: [
|
||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||
{ name: 'enabled', in: 'body', type: 'boolean', desc: 'New enabled state.' },
|
||||
],
|
||||
body: '{\n "enabled": false\n}',
|
||||
response: '{\n "success": true\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'xray-settings',
|
||||
title: 'Xray Settings',
|
||||
description:
|
||||
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/xray/',
|
||||
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
|
||||
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/xray/getDefaultJsonConfig',
|
||||
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/xray/getOutboundsTraffic',
|
||||
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/xray/getXrayResult',
|
||||
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/xray/update',
|
||||
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
|
||||
params: [
|
||||
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
|
||||
{ name: 'outboundTestUrl', in: 'body (form)', type: 'string', desc: 'URL used for outbound reachability tests. Defaults to https://www.google.com/generate_204.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/xray/warp/:action',
|
||||
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
|
||||
params: [
|
||||
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
|
||||
{ name: 'privateKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
|
||||
{ name: 'publicKey', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
|
||||
{ name: 'license', in: 'body (form)', type: 'string', desc: 'Required when action=license.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/xray/nord/:action',
|
||||
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
|
||||
params: [
|
||||
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
|
||||
{ name: 'countryId', in: 'body (form)', type: 'string', desc: 'Required when action=servers.' },
|
||||
{ name: 'token', in: 'body (form)', type: 'string', desc: 'Required when action=reg.' },
|
||||
{ name: 'key', in: 'body (form)', type: 'string', desc: 'Required when action=setKey.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/xray/resetOutboundsTraffic',
|
||||
summary: 'Reset traffic counters for a specific outbound by tag.',
|
||||
params: [
|
||||
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
|
||||
],
|
||||
body: 'tag=proxy',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/xray/testOutbound',
|
||||
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
|
||||
params: [
|
||||
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
|
||||
{ name: 'allOutbounds', in: 'body (form)', type: 'string', desc: 'JSON array of all outbounds — used to resolve dialerProxy chains.' },
|
||||
{ name: 'mode', in: 'body (form)', type: 'string', desc: '"tcp" for a fast dial-only probe (parallel-safe). Default/empty uses a full HTTP probe through a temp xray instance.' },
|
||||
],
|
||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'subscription',
|
||||
title: 'Subscription Server',
|
||||
description:
|
||||
'A separate HTTP/HTTPS server that serves proxy subscription links (standard, JSON, and Clash) to clients. The server listens on its own port (default 10882) and is configured in Settings → Subscription. Paths are configurable; defaults are shown below. All subscription endpoints set response headers for client apps to read traffic/expiry info.',
|
||||
subHeader: [
|
||||
{ name: 'Subscription-Userinfo', desc: 'Traffic and expiry: <code>upload=N; download=N; total=N; expire=TS</code>' },
|
||||
{ name: 'Profile-Title', desc: 'Base64-encoded subscription display name' },
|
||||
{ name: 'Profile-Web-Page-Url', desc: 'Link to the subscription info page' },
|
||||
{ name: 'Support-Url', desc: 'Support contact URL configured in settings' },
|
||||
{ name: 'Profile-Update-Interval', desc: 'Suggested polling interval in minutes (e.g. <code>10</code>)' },
|
||||
{ name: 'Announce', desc: 'Base64-encoded announcement string' },
|
||||
{ name: 'Routing-Enable', desc: '<code>true</code> or <code>false</code> — whether routing rules are included' },
|
||||
{ name: 'Routing', desc: 'Global routing rules for client apps that support them (e.g. Happ)' },
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/{subPath}:subid',
|
||||
summary: 'Return base64-encoded subscription links for all enabled clients matching the subscription ID. When the request has an Accept: text/html header or ?html=1, renders a styled info page instead. Default path: /sub/:subid.',
|
||||
params: [
|
||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/{jsonPath}:subid',
|
||||
summary: 'Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. Default path: /json/:subid.',
|
||||
params: [
|
||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/{clashPath}:subid',
|
||||
summary: 'Return subscription as a Clash/Mihomo-compatible YAML config. Only when Clash subscription is enabled in settings. Default path: /clash/:subid.',
|
||||
params: [
|
||||
{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
id: 'websocket',
|
||||
title: 'WebSocket',
|
||||
description:
|
||||
'Real-time status updates via WebSocket. Connect once at <code>ws://<panel>/ws</code> to receive a stream of JSON messages without polling. Requires an authenticated session cookie (Bearer token auth is not supported). Each message has a <code>type</code> field that identifies the payload shape.',
|
||||
endpoints: [
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/ws',
|
||||
summary: 'Upgrade an HTTP connection to a WebSocket. Requires an authenticated session cookie (Bearer token auth is not supported here). Returns 101 Switching Protocols on success. The server then pushes JSON messages described below.',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: status',
|
||||
summary: 'Server health snapshot pushed every 2 seconds. Contains CPU, memory, swap, disk, network IO, load, and Xray state — same shape as <code>GET /panel/api/server/status</code>.',
|
||||
response: '{\n "type": "status",\n "data": { "cpu": 12.5, "mem": { "current": 2147483648, "total": 8589934592 }, "xray": { "state": "running" } }\n}',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: xrayState',
|
||||
summary: 'Xray process state change. Fired when Xray starts, stops, or encounters an error.',
|
||||
response: '{\n "type": "xrayState",\n "data": "running"\n}',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: notification',
|
||||
summary: 'In-panel toast notification. Fired on Xray stop/restart, DB import, panel restart, etc.',
|
||||
response: '{\n "type": "notification",\n "title": "Xray service restarted",\n "body": "Xray has been restarted successfully",\n "severity": "success"\n}',
|
||||
},
|
||||
{
|
||||
method: 'WS',
|
||||
path: '→ type: invalidate',
|
||||
summary: 'Instructs the UI to re-fetch a resource. Fired when another admin session modifies data (e.g. toggling inbound enable).',
|
||||
response: '{\n "type": "invalidate",\n "resource": "inbounds"\n}',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const methodColors = {
|
||||
GET: 'blue',
|
||||
POST: 'green',
|
||||
PUT: 'orange',
|
||||
PATCH: 'orange',
|
||||
DELETE: 'red',
|
||||
WS: 'purple',
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, RandomUtil, SizeFormatter } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
import {
|
||||
Inbound,
|
||||
Protocols,
|
||||
USERS_SECURITY,
|
||||
TLS_FLOW_CONTROL,
|
||||
} from '@/models/inbound.js';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
|
||||
// Bulk-add up to 500 clients in one go. The legacy panel offers five
|
||||
// generation modes — this component preserves them all:
|
||||
// 0: Random — N fully-random emails (no prefix)
|
||||
// 1: Random+Prefix — N random emails preceded by `prefix`
|
||||
// 2: Random+Prefix+Num — emails like `<rand><prefix><num>` for num in [first..last]
|
||||
// 3: Random+Prefix+Num+Postfix — same + appended postfix
|
||||
// 4: Prefix+Num+Postfix — no random part, just `<prefix><num><postfix>`
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
subEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
// === Reactive form state ===========================================
|
||||
// Cloned inbound (so canEnableTlsFlow() works).
|
||||
const inbound = ref(null);
|
||||
const saving = ref(false);
|
||||
const delayedStart = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
emailMethod: 0,
|
||||
firstNum: 1,
|
||||
lastNum: 1,
|
||||
emailPrefix: '',
|
||||
emailPostfix: '',
|
||||
quantity: 1,
|
||||
security: USERS_SECURITY.AUTO,
|
||||
flow: '',
|
||||
subId: '',
|
||||
tgId: 0,
|
||||
comment: '',
|
||||
limitIp: 0,
|
||||
totalGB: 0,
|
||||
expiryTime: 0, // ms epoch; negative => delayed start days
|
||||
reset: 0,
|
||||
});
|
||||
|
||||
const expiryDate = computed({
|
||||
get: () => (form.expiryTime > 0 ? dayjs(form.expiryTime) : null),
|
||||
set: (next) => { form.expiryTime = next ? next.valueOf() : 0; },
|
||||
});
|
||||
|
||||
const delayedExpireDays = computed({
|
||||
get: () => (form.expiryTime < 0 ? form.expiryTime / -86400000 : 0),
|
||||
set: (days) => { form.expiryTime = -86400000 * (days || 0); },
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (!props.dbInbound) return;
|
||||
inbound.value = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||
// Reset all form fields on every open — bulk add is intentionally
|
||||
// stateless between sessions (legacy resets on .show()).
|
||||
form.emailMethod = 0;
|
||||
form.firstNum = 1;
|
||||
form.lastNum = 1;
|
||||
form.emailPrefix = '';
|
||||
form.emailPostfix = '';
|
||||
form.quantity = 1;
|
||||
form.security = USERS_SECURITY.AUTO;
|
||||
form.flow = '';
|
||||
form.subId = '';
|
||||
form.tgId = 0;
|
||||
form.comment = '';
|
||||
form.limitIp = 0;
|
||||
form.totalGB = 0;
|
||||
form.expiryTime = 0;
|
||||
form.reset = 0;
|
||||
delayedStart.value = false;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function makeNewClient(parsed) {
|
||||
switch (parsed.protocol) {
|
||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: {
|
||||
const method = parsed.settings.shadowsockses[0]?.method || parsed.settings.method;
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(method);
|
||||
}
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildClients() {
|
||||
if (!inbound.value) return [];
|
||||
const out = [];
|
||||
const method = form.emailMethod;
|
||||
let start;
|
||||
let end;
|
||||
if (method > 1) {
|
||||
start = form.firstNum;
|
||||
end = form.lastNum + 1;
|
||||
} else {
|
||||
start = 0;
|
||||
end = form.quantity;
|
||||
}
|
||||
const prefix = method > 0 && form.emailPrefix.length > 0 ? form.emailPrefix : '';
|
||||
const useNum = method > 1;
|
||||
const postfix = method > 2 && form.emailPostfix.length > 0 ? form.emailPostfix : '';
|
||||
|
||||
for (let i = start; i < end; i++) {
|
||||
const c = makeNewClient(inbound.value);
|
||||
if (!c) continue;
|
||||
if (method === 4) c.email = '';
|
||||
c.email += useNum ? prefix + String(i) + postfix : prefix + postfix;
|
||||
|
||||
if (form.subId.length > 0) c.subId = form.subId;
|
||||
c.tgId = form.tgId;
|
||||
if (form.comment.length > 0) c.comment = form.comment;
|
||||
c.security = form.security;
|
||||
c.limitIp = form.limitIp;
|
||||
// Use the clien's totalGB setter (ms epoch and bytes already handled
|
||||
// identically for bulk and single client paths).
|
||||
c.totalGB = Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB);
|
||||
c.expiryTime = form.expiryTime;
|
||||
if (inbound.value.canEnableTlsFlow()) c.flow = form.flow;
|
||||
c.reset = form.reset;
|
||||
out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const clients = buildClients();
|
||||
if (clients.length === 0) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: props.dbInbound.id,
|
||||
// Clients all serialize via toString() — same shape the single-
|
||||
// client modal posts. Joining with `,` lets the Go side parse the
|
||||
// outer array directly.
|
||||
settings: `{"clients": [${clients.map((c) => c.toString()).join(',')}]}`,
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/addClient', payload);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.client.bulk')" :ok-text="t('create')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||
<a-form v-if="inbound" :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
||||
<a-form-item :label="t('pages.client.method')">
|
||||
<a-select v-model:value="form.emailMethod">
|
||||
<a-select-option :value="0">Random</a-select-option>
|
||||
<a-select-option :value="1">Random + Prefix</a-select-option>
|
||||
<a-select-option :value="2">Random + Prefix + Num</a-select-option>
|
||||
<a-select-option :value="3">Random + Prefix + Num + Postfix</a-select-option>
|
||||
<a-select-option :value="4">Prefix + Num + Postfix</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.first')">
|
||||
<a-input-number v-model:value="form.firstNum" :min="1" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 1" :label="t('pages.client.last')">
|
||||
<a-input-number v-model:value="form.lastNum" :min="form.firstNum" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 0" :label="t('pages.client.prefix')">
|
||||
<a-input v-model:value="form.emailPrefix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod > 2" :label="t('pages.client.postfix')">
|
||||
<a-input v-model:value="form.emailPostfix" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.emailMethod < 2" :label="t('pages.client.clientCount')">
|
||||
<a-input-number v-model:value="form.quantity" :min="1" :max="500" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.protocol === Protocols.VMESS" :label="t('security')">
|
||||
<a-select v-model:value="form.security">
|
||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select v-model:value="form.flow">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="subEnable">
|
||||
<template #label>
|
||||
{{ t('subscription.title') }}
|
||||
<SyncOutlined class="random-icon" @click="form.subId = RandomUtil.randomLowerAndNum(16)" />
|
||||
</template>
|
||||
<a-input v-model:value="form.subId" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="tgBotEnable" label="Telegram ID">
|
||||
<a-input-number v-model:value="form.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('comment')">
|
||||
<a-input v-model:value="form.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="form.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="form.totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.client.delayedStart')">
|
||||
<a-switch v-model:checked="delayedStart" @click="form.expiryTime = 0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||
}}</a-tooltip>
|
||||
</template>
|
||||
<DateTimePicker v-model:value="expiryDate" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.expiryTime !== 0">
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="form.reset" :min="0" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.random-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import dayjs from 'dayjs';
|
||||
import { SyncOutlined, RetweetOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
import {
|
||||
HttpUtil,
|
||||
RandomUtil,
|
||||
SizeFormatter,
|
||||
ColorUtils,
|
||||
} from '@/utils';
|
||||
import { Inbound, Protocols, USERS_SECURITY, TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
import DateTimePicker from '@/components/DateTimePicker.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Add OR edit a single client on a multi-user inbound (VMess / VLess /
|
||||
// Trojan / Shadowsocks-multi / Hysteria). The legacy panel routes both
|
||||
// flows through the same modal — same here.
|
||||
//
|
||||
// On submit we serialize the client via its toString() (which is just
|
||||
// JSON.stringify of toJson()) and post it inside a one-element clients
|
||||
// array so the Go side reuses the same parsing path as the inbound
|
||||
// settings update.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||
dbInbound: { type: Object, default: null },
|
||||
clientIndex: { type: Number, default: null },
|
||||
// Sidecar config from the inbounds page — controls visibility of
|
||||
// the Subscription, Telegram, and IP-limit fields.
|
||||
subEnable: { type: Boolean, default: false },
|
||||
tgBotEnable: { type: Boolean, default: false },
|
||||
ipLimitEnable: { type: Boolean, default: false },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
// === Reactive draft =================================================
|
||||
const inbound = ref(null);
|
||||
const client = ref(null);
|
||||
const oldClientId = ref('');
|
||||
const clientStats = ref(null);
|
||||
|
||||
const saving = ref(false);
|
||||
const delayedStart = ref(false);
|
||||
|
||||
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const protocol = computed(() => inbound.value?.protocol);
|
||||
const isVmessOrVless = computed(() =>
|
||||
protocol.value === Protocols.VMESS || protocol.value === Protocols.VLESS,
|
||||
);
|
||||
const isTrojanOrSS = computed(() =>
|
||||
protocol.value === Protocols.TROJAN || protocol.value === Protocols.SHADOWSOCKS,
|
||||
);
|
||||
|
||||
const expiryDate = computed({
|
||||
get: () => (client.value?.expiryTime > 0 ? dayjs(client.value.expiryTime) : null),
|
||||
set: (next) => { if (client.value) client.value.expiryTime = next ? next.valueOf() : 0; },
|
||||
});
|
||||
|
||||
const delayedExpireDays = computed({
|
||||
get: () => {
|
||||
if (!client.value || client.value.expiryTime >= 0) return 0;
|
||||
return client.value.expiryTime / -86400000;
|
||||
},
|
||||
set: (days) => {
|
||||
if (!client.value) return;
|
||||
client.value.expiryTime = -86400000 * (days || 0);
|
||||
},
|
||||
});
|
||||
|
||||
const totalGB = computed({
|
||||
get: () => {
|
||||
if (!client.value || !client.value.totalGB) return 0;
|
||||
return Math.round((client.value.totalGB / SizeFormatter.ONE_GB) * 100) / 100;
|
||||
},
|
||||
set: (gb) => {
|
||||
if (!client.value) return;
|
||||
client.value.totalGB = Math.round((gb || 0) * SizeFormatter.ONE_GB);
|
||||
},
|
||||
});
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (props.mode !== 'edit' || !client.value) return false;
|
||||
return client.value.expiryTime > 0 && client.value.expiryTime < Date.now();
|
||||
});
|
||||
const isTrafficExhausted = computed(() => {
|
||||
if (!clientStats.value || clientStats.value.total <= 0) return false;
|
||||
return clientStats.value.up + clientStats.value.down >= clientStats.value.total;
|
||||
});
|
||||
|
||||
function getClientId(proto, c) {
|
||||
switch (proto) {
|
||||
case Protocols.TROJAN: return c.password;
|
||||
case Protocols.SHADOWSOCKS: return c.email;
|
||||
case Protocols.HYSTERIA: return c.auth;
|
||||
default: return c.id;
|
||||
}
|
||||
}
|
||||
|
||||
function makeNewClient(proto, parsed) {
|
||||
switch (proto) {
|
||||
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
|
||||
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
|
||||
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
|
||||
case Protocols.SHADOWSOCKS: {
|
||||
const method = parsed.settings.method;
|
||||
return new Inbound.ShadowsocksSettings.Shadowsocks(
|
||||
method,
|
||||
RandomUtil.randomShadowsocksPassword(method),
|
||||
);
|
||||
}
|
||||
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (!props.dbInbound) return;
|
||||
const parsed = Inbound.fromJson(props.dbInbound.toInbound().toJson());
|
||||
inbound.value = parsed;
|
||||
delayedStart.value = false;
|
||||
|
||||
if (props.mode === 'edit') {
|
||||
const idx = props.clientIndex ?? 0;
|
||||
client.value = parsed.clients[idx];
|
||||
if (client.value && client.value.expiryTime < 0) delayedStart.value = true;
|
||||
oldClientId.value = getClientId(parsed.protocol, client.value);
|
||||
} else {
|
||||
const c = makeNewClient(parsed.protocol, parsed);
|
||||
if (c) parsed.clients.push(c);
|
||||
client.value = parsed.clients[parsed.clients.length - 1];
|
||||
oldClientId.value = '';
|
||||
}
|
||||
|
||||
clientStats.value = (props.dbInbound.clientStats || []).find(
|
||||
(s) => s.email === client.value?.email,
|
||||
) || null;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function randomEmail() {
|
||||
if (client.value) client.value.email = RandomUtil.randomLowerAndNum(9);
|
||||
}
|
||||
function randomId() {
|
||||
if (client.value) client.value.id = RandomUtil.randomUUID();
|
||||
}
|
||||
function randomPassword() {
|
||||
if (!client.value || !inbound.value) return;
|
||||
if (inbound.value.protocol === Protocols.SHADOWSOCKS) {
|
||||
client.value.password = RandomUtil.randomShadowsocksPassword(
|
||||
inbound.value.settings.method,
|
||||
);
|
||||
} else {
|
||||
client.value.password = RandomUtil.randomSeq(10);
|
||||
}
|
||||
}
|
||||
function randomAuth() {
|
||||
if (client.value) client.value.auth = RandomUtil.randomSeq(10);
|
||||
}
|
||||
function randomSubId() {
|
||||
if (client.value) client.value.subId = RandomUtil.randomLowerAndNum(16);
|
||||
}
|
||||
|
||||
const clientIpsText = ref('');
|
||||
async function loadClientIps() {
|
||||
if (!client.value?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clientIps/${client.value.email}`);
|
||||
if (!msg?.success) {
|
||||
clientIpsText.value = msg?.obj || '';
|
||||
return;
|
||||
}
|
||||
let ips = msg.obj;
|
||||
if (typeof ips === 'string' && ips.startsWith('[') && ips.endsWith(']')) {
|
||||
try {
|
||||
const parsed = JSON.parse(ips);
|
||||
ips = Array.isArray(parsed) ? parsed.join('\n') : ips;
|
||||
} catch (_e) {
|
||||
// leave as raw
|
||||
}
|
||||
}
|
||||
clientIpsText.value = ips || '';
|
||||
}
|
||||
async function clearClientIps() {
|
||||
if (!client.value?.email) return;
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/clearClientIps/${client.value.email}`);
|
||||
if (msg?.success) clientIpsText.value = '';
|
||||
}
|
||||
|
||||
async function resetClientTraffic() {
|
||||
if (!clientStats.value || !client.value?.email) return;
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${props.dbInbound.id}/resetClientTraffic/${client.value.email}`,
|
||||
);
|
||||
if (msg?.success) {
|
||||
clientStats.value.up = 0;
|
||||
clientStats.value.down = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!client.value || !inbound.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: props.dbInbound.id,
|
||||
settings: `{"clients": [${client.value.toString()}]}`,
|
||||
};
|
||||
const url = props.mode === 'edit'
|
||||
? `/panel/api/inbounds/updateClient/${oldClientId.value}`
|
||||
: '/panel/api/inbounds/addClient';
|
||||
const msg = await HttpUtil.post(url, payload);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const title = computed(() =>
|
||||
props.mode === 'edit' ? t('pages.client.edit') : t('pages.client.add'),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title"
|
||||
:ok-text="mode === 'edit' ? t('pages.client.submitEdit') : t('pages.client.submitAdd')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" @ok="submit" @cancel="close">
|
||||
<a-tag v-if="mode === 'edit' && (isExpired || isTrafficExhausted)" color="red" class="status-banner">
|
||||
{{ t('depleted') }}
|
||||
</a-tag>
|
||||
|
||||
<a-form v-if="client && inbound" layout="horizontal" :colon="false" :label-col="{ sm: { span: 8 } }"
|
||||
:wrapper-col="{ sm: { span: 14 } }">
|
||||
<a-form-item :label="t('enable')">
|
||||
<a-switch v-model:checked="client.enable" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
{{ t('pages.inbounds.email') }}
|
||||
<SyncOutlined class="random-icon" @click="randomEmail" />
|
||||
</template>
|
||||
<a-input v-model:value="client.email" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isTrojanOrSS">
|
||||
<template #label>
|
||||
{{ t('password') }}
|
||||
<SyncOutlined class="random-icon" @click="randomPassword" />
|
||||
</template>
|
||||
<a-input v-model:value="client.password" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.HYSTERIA">
|
||||
<template #label>
|
||||
{{ t('password') }}
|
||||
<SyncOutlined class="random-icon" @click="randomAuth" />
|
||||
</template>
|
||||
<a-input v-model:value="client.auth" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isVmessOrVless">
|
||||
<template #label>
|
||||
ID
|
||||
<SyncOutlined class="random-icon" @click="randomId" />
|
||||
</template>
|
||||
<a-input v-model:value="client.id" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.VMESS" :label="t('security')">
|
||||
<a-select v-model:value="client.security">
|
||||
<a-select-option v-for="key in SECURITY_OPTIONS" :key="key" :value="key">
|
||||
{{ key }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email && subEnable">
|
||||
<template #label>
|
||||
{{ t('subscription.title') }}
|
||||
<SyncOutlined class="random-icon" @click="randomSubId" />
|
||||
</template>
|
||||
<a-input v-model:value="client.subId" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email && tgBotEnable" label="Telegram ID">
|
||||
<a-input-number v-model:value="client.tgId" :min="0" :style="{ width: '50%' }" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.email" :label="t('comment')">
|
||||
<a-input v-model:value="client.comment" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable" :label="t('pages.inbounds.IPLimit')">
|
||||
<a-input-number v-model:value="client.limitIp" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="ipLimitEnable && client.limitIp > 0 && client.email && mode === 'edit'"
|
||||
:label="t('pages.inbounds.IPLimitlog')">
|
||||
<a-textarea v-model:value="clientIpsText" readonly :placeholder="t('pages.inbounds.IPLimitlogDesc')"
|
||||
:auto-size="{ minRows: 3, maxRows: 8 }" @click="loadClientIps" />
|
||||
<a-button type="link" size="small" danger @click="clearClientIps">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
{{ t('pages.inbounds.IPLimitlogclear') }}
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
|
||||
<a-select v-model:value="client.flow">
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">
|
||||
{{ key }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="protocol === Protocols.VLESS" label="Reverse tag">
|
||||
<a-input v-model:value="client.reverseTag" placeholder="Optional reverse tag" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.meansNoLimit')">{{ t('pages.inbounds.totalFlow') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="totalGB" :min="0" :step="0.1" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="mode === 'edit' && clientStats" :label="t('usage')">
|
||||
<a-tag :color="ColorUtils.clientUsageColor(clientStats, trafficDiff)">
|
||||
{{ SizeFormatter.sizeFormat(clientStats.up) }} /
|
||||
{{ SizeFormatter.sizeFormat(clientStats.down) }}
|
||||
({{ SizeFormatter.sizeFormat(clientStats.up + clientStats.down) }})
|
||||
</a-tag>
|
||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||
<RetweetOutlined class="action-icon" @click="resetClientTraffic" />
|
||||
</a-tooltip>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.client.delayedStart')">
|
||||
<a-switch v-model:checked="delayedStart" @click="client.expiryTime = 0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="delayedStart" :label="t('pages.client.expireDays')">
|
||||
<a-input-number v-model:value="delayedExpireDays" :min="0" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-else>
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
|
||||
}}</a-tooltip>
|
||||
</template>
|
||||
<DateTimePicker v-model:value="expiryDate" />
|
||||
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="client.expiryTime !== 0">
|
||||
<template #label>
|
||||
<a-tooltip :title="t('pages.client.renewDesc')">{{ t('pages.client.renew') }}</a-tooltip>
|
||||
</template>
|
||||
<a-input-number v-model:value="client.reset" :min="0" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-banner {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.random-icon,
|
||||
.action-icon {
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-primary-color, #1890ff);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,841 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
EditOutlined,
|
||||
InfoCircleOutlined,
|
||||
QrcodeOutlined,
|
||||
RetweetOutlined,
|
||||
DeleteOutlined,
|
||||
EllipsisOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
import { SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Per-inbound expand-row content. CSS-grid layout (not a nested
|
||||
// <a-table>) so it sits flush inside the parent's expanded cell.
|
||||
// No API calls here — events bubble to the parent's modals.
|
||||
|
||||
const props = defineProps({
|
||||
dbInbound: { type: Object, required: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
expireDiff: { type: Number, default: 0 },
|
||||
onlineClients: { type: Array, default: () => [] },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
totalClientCount: { type: Number, default: 0 },
|
||||
statsVersion: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'edit-client',
|
||||
'qrcode-client',
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
const inbound = computed(() => props.dbInbound.toInbound());
|
||||
const clients = computed(() => inbound.value?.clients || []);
|
||||
|
||||
const currentPage = ref(1);
|
||||
const paginatedClients = computed(() => {
|
||||
if (!props.pageSize || props.pageSize <= 0) return clients.value;
|
||||
const start = (currentPage.value - 1) * props.pageSize;
|
||||
return clients.value.slice(start, start + props.pageSize);
|
||||
});
|
||||
|
||||
watch([clients, () => props.pageSize], () => {
|
||||
const total = clients.value.length;
|
||||
const size = props.pageSize > 0 ? props.pageSize : (total || 1);
|
||||
const maxPage = Math.max(1, Math.ceil(total / size));
|
||||
if (currentPage.value > maxPage) currentPage.value = maxPage;
|
||||
});
|
||||
|
||||
// === Per-client stats lookup =======================================
|
||||
// statsVersion bumps on every ws merge so this computed re-evaluates
|
||||
// (DBInbound isn't reactive — the in-place stat mutations alone don't
|
||||
// trigger Vue's tracking).
|
||||
const statsMap = computed(() => {
|
||||
void props.statsVersion;
|
||||
const m = new Map();
|
||||
for (const cs of (props.dbInbound.clientStats || [])) m.set(cs.email, cs);
|
||||
return m;
|
||||
});
|
||||
function statsFor(email) {
|
||||
return email ? statsMap.value.get(email) : null;
|
||||
}
|
||||
|
||||
function getUp(email) { return statsFor(email)?.up || 0; }
|
||||
function getDown(email) { return statsFor(email)?.down || 0; }
|
||||
function getSum(email) { const s = statsFor(email); return s ? s.up + s.down : 0; }
|
||||
function getRem(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
const r = s.total - s.up - s.down;
|
||||
return r > 0 ? r : 0;
|
||||
}
|
||||
function getAllTime(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
// allTime is the cumulative-historical counter; never let it dip
|
||||
// below up+down (manual edits / partial migrations can push it under).
|
||||
const current = (s.up || 0) + (s.down || 0);
|
||||
return s.allTime > current ? s.allTime : current;
|
||||
}
|
||||
function isClientDepleted(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return false;
|
||||
const total = s.total ?? 0;
|
||||
const used = (s.up ?? 0) + (s.down ?? 0);
|
||||
if (total > 0 && used >= total) return true;
|
||||
const exp = s.expiryTime ?? 0;
|
||||
if (exp > 0 && Date.now() >= exp) return true;
|
||||
return false;
|
||||
}
|
||||
function isClientOnline(email) {
|
||||
return !!email && props.onlineClients.includes(email);
|
||||
}
|
||||
function lastOnlineLabel(email) {
|
||||
const ts = props.lastOnlineMap[email];
|
||||
if (!ts) return '-';
|
||||
return IntlUtil.formatDate(ts, datepicker.value);
|
||||
}
|
||||
|
||||
function statsProgress(email) {
|
||||
const s = statsFor(email);
|
||||
if (!s) return 0;
|
||||
if (s.total === 0) return 100;
|
||||
return (100 * (s.down + s.up)) / s.total;
|
||||
}
|
||||
function expireProgress(expTime, reset) {
|
||||
const now = Date.now();
|
||||
const remainedSec = expTime < 0 ? -expTime / 1000 : (expTime - now) / 1000;
|
||||
const resetSec = reset * 86400;
|
||||
if (remainedSec >= resetSec) return 0;
|
||||
return 100 * (1 - remainedSec / resetSec);
|
||||
}
|
||||
function clientStatsColor(email) {
|
||||
return ColorUtils.clientUsageColor(statsFor(email), props.trafficDiff);
|
||||
}
|
||||
function statsExpColor(email) {
|
||||
// AD-Vue 4 semantic palette mirrors ColorUtils.* so the badge dot
|
||||
// matches the row's traffic/expiry tags.
|
||||
const PURPLE = '#722ed1', SUCCESS = '#52c41a', WARN = '#faad14', DANGER = '#ff4d4f';
|
||||
if (!email) return PURPLE;
|
||||
const s = statsFor(email);
|
||||
if (!s) return PURPLE;
|
||||
const a = ColorUtils.usageColor(s.down + s.up, props.trafficDiff, s.total);
|
||||
const b = ColorUtils.usageColor(Date.now(), props.expireDiff, s.expiryTime);
|
||||
if (a === 'red' || b === 'red') return DANGER;
|
||||
if (a === 'orange' || b === 'orange') return WARN;
|
||||
if (a === 'green' || b === 'green') return SUCCESS;
|
||||
return PURPLE;
|
||||
}
|
||||
|
||||
const isRemovable = computed(() => (props.totalClientCount || clients.value.length) > 1);
|
||||
|
||||
function totalGbDisplay(client) {
|
||||
if (!client.totalGB || client.totalGB <= 0) return '';
|
||||
return `${Math.round((client.totalGB / 1073741824) * 100) / 100} GB`;
|
||||
}
|
||||
|
||||
const isUnlimitedTotal = (client) => !client.totalGB || client.totalGB <= 0;
|
||||
|
||||
function statusBadgeColor(client) {
|
||||
if (!client.enable) return props.isDarkTheme ? '#2c3950' : '#bcbcbc';
|
||||
return statsExpColor(client.email);
|
||||
}
|
||||
|
||||
// === Action confirms ==============================================
|
||||
function confirmReset(client) {
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.resetTraffic')} — ${client.email}`,
|
||||
content: t('pages.inbounds.resetTrafficContent'),
|
||||
okText: t('reset'),
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => emit('reset-traffic-client', { dbInbound: props.dbInbound, client }),
|
||||
});
|
||||
}
|
||||
function confirmDelete(client) {
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.deleteClient')} — ${client.email}`,
|
||||
content: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => emit('delete-client', { dbInbound: props.dbInbound, client }),
|
||||
});
|
||||
}
|
||||
|
||||
// Stable row key for v-for — falls back through email/id/password
|
||||
// because not every protocol fills the same field.
|
||||
function rowKey(client) {
|
||||
return client.email || client.id || client.password || JSON.stringify(client);
|
||||
}
|
||||
|
||||
const selected = ref(new Set());
|
||||
|
||||
const allSelected = computed(() =>
|
||||
clients.value.length > 0 && clients.value.every((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const someSelected = computed(() =>
|
||||
clients.value.some((c) => selected.value.has(rowKey(c))),
|
||||
);
|
||||
const selectedCount = computed(() => selected.value.size);
|
||||
|
||||
function isSelected(key) {
|
||||
return selected.value.has(key);
|
||||
}
|
||||
function toggleSelect(key, next) {
|
||||
const s = new Set(selected.value);
|
||||
if (next) s.add(key); else s.delete(key);
|
||||
selected.value = s;
|
||||
}
|
||||
function selectAll(next) {
|
||||
if (next) {
|
||||
selected.value = new Set(clients.value.map(rowKey));
|
||||
} else {
|
||||
selected.value = new Set();
|
||||
}
|
||||
}
|
||||
function clearSelection() {
|
||||
selected.value = new Set();
|
||||
}
|
||||
|
||||
watch(clients, (list) => {
|
||||
if (selected.value.size === 0) return;
|
||||
const valid = new Set(list.map(rowKey));
|
||||
const next = new Set();
|
||||
for (const k of selected.value) if (valid.has(k)) next.add(k);
|
||||
if (next.size !== selected.value.size) selected.value = next;
|
||||
});
|
||||
|
||||
const statsClient = ref(null);
|
||||
function openStats(client) {
|
||||
statsClient.value = client;
|
||||
}
|
||||
function closeStats() {
|
||||
statsClient.value = null;
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const picked = clients.value.filter((c) => selected.value.has(rowKey(c)));
|
||||
if (picked.length === 0) return;
|
||||
|
||||
const total = clients.value.length;
|
||||
const keepLast = picked.length === total;
|
||||
const toDelete = keepLast ? picked.slice(0, -1) : picked;
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
Modal.warning({
|
||||
title: t('pages.inbounds.deleteClient'),
|
||||
content: 'Inbound must keep at least one client — delete the inbound to remove all.',
|
||||
okText: t('confirm'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `${t('pages.inbounds.deleteClient')} — ${toDelete.length}${keepLast ? ` / ${total}` : ''}`,
|
||||
content: keepLast
|
||||
? 'Inbound must keep at least one client — the last selected will remain. Delete the inbound to remove all.'
|
||||
: t('pages.inbounds.deleteClientContent'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: () => {
|
||||
emit('delete-clients', { dbInbound: props.dbInbound, clients: toDelete });
|
||||
clearSelection();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="client-list"
|
||||
:class="{ 'is-mobile': isMobile, 'is-dark': isDarkTheme, 'has-select': isRemovable }">
|
||||
<div v-if="isRemovable && selectedCount > 0" class="bulk-bar">
|
||||
<span class="bulk-count">{{ selectedCount }} selected</span>
|
||||
<a-button size="small" type="link" @click="clearSelection">{{ t('cancel') }}</a-button>
|
||||
<a-button size="small" danger @click="confirmBulkDelete">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Desktop: grid table ===================== -->
|
||||
<template v-if="!isMobile">
|
||||
<div class="client-row client-list-header">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="allSelected" :indeterminate="someSelected && !allSelected"
|
||||
@change="(e) => selectAll(e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">{{ t('pages.settings.actions') }}</div>
|
||||
<div class="cell cell-enable">{{ t('enable') }}</div>
|
||||
<div class="cell cell-online">{{ t('online') }}</div>
|
||||
<div class="cell cell-client">{{ t('pages.inbounds.client') }}</div>
|
||||
<div class="cell cell-traffic">{{ t('pages.inbounds.traffic') }}</div>
|
||||
<div class="cell cell-remained">{{ t('remained') }}</div>
|
||||
<div class="cell cell-alltime">{{ t('pages.inbounds.allTimeTraffic') }}</div>
|
||||
<div class="cell cell-expiry">{{ t('pages.inbounds.expireDate') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-row"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div v-if="isRemovable" class="cell cell-select">
|
||||
<a-checkbox :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
</div>
|
||||
<div class="cell cell-actions">
|
||||
<a-tooltip v-if="dbInbound.hasLink()" :title="t('qrCode')">
|
||||
<QrcodeOutlined class="row-icon" @click="emit('qrcode-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('edit')">
|
||||
<EditOutlined class="row-icon" @click="emit('edit-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-icon" @click="emit('info-client', { dbInbound, client })" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="client.email" :title="t('pages.inbounds.resetTraffic')">
|
||||
<RetweetOutlined class="row-icon" @click="confirmReset(client)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="isRemovable" :title="t('delete')">
|
||||
<DeleteOutlined class="row-icon danger" @click="confirmDelete(client)" />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-enable">
|
||||
<a-switch :checked="client.enable" size="small"
|
||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||
</div>
|
||||
|
||||
<div class="cell cell-online">
|
||||
<a-popover>
|
||||
<template #content>{{ t('lastOnline') }}: {{ lastOnlineLabel(client.email) }}</template>
|
||||
<a-tag v-if="client.enable && isClientOnline(client.email)" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-client">
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||
<template v-else>{{ t('offline') }}</template>
|
||||
</template>
|
||||
<a-badge :color="statusBadgeColor(client)" />
|
||||
</a-tooltip>
|
||||
<div class="client-id-stack">
|
||||
<a-tooltip :title="client.email">
|
||||
<span class="client-email">{{ client.email }}</span>
|
||||
</a-tooltip>
|
||||
<span v-if="client.comment && client.comment.trim()" class="client-comment">
|
||||
{{ client.comment.length > 50 ? client.comment.substring(0, 47) + '…' : client.comment }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-traffic">
|
||||
<a-popover>
|
||||
<template v-if="client.email" #content>
|
||||
<table cellpadding="2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑ {{ SizeFormatter.sizeFormat(getUp(client.email)) }}</td>
|
||||
<td>↓ {{ SizeFormatter.sizeFormat(getDown(client.email)) }}</td>
|
||||
</tr>
|
||||
<tr v-if="client.totalGB > 0">
|
||||
<td>{{ t('remained') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(getRem(client.email)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<div class="usage-bar">
|
||||
<span class="usage-text">{{ SizeFormatter.sizeFormat(getSum(client.email)) }}</span>
|
||||
<a-progress v-if="!client.enable" :stroke-color="isDarkTheme ? 'rgb(72,84,105)' : '#bcbcbc'"
|
||||
:show-info="false" :percent="statsProgress(client.email)" size="small" />
|
||||
<a-progress v-else-if="client.totalGB > 0" :stroke-color="clientStatsColor(client.email)"
|
||||
:show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||
:percent="statsProgress(client.email)" size="small" />
|
||||
<a-progress v-else :show-info="false" :percent="100" stroke-color="#722ed1" size="small" />
|
||||
<span class="usage-text">
|
||||
<InfinityIcon v-if="isUnlimitedTotal(client)" />
|
||||
<template v-else>{{ totalGbDisplay(client) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</a-popover>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-remained">
|
||||
<a-tag v-if="isUnlimitedTotal(client)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(client.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(client.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-alltime">
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(client.email)) }}</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="cell cell-expiry">
|
||||
<template v-if="client.expiryTime !== 0 && client.reset > 0">
|
||||
<a-popover>
|
||||
<template #content>
|
||||
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime, datepicker) }}</span>
|
||||
</template>
|
||||
<div class="usage-bar">
|
||||
<span class="usage-text">{{ IntlUtil.formatRelativeTime(client.expiryTime) }}</span>
|
||||
<a-progress :show-info="false" :status="isClientDepleted(client.email) ? 'exception' : ''"
|
||||
:percent="expireProgress(client.expiryTime, client.reset)" size="small" />
|
||||
<span class="usage-text">{{ client.reset }}d</span>
|
||||
</div>
|
||||
</a-popover>
|
||||
</template>
|
||||
<a-popover v-else-if="client.expiryTime !== 0">
|
||||
<template #content>
|
||||
<span v-if="client.expiryTime < 0">{{ t('pages.client.delayedStart') }}</span>
|
||||
<span v-else>{{ IntlUtil.formatDate(client.expiryTime) }}</span>
|
||||
</template>
|
||||
<a-tag :style="{ minWidth: '50px', border: 'none' }"
|
||||
:color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
|
||||
class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<template v-else>
|
||||
<div v-for="client in paginatedClients" :key="rowKey(client)" class="client-card"
|
||||
:class="{ 'is-selected': isSelected(rowKey(client)) }">
|
||||
<div class="client-card-head">
|
||||
<a-checkbox v-if="isRemovable" :checked="isSelected(rowKey(client))"
|
||||
@change="(e) => toggleSelect(rowKey(client), e.target.checked)" />
|
||||
<a-tooltip>
|
||||
<template #title>
|
||||
<template v-if="isClientDepleted(client.email)">{{ t('depleted') }}</template>
|
||||
<template v-else-if="!client.enable">{{ t('disabled') }}</template>
|
||||
<template v-else-if="isClientOnline(client.email)">{{ t('online') }}</template>
|
||||
<template v-else>{{ t('offline') }}</template>
|
||||
</template>
|
||||
<a-badge :color="statusBadgeColor(client)" />
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="client.email">
|
||||
<span class="client-email">{{ client.email }}</span>
|
||||
</a-tooltip>
|
||||
<div class="client-card-actions">
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-icon" @click="openStats(client)" />
|
||||
</a-tooltip>
|
||||
<a-switch :checked="client.enable" size="small"
|
||||
@change="(next) => emit('toggle-enable-client', { dbInbound, client, next })" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<EllipsisOutlined class="row-icon" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item v-if="dbInbound.hasLink()" @click="emit('qrcode-client', { dbInbound, client })">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="emit('edit-client', { dbInbound, client })">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="emit('info-client', { dbInbound, client })">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="client.email" @click="confirmReset(client)">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="isRemovable" @click="confirmDelete(client)">
|
||||
<DeleteOutlined /> <span class="danger">{{ t('delete') }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-modal :open="!!statsClient" :footer="null" :width="360" centered
|
||||
:title="statsClient ? statsClient.email || t('info') : ''" @cancel="closeStats">
|
||||
<div v-if="statsClient" class="client-card-foot">
|
||||
<div v-if="statsClient.comment && statsClient.comment.trim()" class="client-comment-line">
|
||||
{{ statsClient.comment }}
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="clientStatsColor(statsClient.email)">
|
||||
{{ SizeFormatter.sizeFormat(getSum(statsClient.email)) }} /
|
||||
<InfinityIcon v-if="isUnlimitedTotal(statsClient)" />
|
||||
<template v-else>{{ totalGbDisplay(statsClient) }}</template>
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('remained') }}</span>
|
||||
<a-tag v-if="isUnlimitedTotal(statsClient)" color="purple" :style="{ border: 'none' }" class="infinite-tag">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
<a-tag v-else :color="isClientDepleted(statsClient.email) ? 'red' : ''">
|
||||
{{ SizeFormatter.sizeFormat(getRem(statsClient.email)) }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(getAllTime(statsClient.email)) }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('online') }}</span>
|
||||
<a-tag v-if="statsClient.enable && isClientOnline(statsClient.email)" color="green">{{ t('online') }}</a-tag>
|
||||
<a-tag v-else>{{ t('offline') }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="statsClient.expiryTime > 0"
|
||||
:color="ColorUtils.userExpiryColor(expireDiff, statsClient, isDarkTheme)">
|
||||
{{ IntlUtil.formatRelativeTime(statsClient.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="statsClient.expiryTime < 0" color="green">
|
||||
{{ -statsClient.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<a-pagination v-if="pageSize > 0 && clients.length > pageSize" v-model:current="currentPage"
|
||||
:page-size="pageSize" :total="clients.length" :show-size-changer="false" size="small"
|
||||
class="client-list-pagination" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.client-list {
|
||||
margin: -8px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 6px 16px;
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-bottom: 1px solid rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.is-selected {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: grid;
|
||||
/* Default — no select column (single-client inbounds). The .has-select
|
||||
* modifier below prepends the 40px checkbox column. */
|
||||
grid-template-columns:
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list.has-select .client-row {
|
||||
grid-template-columns:
|
||||
40px
|
||||
/* select */
|
||||
140px
|
||||
/* actions */
|
||||
60px
|
||||
/* enable */
|
||||
80px
|
||||
/* online */
|
||||
minmax(160px, 2fr)
|
||||
/* client identity */
|
||||
minmax(160px, 2fr)
|
||||
/* traffic */
|
||||
130px
|
||||
/* all-time */
|
||||
130px
|
||||
/* remained */
|
||||
140px;
|
||||
/* expiry */
|
||||
}
|
||||
|
||||
.client-row:last-child {
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
|
||||
}
|
||||
|
||||
.client-list-header {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
border-top: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-width: 0;
|
||||
/* allow grid children to shrink instead of overflowing */
|
||||
}
|
||||
|
||||
.cell-select,
|
||||
.cell-actions,
|
||||
.cell-enable,
|
||||
.cell-online,
|
||||
.cell-alltime,
|
||||
.cell-remained {
|
||||
text-align: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.cell-client {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cell-traffic,
|
||||
.cell-expiry {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-list-header .cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-list-header .cell-actions,
|
||||
.client-list-header .cell-client {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Action icons */
|
||||
.row-icon {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
color: inherit;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.row-icon:hover {
|
||||
color: var(--ant-color-primary, #1677ff);
|
||||
}
|
||||
|
||||
.row-icon.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Client identity stack (badge + email + comment) */
|
||||
.client-id-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-email {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.client-comment {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Traffic / expiry inline bar: text | progress | text */
|
||||
.usage-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, auto) minmax(40px, 1fr) minmax(40px, auto);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.usage-text {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-bar :deep(.ant-progress) {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.infinite-tag {
|
||||
min-width: 50px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Strip AD-Vue's default expanded-cell padding so the desktop grid
|
||||
* sits flush against the inbound row's left/right edges. */
|
||||
:deep(.ant-table-expanded-row > .ant-table-cell) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.client-list-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px 16px 4px;
|
||||
}
|
||||
|
||||
/* ===== Mobile card list =========================================== */
|
||||
.client-list.is-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.18);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:global(body.dark) .client-card {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.client-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-card-head .client-email {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.client-card-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-card-actions .row-icon {
|
||||
font-size: 20px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.client-comment-line {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.client-card-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-card-foot .stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.client-card-foot .stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-card-foot :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Bigger status badge for thumb-readable state at a glance. */
|
||||
.client-card-head :deep(.ant-badge-status-dot) {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, IntlUtil } from '@/utils';
|
||||
import { TLS_FLOW_CONTROL } from '@/models/inbound.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
dbInbounds: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
|
||||
const sourceInboundId = ref(null);
|
||||
const selectedEmails = ref([]);
|
||||
const flow = ref('');
|
||||
const saving = ref(false);
|
||||
|
||||
const sources = computed(() => {
|
||||
if (!props.dbInbound) return [];
|
||||
return props.dbInbounds
|
||||
.filter(
|
||||
(row) =>
|
||||
row.id !== props.dbInbound.id &&
|
||||
typeof row.isMultiUser === 'function' &&
|
||||
row.isMultiUser(),
|
||||
)
|
||||
.map((row) => {
|
||||
let count = 0;
|
||||
try { count = (row.toInbound().clients || []).length; } catch (_e) { /* ignore */ }
|
||||
return { id: row.id, label: `${row.remark || `#${row.id}`} (${row.protocol}, ${count})` };
|
||||
});
|
||||
});
|
||||
|
||||
const sourceInbound = computed(() => {
|
||||
if (!sourceInboundId.value) return null;
|
||||
return props.dbInbounds.find((r) => r.id === sourceInboundId.value) || null;
|
||||
});
|
||||
|
||||
const sourceClients = computed(() => {
|
||||
const sb = sourceInbound.value;
|
||||
if (!sb) return [];
|
||||
let list = [];
|
||||
try { list = sb.toInbound().clients || []; } catch (_e) { /* ignore */ }
|
||||
const stats = new Map((sb.clientStats || []).map((s) => [s.email, s]));
|
||||
return list
|
||||
.filter((c) => c.email)
|
||||
.map((c) => {
|
||||
const s = stats.get(c.email);
|
||||
const used = s ? (s.up || 0) + (s.down || 0) : 0;
|
||||
let expiryLabel = t('unlimited');
|
||||
if (c.expiryTime > 0) expiryLabel = IntlUtil.formatDate(c.expiryTime);
|
||||
else if (c.expiryTime < 0) expiryLabel = `${-c.expiryTime / 86400000}d`;
|
||||
return { email: c.email, trafficLabel: SizeFormatter.sizeFormat(used), expiryLabel };
|
||||
});
|
||||
});
|
||||
|
||||
const showFlow = computed(() => {
|
||||
if (!props.dbInbound) return false;
|
||||
try {
|
||||
const inb = props.dbInbound.toInbound();
|
||||
return !!(inb && typeof inb.canEnableTlsFlow === 'function' && inb.canEnableTlsFlow());
|
||||
} catch (_e) { return false; }
|
||||
});
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.inbounds.email'), dataIndex: 'email', width: 280 },
|
||||
{ title: t('pages.inbounds.traffic'), dataIndex: 'trafficLabel', width: 140 },
|
||||
{ title: t('pages.inbounds.expireDate'), dataIndex: 'expiryLabel', width: 160 },
|
||||
]);
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedEmails.value,
|
||||
onChange: (keys) => { selectedEmails.value = keys; },
|
||||
}));
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.dbInbound) return t('pages.client.copyFromInbound');
|
||||
const target = props.dbInbound.remark || `#${props.dbInbound.id}`;
|
||||
return `${t('pages.client.copyToInbound')} ${target}`;
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
sourceInboundId.value = null;
|
||||
selectedEmails.value = [];
|
||||
flow.value = '';
|
||||
saving.value = false;
|
||||
});
|
||||
|
||||
watch(sourceInboundId, () => {
|
||||
selectedEmails.value = [];
|
||||
});
|
||||
|
||||
function selectAll() {
|
||||
selectedEmails.value = sourceClients.value.map((c) => c.email);
|
||||
}
|
||||
function clearAll() {
|
||||
selectedEmails.value = [];
|
||||
}
|
||||
|
||||
async function ok() {
|
||||
if (!sourceInboundId.value) {
|
||||
message.error(t('pages.client.copySelectSourceFirst'));
|
||||
return;
|
||||
}
|
||||
if (!props.dbInbound) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
sourceInboundId: sourceInboundId.value,
|
||||
clientEmails: selectedEmails.value,
|
||||
};
|
||||
if (showFlow.value && flow.value) payload.flow = flow.value;
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${props.dbInbound.id}/copyClients`,
|
||||
payload,
|
||||
);
|
||||
if (!msg?.success) return;
|
||||
const obj = msg.obj || {};
|
||||
const addedCount = (obj.added || []).length;
|
||||
const errorList = obj.errors || [];
|
||||
if (addedCount > 0) {
|
||||
message.success(`${t('pages.client.copyResultSuccess')}: ${addedCount}`);
|
||||
} else {
|
||||
message.warning(t('pages.client.copyResultNone'));
|
||||
}
|
||||
if (errorList.length > 0) {
|
||||
message.error(`${t('pages.client.copyResultErrors')}: ${errorList.join('; ')}`);
|
||||
}
|
||||
emit('saved');
|
||||
emit('update:open', false);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (saving.value) return;
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :ok-text="t('pages.client.copySelected')" :cancel-text="t('close')"
|
||||
:confirm-loading="saving" :mask-closable="false" width="720px" @ok="ok" @cancel="close">
|
||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||
<div>
|
||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copySource') }}</div>
|
||||
<a-select v-model:value="sourceInboundId" :style="{ width: '100%' }" allow-clear>
|
||||
<a-select-option v-for="item in sources" :key="item.id" :value="item.id">
|
||||
{{ item.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<div v-if="sourceInboundId">
|
||||
<a-space :style="{ marginBottom: '8px' }">
|
||||
<a-button size="small" @click="selectAll">{{ t('pages.client.selectAll') }}</a-button>
|
||||
<a-button size="small" @click="clearAll">{{ t('pages.client.clearAll') }}</a-button>
|
||||
</a-space>
|
||||
<a-table :columns="columns" :data-source="sourceClients" :pagination="false" size="small"
|
||||
:row-key="(r) => r.email" :row-selection="rowSelection" :scroll="{ y: 280 }" />
|
||||
</div>
|
||||
|
||||
<div v-if="showFlow">
|
||||
<div :style="{ marginBottom: '6px' }">{{ t('pages.client.copyFlowLabel') }}</div>
|
||||
<a-select v-model:value="flow" :style="{ width: '100%' }" allow-clear>
|
||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
|
||||
</a-select>
|
||||
<div :style="{ marginTop: '4px', fontSize: '12px', opacity: 0.7 }">
|
||||
{{ t('pages.client.copyFlowHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-modal>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,982 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MenuOutlined,
|
||||
SearchOutlined,
|
||||
FilterOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
QrcodeOutlined,
|
||||
UserAddOutlined,
|
||||
UsergroupAddOutlined,
|
||||
CopyOutlined,
|
||||
FileDoneOutlined,
|
||||
ExportOutlined,
|
||||
ImportOutlined,
|
||||
ReloadOutlined,
|
||||
RestOutlined,
|
||||
RetweetOutlined,
|
||||
BlockOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, ObjectUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import InfinityIcon from '@/components/InfinityIcon.vue';
|
||||
import ClientRowTable from './ClientRowTable.vue';
|
||||
import { useDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
dbInbounds: { type: Array, required: true },
|
||||
clientCount: { type: Object, required: true },
|
||||
onlineClients: { type: Array, required: true },
|
||||
lastOnlineMap: { type: Object, default: () => ({}) },
|
||||
expireDiff: { type: Number, default: 0 },
|
||||
trafficDiff: { type: Number, default: 0 },
|
||||
pageSize: { type: Number, default: 0 },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
isDarkTheme: { type: Boolean, default: false },
|
||||
subEnable: { type: Boolean, default: false },
|
||||
// Map node id -> node row, supplied by the parent page so each
|
||||
// inbound row can render its node name without an extra fetch.
|
||||
nodesById: { type: Map, default: () => new Map() },
|
||||
hasActiveNode: { type: Boolean, default: false },
|
||||
statsVersion: { type: Number, default: 0 },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'refresh',
|
||||
'add-inbound',
|
||||
'general-action',
|
||||
'row-action',
|
||||
// Per-client events surfaced from the expand-row table.
|
||||
'edit-client',
|
||||
'qrcode-client',
|
||||
'info-client',
|
||||
'reset-traffic-client',
|
||||
'delete-client',
|
||||
'delete-clients',
|
||||
'toggle-enable-client',
|
||||
]);
|
||||
|
||||
// ============ Toolbar / search & filter =============================
|
||||
const FILTER_STATE_KEY = 'inboundsFilterState';
|
||||
const savedFilterState = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(FILTER_STATE_KEY) || '{}');
|
||||
} catch (_e) {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
const enableFilter = ref(!!savedFilterState.enableFilter);
|
||||
const searchKey = ref(savedFilterState.searchKey || '');
|
||||
const filterBy = ref(savedFilterState.filterBy || '');
|
||||
const protocolFilter = ref(savedFilterState.protocolFilter || '');
|
||||
const nodeFilter = ref(savedFilterState.nodeFilter || '');
|
||||
|
||||
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {
|
||||
localStorage.setItem(FILTER_STATE_KEY, JSON.stringify({
|
||||
enableFilter: enableFilter.value,
|
||||
searchKey: searchKey.value,
|
||||
filterBy: filterBy.value,
|
||||
protocolFilter: protocolFilter.value,
|
||||
nodeFilter: nodeFilter.value,
|
||||
}));
|
||||
});
|
||||
|
||||
// Toggle the filter mode — flip cleans the other input.
|
||||
function onToggleFilter() {
|
||||
if (enableFilter.value) searchKey.value = '';
|
||||
else filterBy.value = '';
|
||||
}
|
||||
|
||||
const protocolOptions = computed(() => {
|
||||
const values = new Set(props.dbInbounds.map((i) => i.protocol).filter(Boolean));
|
||||
return [...values].sort();
|
||||
});
|
||||
|
||||
const nodeOptions = computed(() => {
|
||||
const values = new Map();
|
||||
if (props.dbInbounds.some((i) => i.nodeId == null)) {
|
||||
values.set('local', t('pages.inbounds.localPanel'));
|
||||
}
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
if (dbInbound.nodeId == null) continue;
|
||||
const node = props.nodesById.get(dbInbound.nodeId);
|
||||
values.set(String(dbInbound.nodeId), node?.name || `#${dbInbound.nodeId}`);
|
||||
}
|
||||
return [...values.entries()].map(([value, label]) => ({ value, label }));
|
||||
});
|
||||
|
||||
function applySecondaryFilters(rows) {
|
||||
return rows.filter((dbInbound) => {
|
||||
if (protocolFilter.value && dbInbound.protocol !== protocolFilter.value) return false;
|
||||
if (nodeFilter.value) {
|
||||
const nodeValue = dbInbound.nodeId == null ? 'local' : String(dbInbound.nodeId);
|
||||
if (nodeValue !== nodeFilter.value) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Search / filter projection =============================
|
||||
// Mirrors the legacy logic: when searching, keep inbounds that match
|
||||
// anywhere (deep search); when filtering, keep inbounds that have at
|
||||
// least one client in the requested bucket and reduce their settings
|
||||
// to that bucket.
|
||||
function projectInbound(dbInbound, predicate) {
|
||||
const next = new DBInbound(dbInbound);
|
||||
let settings;
|
||||
try {
|
||||
settings = JSON.parse(dbInbound.settings || '{}');
|
||||
} catch (_e) {
|
||||
settings = {};
|
||||
}
|
||||
if (!Array.isArray(settings.clients)) return next;
|
||||
const filtered = settings.clients.filter(predicate);
|
||||
next.settings = Inbound.Settings.fromJson(dbInbound.protocol, { clients: filtered });
|
||||
next.invalidateCache();
|
||||
return next;
|
||||
}
|
||||
|
||||
const visibleInbounds = computed(() => {
|
||||
if (enableFilter.value) {
|
||||
if (ObjectUtil.isEmpty(filterBy.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||
const out = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
const c = props.clientCount[dbInbound.id];
|
||||
if (!c || !c[filterBy.value] || c[filterBy.value].length === 0) continue;
|
||||
const list = c[filterBy.value];
|
||||
out.push(projectInbound(dbInbound, (client) => list.includes(client.email)));
|
||||
}
|
||||
return applySecondaryFilters(out);
|
||||
}
|
||||
if (ObjectUtil.isEmpty(searchKey.value)) return applySecondaryFilters([...props.dbInbounds]);
|
||||
const out = [];
|
||||
for (const dbInbound of props.dbInbounds) {
|
||||
if (!ObjectUtil.deepSearch(dbInbound, searchKey.value)) continue;
|
||||
out.push(projectInbound(dbInbound, (client) => ObjectUtil.deepSearch(client, searchKey.value)));
|
||||
}
|
||||
return applySecondaryFilters(out);
|
||||
});
|
||||
|
||||
// ============ Sorting =================================================
|
||||
const sortState = ref({ column: null, order: null });
|
||||
|
||||
function sortableCol(col, key) {
|
||||
return {
|
||||
...col,
|
||||
sorter: true,
|
||||
showSorterTooltip: false,
|
||||
sortOrder: sortState.value.column === key ? sortState.value.order : null,
|
||||
sortDirections: ['ascend', 'descend'],
|
||||
};
|
||||
}
|
||||
|
||||
const sortFns = {
|
||||
id: (a, b) => a.id - b.id,
|
||||
enable: (a, b) => Number(a.enable) - Number(b.enable),
|
||||
remark: (a, b) => (a.remark || '').localeCompare(b.remark || ''),
|
||||
port: (a, b) => a.port - b.port,
|
||||
protocol: (a, b) => a.protocol.localeCompare(b.protocol),
|
||||
traffic: (a, b) => (a.up + a.down) - (b.up + b.down),
|
||||
allTimeInbound: (a, b) => (a.allTime || 0) - (b.allTime || 0),
|
||||
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
|
||||
node: (a, b) => {
|
||||
const nameA = props.nodesById.get(a.nodeId)?.name ?? (a.nodeId == null ? '\uffff' : `node #${a.nodeId}`);
|
||||
const nameB = props.nodesById.get(b.nodeId)?.name ?? (b.nodeId == null ? '\uffff' : `node #${b.nodeId}`);
|
||||
return nameA.localeCompare(nameB);
|
||||
},
|
||||
clients: (a, b) => (props.clientCount[a.id]?.clients || 0) - (props.clientCount[b.id]?.clients || 0),
|
||||
};
|
||||
|
||||
const sortedInbounds = computed(() => {
|
||||
const { column, order } = sortState.value;
|
||||
if (!column || !order) return visibleInbounds.value;
|
||||
const fn = sortFns[column];
|
||||
if (!fn) return visibleInbounds.value;
|
||||
const sorted = [...visibleInbounds.value].sort(fn);
|
||||
return order === 'descend' ? sorted.reverse() : sorted;
|
||||
});
|
||||
|
||||
function onTableChange(_pag, _filters, sorter) {
|
||||
sortState.value = {
|
||||
column: sorter?.columnKey || sorter?.field || null,
|
||||
order: sorter?.order || null,
|
||||
};
|
||||
}
|
||||
|
||||
watch([searchKey, filterBy], () => {
|
||||
sortState.value = { column: null, order: null };
|
||||
});
|
||||
|
||||
// ============ Columns =================================================
|
||||
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
|
||||
// `responsive` array still works on column defs. Computed so column
|
||||
// labels react to live locale switches.
|
||||
const hasAnyRemark = computed(() =>
|
||||
props.dbInbounds.some((i) => typeof i?.remark === 'string' && i.remark.trim() !== ''),
|
||||
);
|
||||
|
||||
const desktopColumns = computed(() => {
|
||||
const cols = [
|
||||
sortableCol({ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30 }, 'id'),
|
||||
{ title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 60 },
|
||||
sortableCol({ title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 }, 'enable'),
|
||||
];
|
||||
if (hasAnyRemark.value) {
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, 'remark'));
|
||||
}
|
||||
if (props.hasActiveNode) {
|
||||
cols.push(sortableCol({ title: t('pages.inbounds.node'), key: 'node', align: 'center', width: 60 }, 'node'));
|
||||
}
|
||||
cols.push(
|
||||
sortableCol({ title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 }, 'port'),
|
||||
sortableCol({ title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 130 }, 'protocol'),
|
||||
sortableCol({ title: t('clients'), key: 'clients', align: 'left', width: 50 }, 'clients'),
|
||||
sortableCol({ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 }, 'traffic'),
|
||||
sortableCol({ title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 95 }, 'allTimeInbound'),
|
||||
sortableCol({ title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
|
||||
);
|
||||
return cols;
|
||||
});
|
||||
const columns = computed(() => desktopColumns.value);
|
||||
|
||||
// Mobile expansion state — replaces a-table's expandable() since the
|
||||
// mobile branch renders a hand-rolled card list rather than a table.
|
||||
const expandedIds = ref(new Set());
|
||||
function toggleExpanded(id) {
|
||||
const next = new Set(expandedIds.value);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
expandedIds.value = next;
|
||||
}
|
||||
function isExpanded(id) {
|
||||
return expandedIds.value.has(id);
|
||||
}
|
||||
|
||||
const statsRecord = ref(null);
|
||||
function openStats(record) {
|
||||
statsRecord.value = record;
|
||||
}
|
||||
function closeStats() {
|
||||
statsRecord.value = null;
|
||||
}
|
||||
|
||||
// ============ Pagination ============================================
|
||||
function paginationFor(rows) {
|
||||
const size = props.pageSize > 0 ? props.pageSize : rows.length || 1;
|
||||
return {
|
||||
pageSize: size,
|
||||
showSizeChanger: false,
|
||||
hideOnSinglePage: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Per-row enable switch =================================
|
||||
async function onSwitchEnable(dbInbound, next) {
|
||||
const previous = dbInbound.enable;
|
||||
dbInbound.enable = next; // optimistic
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('enable', String(next));
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
|
||||
if (!msg?.success) dbInbound.enable = previous;
|
||||
} catch (_e) {
|
||||
dbInbound.enable = previous;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Helpers shared with the templates =====================
|
||||
// Whether to show the "Switch xray" / qrcode menu entry — same predicate
|
||||
// as legacy: SS single-user inbounds and WireGuard inbounds expose
|
||||
// inbound-wide QR codes.
|
||||
function showQrCodeMenu(dbInbound) {
|
||||
if (dbInbound.isWireguard) return true;
|
||||
if (dbInbound.isSS) {
|
||||
try {
|
||||
return !dbInbound.toInbound().isSSMultiUser;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-card hoverable>
|
||||
<template #title>
|
||||
<a-space direction="horizontal">
|
||||
<a-button type="primary" @click="emit('add-inbound')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
|
||||
</a-button>
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="primary">
|
||||
<template #icon>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('general-action', a.key)">
|
||||
<a-menu-item key="import">
|
||||
<ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetInbounds">
|
||||
<ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-space direction="vertical" :style="{ width: '100%' }">
|
||||
<!-- Search / filter toolbar -->
|
||||
<div :class="isMobile ? 'filter-bar mobile' : 'filter-bar'">
|
||||
<a-switch v-model:checked="enableFilter" @change="onToggleFilter">
|
||||
<template #checkedChildren>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
<template #unCheckedChildren>
|
||||
<FilterOutlined />
|
||||
</template>
|
||||
</a-switch>
|
||||
<a-input v-if="!enableFilter" v-model:value="searchKey" :placeholder="t('search')" autofocus
|
||||
:size="isMobile ? 'small' : 'middle'" :style="{ maxWidth: '300px' }" />
|
||||
<a-radio-group v-if="enableFilter" v-model:value="filterBy" button-style="solid"
|
||||
:size="isMobile ? 'small' : 'middle'">
|
||||
<a-radio-button value="">{{ t('none') }}</a-radio-button>
|
||||
<a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
|
||||
<a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
|
||||
<a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
|
||||
<a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
|
||||
<a-radio-button value="online">{{ t('online') }}</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-select v-model:value="protocolFilter" allow-clear :placeholder="t('pages.inbounds.protocol')"
|
||||
:size="isMobile ? 'small' : 'middle'" :style="{ width: '150px' }">
|
||||
<a-select-option v-for="protocol in protocolOptions" :key="protocol" :value="protocol">
|
||||
{{ protocol }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-if="hasActiveNode && nodeOptions.length > 0" v-model:value="nodeFilter" allow-clear
|
||||
:placeholder="t('pages.inbounds.node')" :size="isMobile ? 'small' : 'middle'" :style="{ width: '170px' }">
|
||||
<a-select-option v-for="node in nodeOptions" :key="node.value" :value="node.value">
|
||||
{{ node.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Mobile: card list ======================= -->
|
||||
<div v-if="isMobile" class="inbound-cards">
|
||||
<div v-if="visibleInbounds.length === 0" class="card-empty">—</div>
|
||||
|
||||
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
|
||||
<!-- Header: chevron (multi-user only) + id + remark + info + enable + actions -->
|
||||
<div class="card-head" @click="record.isMultiUser() && toggleExpanded(record.id)">
|
||||
<RightOutlined v-if="record.isMultiUser()" class="card-expand"
|
||||
:class="{ 'is-expanded': isExpanded(record.id) }" />
|
||||
<span class="card-id">#{{ record.id }}</span>
|
||||
<span class="tag-name">{{ record.remark }}</span>
|
||||
<div class="card-actions" @click.stop>
|
||||
<a-tooltip :title="t('info')">
|
||||
<InfoCircleOutlined class="row-action-trigger" @click="openStats(record)" />
|
||||
</a-tooltip>
|
||||
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<MoreOutlined class="row-action-trigger" @click.prevent />
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||
<a-menu-item key="edit">
|
||||
<EditOutlined /> {{ t('edit') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded client list (multi-user only) -->
|
||||
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
|
||||
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
|
||||
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
|
||||
:page-size="pageSize" :total-client-count="clientCount[record.id]?.clients || 0"
|
||||
:stats-version="statsVersion"
|
||||
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
|
||||
@info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================== Mobile: info modal ====================== -->
|
||||
<a-modal v-if="isMobile" :open="!!statsRecord" :footer="null" :width="360" centered
|
||||
:title="statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''" @cancel="closeStats">
|
||||
<div v-if="statsRecord" class="card-stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.protocol') }}</span>
|
||||
<a-tag color="purple">{{ statsRecord.protocol }}</a-tag>
|
||||
<template
|
||||
v-if="statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria">
|
||||
<a-tag color="green">{{ statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream.network }}</a-tag>
|
||||
<a-tag v-if="statsRecord.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="statsRecord.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.port') }}</span>
|
||||
<a-tag>{{ statsRecord.port }}</a-tag>
|
||||
</div>
|
||||
<div v-if="hasActiveNode" class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.node') }}</span>
|
||||
<a-tag v-if="statsRecord.nodeId == null" color="default">
|
||||
{{ t('pages.inbounds.localPanel') }}
|
||||
</a-tag>
|
||||
<a-tag v-else-if="nodesById.get(statsRecord.nodeId)"
|
||||
:color="nodesById.get(statsRecord.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||
{{ nodesById.get(statsRecord.nodeId).name }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="orange">#{{ statsRecord.nodeId }}</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.traffic') }}</span>
|
||||
<a-tag :color="ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)">
|
||||
{{ SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down) }} /
|
||||
<template v-if="statsRecord.total > 0">{{ SizeFormatter.sizeFormat(statsRecord.total) }}</template>
|
||||
<InfinityIcon v-else />
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.allTimeTraffic') }}</span>
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(statsRecord.allTime || 0) }}</a-tag>
|
||||
</div>
|
||||
<div v-if="clientCount[statsRecord.id]" class="stat-row">
|
||||
<span class="stat-label">{{ t('clients') }}</span>
|
||||
<a-tag color="green" class="client-count-tag">{{ clientCount[statsRecord.id].clients }}</a-tag>
|
||||
<a-tag v-if="clientCount[statsRecord.id].online.length" color="blue">
|
||||
{{ clientCount[statsRecord.id].online.length }} {{ t('online') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[statsRecord.id].depleted.length" color="red">
|
||||
{{ clientCount[statsRecord.id].depleted.length }} {{ t('depleted') }}
|
||||
</a-tag>
|
||||
<a-tag v-if="clientCount[statsRecord.id].expiring.length" color="orange">
|
||||
{{ clientCount[statsRecord.id].expiring.length }} {{ t('depletingSoon') }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">{{ t('pages.inbounds.expireDate') }}</span>
|
||||
<a-tag v-if="statsRecord.expiryTime > 0"
|
||||
:color="ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)">
|
||||
{{ IntlUtil.formatRelativeTime(statsRecord.expiryTime) }}
|
||||
</a-tag>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- ====================== Desktop: a-table ======================== -->
|
||||
<a-table v-else :columns="columns" :data-source="sortedInbounds" :row-key="(r) => r.id"
|
||||
:pagination="paginationFor(sortedInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
|
||||
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')" @change="onTableChange">
|
||||
<!-- Per-inbound client list, expanded by clicking the row's
|
||||
default expand chevron. Hidden via row-class-name for
|
||||
non-multi-user inbounds (matches legacy behavior). -->
|
||||
<template #expandedRowRender="{ record }">
|
||||
<ClientRowTable v-if="record.isMultiUser()" :db-inbound="record" :is-mobile="isMobile"
|
||||
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme" :page-size="pageSize"
|
||||
:total-client-count="clientCount[record.id]?.clients || 0"
|
||||
:stats-version="statsVersion"
|
||||
@edit-client="(p) => emit('edit-client', p)"
|
||||
@qrcode-client="(p) => emit('qrcode-client', p)" @info-client="(p) => emit('info-client', p)"
|
||||
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
|
||||
@delete-client="(p) => emit('delete-client', p)"
|
||||
@delete-clients="(p) => emit('delete-clients', p)"
|
||||
@toggle-enable-client="(p) => emit('toggle-enable-client', p)" />
|
||||
</template>
|
||||
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- ============== Action dropdown ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
<div class="action-buttons">
|
||||
<a-button type="text" size="small" @click.prevent="emit('row-action', {key: 'edit', dbInbound: record})">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
|
||||
<a-dropdown :trigger="['click']">
|
||||
<a-button type="text" size="small" @click.prevent>
|
||||
<template #icon>
|
||||
<MoreOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
|
||||
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
|
||||
<QrcodeOutlined /> {{ t('qrCode') }}
|
||||
</a-menu-item>
|
||||
<template v-if="record.isMultiUser()">
|
||||
<a-menu-item key="addClient">
|
||||
<UserAddOutlined /> {{ t('pages.client.add') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="addBulkClient">
|
||||
<UsergroupAddOutlined /> {{ t('pages.client.bulk') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="copyClients">
|
||||
<CopyOutlined /> {{ t('pages.client.copyFromInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetClients">
|
||||
<FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="export">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="subEnable" key="subs">
|
||||
<ExportOutlined /> {{ t('pages.inbounds.export') }} — {{ t('pages.settings.subSettings') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delDepletedClients" class="danger-item">
|
||||
<RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-menu-item key="showInfo">
|
||||
<InfoCircleOutlined /> {{ t('info') }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
<a-menu-item key="clipboard">
|
||||
<CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="resetTraffic">
|
||||
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="clone">
|
||||
<BlockOutlined /> {{ t('pages.inbounds.clone') }}
|
||||
</a-menu-item>
|
||||
<a-menu-item key="delete" class="danger-item">
|
||||
<DeleteOutlined /> {{ t('delete') }}
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Enable switch (desktop) ============== -->
|
||||
<template v-else-if="column.key === 'enable'">
|
||||
<a-switch :checked="record.enable" @change="(next) => onSwitchEnable(record, next)" />
|
||||
</template>
|
||||
|
||||
<!-- ============== Node deployment tag ============== -->
|
||||
<template v-else-if="column.key === 'node'">
|
||||
<template v-if="record.nodeId == null">
|
||||
<a-tag color="default">{{ t('pages.inbounds.localPanel') }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="nodesById.get(record.nodeId)">
|
||||
<a-tag :color="nodesById.get(record.nodeId).status === 'online' ? 'blue' : 'red'">
|
||||
{{ nodesById.get(record.nodeId).name }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Node row was deleted but inbound still references it. -->
|
||||
<a-tag color="orange">node #{{ record.nodeId }}</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============== Protocol tags ============== -->
|
||||
<template v-else-if="column.key === 'protocol'">
|
||||
<div class="protocol-tags">
|
||||
<a-tag color="purple">{{ record.protocol }}</a-tag>
|
||||
<template v-if="record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria">
|
||||
<a-tag color="green">{{ record.isHysteria ? 'UDP' : record.toInbound().stream.network }}</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isTls" color="blue">TLS</a-tag>
|
||||
<a-tag v-if="record.toInbound().stream.isReality" color="blue">Reality</a-tag>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ============== Clients tag + popovers ============== -->
|
||||
<template v-else-if="column.key === 'clients'">
|
||||
<template v-if="clientCount[record.id]">
|
||||
<a-tag color="green" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].clients }}</a-tag>
|
||||
<a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length
|
||||
}}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue" class="client-count-tag" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- ============== Traffic ============== -->
|
||||
<template v-else-if="column.key === 'traffic'">
|
||||
<a-popover>
|
||||
<template #content>
|
||||
<table cellpadding="2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>↑ {{ SizeFormatter.sizeFormat(record.up) }}</td>
|
||||
<td>↓ {{ SizeFormatter.sizeFormat(record.down) }}</td>
|
||||
</tr>
|
||||
<tr v-if="record.total > 0 && record.up + record.down < record.total">
|
||||
<td>{{ t('remained') }}</td>
|
||||
<td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<a-tag :color="ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)">
|
||||
{{ SizeFormatter.sizeFormat(record.up + record.down) }} /
|
||||
<template v-if="record.total > 0">{{ SizeFormatter.sizeFormat(record.total) }}</template>
|
||||
<InfinityIcon v-else />
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
</template>
|
||||
|
||||
<!-- ============== All-time inbound traffic ============== -->
|
||||
<template v-else-if="column.key === 'allTimeInbound'">
|
||||
<a-tag>{{ SizeFormatter.sizeFormat(record.allTime || 0) }}</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- ============== Expiry ============== -->
|
||||
<template v-else-if="column.key === 'expiryTime'">
|
||||
<a-popover v-if="record.expiryTime > 0">
|
||||
<template #content>{{ IntlUtil.formatDate(record.expiryTime, datepicker) }}</template>
|
||||
<a-tag :color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)" style="min-width: 50px">
|
||||
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
|
||||
</a-tag>
|
||||
</a-popover>
|
||||
<a-tag v-else color="purple">
|
||||
<InfinityIcon />
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</a-table>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-bar.mobile>* {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.protocol-tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.client-count-tag {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.danger-item {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* Hide the expand chevron on rows whose inbound has no client list
|
||||
* (HTTP/Mixed/Tunnel/WireGuard single-config). */
|
||||
:deep(.hide-expand-icon .ant-table-row-expand-icon) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Push the expand chevron away from the table's left edge so it has
|
||||
* a little breathing room instead of being flush against the corner. */
|
||||
:deep(.ant-table-tbody .ant-table-cell-with-append) {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-row-expand-icon) {
|
||||
margin-inline-end: 10px;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
/* Round the table's outer corners — AD-Vue gives .ant-table the radius
|
||||
* token, but the inner header strip and footer touch the edges, so clip
|
||||
* them here. */
|
||||
:deep(.ant-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-container) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr:first-child > *:first-child) {
|
||||
border-start-start-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr:first-child > *:last-child) {
|
||||
border-start-end-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child > *:first-child) {
|
||||
border-end-start-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child > *:last-child) {
|
||||
border-end-end-radius: 8px;
|
||||
}
|
||||
|
||||
/* ===== Mobile card list ===========================================
|
||||
* <768px renders inbounds as a vertical stack of cards via the
|
||||
* v-if="isMobile" branch above; the desktop <a-table> isn't mounted
|
||||
* so the legacy table-cell tightening rules went away. */
|
||||
.inbound-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.inbound-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:global(body.dark) .inbound-card {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card-id {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
transition: transform 150ms ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-expand.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.6;
|
||||
min-width: 96px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-stats :deep(.ant-tag) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-clients {
|
||||
margin-top: 4px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.ant-card-head) {
|
||||
padding: 0 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head-title),
|
||||
:deep(.ant-card-extra) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filter-bar.mobile>* {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.row-action-trigger {
|
||||
font-size: 22px;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,754 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
SwapOutlined,
|
||||
PieChartOutlined,
|
||||
HistoryOutlined,
|
||||
BarsOutlined,
|
||||
TeamOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
|
||||
import { Inbound } from '@/models/inbound.js';
|
||||
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
|
||||
import { useMediaQuery } from '@/composables/useMediaQuery.js';
|
||||
import AppSidebar from '@/components/AppSidebar.vue';
|
||||
import CustomStatistic from '@/components/CustomStatistic.vue';
|
||||
import { useNodeList } from '@/composables/useNodeList.js';
|
||||
import InboundList from './InboundList.vue';
|
||||
import InboundFormModal from './InboundFormModal.vue';
|
||||
import ClientFormModal from './ClientFormModal.vue';
|
||||
import ClientBulkModal from './ClientBulkModal.vue';
|
||||
import CopyClientsModal from './CopyClientsModal.vue';
|
||||
import InboundInfoModal from './InboundInfoModal.vue';
|
||||
import QrCodeModal from './QrCodeModal.vue';
|
||||
import TextModal from '@/components/TextModal.vue';
|
||||
import PromptModal from '@/components/PromptModal.vue';
|
||||
import { useInbounds } from './useInbounds.js';
|
||||
import { useWebSocket } from '@/composables/useWebSocket.js';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
fetched,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
pageSize,
|
||||
subSettings,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
remarkModel,
|
||||
lastOnlineMap,
|
||||
statsVersion,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
} = useInbounds();
|
||||
|
||||
// Live updates over WebSocket — replaces the old 5s polling loop.
|
||||
// The backend pushes traffic + per-client deltas every ~10s; we merge
|
||||
// them into the local refs in-place so counters and online badges
|
||||
// update without re-fetching the whole list.
|
||||
useWebSocket({
|
||||
traffic: applyTrafficEvent,
|
||||
client_stats: applyClientStatsEvent,
|
||||
invalidate: applyInvalidate,
|
||||
inbounds: applyInboundsEvent,
|
||||
});
|
||||
const { isMobile } = useMediaQuery();
|
||||
// Node list lives on the central panel; the Inbounds page consumes
|
||||
// the id→node map for the new "Node" column. Fetched once on mount.
|
||||
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
|
||||
|
||||
const basePath = window.X_UI_BASE_PATH || '';
|
||||
const requestUri = window.location.pathname;
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchDefaultSettings();
|
||||
await refresh();
|
||||
});
|
||||
|
||||
// === Add/Edit modal ===================================================
|
||||
const formOpen = ref(false);
|
||||
const formMode = ref('add');
|
||||
const formDbInbound = ref(null);
|
||||
|
||||
// === Client modal (single + bulk) =====================================
|
||||
const clientOpen = ref(false);
|
||||
const clientMode = ref('add');
|
||||
const clientDbInbound = ref(null);
|
||||
const clientIndex = ref(null);
|
||||
|
||||
const bulkOpen = ref(false);
|
||||
const bulkDbInbound = ref(null);
|
||||
const copyOpen = ref(false);
|
||||
const copyDbInbound = ref(null);
|
||||
|
||||
// === Info / QR-code modals ===========================================
|
||||
const infoOpen = ref(false);
|
||||
const infoDbInbound = ref(null);
|
||||
const infoClientIndex = ref(0);
|
||||
|
||||
const qrOpen = ref(false);
|
||||
const qrDbInbound = ref(null);
|
||||
const qrClient = ref(null);
|
||||
|
||||
// hostOverrideFor returns the node's address for a node-managed inbound,
|
||||
// or '' when the inbound runs locally. Wired into the QR / Info modals
|
||||
// and into export-all-links functions so generated share links point at
|
||||
// the node, not the central panel.
|
||||
function hostOverrideFor(dbInbound) {
|
||||
if (!dbInbound || dbInbound.nodeId == null) return '';
|
||||
return nodesById.value.get(dbInbound.nodeId)?.address || '';
|
||||
}
|
||||
|
||||
const infoNodeAddress = computed(() => hostOverrideFor(infoDbInbound.value));
|
||||
const qrNodeAddress = computed(() => hostOverrideFor(qrDbInbound.value));
|
||||
|
||||
// === Shared text + prompt modal state =================================
|
||||
const textOpen = ref(false);
|
||||
const textTitle = ref('');
|
||||
const textContent = ref('');
|
||||
const textFileName = ref('');
|
||||
|
||||
const promptOpen = ref(false);
|
||||
const promptTitle = ref('');
|
||||
const promptOkText = ref('OK');
|
||||
const promptType = ref('textarea');
|
||||
const promptInitial = ref('');
|
||||
const promptLoading = ref(false);
|
||||
let promptHandler = null;
|
||||
|
||||
function openText({ title, content, fileName = '' }) {
|
||||
textTitle.value = title;
|
||||
textContent.value = content;
|
||||
textFileName.value = fileName;
|
||||
textOpen.value = true;
|
||||
}
|
||||
|
||||
function openPrompt({ title, okText, type = 'textarea', value = '', confirm }) {
|
||||
promptTitle.value = title;
|
||||
promptOkText.value = okText || 'OK';
|
||||
promptType.value = type;
|
||||
promptInitial.value = value;
|
||||
promptHandler = confirm;
|
||||
promptOpen.value = true;
|
||||
}
|
||||
|
||||
async function onPromptConfirm(value) {
|
||||
if (!promptHandler) { promptOpen.value = false; return; }
|
||||
promptLoading.value = true;
|
||||
try {
|
||||
const ok = await promptHandler(value);
|
||||
if (ok !== false) promptOpen.value = false;
|
||||
} finally {
|
||||
promptLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// === Export helpers — mirror legacy txtModal call sites ==============
|
||||
function exportInboundLinks(dbInbound) {
|
||||
const projected = checkFallback(dbInbound);
|
||||
openText({
|
||||
title: 'Export inbound links',
|
||||
content: projected.genInboundLinks(remarkModel.value, hostOverrideFor(dbInbound)),
|
||||
fileName: projected.remark || 'inbound',
|
||||
});
|
||||
}
|
||||
|
||||
function exportInboundClipboard(dbInbound) {
|
||||
openText({
|
||||
title: 'Inbound JSON',
|
||||
content: JSON.stringify(dbInbound, null, 2),
|
||||
});
|
||||
}
|
||||
|
||||
function exportInboundSubs(dbInbound) {
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const subLinks = [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.value.subURI) {
|
||||
subLinks.push(subSettings.value.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
openText({
|
||||
title: 'Export subscription links',
|
||||
content: [...new Set(subLinks)].join('\n'),
|
||||
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
|
||||
});
|
||||
}
|
||||
|
||||
function exportAllLinks() {
|
||||
const out = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
|
||||
}
|
||||
openText({
|
||||
title: 'Export all inbound links',
|
||||
content: out.join('\r\n'),
|
||||
fileName: 'All-Inbounds',
|
||||
});
|
||||
}
|
||||
|
||||
function exportAllSubs() {
|
||||
const out = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
const inbound = ib.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
for (const c of clients) {
|
||||
if (c.subId && subSettings.value.subURI) {
|
||||
out.push(subSettings.value.subURI + c.subId);
|
||||
}
|
||||
}
|
||||
}
|
||||
openText({
|
||||
title: 'Export all subscription links',
|
||||
content: [...new Set(out)].join('\r\n'),
|
||||
fileName: 'All-Inbounds-Subs',
|
||||
});
|
||||
}
|
||||
|
||||
function importInbound() {
|
||||
openPrompt({
|
||||
title: 'Import inbound',
|
||||
okText: 'Import',
|
||||
type: 'textarea',
|
||||
value: '',
|
||||
confirm: async (value) => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/import', { data: value });
|
||||
if (msg?.success) {
|
||||
await refresh();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// `checkFallback` mirrors the legacy helper: when an inbound listens
|
||||
// on a unix-socket fallback (`@<name>`), point the link generator at
|
||||
// the root inbound that owns the listen address so QRs/links carry
|
||||
// the externally-reachable host:port and the right TLS state.
|
||||
function checkFallback(dbInbound) {
|
||||
// We don't keep parsed Inbounds in state right now (the page works
|
||||
// off DBInbounds); compute on the fly.
|
||||
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
|
||||
for (const candidate of dbInbounds.value) {
|
||||
if (candidate.id === dbInbound.id) continue;
|
||||
const parsed = candidate.toInbound();
|
||||
if (!parsed.isTcp) continue;
|
||||
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
|
||||
const fallbacks = parsed.settings.fallbacks || [];
|
||||
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
|
||||
// Build a one-off DBInbound copy with the parent's listen/port +
|
||||
// copied stream so the link gen sees the public endpoint.
|
||||
const projected = JSON.parse(JSON.stringify(dbInbound));
|
||||
projected.listen = candidate.listen;
|
||||
projected.port = candidate.port;
|
||||
const inheritedStream = parsed.stream;
|
||||
const ownInbound = dbInbound.toInbound();
|
||||
ownInbound.stream.security = inheritedStream.security;
|
||||
ownInbound.stream.tls = inheritedStream.tls;
|
||||
ownInbound.stream.externalProxy = inheritedStream.externalProxy;
|
||||
projected.streamSettings = ownInbound.stream.toString();
|
||||
// Re-wrap so callers get the same DBInbound shape they had.
|
||||
return new dbInbound.constructor(projected);
|
||||
}
|
||||
return dbInbound;
|
||||
}
|
||||
|
||||
function findClientIndex(dbInbound, client) {
|
||||
if (!client) return 0;
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = clients.findIndex((c) => {
|
||||
if (!c) return false;
|
||||
switch (dbInbound.protocol) {
|
||||
case 'trojan':
|
||||
case 'shadowsocks':
|
||||
return c.password === client.password && c.email === client.email;
|
||||
default:
|
||||
return c.id === client.id && c.email === client.email;
|
||||
}
|
||||
});
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
function getClientId(protocol, client) {
|
||||
switch (protocol) {
|
||||
case 'trojan': return client.password;
|
||||
case 'shadowsocks': return client.email;
|
||||
case 'hysteria': return client.auth;
|
||||
default: return client.id;
|
||||
}
|
||||
}
|
||||
|
||||
// === Per-client handlers (called from the expand-row table) =========
|
||||
function onEditClient({ dbInbound, client }) {
|
||||
clientMode.value = 'edit';
|
||||
clientDbInbound.value = dbInbound;
|
||||
clientIndex.value = findClientIndex(dbInbound, client);
|
||||
clientOpen.value = true;
|
||||
}
|
||||
|
||||
function onQrcodeClient({ dbInbound, client }) {
|
||||
qrDbInbound.value = checkFallback(dbInbound);
|
||||
qrClient.value = client || null;
|
||||
qrOpen.value = true;
|
||||
}
|
||||
|
||||
function onInfoClient({ dbInbound, client }) {
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, client);
|
||||
infoOpen.value = true;
|
||||
}
|
||||
|
||||
async function onResetTrafficClient({ dbInbound, client }) {
|
||||
const msg = await HttpUtil.post(
|
||||
`/panel/api/inbounds/${dbInbound.id}/resetClientTraffic/${client.email}`,
|
||||
);
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClient({ dbInbound, client }) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
async function onDeleteClients({ dbInbound, clients }) {
|
||||
for (const client of clients) {
|
||||
const clientId = getClientId(dbInbound.protocol, client);
|
||||
await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/delClient/${clientId}`);
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function onToggleEnableClient({ dbInbound, client, next }) {
|
||||
// Mirror legacy: clone the parsed inbound, flip enable on the matching
|
||||
// client, and post the whole client back through updateClient. This
|
||||
// keeps the wire shape identical to the modal save path.
|
||||
const inbound = dbInbound.toInbound();
|
||||
const clients = inbound?.clients || [];
|
||||
const idx = findClientIndex(dbInbound, client);
|
||||
if (idx < 0 || !clients[idx]) return;
|
||||
clients[idx].enable = next;
|
||||
const clientId = getClientId(dbInbound.protocol, clients[idx]);
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/updateClient/${clientId}`, {
|
||||
id: dbInbound.id,
|
||||
settings: `{"clients": [${clients[idx].toString()}]}`,
|
||||
});
|
||||
if (msg?.success) await refresh();
|
||||
}
|
||||
|
||||
function onAddInbound() {
|
||||
formMode.value = 'add';
|
||||
formDbInbound.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(dbInbound) {
|
||||
formMode.value = 'edit';
|
||||
formDbInbound.value = dbInbound;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openAddClient(dbInbound) {
|
||||
clientMode.value = 'add';
|
||||
clientDbInbound.value = dbInbound;
|
||||
clientIndex.value = null;
|
||||
clientOpen.value = true;
|
||||
}
|
||||
|
||||
function openAddBulkClient(dbInbound) {
|
||||
bulkDbInbound.value = dbInbound;
|
||||
bulkOpen.value = true;
|
||||
}
|
||||
|
||||
// Per-row destructive actions go through Modal.confirm (matches legacy).
|
||||
function confirmDelete(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Delete inbound "${dbInbound.remark}"?`,
|
||||
content: 'This removes the inbound and all its clients. This cannot be undone.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/del/${dbInbound.id}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmResetTraffic(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Reset traffic for "${dbInbound.remark}"?`,
|
||||
content: 'Resets up/down counters to 0 for this inbound.',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/${dbInbound.id}/resetTraffic`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDelDepleted(dbInboundId) {
|
||||
Modal.confirm({
|
||||
title: 'Delete depleted clients?',
|
||||
content: 'Removes every client whose traffic is exhausted or whose expiry has passed.',
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/delDepletedClients/${dbInboundId}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Clone — adds a new inbound with the same protocol+stream+sniffing
|
||||
// but a fresh remark/port and an empty client list.
|
||||
function confirmClone(dbInbound) {
|
||||
Modal.confirm({
|
||||
title: `Clone inbound "${dbInbound.remark}"?`,
|
||||
content: 'Creates a copy with a new port and an empty client list.',
|
||||
okText: 'Clone',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const baseInbound = dbInbound.toInbound();
|
||||
const data = {
|
||||
up: 0,
|
||||
down: 0,
|
||||
total: 0,
|
||||
remark: `${dbInbound.remark} (clone)`,
|
||||
enable: false,
|
||||
expiryTime: 0,
|
||||
listen: '',
|
||||
port: RandomUtil.randomInteger(10000, 60000),
|
||||
protocol: baseInbound.protocol,
|
||||
settings: Inbound.Settings.getSettings(baseInbound.protocol).toString(),
|
||||
streamSettings: baseInbound.stream.toString(),
|
||||
sniffing: baseInbound.sniffing.toString(),
|
||||
};
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onGeneralAction(key) {
|
||||
switch (key) {
|
||||
case 'import':
|
||||
importInbound();
|
||||
break;
|
||||
case 'export':
|
||||
exportAllLinks();
|
||||
break;
|
||||
case 'subs':
|
||||
exportAllSubs();
|
||||
break;
|
||||
case 'resetInbounds':
|
||||
Modal.confirm({
|
||||
title: 'Reset all inbound traffic?',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllTraffics');
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'resetClients':
|
||||
Modal.confirm({
|
||||
title: 'Reset all client traffic across all inbounds?',
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/resetAllClientTraffics/-1');
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'delDepletedClients':
|
||||
confirmDelDepleted(-1);
|
||||
break;
|
||||
default:
|
||||
message.info(`General action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}
|
||||
|
||||
function onRowAction({ key, dbInbound }) {
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
openEdit(dbInbound);
|
||||
break;
|
||||
case 'addClient':
|
||||
openAddClient(dbInbound);
|
||||
break;
|
||||
case 'addBulkClient':
|
||||
openAddBulkClient(dbInbound);
|
||||
break;
|
||||
case 'showInfo':
|
||||
infoDbInbound.value = checkFallback(dbInbound);
|
||||
infoClientIndex.value = findClientIndex(dbInbound, null);
|
||||
infoOpen.value = true;
|
||||
break;
|
||||
case 'qrcode':
|
||||
qrDbInbound.value = checkFallback(dbInbound);
|
||||
qrClient.value = null;
|
||||
qrOpen.value = true;
|
||||
break;
|
||||
case 'export':
|
||||
exportInboundLinks(dbInbound);
|
||||
break;
|
||||
case 'subs':
|
||||
exportInboundSubs(dbInbound);
|
||||
break;
|
||||
case 'clipboard':
|
||||
exportInboundClipboard(dbInbound);
|
||||
break;
|
||||
case 'copyClients':
|
||||
copyDbInbound.value = dbInbound;
|
||||
copyOpen.value = true;
|
||||
break;
|
||||
case 'delete':
|
||||
confirmDelete(dbInbound);
|
||||
break;
|
||||
case 'resetTraffic':
|
||||
confirmResetTraffic(dbInbound);
|
||||
break;
|
||||
case 'clone':
|
||||
confirmClone(dbInbound);
|
||||
break;
|
||||
case 'resetClients':
|
||||
Modal.confirm({
|
||||
title: `Reset client traffic on "${dbInbound.remark}"?`,
|
||||
okText: 'Reset',
|
||||
cancelText: 'Cancel',
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/inbounds/resetAllClientTraffics/${dbInbound.id}`);
|
||||
if (msg?.success) await refresh();
|
||||
},
|
||||
});
|
||||
break;
|
||||
case 'delDepletedClients':
|
||||
confirmDelDepleted(dbInbound.id);
|
||||
break;
|
||||
default:
|
||||
message.info(`Action "${key}" — coming in a later 5f subphase`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout class="inbounds-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
<a-layout-content id="content-layout" class="content-area">
|
||||
<a-spin :spinning="!fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-row v-else :gutter="[isMobile ? 8 : 16, 12]">
|
||||
<!-- Summary statistics card -->
|
||||
<a-col :span="24">
|
||||
<a-card size="small" hoverable class="summary-card">
|
||||
<a-row :gutter="[16, 12]">
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalDownUp')"
|
||||
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`">
|
||||
<template #prefix>
|
||||
<SwapOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
|
||||
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
|
||||
<template #prefix>
|
||||
<PieChartOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.allTimeTrafficUsage')"
|
||||
:value="SizeFormatter.sizeFormat(totals.allTime)">
|
||||
<template #prefix>
|
||||
<HistoryOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="12" :md="5">
|
||||
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
|
||||
<template #prefix>
|
||||
<BarsOutlined />
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="24" :md="4">
|
||||
<CustomStatistic :title="t('clients')" value=" ">
|
||||
<template #prefix>
|
||||
<a-space direction="horizontal">
|
||||
<TeamOutlined />
|
||||
<a-tag color="green">{{ totals.clients }}</a-tag>
|
||||
<a-popover v-if="totals.deactive.length" :title="t('disabled')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.deactive" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag>{{ totals.deactive.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.depleted.length" :title="t('depleted')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.depleted" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="red">{{ totals.depleted.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.expiring.length" :title="t('depletingSoon')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.expiring" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="orange">{{ totals.expiring.length }}</a-tag>
|
||||
</a-popover>
|
||||
<a-popover v-if="totals.online.length" :title="t('online')">
|
||||
<template #content>
|
||||
<div class="client-email-list">
|
||||
<div v-for="email in totals.online" :key="email">{{ email }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<a-tag color="blue">{{ totals.online.length }}</a-tag>
|
||||
</a-popover>
|
||||
</a-space>
|
||||
</template>
|
||||
</CustomStatistic>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- Inbound list — toolbar, search/filter, columns, row actions -->
|
||||
<a-col :span="24">
|
||||
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
|
||||
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
|
||||
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
|
||||
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" :has-active-node="hasActiveNode"
|
||||
:stats-version="statsVersion"
|
||||
@refresh="refresh"
|
||||
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
|
||||
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
|
||||
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
|
||||
@delete-clients="onDeleteClients" @toggle-enable-client="onToggleEnableClient" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-spin>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" @saved="refresh" />
|
||||
<ClientFormModal v-model:open="clientOpen" :mode="clientMode" :db-inbound="clientDbInbound"
|
||||
:client-index="clientIndex" :sub-enable="subSettings.enable" :tg-bot-enable="tgBotEnable"
|
||||
:ip-limit-enable="ipLimitEnable" :traffic-diff="trafficDiff" @saved="refresh" />
|
||||
<ClientBulkModal v-model:open="bulkOpen" :db-inbound="bulkDbInbound" :sub-enable="subSettings.enable"
|
||||
:tg-bot-enable="tgBotEnable" :ip-limit-enable="ipLimitEnable" @saved="refresh" />
|
||||
<CopyClientsModal v-model:open="copyOpen" :db-inbound="copyDbInbound" :db-inbounds="dbInbounds"
|
||||
@saved="refresh" />
|
||||
<InboundInfoModal v-model:open="infoOpen" :db-inbound="infoDbInbound" :client-index="infoClientIndex"
|
||||
:remark-model="remarkModel" :expire-diff="expireDiff" :traffic-diff="trafficDiff"
|
||||
:ip-limit-enable="ipLimitEnable" :tg-bot-enable="tgBotEnable" :sub-settings="subSettings"
|
||||
:last-online-map="lastOnlineMap" :node-address="infoNodeAddress" />
|
||||
<QrCodeModal v-model:open="qrOpen" :db-inbound="qrDbInbound" :client="qrClient" :remark-model="remarkModel"
|
||||
:node-address="qrNodeAddress" :sub-settings="subSettings" />
|
||||
|
||||
<TextModal v-model:open="textOpen" :title="textTitle" :content="textContent" :file-name="textFileName" />
|
||||
<PromptModal v-model:open="promptOpen" :title="promptTitle" :ok-text="promptOkText" :type="promptType"
|
||||
:initial-value="promptInitial" :loading="promptLoading" @confirm="onPromptConfirm" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbounds-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark {
|
||||
--bg-page: #1e1e1e;
|
||||
--bg-card: #252526;
|
||||
}
|
||||
|
||||
.inbounds-page.is-dark.is-ultra {
|
||||
--bg-page: #050505;
|
||||
--bg-card: #0c0e12;
|
||||
}
|
||||
|
||||
.inbounds-page :deep(.ant-layout),
|
||||
.inbounds-page :deep(.ant-layout-content) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-area {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-card {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* AD-Vue popovers teleport their content to <body>, so scoped styles
|
||||
don't reach them — this block has to be unscoped. */
|
||||
.client-email-list {
|
||||
max-height: 280px;
|
||||
min-width: 160px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.client-email-list > div {
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import QrPanel from './QrPanel.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
dbInbound: { type: Object, default: null },
|
||||
client: { type: Object, default: null },
|
||||
remarkModel: { type: String, default: '-ieo' },
|
||||
nodeAddress: { type: String, default: '' },
|
||||
subSettings: {
|
||||
type: Object,
|
||||
default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const links = ref([]);
|
||||
const wireguardConfigs = ref([]);
|
||||
const wireguardLinks = ref([]);
|
||||
const subLink = ref('');
|
||||
const subJsonLink = ref('');
|
||||
const activeKeys = ref([]);
|
||||
|
||||
const qrItems = computed(() => {
|
||||
const items = [];
|
||||
if (subLink.value) {
|
||||
items.push({
|
||||
key: 'sub',
|
||||
header: t('subscription.title'),
|
||||
value: subLink.value,
|
||||
});
|
||||
}
|
||||
if (subJsonLink.value) {
|
||||
items.push({
|
||||
key: 'sub-json',
|
||||
header: `${t('subscription.title')} (JSON)`,
|
||||
value: subJsonLink.value,
|
||||
});
|
||||
}
|
||||
links.value.forEach((link, idx) => {
|
||||
items.push({
|
||||
key: `l${idx}`,
|
||||
header: link.remark || `Link ${idx + 1}`,
|
||||
value: link.link,
|
||||
});
|
||||
});
|
||||
wireguardConfigs.value.forEach((cfg, idx) => {
|
||||
items.push({
|
||||
key: `wc${idx}`,
|
||||
header: `Peer ${idx + 1} config`,
|
||||
value: cfg,
|
||||
downloadName: `peer-${idx + 1}.conf`,
|
||||
});
|
||||
if (wireguardLinks.value[idx]) {
|
||||
items.push({
|
||||
key: `wl${idx}`,
|
||||
header: `Peer ${idx + 1} link`,
|
||||
value: wireguardLinks.value[idx],
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next || !props.dbInbound) return;
|
||||
const inbound = props.dbInbound.toInbound();
|
||||
if (inbound.protocol === Protocols.WIREGUARD) {
|
||||
const peerRemark = props.client?.email
|
||||
? `${props.dbInbound.remark}-${props.client.email}`
|
||||
: props.dbInbound.remark;
|
||||
wireguardConfigs.value = inbound.genWireguardConfigs(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||
wireguardLinks.value = inbound.genWireguardLinks(peerRemark, '-ieo', props.nodeAddress).split('\r\n');
|
||||
links.value = [];
|
||||
} else {
|
||||
// When a client is provided we generate per-client share links;
|
||||
// otherwise (single-user SS) fall back to the inbound's settings.
|
||||
links.value = inbound.genAllLinks(props.dbInbound.remark, props.remarkModel, props.client, props.nodeAddress);
|
||||
wireguardConfigs.value = [];
|
||||
wireguardLinks.value = [];
|
||||
}
|
||||
|
||||
const subId = props.client?.subId;
|
||||
if (props.subSettings?.enable && subId) {
|
||||
subLink.value = (props.subSettings.subURI || '') + subId;
|
||||
subJsonLink.value = props.subSettings.subJsonEnable
|
||||
? (props.subSettings.subJsonURI || '') + subId
|
||||
: '';
|
||||
} else {
|
||||
subLink.value = '';
|
||||
subJsonLink.value = '';
|
||||
}
|
||||
const open = [];
|
||||
if (subLink.value) open.push('sub');
|
||||
activeKeys.value = open;
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('qrCode')" :footer="null" width="420px" @cancel="close">
|
||||
<template v-if="dbInbound">
|
||||
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
|
||||
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
|
||||
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-collapse :deep(.ant-collapse-content-box) {
|
||||
padding: 8px 0 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ClipboardManager, FileManager } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: String, required: true },
|
||||
remark: { type: String, default: '' },
|
||||
downloadName: { type: String, default: '' },
|
||||
size: { type: Number, default: 360 },
|
||||
showQr: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
async function copy() {
|
||||
const ok = await ClipboardManager.copyText(props.value);
|
||||
if (ok) message.success(t('copied'));
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!props.downloadName) return;
|
||||
FileManager.downloadTextFile(props.value, props.downloadName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="qr-panel">
|
||||
<div class="qr-panel-header">
|
||||
<a-tag color="green" class="qr-remark">{{ remark }}</a-tag>
|
||||
<a-tooltip :title="t('copy')">
|
||||
<a-button size="small" @click="copy">
|
||||
<template #icon>
|
||||
<CopyOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="downloadName" :title="t('download')">
|
||||
<a-button size="small" @click="download">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<div v-if="showQr" class="qr-panel-canvas">
|
||||
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
|
||||
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-panel {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.qr-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.qr-remark {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code {
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.qr-panel-canvas .qr-code :deep(svg) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 360px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
// Loads the inbound list + sidecar data the page needs (online users,
|
||||
// last-online-map, default settings) and computes the per-inbound client
|
||||
// roll-ups the legacy panel surfaces in the popovers.
|
||||
//
|
||||
// Live-update model: initial GET on mount, then the WebSocket delta path
|
||||
// keeps the table fresh — the page subscribes to the server's `traffic`,
|
||||
// `client_stats`, and `invalidate` events and merges them into local
|
||||
// refs in-place. The manual refresh button is kept as a fallback.
|
||||
|
||||
import { computed, ref, shallowRef } from 'vue';
|
||||
import { HttpUtil, ObjectUtil } from '@/utils';
|
||||
import { DBInbound } from '@/models/dbinbound.js';
|
||||
import { Protocols } from '@/models/inbound.js';
|
||||
import { setDatepicker } from '@/composables/useDatepicker.js';
|
||||
|
||||
export function useInbounds() {
|
||||
const fetched = ref(false);
|
||||
const refreshing = ref(false);
|
||||
|
||||
// shallowRef because each refresh swaps the array; per-row reactivity is
|
||||
// unnecessary at the page level (modals work on copies).
|
||||
const dbInbounds = shallowRef([]);
|
||||
const clientCount = ref({});
|
||||
const onlineClients = ref([]);
|
||||
const lastOnlineMap = ref({});
|
||||
// Bumps on every client_stats merge so the per-inbound ClientRowTable
|
||||
// child can re-render. DBInbound is a plain class instance, not reactive,
|
||||
// so the in-place mutations on its clientStats array are invisible to
|
||||
// Vue's tracking unless something else (this tick) signals the change.
|
||||
const statsVersion = ref(0);
|
||||
|
||||
// Default-settings sidecar fields the table needs for color/expiry math.
|
||||
const expireDiff = ref(0);
|
||||
const trafficDiff = ref(0);
|
||||
const subSettings = ref({
|
||||
enable: false,
|
||||
subTitle: '',
|
||||
subURI: '',
|
||||
subJsonURI: '',
|
||||
subJsonEnable: false,
|
||||
});
|
||||
const remarkModel = ref('-ieo');
|
||||
const datepicker = ref('gregorian');
|
||||
const tgBotEnable = ref(false);
|
||||
const ipLimitEnable = ref(false);
|
||||
const pageSize = ref(0);
|
||||
|
||||
function isClientOnline(email) {
|
||||
return onlineClients.value.includes(email);
|
||||
}
|
||||
|
||||
// Roll-up of {clients, active, deactive, depleted, expiring, online,
|
||||
// comments} for a single inbound. Mirrors getClientCounts in the legacy
|
||||
// template. Skipped for protocols that don't have multi-user clients
|
||||
// (HTTP, MIXED, WireGuard) since their settings have no client list.
|
||||
function rollupClients(dbInbound, inbound) {
|
||||
const clientStats = Array.isArray(dbInbound.clientStats) ? dbInbound.clientStats : [];
|
||||
const clients = inbound?.clients || [];
|
||||
const active = [];
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
const comments = new Map();
|
||||
const now = Date.now();
|
||||
|
||||
if (dbInbound.enable) {
|
||||
for (const client of clients) {
|
||||
if (client.comment) comments.set(client.email, client.comment);
|
||||
if (client.enable) {
|
||||
active.push(client.email);
|
||||
if (isClientOnline(client.email)) online.push(client.email);
|
||||
} else {
|
||||
deactive.push(client.email);
|
||||
}
|
||||
}
|
||||
for (const stats of clientStats) {
|
||||
const exhausted = stats.total > 0 && stats.up + stats.down >= stats.total;
|
||||
const expired = stats.expiryTime > 0 && stats.expiryTime <= now;
|
||||
if (expired || exhausted) {
|
||||
depleted.push(stats.email);
|
||||
} else {
|
||||
const expiringSoon =
|
||||
(stats.expiryTime > 0 && stats.expiryTime - now < expireDiff.value) ||
|
||||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiff.value);
|
||||
if (expiringSoon) expiring.push(stats.email);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const client of clients) deactive.push(client.email);
|
||||
}
|
||||
|
||||
return {
|
||||
clients: clients.length,
|
||||
active,
|
||||
deactive,
|
||||
depleted,
|
||||
expiring,
|
||||
online,
|
||||
comments,
|
||||
};
|
||||
}
|
||||
|
||||
function setInbounds(rows) {
|
||||
const next = [];
|
||||
const counts = {};
|
||||
for (const row of rows) {
|
||||
const dbInbound = new DBInbound(row);
|
||||
const parsed = dbInbound.toInbound();
|
||||
next.push(dbInbound);
|
||||
const tracked = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
if (tracked.includes(row.protocol)) {
|
||||
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[row.id] = rollupClients(dbInbound, parsed);
|
||||
}
|
||||
}
|
||||
dbInbounds.value = next;
|
||||
clientCount.value = counts;
|
||||
fetched.value = true;
|
||||
}
|
||||
|
||||
async function fetchOnlineUsers() {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/onlines');
|
||||
if (msg?.success) onlineClients.value = msg.obj || [];
|
||||
}
|
||||
|
||||
async function fetchLastOnlineMap() {
|
||||
const msg = await HttpUtil.post('/panel/api/inbounds/lastOnline');
|
||||
if (msg?.success && msg.obj) lastOnlineMap.value = msg.obj;
|
||||
}
|
||||
|
||||
async function fetchDefaultSettings() {
|
||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
||||
if (!msg?.success) return;
|
||||
const s = msg.obj || {};
|
||||
expireDiff.value = (s.expireDiff ?? 0) * 86400000;
|
||||
trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
|
||||
tgBotEnable.value = !!s.tgBotEnable;
|
||||
subSettings.value = {
|
||||
enable: !!s.subEnable,
|
||||
subTitle: s.subTitle || '',
|
||||
subURI: s.subURI || '',
|
||||
subJsonURI: s.subJsonURI || '',
|
||||
subJsonEnable: !!s.subJsonEnable,
|
||||
};
|
||||
pageSize.value = s.pageSize ?? 0;
|
||||
remarkModel.value = s.remarkModel || '-ieo';
|
||||
datepicker.value = s.datepicker || 'gregorian';
|
||||
// Mirror into the global composable so date-pickers in modals can
|
||||
// pick the right calendar without re-fetching the settings.
|
||||
setDatepicker(datepicker.value);
|
||||
ipLimitEnable.value = !!s.ipLimitEnable;
|
||||
}
|
||||
|
||||
// ============ WebSocket live-update merge ===========================
|
||||
// The xray traffic job and the node traffic sync job each broadcast
|
||||
// a `traffic` payload every ~10s. We merge it into onlineClients +
|
||||
// lastOnlineMap; per-inbound counters arrive in the parallel
|
||||
// client_stats event below.
|
||||
function applyTrafficEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (Array.isArray(payload.onlineClients)) {
|
||||
onlineClients.value = payload.onlineClients;
|
||||
}
|
||||
if (payload.lastOnlineMap && typeof payload.lastOnlineMap === 'object') {
|
||||
// Merge so a subsequent payload that drops a quiet client doesn't
|
||||
// wipe their last-seen timestamp.
|
||||
lastOnlineMap.value = { ...lastOnlineMap.value, ...payload.lastOnlineMap };
|
||||
}
|
||||
// Recompute per-inbound rollups so the "online" badges in the
|
||||
// expand-row table flip without waiting for a full refresh.
|
||||
rebuildClientCount();
|
||||
}
|
||||
|
||||
// The client_stats payload carries absolute traffic counters for every
|
||||
// client + per-inbound totals (full snapshot, not deltas). Both are
|
||||
// overwritten in place.
|
||||
function applyClientStatsEvent(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
let touched = false;
|
||||
|
||||
if (Array.isArray(payload.inbounds) && payload.inbounds.length > 0) {
|
||||
const byId = new Map();
|
||||
for (const row of payload.inbounds) {
|
||||
if (row && row.id != null) byId.set(row.id, row);
|
||||
}
|
||||
for (const ib of dbInbounds.value) {
|
||||
const upd = byId.get(ib.id);
|
||||
if (!upd) continue;
|
||||
if (typeof upd.up === 'number') ib.up = upd.up;
|
||||
if (typeof upd.down === 'number') ib.down = upd.down;
|
||||
if (typeof upd.allTime === 'number') ib.allTime = upd.allTime;
|
||||
if (typeof upd.total === 'number') ib.total = upd.total;
|
||||
if (typeof upd.enable === 'boolean') ib.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.clients) && payload.clients.length > 0) {
|
||||
const byEmail = new Map();
|
||||
for (const row of payload.clients) {
|
||||
if (row && row.email) byEmail.set(row.email, row);
|
||||
}
|
||||
for (const ib of dbInbounds.value) {
|
||||
if (!Array.isArray(ib.clientStats)) continue;
|
||||
for (let i = 0; i < ib.clientStats.length; i++) {
|
||||
const stat = ib.clientStats[i];
|
||||
const upd = byEmail.get(stat.email);
|
||||
if (!upd) continue;
|
||||
if (typeof upd.up === 'number') stat.up = upd.up;
|
||||
if (typeof upd.down === 'number') stat.down = upd.down;
|
||||
if (typeof upd.total === 'number') stat.total = upd.total;
|
||||
if (typeof upd.allTime === 'number') stat.allTime = upd.allTime;
|
||||
if (typeof upd.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
|
||||
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
|
||||
touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
statsVersion.value++;
|
||||
dbInbounds.value = [...dbInbounds.value];
|
||||
rebuildClientCount();
|
||||
}
|
||||
}
|
||||
|
||||
// The hub may decide a payload is too large to push directly and emit
|
||||
// an `invalidate` event with the affected dataType instead. For the
|
||||
// inbounds page that means "the inbound list changed elsewhere — go
|
||||
// re-fetch via REST".
|
||||
function applyInvalidate(payload) {
|
||||
if (!payload || typeof payload !== 'object') return;
|
||||
if (payload.type === 'inbounds') {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function applyInboundsEvent(payload) {
|
||||
if (!Array.isArray(payload)) return;
|
||||
setInbounds(payload);
|
||||
}
|
||||
|
||||
// Recompute the per-inbound roll-up after any in-place mutation.
|
||||
// Cheap because rollupClients only iterates a single inbound's
|
||||
// clients + clientStats arrays.
|
||||
function rebuildClientCount() {
|
||||
const counts = {};
|
||||
const tracked = [
|
||||
Protocols.VMESS,
|
||||
Protocols.VLESS,
|
||||
Protocols.TROJAN,
|
||||
Protocols.SHADOWSOCKS,
|
||||
Protocols.HYSTERIA,
|
||||
];
|
||||
for (const dbInbound of dbInbounds.value) {
|
||||
const parsed = dbInbound.toInbound();
|
||||
if (!tracked.includes(dbInbound.protocol)) continue;
|
||||
if (dbInbound.isSS && !parsed.isSSMultiUser) continue;
|
||||
counts[dbInbound.id] = rollupClients(dbInbound, parsed);
|
||||
}
|
||||
clientCount.value = counts;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
refreshing.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/inbounds/list');
|
||||
if (!msg?.success) return;
|
||||
await fetchLastOnlineMap();
|
||||
await fetchOnlineUsers();
|
||||
setInbounds(Array.isArray(msg.obj) ? msg.obj : []);
|
||||
} finally {
|
||||
// Match legacy: keep the spinning-icon state visible briefly so
|
||||
// a fast network doesn't make the button feel like it didn't fire.
|
||||
setTimeout(() => { refreshing.value = false; }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate totals shown in the dashboard summary card. allTime falls
|
||||
// back to up+down when the per-inbound counter isn't populated yet.
|
||||
const totals = computed(() => {
|
||||
let up = 0;
|
||||
let down = 0;
|
||||
let allTime = 0;
|
||||
let clients = 0;
|
||||
const deactive = [];
|
||||
const depleted = [];
|
||||
const expiring = [];
|
||||
const online = [];
|
||||
for (const ib of dbInbounds.value) {
|
||||
up += ib.up || 0;
|
||||
down += ib.down || 0;
|
||||
allTime += ib.allTime || (ib.up + ib.down) || 0;
|
||||
const c = clientCount.value[ib.id];
|
||||
if (c) {
|
||||
clients += c.clients;
|
||||
deactive.push(...c.deactive);
|
||||
depleted.push(...c.depleted);
|
||||
expiring.push(...c.expiring);
|
||||
online.push(...c.online);
|
||||
}
|
||||
}
|
||||
return { up, down, allTime, clients, deactive, depleted, expiring, online };
|
||||
});
|
||||
|
||||
// ObjectUtil reference is wired at module load — keeping a no-op import
|
||||
// here so the linter doesn't drop it; the legacy search uses it.
|
||||
void ObjectUtil;
|
||||
|
||||
return {
|
||||
fetched,
|
||||
refreshing,
|
||||
dbInbounds,
|
||||
clientCount,
|
||||
onlineClients,
|
||||
lastOnlineMap,
|
||||
statsVersion,
|
||||
totals,
|
||||
expireDiff,
|
||||
trafficDiff,
|
||||
subSettings,
|
||||
remarkModel,
|
||||
datepicker,
|
||||
tgBotEnable,
|
||||
ipLimitEnable,
|
||||
pageSize,
|
||||
refresh,
|
||||
fetchDefaultSettings,
|
||||
applyTrafficEvent,
|
||||
applyClientStatsEvent,
|
||||
applyInvalidate,
|
||||
applyInboundsEvent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
|
||||
import { HttpUtil, PromiseUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
basePath: { type: String, default: '' },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'busy']);
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function exportDb() {
|
||||
// The Go endpoint streams x-ui.db as a download. Setting
|
||||
// window.location triggers a browser download without leaving
|
||||
// the page (the Go side responds with Content-Disposition: attachment).
|
||||
window.location = window.X_UI_BASE_PATH+'panel/api/server/getDb';
|
||||
}
|
||||
|
||||
function importDb() {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.accept = '.db';
|
||||
fileInput.addEventListener('change', async (e) => {
|
||||
const dbFile = e.target.files?.[0];
|
||||
if (!dbFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('db', dbFile);
|
||||
|
||||
close();
|
||||
emit('busy', { busy: true, tip: t('pages.index.importDatabase') + '…' });
|
||||
|
||||
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
if (!upload?.success) {
|
||||
emit('busy', { busy: false });
|
||||
return;
|
||||
}
|
||||
|
||||
emit('busy', { busy: true, tip: t('pages.settings.restartPanel') + '…' });
|
||||
const restart = await HttpUtil.post('/panel/setting/restartPanel');
|
||||
if (restart?.success) {
|
||||
await PromiseUtil.sleep(5000);
|
||||
window.location.reload();
|
||||
} else {
|
||||
emit('busy', { busy: false });
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
|
||||
<a-list bordered class="backup-list">
|
||||
<a-list-item class="backup-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ t('pages.index.exportDatabase') }}</template>
|
||||
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
|
||||
</a-list-item-meta>
|
||||
<a-button type="primary" @click="exportDb">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
|
||||
<a-list-item class="backup-item">
|
||||
<a-list-item-meta>
|
||||
<template #title>{{ t('pages.index.importDatabase') }}</template>
|
||||
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
|
||||
</a-list-item-meta>
|
||||
<a-button type="primary" @click="importDb">
|
||||
<template #icon>
|
||||
<UploadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.backup-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.backup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup>
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { HttpUtil } from '@/utils';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
// Populate with the record when editing; null/undefined when adding.
|
||||
record: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'saved']);
|
||||
|
||||
const form = reactive({ type: 'geosite', alias: '', url: '' });
|
||||
const saving = ref(false);
|
||||
|
||||
const editing = ref(false);
|
||||
const editId = ref(null);
|
||||
|
||||
watch(() => props.open, (next) => {
|
||||
if (!next) return;
|
||||
if (props.record) {
|
||||
editing.value = true;
|
||||
editId.value = props.record.id;
|
||||
form.type = props.record.type;
|
||||
form.alias = props.record.alias;
|
||||
form.url = props.record.url;
|
||||
} else {
|
||||
editing.value = false;
|
||||
editId.value = null;
|
||||
form.type = 'geosite';
|
||||
form.alias = '';
|
||||
form.url = '';
|
||||
}
|
||||
});
|
||||
|
||||
function close() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function validate() {
|
||||
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
|
||||
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
|
||||
message.error(t('pages.index.customGeoValidationAlias'));
|
||||
return false;
|
||||
}
|
||||
const u = (form.url || '').trim();
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(u);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
} catch (_e) {
|
||||
message.error(t('pages.index.customGeoValidationUrl'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const url = editing.value
|
||||
? `/panel/api/custom-geo/update/${editId.value}`
|
||||
: '/panel/api/custom-geo/add';
|
||||
const msg = await HttpUtil.post(url, form);
|
||||
if (msg?.success) {
|
||||
emit('saved');
|
||||
close();
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
|
||||
:confirm-loading="saving" :ok-text="t('pages.index.customGeoModalSave')" :cancel-text="t('close')" @ok="submit"
|
||||
@cancel="close">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item :label="t('pages.index.customGeoType')">
|
||||
<a-select v-model:value="form.type" :disabled="editing">
|
||||
<a-select-option value="geosite">geosite</a-select-option>
|
||||
<a-select-option value="geoip">geoip</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.index.customGeoAlias')">
|
||||
<a-input v-model:value="form.alias" :disabled="editing"
|
||||
:placeholder="t('pages.index.customGeoAliasPlaceholder')" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.index.customGeoUrl')">
|
||||
<a-input v-model:value="form.url" placeholder="https://" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import { HttpUtil, ClipboardManager } from '@/utils';
|
||||
import CustomGeoFormModal from './CustomGeoFormModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
// Re-fetch the list when the parent collapse expands this section.
|
||||
active: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const list = ref([]);
|
||||
const loading = ref(false);
|
||||
const updatingAll = ref(false);
|
||||
const actionId = ref(null);
|
||||
|
||||
const formOpen = ref(false);
|
||||
const editingRecord = ref(null);
|
||||
|
||||
// Computed so column titles re-render after a locale swap.
|
||||
const columns = computed(() => [
|
||||
{ title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
|
||||
{ title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
|
||||
{ title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
|
||||
{ title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
|
||||
{ title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
|
||||
]);
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.get('/panel/api/custom-geo/list');
|
||||
if (msg?.success && Array.isArray(msg.obj)) list.value = msg.obj;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingRecord.value = null;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
editingRecord.value = record;
|
||||
formOpen.value = true;
|
||||
}
|
||||
|
||||
function extDisplay(record) {
|
||||
const fn = record.type === 'geoip'
|
||||
? `geoip_${record.alias}.dat`
|
||||
: `geosite_${record.alias}.dat`;
|
||||
return `ext:${fn}:tag`;
|
||||
}
|
||||
|
||||
async function copyExt(record) {
|
||||
const text = extDisplay(record);
|
||||
const ok = await ClipboardManager.copyText(text);
|
||||
if (ok) message.success(`${t('copied')}: ${text}`);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
const d = new Date(ts * 1000);
|
||||
if (isNaN(d.getTime())) return String(ts);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Tiny inline relative-time formatter so we don't pull in moment.
|
||||
function relativeTime(ts) {
|
||||
if (!ts) return '';
|
||||
const diff = Math.floor(Date.now() / 1000) - ts;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
|
||||
if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
|
||||
return formatTime(ts);
|
||||
}
|
||||
|
||||
function confirmDelete(record) {
|
||||
Modal.confirm({
|
||||
title: t('pages.index.customGeoDelete'),
|
||||
content: t('pages.index.customGeoDeleteConfirm'),
|
||||
okText: t('delete'),
|
||||
okType: 'danger',
|
||||
cancelText: t('cancel'),
|
||||
onOk: async () => {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
|
||||
if (msg?.success) await loadList();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadOne(id) {
|
||||
actionId.value = id;
|
||||
try {
|
||||
const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
|
||||
if (msg?.success) await loadList();
|
||||
} finally {
|
||||
actionId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAll() {
|
||||
updatingAll.value = true;
|
||||
try {
|
||||
const msg = await HttpUtil.post('/panel/api/custom-geo/update-all');
|
||||
const ok = msg?.obj?.succeeded?.length || 0;
|
||||
const failed = msg?.obj?.failed?.length || 0;
|
||||
if (msg?.success || ok > 0) {
|
||||
await loadList();
|
||||
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`);
|
||||
}
|
||||
} finally {
|
||||
updatingAll.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load: only fetch when the parent collapse opens this panel.
|
||||
watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="custom-geo-section">
|
||||
<a-alert type="info" show-icon class="mb-10" :message="t('pages.index.customGeoRoutingHint')" />
|
||||
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" :loading="loading" @click="openAdd">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.customGeoAdd') }}
|
||||
</a-button>
|
||||
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
{{ t('pages.index.geofilesUpdateAll') }}
|
||||
</a-button>
|
||||
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" :pagination="false" :row-key="(r) => r.id" :loading="loading"
|
||||
size="small" :scroll="{ x: 760 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'alias'">
|
||||
<div class="custom-geo-alias-cell">
|
||||
<a-tag :color="record.type === 'geoip' ? 'cyan' : 'purple'" class="custom-geo-type-tag">
|
||||
{{ record.type }}
|
||||
</a-tag>
|
||||
<span class="custom-geo-alias">{{ record.alias }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'url'">
|
||||
<a-tooltip placement="topLeft" :title="record.url">
|
||||
<a :href="record.url" target="_blank" rel="noopener noreferrer" class="custom-geo-url">
|
||||
{{ record.url }}
|
||||
</a>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'extDat'">
|
||||
<a-tooltip :title="t('copy')">
|
||||
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
|
||||
{{ extDisplay(record) }}
|
||||
</code>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'lastUpdatedAt'">
|
||||
<a-tooltip v-if="record.lastUpdatedAt" :title="formatTime(record.lastUpdatedAt)">
|
||||
<span>{{ relativeTime(record.lastUpdatedAt) }}</span>
|
||||
</a-tooltip>
|
||||
<span v-else class="custom-geo-muted">—</span>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space size="small">
|
||||
<a-tooltip :title="t('pages.index.customGeoEdit')">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">
|
||||
<template #icon>
|
||||
<EditOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('pages.index.customGeoDownload')">
|
||||
<a-button type="link" size="small" :loading="actionId === record.id" @click="downloadOne(record.id)">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="t('pages.index.customGeoDelete')">
|
||||
<a-button type="link" size="small" danger @click="confirmDelete(record)">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #emptyText>
|
||||
<div class="custom-geo-empty">
|
||||
<InboxOutlined class="custom-geo-empty-icon" />
|
||||
<div>{{ t('pages.index.customGeoEmpty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<CustomGeoFormModal v-model:open="formOpen" :record="editingRecord" @saved="loadList" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-10 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.custom-geo-count {
|
||||
margin-left: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
font-size: 12px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-count {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.custom-geo-alias-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.custom-geo-alias {
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-type-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.custom-geo-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.custom-geo-ext-code {
|
||||
cursor: pointer;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.custom-geo-copyable:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-ext-code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
:global(body.dark) .custom-geo-copyable:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.custom-geo-muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.custom-geo-empty {
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.custom-geo-empty-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user