refactor(frontend): port inbounds to react+ts and drop vue toolchain

Step 9 — the last entry. Ports the inbounds entry: page shell, list with
desktop table + mobile cards, info modal, qr-code modal, share-link
helpers, and the protocol-aware form modal (basics / protocol /
stream / security / sniffing / advanced JSON). useInbounds replaces
the Vue composable with WebSocket-driven traffic + client-stats merge.

Inbound and DBInbound models stay in JS so the class-driven form keeps
its mutation API; instance access is typed loosely inside the form to
match. FinalMaskForm/JsonEditor/TextModal/PromptModal/InfinityIcon are
the last shared bits to flip; their .vue counterparts go too.

Toolchain cleanup now that no entry needs Vue: drop plugin-vue from
vite.config, remove the .vue lint block + parser, prune vue / vue-i18n
/ ant-design-vue / @ant-design/icons-vue / vue3-persian-datetime-picker
/ moment-jalaali override from package.json, and switch utils/index.js
to import { message } from 'antd' instead of ant-design-vue.
This commit is contained in:
MHSanaei 2026-05-21 23:35:23 +02:00
parent 23542e9e8d
commit d6f42b3395
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
47 changed files with 5389 additions and 8673 deletions

View file

@ -1,6 +1,4 @@
import js from '@eslint/js';
import vue from 'eslint-plugin-vue';
import vueParser from 'vue-eslint-parser';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
@ -8,16 +6,11 @@ import globals from 'globals';
export default [
{ ignores: ['node_modules/**', '../web/dist/**'] },
js.configs.recommended,
...vue.configs['flat/recommended'],
{
files: ['**/*.{js,vue}'],
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: vueParser,
parserOptions: {
ecmaFeatures: { jsx: false },
},
globals: {
...globals.browser,
...globals.node,
@ -31,30 +24,6 @@ export default [
}],
'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',
},
},
...tseslint.configs.recommended.map((config) => ({

View file

@ -8,6 +8,6 @@
<body>
<div id="message"></div>
<div id="app"></div>
<script type="module" src="/src/entries/inbounds.js"></script>
<script type="module" src="/src/entries/inbounds.tsx"></script>
</body>
</html>

View file

@ -9,10 +9,8 @@
"version": "0.0.3",
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@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",
"antd": "^6.4.3",
"axios": "^1.16.1",
"codemirror": "^6.0.2",
@ -22,25 +20,19 @@
"qs": "^6.15.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"vue": "^3.5.34",
"vue-i18n": "^11.4.4",
"vue3-persian-datetime-picker": "^1.2.2"
"react-i18next": "^17.0.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-vue": "^6.0.7",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-vue": "^10.9.1",
"globals": "^17.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "8.0.13",
"vue-eslint-parser": "^10.4.0"
"vite": "8.0.13"
},
"engines": {
"node": ">=22.0.0",
@ -136,28 +128,6 @@
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
"license": "MIT"
},
"node_modules/@ant-design/icons-vue": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
"integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-svg": "^4.2.1"
},
"peerDependencies": {
"vue": ">=3.0.3"
}
},
"node_modules/@ant-design/icons-vue/node_modules/@ant-design/colors": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
"integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.0"
}
},
"node_modules/@ant-design/react-slick": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz",
@ -310,6 +280,7 @@
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -319,6 +290,7 @@
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@ -352,6 +324,7 @@
"version": "7.29.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
@ -410,6 +383,7 @@
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
@ -522,15 +496,6 @@
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
@ -565,18 +530,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@ -771,67 +724,6 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@intlify/core-base": {
"version": "11.4.4",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.4.tgz",
"integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==",
"license": "MIT",
"dependencies": {
"@intlify/devtools-types": "11.4.4",
"@intlify/message-compiler": "11.4.4",
"@intlify/shared": "11.4.4"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/devtools-types": {
"version": "11.4.4",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.4.tgz",
"integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.4",
"@intlify/shared": "11.4.4"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.4.4",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.4.tgz",
"integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.4.4",
"source-map-js": "^1.0.2"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@intlify/shared": {
"version": "11.4.4",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.4.tgz",
"integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==",
"license": "MIT",
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -868,6 +760,7 @@
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@ -1942,16 +1835,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@simonwep/pickr": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.8.2.tgz",
"integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
"license": "MIT",
"dependencies": {
"core-js": "^3.15.1",
"nanopop": "^2.1.0"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
@ -2273,129 +2156,6 @@
}
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.7",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
"integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
"vue": "^3.2.25"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@vue/shared": "3.5.34",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
"integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.3",
"@vue/compiler-core": "3.5.34",
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.14",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
"integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
"integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
"integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.34",
"@vue/shared": "3.5.34"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
"integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.34",
"@vue/runtime-core": "3.5.34",
"@vue/shared": "3.5.34",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
"integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"vue": "3.5.34"
}
},
"node_modules/@vue/shared": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@ -2448,55 +2208,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ant-design-vue": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
"integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-vue": "^7.0.0",
"@babel/runtime": "^7.10.5",
"@ctrl/tinycolor": "^3.5.0",
"@emotion/hash": "^0.9.0",
"@emotion/unitless": "^0.8.0",
"@simonwep/pickr": "~1.8.0",
"array-tree-filter": "^2.1.0",
"async-validator": "^4.0.0",
"csstype": "^3.1.1",
"dayjs": "^1.10.5",
"dom-align": "^1.12.1",
"dom-scroll-into-view": "^2.0.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.15",
"resize-observer-polyfill": "^1.5.1",
"scroll-into-view-if-needed": "^2.2.25",
"shallow-equal": "^1.0.0",
"stylis": "^4.1.3",
"throttle-debounce": "^5.0.0",
"vue-types": "^3.0.0",
"warning": "^4.0.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ant-design-vue"
},
"peerDependencies": {
"vue": ">=3.2.0"
}
},
"node_modules/ant-design-vue/node_modules/@ant-design/colors": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-6.0.0.tgz",
"integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.0"
}
},
"node_modules/antd": {
"version": "6.4.3",
"resolved": "https://registry.npmjs.org/antd/-/antd-6.4.3.tgz",
@ -2575,18 +2286,6 @@
"compute-scroll-into-view": "^3.0.2"
}
},
"node_modules/array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
"integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
"license": "MIT"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@ -2628,13 +2327,6 @@
"node": ">=6.0.0"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@ -2768,12 +2460,6 @@
"node": ">= 0.8"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -2781,17 +2467,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
@ -2813,19 +2488,6 @@
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
"engines": {
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -2881,18 +2543,6 @@
"node": ">=8"
}
},
"node_modules/dom-align": {
"version": "1.12.4",
"resolved": "https://registry.npmjs.org/dom-align/-/dom-align-1.12.4.tgz",
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
"license": "MIT"
},
"node_modules/dom-scroll-into-view": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2914,18 +2564,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -3070,51 +2708,6 @@
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-vue": {
"version": "10.9.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.1.tgz",
"integrity": "sha512-cHB0Tf4Duvzwecwd/AqWzZvF/QszE13BhjVUpVXWCy9AeMR5GjkAjP3i85vqgLgOuTmkHR1OJ5oMeqLHtuw8zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
"nth-check": "^2.1.1",
"postcss-selector-parser": "^7.1.0",
"semver": "^7.6.3",
"xml-name-validator": "^4.0.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"@typescript-eslint/parser": "^7.0.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"vue-eslint-parser": "^10.3.0"
},
"peerDependenciesMeta": {
"@stylistic/eslint-plugin": {
"optional": true
},
"@typescript-eslint/parser": {
"optional": true
}
}
},
"node_modules/eslint-plugin-vue/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
@ -3201,12 +2794,6 @@
"node": ">=4.0"
}
},
"node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@ -3607,15 +3194,6 @@
"integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==",
"license": "MIT"
},
"node_modules/is-plain-object": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-3.0.1.tgz",
"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3623,16 +3201,11 @@
"dev": true,
"license": "ISC"
},
"node_modules/jalaali-js": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/jalaali-js/-/jalaali-js-1.2.8.tgz",
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/jsesc": {
@ -4004,30 +3577,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -4038,15 +3587,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -4093,38 +3633,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/moment-jalaali": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/moment-jalaali/-/moment-jalaali-0.10.4.tgz",
"integrity": "sha512-/eD0HeyvATznb5iE0G1BHjKRZAFEpJ9ZNUkcHwXhNgt1WJJVVzHD7+uDmqzZWVFLdbGme2gvIXKb3ezDYOXcZA==",
"license": "MIT",
"dependencies": {
"jalaali-js": "^1.2.7",
"moment": "^2.29.4",
"moment-timezone": "^0.5.46"
}
},
"node_modules/moment-timezone": {
"version": "0.5.48",
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
"license": "MIT",
"dependencies": {
"moment": "^2.29.4"
},
"engines": {
"node": "*"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -4135,6 +3643,7 @@
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
"type": "github",
@ -4149,12 +3658,6 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanopop": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.4.2.tgz",
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@ -4172,19 +3675,6 @@
"node": ">=18"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -4283,6 +3773,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
@ -4302,6 +3793,7 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
@ -4326,20 +3818,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -4438,12 +3916,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
@ -4484,15 +3956,6 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"license": "MIT",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@ -4503,12 +3966,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -4608,6 +4065,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@ -4779,13 +4237,6 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
@ -4873,124 +4324,12 @@
"node": ">=0.10.0"
}
},
"node_modules/vue": {
"version": "3.5.34",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
"integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.34",
"@vue/compiler-sfc": "3.5.34",
"@vue/runtime-dom": "3.5.34",
"@vue/server-renderer": "3.5.34",
"@vue/shared": "3.5.34"
},
"peerDependencies": {
"typescript": "*"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/vue-eslint-parser": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
"integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
"eslint-visitor-keys": "^4.2.0 || ^5.0.0",
"espree": "^10.3.0 || ^11.0.0",
"esquery": "^1.6.0",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/vue-eslint-parser/node_modules/semver": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vue-i18n": {
"version": "11.4.4",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.4.tgz",
"integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.4.4",
"@intlify/devtools-types": "11.4.4",
"@intlify/shared": "11.4.4",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
"node": ">= 22"
},
"funding": {
"url": "https://github.com/sponsors/kazupon"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/vue-types/-/vue-types-3.0.2.tgz",
"integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
"license": "MIT",
"dependencies": {
"is-plain-object": "3.0.1"
},
"engines": {
"node": ">=10.15.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue3-persian-datetime-picker": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/vue3-persian-datetime-picker/-/vue3-persian-datetime-picker-1.2.2.tgz",
"integrity": "sha512-d7nkj5vgtUvEXZboSdRmP1uwBfXvXgXqdvsOOMQb34jiMZU/aBDrTYWTEe1N+XKF9pvTTJn8Rws9ttJmyhK/hw==",
"license": "MIT",
"dependencies": {
"moment-jalaali": "^0.9.4"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -5017,16 +4356,6 @@
"node": ">=0.10.0"
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
"integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -17,10 +17,8 @@
},
"dependencies": {
"@ant-design/icons": "^6.2.3",
"@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",
"antd": "^6.4.3",
"axios": "^1.16.1",
"codemirror": "^6.0.2",
@ -30,27 +28,18 @@
"qs": "^6.15.2",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.8",
"vue": "^3.5.34",
"vue-i18n": "^11.4.4",
"vue3-persian-datetime-picker": "^1.2.2"
"react-i18next": "^17.0.8"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitejs/plugin-vue": "^6.0.7",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-vue": "^10.9.1",
"globals": "^17.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "8.0.13",
"vue-eslint-parser": "^10.4.0"
},
"overrides": {
"moment-jalaali": "^0.10.4"
"vite": "8.0.13"
}
}
}

View file

@ -1,432 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
DashboardOutlined,
UserOutlined,
TeamOutlined,
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,
team: TeamOutlined,
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/clients`, icon: 'team', title: t('menu.clients') },
{ 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>

View file

@ -1,31 +0,0 @@
<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>

View file

@ -1,10 +1,3 @@
// React port of DateTimePicker.vue. For now this delegates to AntD's
// <DatePicker>; the Jalali calendar UI from vue3-persian-datetime-picker
// has no clean React equivalent and is tracked as a follow-up for when
// the inbounds entry migrates. Read-only Jalali display still works via
// IntlUtil.formatDate, which uses Intl.DateTimeFormat with the persian
// calendar extension.
import { DatePicker } from 'antd';
import type { Dayjs } from 'dayjs';

View file

@ -1,366 +0,0 @@
<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>

View file

@ -1,510 +0,0 @@
<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>

View file

@ -0,0 +1,19 @@
interface InfinityIconProps {
width?: number | string;
height?: number | string;
}
export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) {
return (
<svg
width={width}
height={height}
viewBox="0 0 640 512"
fill="currentColor"
aria-hidden="true"
style={{ verticalAlign: '-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>
);
}

View file

@ -1,18 +0,0 @@
<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>

View file

@ -1,185 +0,0 @@
<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>

View file

@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import { Input, Modal } from 'antd';
import type { InputRef } from 'antd';
interface PromptModalProps {
open: boolean;
onClose: () => void;
title: string;
okText?: string;
type?: 'input' | 'textarea';
initialValue?: string;
loading?: boolean;
onConfirm: (value: string) => void;
}
export default function PromptModal({
open,
onClose,
title,
okText = 'OK',
type = 'input',
initialValue = '',
loading = false,
onConfirm,
}: PromptModalProps) {
const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const inputRef = useRef<InputRef | null>(null);
useEffect(() => {
if (open) {
setValue(initialValue);
setTimeout(() => {
if (type === 'textarea') textareaRef.current?.focus();
else inputRef.current?.focus();
}, 50);
}
}, [open, initialValue, type]);
function onKeydown(e: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) {
if (type !== 'textarea' && e.key === 'Enter') {
e.preventDefault();
onConfirm(value);
return;
}
if (type === 'textarea' && e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault();
onConfirm(value);
}
}
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText="Cancel"
maskClosable={false}
confirmLoading={loading}
onOk={() => onConfirm(value)}
onCancel={onClose}
destroyOnClose
>
{type === 'textarea' ? (
<Input.TextArea
ref={(el) => { textareaRef.current = (el as unknown as { resizableTextArea?: { textArea: HTMLTextAreaElement } })?.resizableTextArea?.textArea ?? null; }}
value={value}
onChange={(e) => setValue(e.target.value)}
autoSize={{ minRows: 10, maxRows: 20 }}
onKeyDown={onKeydown}
/>
) : (
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeydown}
/>
)}
</Modal>
);
}

View file

@ -1,52 +0,0 @@
<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>

View file

@ -1,35 +0,0 @@
<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>

View file

@ -1,297 +0,0 @@
<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>

View file

@ -1,311 +0,0 @@
<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>

View file

@ -0,0 +1,56 @@
import { Button, Input, Modal, message } from 'antd';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils';
interface TextModalProps {
open: boolean;
onClose: () => void;
title: string;
content: string;
fileName?: string;
}
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
async function copy() {
const ok = await ClipboardManager.copyText(content || '');
if (ok) {
message.success('Copied');
onClose();
}
}
function download() {
if (!fileName) return;
FileManager.downloadTextFile(content, fileName);
}
return (
<Modal
open={open}
title={title}
onCancel={onClose}
closable
destroyOnClose
footer={(
<>
{fileName && (
<Button icon={<DownloadOutlined />} onClick={download}>{fileName}</Button>
)}
<Button type="primary" icon={<CopyOutlined />} onClick={copy}>Copy</Button>
</>
)}
>
<Input.TextArea
value={content}
readOnly
autoSize={{ minRows: 10, maxRows: 20 }}
style={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 12,
overflowY: 'auto',
}}
/>
</Modal>
);
}

View file

@ -1,66 +0,0 @@
<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>

View file

@ -1,45 +0,0 @@
// 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) };
}

View file

@ -1,26 +0,0 @@
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 };
}

View file

@ -1,44 +0,0 @@
// 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 };
}

View file

@ -1,128 +0,0 @@
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';
});

View file

@ -1,48 +0,0 @@
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 };
}

View file

@ -1,21 +0,0 @@
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, readyI18n } 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 });
}
readyI18n().then(() => {
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -0,0 +1,28 @@
import { createRoot } from 'react-dom/client';
import { message } from 'antd';
import 'antd/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import { applyDocumentTitle } from '@/utils';
import { readyI18n } from '@/i18n/react';
import { ThemeProvider } from '@/hooks/useTheme';
import InboundsPage from '@/pages/inbounds/InboundsPage';
setupAxios();
applyDocumentTitle();
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
readyI18n().then(() => {
const root = document.getElementById('app');
if (root) {
createRoot(root).render(
<ThemeProvider>
<InboundsPage />
</ThemeProvider>,
);
}
});

View file

@ -1,54 +0,0 @@
import { createI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils';
import enUS from '../../../web/translation/en-US.json';
const FALLBACK = 'en-US';
const lazyModules = import.meta.glob([
'../../../web/translation/*.json',
'!../../../web/translation/en-US.json',
]);
function moduleKeyFor(code) {
return `../../../web/translation/${code}.json`;
}
let active = LanguageManager.getLanguage();
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
active = FALLBACK;
}
export const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: active,
fallbackLocale: FALLBACK,
messages: { [FALLBACK]: enUS },
warnHtmlMessage: false,
missingWarn: false,
fallbackWarn: false,
});
export function t(key, params) {
return i18n.global.t(key, params || {});
}
export async function loadLocale(code) {
if (code === FALLBACK) {
i18n.global.locale.value = FALLBACK;
return true;
}
const loader = lazyModules[moduleKeyFor(code)];
if (!loader) return false;
const mod = await loader();
i18n.global.setLocaleMessage(code, mod.default || mod);
i18n.global.locale.value = code;
return true;
}
export async function readyI18n() {
if (active !== FALLBACK) {
await loadLocale(active);
}
return i18n;
}

View file

@ -1,9 +0,0 @@
// Slim TS surface for what the React client pages need. The full
// inbound model (StreamSettings, RealityStreamSettings, etc.) still
// lives in inbound.js for the remaining vue entries; this file ports
// only the enum-like constants the React clients page consumes.
export const TLS_FLOW_CONTROL = {
xtls_rprx_vision: 'xtls-rprx-vision',
xtls_rprx_vision_udp443: 'xtls-rprx-vision-udp443',
} as const;

View file

@ -0,0 +1,87 @@
.mt-4 { margin-top: 4px; }
.mt-8 { margin-top: 8px; }
.mt-12 { margin-top: 12px; }
.mb-4 { margin-bottom: 4px; }
.mb-8 { margin-bottom: 8px; }
.mb-12 { margin-bottom: 12px; }
.random-icon {
margin-left: 4px;
cursor: pointer;
color: var(--ant-color-primary, #1890ff);
}
.danger-icon {
margin-left: 6px;
cursor: pointer;
color: #ff4d4f;
}
.vless-auth-state {
display: block;
margin-top: 6px;
}
.wg-peer {
margin-top: 4px;
}
.advanced-shell {
display: flex;
flex-direction: column;
gap: 12px;
}
.advanced-panel {
padding: 14px;
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 12px;
background: rgba(128, 128, 128, 0.04);
}
.advanced-panel__header {
margin-bottom: 12px;
}
.advanced-panel__title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.advanced-panel__subtitle {
margin-top: 4px;
opacity: 0.7;
line-height: 1.5;
}
.advanced-inner-tabs .ant-tabs-nav {
margin-bottom: 12px;
}
.advanced-inner-tabs .ant-tabs-tab {
padding-inline: 14px;
}
.advanced-editor-meta {
margin-bottom: 10px;
opacity: 0.75;
line-height: 1.5;
}
@media (max-width: 768px) {
.advanced-panel {
padding: 12px;
border-radius: 10px;
}
.advanced-inner-tabs .ant-tabs-tab {
padding-inline: 10px;
}
}
body.dark .advanced-panel,
html[data-theme='ultra-dark'] .advanced-panel {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,215 @@
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table.block {
margin-bottom: 10px;
}
.info-table td,
.info-table th {
padding: 4px 8px;
vertical-align: top;
}
.info-table th {
text-align: center;
font-weight: 500;
}
.info-large-tag {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.info-list {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.info-row {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 6px 0;
border-bottom: 1px solid rgba(128, 128, 128, 0.12);
}
.info-row:last-child {
border-bottom: none;
}
.info-list-block {
margin-top: 10px;
}
.account-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.account-sep {
opacity: 0.55;
font-weight: 600;
}
.info-row dt {
margin: 0;
font-size: 13px;
opacity: 0.75;
}
.info-row dd {
margin: 0;
min-width: 0;
}
.value-tag {
max-width: 100%;
white-space: normal;
word-break: break-all;
display: inline-block;
margin-right: 0;
}
.value-block {
display: flex;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.value-code {
flex: 1;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
word-break: break-all;
white-space: pre-wrap;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
user-select: all;
min-width: 0;
}
body.dark .value-code {
background: rgba(255, 255, 255, 0.05);
}
.value-copy {
flex-shrink: 0;
}
.share-buttons {
margin-inline-start: 4px;
padding-inline-start: 8px;
border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
}
.summary-table {
width: 100%;
text-align: center;
margin: 10px 0;
}
.tg-row {
display: flex;
align-items: center;
gap: 6px;
}
.ip-log {
max-height: 150px;
overflow-y: auto;
text-align: left;
}
.ip-log-row {
display: block;
margin: 2px 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
}
.ip-log-actions {
display: flex;
gap: 12px;
margin-top: 5px;
font-size: 16px;
cursor: pointer;
}
.protocol-table {
margin-top: 10px;
}
.wg-table td {
word-break: break-all;
}
.link-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;
}
.link-panel-header {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.link-panel-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
white-space: pre-wrap;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
user-select: all;
}
body.dark .link-panel-text {
background: rgba(255, 255, 255, 0.05);
}
.link-panel-anchor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
word-break: break-all;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.04);
border-radius: 4px;
color: var(--ant-color-primary, #1677ff);
text-decoration: underline;
text-decoration-color: rgba(22, 119, 255, 0.4);
transition: background 120ms ease, text-decoration-color 120ms ease;
}
.link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.08);
text-decoration-color: var(--ant-color-primary, #1677ff);
}
body.dark .link-panel-anchor {
background: rgba(255, 255, 255, 0.05);
}
body.dark .link-panel-anchor:hover {
background: rgba(22, 119, 255, 0.16);
}

View file

@ -0,0 +1,889 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import {
HttpUtil,
IntlUtil,
SizeFormatter,
ColorUtils,
ClipboardManager,
FileManager,
} from '@/utils';
import { Protocols } from '@/models/inbound.js';
import InfinityIcon from '@/components/InfinityIcon';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { SubSettings } from './useInbounds';
import './InboundInfoModal.css';
interface ClientStats {
email: string;
up: number;
down: number;
total: number;
expiryTime: number;
enable?: boolean;
}
interface ClientSetting {
email?: string;
id?: string;
security?: string;
password?: string;
flow?: string;
subId?: string;
totalGB?: number;
expiryTime?: number;
comment?: string;
tgId?: string;
enable?: boolean;
limitIp?: number;
created_at?: number;
updated_at?: number;
}
interface InboundLike {
protocol: string;
clients?: ClientSetting[];
settings?: Record<string, unknown>;
serverName?: string;
isTcp?: boolean;
isWs?: boolean;
isHttpupgrade?: boolean;
isXHTTP?: boolean;
isGrpc?: boolean;
isSSMultiUser?: boolean;
isSS2022?: boolean;
host?: string;
path?: string;
serviceName?: string;
stream?: {
network?: string;
security?: string;
xhttp?: { mode?: string };
grpc?: { multiMode?: boolean };
};
canEnableTlsFlow?: () => boolean;
genWireguardConfigs: (remark: string, model: string, host: string) => string;
genWireguardLinks: (remark: string, model: string, host: string) => string;
genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[];
}
interface DBInboundLike {
id: number;
address: string;
port: number;
protocol: string;
remark: string;
enable?: boolean;
isVMess?: boolean;
isVLess?: boolean;
isTrojan?: boolean;
isSS?: boolean;
isMixed?: boolean;
isHTTP?: boolean;
isWireguard?: boolean;
clientStats?: ClientStats[];
hasLink: () => boolean;
toInbound: () => InboundLike;
}
interface InboundInfoModalProps {
open: boolean;
onClose: () => void;
dbInbound: DBInboundLike | null;
clientIndex?: number;
remarkModel?: string;
expireDiff?: number;
trafficDiff?: number;
ipLimitEnable?: boolean;
tgBotEnable?: boolean;
nodeAddress?: string;
subSettings?: SubSettings;
lastOnlineMap?: Record<string, number>;
}
function copyText(value: unknown, t: (k: string) => string) {
ClipboardManager.copyText(String(value ?? '')).then((ok) => {
if (ok) message.success(t('copied'));
});
}
function downloadText(content: string, filename: string) {
FileManager.downloadTextFile(content, filename);
}
function statsColor(stats: ClientStats, trafficDiff: number) {
return ColorUtils.usageColor(stats.up + stats.down, trafficDiff, stats.total);
}
function formatIpInfo(record: unknown) {
if (record == null) return '';
if (typeof record === 'string' || typeof record === 'number') return String(record);
const r = record as { ip?: string; IP?: string; timestamp?: number | string; Timestamp?: number | string };
const ip = r.ip || r.IP || '';
const ts = r.timestamp || r.Timestamp || 0;
if (!ip) return String(record);
if (!ts) return String(ip);
const date = new Date(Number(ts) * 1000);
const timeStr = date
.toLocaleString('en-GB', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
})
.replace(',', '');
return `${ip} (${timeStr})`;
}
export default function InboundInfoModal({
open,
onClose,
dbInbound,
clientIndex = 0,
remarkModel = '-ieo',
expireDiff = 0,
trafficDiff = 0,
ipLimitEnable = false,
tgBotEnable = false,
nodeAddress = '',
subSettings,
lastOnlineMap = {},
}: InboundInfoModalProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const [inbound, setInbound] = useState<InboundLike | null>(null);
const [clientSettings, setClientSettings] = useState<ClientSetting | null>(null);
const [clientStats, setClientStats] = useState<ClientStats | null>(null);
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [clientIpsArray, setClientIpsArray] = useState<string[]>([]);
const [clientIpsText, setClientIpsText] = useState('');
const [activeTab, setActiveTab] = useState('client');
const loadClientIps = useCallback(async () => {
if (!clientStats?.email) return;
setRefreshing(true);
try {
const msg = await HttpUtil.post(`/panel/api/clients/ips/${clientStats.email}`);
if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record');
setClientIpsArray([]);
return;
}
let ips: unknown = msg.obj;
if (typeof ips === 'string') {
try {
ips = JSON.parse(ips);
} catch {
setClientIpsText(String(ips));
setClientIpsArray([String(ips)]);
return;
}
}
if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips];
if (Array.isArray(ips) && ips.length > 0) {
const arr = (ips as unknown[]).map(formatIpInfo).filter(Boolean) as string[];
setClientIpsArray(arr);
setClientIpsText(arr.join(' | '));
} else {
setClientIpsArray([]);
setClientIpsText(String(ips || t('tgbot.noIpRecord')));
}
} finally {
setRefreshing(false);
}
}, [clientStats, t]);
const clearClientIps = useCallback(async () => {
if (!clientStats?.email) return;
const msg = await HttpUtil.post(`/panel/api/clients/clearIps/${clientStats.email}`);
if (msg?.success) {
setClientIpsArray([]);
setClientIpsText(t('tgbot.noIpRecord'));
}
}, [clientStats, t]);
useEffect(() => {
if (!open || !dbInbound) return;
const parsed = dbInbound.toInbound();
setInbound(parsed);
setActiveTab((parsed.clients?.length ?? 0) > 0 ? 'client' : 'inbound');
const idx = clientIndex ?? 0;
const clientSet = (parsed.clients?.length ?? 0) > 0 ? (parsed.clients?.[idx] || null) : null;
setClientSettings(clientSet);
const stats = clientSet
? (dbInbound.clientStats || []).find((s) => s.email === clientSet.email) || null
: null;
setClientStats(stats);
if (parsed.protocol === Protocols.WIREGUARD) {
setWireguardConfigs(parsed.genWireguardConfigs(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
setWireguardLinks(parsed.genWireguardLinks(dbInbound.remark, '-ieo', nodeAddress).split('\r\n'));
setLinks([]);
} else {
setLinks(parsed.genAllLinks(dbInbound.remark, remarkModel, clientSet, nodeAddress));
setWireguardConfigs([]);
setWireguardLinks([]);
}
if (clientSet?.subId) {
setSubLink((subSettings?.subURI || '') + clientSet.subId);
setSubJsonLink(
subSettings?.subJsonEnable ? (subSettings?.subJsonURI || '') + clientSet.subId : '',
);
} else {
setSubLink('');
setSubJsonLink('');
}
setClientIpsArray([]);
setClientIpsText('');
if (ipLimitEnable && (clientSet?.limitIp ?? 0) > 0 && stats?.email) {
void HttpUtil.post(`/panel/api/clients/ips/${stats.email}`).then((msg) => {
if (!msg?.success) {
setClientIpsText((msg?.obj as string) || 'No IP record');
return;
}
let ips: unknown = msg.obj;
if (typeof ips === 'string') {
try {
ips = JSON.parse(ips);
} catch {
setClientIpsText(String(ips));
setClientIpsArray([String(ips)]);
return;
}
}
if (ips && !Array.isArray(ips) && typeof ips === 'object') ips = [ips];
if (Array.isArray(ips) && ips.length > 0) {
const arr = (ips as unknown[]).map(formatIpInfo).filter(Boolean) as string[];
setClientIpsArray(arr);
setClientIpsText(arr.join(' | '));
} else {
setClientIpsText(String(ips || t('tgbot.noIpRecord')));
}
});
}
}, [open, dbInbound, clientIndex, remarkModel, nodeAddress, subSettings, ipLimitEnable, t]);
const isEnable = useMemo(() => {
if (clientSettings) return !!clientSettings.enable;
return dbInbound?.enable ?? true;
}, [clientSettings, dbInbound]);
const isDepleted = useMemo(() => {
if (!clientStats || !clientSettings) return false;
const total = clientStats.total ?? 0;
const used = (clientStats.up ?? 0) + (clientStats.down ?? 0);
if (total > 0 && used >= total) return true;
const expiry = clientSettings.expiryTime ?? 0;
if (expiry > 0 && Date.now() >= expiry) return true;
return false;
}, [clientStats, clientSettings]);
const remainingStats = useMemo(() => {
if (!clientStats || !clientSettings) return '-';
const remained = clientStats.total - clientStats.up - clientStats.down;
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
}, [clientStats, clientSettings]);
const formatLastOnline = useCallback(
(email: string) => {
const ts = lastOnlineMap[email];
if (!ts) return '-';
return IntlUtil.formatDate(ts, datepicker);
},
[lastOnlineMap, datepicker],
);
const networkLabel = inbound?.stream?.network || '';
const securityLabel = inbound?.stream?.security || 'none';
const securityColor = securityLabel === 'none' ? 'red' : 'green';
const encryptionLabel = (inbound?.settings?.encryption as string) || '';
const serverNameLabel = inbound?.serverName || '';
const showClientTab = !!clientSettings;
const showSubscriptionTab = !!(subSettings?.enable && clientSettings?.subId);
if (!dbInbound || !inbound) {
return (
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} />
);
}
const clientTab = (
<>
<table className="info-table block">
<tbody>
<tr>
<td>{t('pages.inbounds.email')}</td>
<td>
{clientSettings?.email ? (
<Tag color="green">{clientSettings.email}</Tag>
) : (
<Tag color="red">{t('none')}</Tag>
)}
</td>
</tr>
{clientSettings?.id && (
<tr><td>ID</td><td><Tag>{clientSettings.id}</Tag></td></tr>
)}
{dbInbound.isVMess && (
<tr><td>{t('security')}</td><td><Tag>{clientSettings?.security}</Tag></td></tr>
)}
{inbound.canEnableTlsFlow?.() && (
<tr>
<td>Flow</td>
<td>
{clientSettings?.flow ? <Tag>{clientSettings.flow}</Tag> : <Tag color="orange">{t('none')}</Tag>}
</td>
</tr>
)}
{clientSettings?.password && (
<tr>
<td>{t('password')}</td>
<td><Tag className="info-large-tag">{clientSettings.password}</Tag></td>
</tr>
)}
<tr>
<td>{t('status')}</td>
<td>
{isDepleted ? (
<Tag color="red">{t('depleted')}</Tag>
) : isEnable ? (
<Tag color="green">{t('enabled')}</Tag>
) : (
<Tag>{t('disabled')}</Tag>
)}
</td>
</tr>
{clientStats && (
<tr>
<td>{t('usage')}</td>
<td>
<Tag color="green">{SizeFormatter.sizeFormat(clientStats.up + clientStats.down)}</Tag>
<Tag>
{SizeFormatter.sizeFormat(clientStats.up)} /
{' '}{SizeFormatter.sizeFormat(clientStats.down)}
</Tag>
</td>
</tr>
)}
<tr>
<td>{t('pages.inbounds.createdAt')}</td>
<td>
{clientSettings?.created_at ? (
<Tag>{IntlUtil.formatDate(clientSettings.created_at, datepicker)}</Tag>
) : <Tag>-</Tag>}
</td>
</tr>
<tr>
<td>{t('pages.inbounds.updatedAt')}</td>
<td>
{clientSettings?.updated_at ? (
<Tag>{IntlUtil.formatDate(clientSettings.updated_at, datepicker)}</Tag>
) : <Tag>-</Tag>}
</td>
</tr>
<tr>
<td>{t('lastOnline')}</td>
<td><Tag>{formatLastOnline(clientSettings?.email || '')}</Tag></td>
</tr>
{clientSettings?.comment && (
<tr><td>{t('comment')}</td><td><Tag className="info-large-tag">{clientSettings.comment}</Tag></td></tr>
)}
{ipLimitEnable && (
<tr><td>{t('pages.inbounds.IPLimit')}</td><td><Tag>{clientSettings?.limitIp ?? 0}</Tag></td></tr>
)}
{ipLimitEnable && (clientSettings?.limitIp ?? 0) > 0 && (
<tr>
<td>{t('pages.inbounds.IPLimitlog')}</td>
<td>
<div className="ip-log">
{clientIpsArray.length > 0 ? (
<div>
{clientIpsArray.map((item, idx) => (
<Tag color="blue" className="ip-log-row" key={idx}>{item}</Tag>
))}
</div>
) : (
<Tag>{clientIpsText || t('tgbot.noIpRecord')}</Tag>
)}
</div>
<div className="ip-log-actions">
<SyncOutlined spin={refreshing} onClick={() => loadClientIps()} />
<Tooltip title={t('pages.inbounds.IPLimitlogclear')}>
<DeleteOutlined onClick={() => clearClientIps()} />
</Tooltip>
</div>
</td>
</tr>
)}
</tbody>
</table>
<table className="info-table summary-table">
<thead>
<tr>
<th>{t('remained')}</th>
<th>{t('pages.inbounds.totalUsage')}</th>
<th>{t('pages.inbounds.expireDate')}</th>
</tr>
</thead>
<tbody>
<tr>
<td>
{clientStats && (clientSettings?.totalGB ?? 0) > 0 ? (
<Tag color={statsColor(clientStats, trafficDiff)}>{remainingStats}</Tag>
) : !clientSettings?.totalGB || clientSettings.totalGB <= 0 ? (
<Tag color="purple"><InfinityIcon /></Tag>
) : null}
</td>
<td>
{(clientSettings?.totalGB ?? 0) > 0 ? (
<Tag color={clientStats ? statsColor(clientStats, trafficDiff) : 'default'}>
{SizeFormatter.sizeFormat(clientSettings!.totalGB!)}
</Tag>
) : (
<Tag color="purple"><InfinityIcon /></Tag>
)}
</td>
<td>
{(clientSettings?.expiryTime ?? 0) > 0 ? (
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, clientSettings!.expiryTime!)}>
{IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)}
</Tag>
) : (clientSettings?.expiryTime ?? 0) < 0 ? (
<Tag color="green">{clientSettings!.expiryTime! / -86400000} {t('day')}</Tag>
) : (
<Tag color="purple"><InfinityIcon /></Tag>
)}
</td>
</tr>
</tbody>
</table>
{tgBotEnable && clientSettings?.tgId && (
<>
<Divider>Telegram</Divider>
<div className="tg-row">
<Tag color="blue">{clientSettings.tgId}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(clientSettings.tgId, t)} />
</Tooltip>
</div>
</>
)}
{dbInbound.hasLink() && links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => (
<div key={idx} className="link-panel">
<div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
</Tooltip>
</div>
<code className="link-panel-text">{link.link}</code>
</div>
))}
</>
)}
{showSubscriptionTab && (
<>
<Divider>{t('subscription.title')}</Divider>
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">{t('subscription.title')}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subLink, t)} />
</Tooltip>
</div>
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
</div>
{subSettings?.subJsonEnable && subJsonLink && (
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">JSON</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subJsonLink, t)} />
</Tooltip>
</div>
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
</div>
)}
</>
)}
</>
);
const inboundTab = (
<>
<dl className="info-list">
<div className="info-row">
<dt>{t('pages.inbounds.protocol')}</dt>
<dd><Tag color="purple">{dbInbound.protocol}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.address')}</dt>
<dd><Tag className="value-tag">{dbInbound.address}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.port')}</dt>
<dd><Tag>{dbInbound.port}</Tag></dd>
</div>
{(dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS) && (
<>
<div className="info-row">
<dt>{t('transmission')}</dt>
<dd><Tag color="green">{networkLabel}</Tag></dd>
</div>
{(inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP) && (
<>
<div className="info-row">
<dt>{t('host')}</dt>
<dd>{inbound.host ? <Tag className="value-tag">{inbound.host}</Tag> : <Tag color="orange">{t('none')}</Tag>}</dd>
</div>
<div className="info-row">
<dt>{t('path')}</dt>
<dd>{inbound.path ? <Tag className="value-tag">{inbound.path}</Tag> : <Tag color="orange">{t('none')}</Tag>}</dd>
</div>
</>
)}
{inbound.isXHTTP && (
<div className="info-row">
<dt>Mode</dt>
<dd><Tag>{inbound.stream?.xhttp?.mode}</Tag></dd>
</div>
)}
{inbound.isGrpc && (
<>
<div className="info-row">
<dt>grpc serviceName</dt>
<dd><Tag className="value-tag">{inbound.serviceName}</Tag></dd>
</div>
<div className="info-row">
<dt>grpc multiMode</dt>
<dd><Tag>{String(inbound.stream?.grpc?.multiMode)}</Tag></dd>
</div>
</>
)}
</>
)}
{dbInbound.hasLink() && (
<>
<div className="info-row">
<dt>{t('security')}</dt>
<dd><Tag color={securityColor}>{securityLabel}</Tag></dd>
</div>
{encryptionLabel && (
<div className="info-row">
<dt>{t('encryption')}</dt>
<dd className="value-block">
<code className="value-code">{encryptionLabel}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(encryptionLabel, t)} />
</Tooltip>
</dd>
</div>
)}
{securityLabel !== 'none' && (
<div className="info-row">
<dt>{t('domainName')}</dt>
<dd>
{serverNameLabel ? (
<Tag color="green" className="value-tag">{serverNameLabel}</Tag>
) : (
<Tag color="orange">{t('none')}</Tag>
)}
</dd>
</div>
)}
</>
)}
</dl>
{dbInbound.isSS && inbound.settings && (
<table className="info-table block">
<tbody>
<tr>
<td>{t('encryption')}</td>
<td><Tag color="green">{inbound.settings.method as string}</Tag></td>
</tr>
{inbound.isSS2022 && (
<tr>
<td>{t('password')}</td>
<td><Tag className="info-large-tag">{inbound.settings.password as string}</Tag></td>
</tr>
)}
<tr>
<td>{t('pages.inbounds.network')}</td>
<td><Tag color="green">{inbound.settings.network as string}</Tag></td>
</tr>
</tbody>
</table>
)}
{inbound.protocol === Protocols.TUN && inbound.settings && (
<dl className="info-list info-list-block">
<div className="info-row">
<dt>Interface name</dt>
<dd><Tag color="green" className="value-tag">{inbound.settings.name as string}</Tag></dd>
</div>
<div className="info-row">
<dt>MTU</dt>
<dd><Tag color="green">{inbound.settings.mtu as number}</Tag></dd>
</div>
{Array.isArray(inbound.settings.gateway) && (inbound.settings.gateway as string[]).length > 0 && (
<div className="info-row">
<dt>Gateway</dt>
<dd>
{(inbound.settings.gateway as string[]).map((ip, j) => (
<Tag key={`tun-gw-${j}`} color="green" className="value-tag">{ip}</Tag>
))}
</dd>
</div>
)}
{Array.isArray(inbound.settings.dns) && (inbound.settings.dns as string[]).length > 0 && (
<div className="info-row">
<dt>DNS</dt>
<dd>
{(inbound.settings.dns as string[]).map((ip, j) => (
<Tag key={`tun-dns-${j}`} color="green">{ip}</Tag>
))}
</dd>
</div>
)}
<div className="info-row">
<dt>Outbounds interface</dt>
<dd><Tag color="green">{(inbound.settings.autoOutboundsInterface as string) || 'auto'}</Tag></dd>
</div>
{Array.isArray(inbound.settings.autoSystemRoutingTable) && (inbound.settings.autoSystemRoutingTable as string[]).length > 0 && (
<div className="info-row">
<dt>Auto system routes</dt>
<dd>
{(inbound.settings.autoSystemRoutingTable as string[]).map((cidr, j) => (
<Tag key={`tun-rt-${j}`} color="green">{cidr}</Tag>
))}
</dd>
</div>
)}
</dl>
)}
{inbound.protocol === Protocols.TUNNEL && inbound.settings && (
<dl className="info-list info-list-block">
<div className="info-row">
<dt>{t('pages.inbounds.targetAddress')}</dt>
<dd><Tag color="green" className="value-tag">{inbound.settings.rewriteAddress as string}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.destinationPort')}</dt>
<dd><Tag color="green">{inbound.settings.rewritePort as number}</Tag></dd>
</div>
<div className="info-row">
<dt>{t('pages.inbounds.network')}</dt>
<dd><Tag color="green">{inbound.settings.allowedNetwork as string}</Tag></dd>
</div>
<div className="info-row">
<dt>FollowRedirect</dt>
<dd>
<Tag color={inbound.settings.followRedirect ? 'green' : 'red'}>
{inbound.settings.followRedirect ? t('enabled') : t('disabled')}
</Tag>
</dd>
</div>
</dl>
)}
{dbInbound.isMixed && inbound.settings && (
<dl className="info-list info-list-block">
<div className="info-row">
<dt>Auth</dt>
<dd>
<Tag color={inbound.settings.auth === 'password' ? 'green' : 'orange'}>
{inbound.settings.auth as string}
</Tag>
</dd>
</div>
<div className="info-row">
<dt>UDP</dt>
<dd>
<Tag color={inbound.settings.udp ? 'green' : 'red'}>
{inbound.settings.udp ? t('enabled') : t('disabled')}
</Tag>
</dd>
</div>
{(inbound.settings.ip as string) && (
<div className="info-row">
<dt>IP</dt>
<dd><Tag className="value-tag">{inbound.settings.ip as string}</Tag></dd>
</div>
)}
{inbound.settings.auth === 'password' && Array.isArray(inbound.settings.accounts) && (
<>
{(inbound.settings.accounts as { user: string; pass: string }[]).map((account, idx) => (
<div key={idx} className="info-row">
<dt>{t('username')} #{idx + 1}</dt>
<dd className="account-row">
<Tag color="green" className="value-tag">{account.user}</Tag>
<span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
</Tooltip>
<Space size={4} wrap className="share-buttons">
<Tooltip title={`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
<Button size="small" onClick={() => copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>SOCKS5</Button>
</Tooltip>
<Tooltip title={`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`}>
<Button size="small" onClick={() => copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`, t)}>HTTP</Button>
</Tooltip>
<Tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
<Button size="small" onClick={() => copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`, t)}>Telegram</Button>
</Tooltip>
</Space>
</dd>
</div>
))}
</>
)}
{inbound.settings.auth === 'noauth' && (
<div className="info-row">
<dt>{t('copy')}</dt>
<dd>
<Space size={4} wrap className="share-buttons">
<Tooltip title={`socks5://${dbInbound.address}:${dbInbound.port}`}>
<Button size="small" onClick={() => copyText(`socks5://${dbInbound.address}:${dbInbound.port}`, t)}>SOCKS5</Button>
</Tooltip>
<Tooltip title={`http://${dbInbound.address}:${dbInbound.port}`}>
<Button size="small" onClick={() => copyText(`http://${dbInbound.address}:${dbInbound.port}`, t)}>HTTP</Button>
</Tooltip>
<Tooltip title="https://t.me/socks?server=...&port=...">
<Button size="small" onClick={() => copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}`, t)}>Telegram</Button>
</Tooltip>
</Space>
</dd>
</div>
)}
</dl>
)}
{dbInbound.isHTTP && Array.isArray(inbound.settings?.accounts) && (inbound.settings!.accounts as unknown[]).length > 0 && (
<dl className="info-list info-list-block">
{(inbound.settings!.accounts as { user: string; pass: string }[]).map((account, idx) => (
<div key={idx} className="info-row">
<dt>{t('username')} #{idx + 1}</dt>
<dd className="account-row">
<Tag color="green" className="value-tag">{account.user}</Tag>
<span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
</Tooltip>
</dd>
</div>
))}
</dl>
)}
{dbInbound.isWireguard && inbound.settings && (
<table className="info-table protocol-table wg-table">
<tbody>
<tr><td>Secret key</td><td>{inbound.settings.secretKey as string}</td></tr>
<tr><td>Public key</td><td>{inbound.settings.pubKey as string}</td></tr>
<tr><td>MTU</td><td>{inbound.settings.mtu as number}</td></tr>
<tr><td>No-kernel TUN</td><td>{String(inbound.settings.noKernelTun)}</td></tr>
{Array.isArray(inbound.settings.peers) && (inbound.settings.peers as { privateKey: string; publicKey: string; psk: string; allowedIPs?: string[]; keepAlive?: number }[]).map((peer, idx) => (
<>
<tr key={`p-h-${idx}`}>
<td colSpan={2}><Divider>Peer {idx + 1}</Divider></td>
</tr>
<tr key={`p-sk-${idx}`}><td>Secret key</td><td>{peer.privateKey}</td></tr>
<tr key={`p-pk-${idx}`}><td>Public key</td><td>{peer.publicKey}</td></tr>
<tr key={`p-psk-${idx}`}><td>PSK</td><td>{peer.psk}</td></tr>
<tr key={`p-ai-${idx}`}><td>Allowed IPs</td><td>{(peer.allowedIPs || []).join(',')}</td></tr>
<tr key={`p-ka-${idx}`}><td>Keep alive</td><td>{peer.keepAlive}</td></tr>
{wireguardConfigs[idx] && (
<tr key={`p-conf-${idx}`}>
<td colSpan={2}>
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">Peer {idx + 1} config</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardConfigs[idx], t)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
</Tooltip>
</div>
<code className="link-panel-text">{wireguardConfigs[idx]}</code>
</div>
</td>
</tr>
)}
{wireguardLinks[idx] && (
<tr key={`p-link-${idx}`}>
<td colSpan={2}>
<div className="link-panel">
<div className="link-panel-header">
<Tag color="green">Peer {idx + 1} link</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardLinks[idx], t)} />
</Tooltip>
</div>
<code className="link-panel-text">{wireguardLinks[idx]}</code>
</div>
</td>
</tr>
)}
</>
))}
</tbody>
</table>
)}
{dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && (
<>
<Divider>{t('pages.inbounds.copyLink')}</Divider>
{links.map((link, idx) => (
<div key={idx} className="link-panel">
<div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
</Tooltip>
</div>
<code className="link-panel-text">{link.link}</code>
</div>
))}
</>
)}
</>
);
const tabItems = [];
if (showClientTab) {
tabItems.push({ key: 'client', label: t('pages.inbounds.client'), children: clientTab });
}
tabItems.push({ key: 'inbound', label: t('pages.xray.rules.inbound'), children: inboundTab });
return (
<Modal open={open} onCancel={onClose} title={t('pages.inbounds.inboundData')} footer={null} width={640} destroyOnClose>
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
</Modal>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,163 @@
.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;
}
.client-email-list {
max-height: 200px;
overflow-y: auto;
min-width: 150px;
}
.client-email-list > div {
padding: 2px 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.ant-table {
border-radius: 8px;
overflow: hidden;
}
.ant-table-container {
border-radius: 8px;
overflow: hidden;
}
.ant-table-thead > tr:first-child > *:first-child {
border-start-start-radius: 8px;
}
.ant-table-thead > tr:first-child > *:last-child {
border-start-end-radius: 8px;
}
.ant-table-tbody > tr:last-child > *:first-child {
border-end-start-radius: 8px;
}
.ant-table-tbody > tr:last-child > *:last-child {
border-end-end-radius: 8px;
}
.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;
}
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-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 .ant-tag {
margin: 0;
}
.card-empty {
text-align: center;
opacity: 0.4;
padding: 20px 0;
}
@media (max-width: 768px) {
.ant-card-head {
padding: 0 12px;
min-height: 44px;
}
.ant-card-head-title,
.ant-card-extra {
padding: 8px 0;
}
.ant-card-body {
padding: 8px;
}
.row-action-trigger {
font-size: 22px;
padding: 4px;
}
}

View file

@ -0,0 +1,647 @@
import { useCallback, useMemo, useState, type ReactElement } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Dropdown,
Modal,
Popover,
Space,
Switch,
Table,
Tag,
Tooltip,
type TableColumnType,
type MenuProps,
} from 'antd';
import {
PlusOutlined,
MenuOutlined,
MoreOutlined,
EditOutlined,
QrcodeOutlined,
CopyOutlined,
ExportOutlined,
ImportOutlined,
ReloadOutlined,
RetweetOutlined,
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import InfinityIcon from '@/components/InfinityIcon';
import { useDatepicker } from '@/hooks/useDatepicker';
import type { NodeRecord } from '@/hooks/useNodes';
import './InboundList.css';
type ProtocolFlags = {
isVMess?: boolean;
isVLess?: boolean;
isTrojan?: boolean;
isSS?: boolean;
isHysteria?: boolean;
isMixed?: boolean;
isHTTP?: boolean;
isWireguard?: boolean;
};
interface DBInboundRecord extends ProtocolFlags {
id: number;
enable: boolean;
remark: string;
port: number;
protocol: string;
up: number;
down: number;
total: number;
expiryTime: number;
_expiryTime: unknown;
nodeId?: number | null;
toInbound: () => {
stream?: { network?: string; isTls?: boolean; isReality?: boolean };
isSSMultiUser?: boolean;
};
isMultiUser: () => boolean;
}
export interface ClientCountEntry {
clients: number;
active: string[];
deactive: string[];
depleted: string[];
expiring: string[];
online: string[];
}
export type RowAction =
| 'edit'
| 'showInfo'
| 'qrcode'
| 'export'
| 'subs'
| 'clipboard'
| 'delete'
| 'resetTraffic'
| 'clone';
export type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
interface InboundListProps {
dbInbounds: DBInboundRecord[];
clientCount: Record<number, ClientCountEntry>;
onlineClients: string[];
lastOnlineMap: Record<string, number>;
expireDiff: number;
trafficDiff: number;
pageSize: number;
isMobile: boolean;
subEnable: boolean;
nodesById: Map<number, NodeRecord>;
hasActiveNode: boolean;
onAddInbound: () => void;
onGeneralAction: (key: GeneralAction) => void;
onRowAction: (action: { key: RowAction; dbInbound: DBInboundRecord }) => void;
}
type SortKey =
| 'id'
| 'enable'
| 'remark'
| 'port'
| 'protocol'
| 'traffic'
| 'expiryTime'
| 'node'
| 'clients';
type SortOrder = 'ascend' | 'descend' | null;
const SORT_FNS: Record<SortKey, (a: DBInboundRecord, b: DBInboundRecord, ctx: { nodesById: Map<number, NodeRecord>; clientCount: Record<number, ClientCountEntry> }) => number> = {
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),
expiryTime: (a, b) => (a.expiryTime || Infinity) - (b.expiryTime || Infinity),
node: (a, b, ctx) => {
const nameA = ctx.nodesById.get(a.nodeId ?? -1)?.name ?? (a.nodeId == null ? '￿' : `node #${a.nodeId}`);
const nameB = ctx.nodesById.get(b.nodeId ?? -1)?.name ?? (b.nodeId == null ? '￿' : `node #${b.nodeId}`);
return nameA.localeCompare(nameB);
},
clients: (a, b, ctx) => (ctx.clientCount[a.id]?.clients || 0) - (ctx.clientCount[b.id]?.clients || 0),
};
function showQrCodeMenu(dbInbound: DBInboundRecord): boolean {
if (dbInbound.isWireguard) return true;
if (dbInbound.isSS) {
try {
return !dbInbound.toInbound().isSSMultiUser;
} catch {
return false;
}
}
return false;
}
interface RowActionsMenuProps {
record: DBInboundRecord;
subEnable: boolean;
onClick: (key: RowAction) => void;
isMobile?: boolean;
}
function buildRowActionsMenu({ record, subEnable, t, isMobile }: { record: DBInboundRecord; subEnable: boolean; t: (k: string) => string; isMobile?: boolean }): MenuProps['items'] {
const items: MenuProps['items'] = [];
if (isMobile) {
items.push({ key: 'edit', icon: <EditOutlined />, label: t('edit') });
}
if (showQrCodeMenu(record)) {
items.push({ key: 'qrcode', icon: <QrcodeOutlined />, label: t('qrCode') });
}
if (record.isMultiUser()) {
items.push({ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') });
if (subEnable) {
items.push({
key: 'subs',
icon: <ExportOutlined />,
label: `${t('pages.inbounds.export')}${t('pages.settings.subSettings')}`,
});
}
} else {
items.push({ key: 'showInfo', icon: <InfoCircleOutlined />, label: t('info') });
}
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });
return items;
}
function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) {
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
items: buildRowActionsMenu({ record, subEnable, t }),
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
);
}
export default function InboundList({
dbInbounds,
clientCount,
lastOnlineMap: _lastOnlineMap,
expireDiff,
trafficDiff,
pageSize,
isMobile,
subEnable,
nodesById,
hasActiveNode,
onAddInbound,
onGeneralAction,
onRowAction,
}: InboundListProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
const previous = dbInbound.enable;
dbInbound.enable = next;
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 {
dbInbound.enable = previous;
}
}, []);
const sortedInbounds = useMemo(() => {
if (!sortKey || !sortOrder) return dbInbounds;
const fn = SORT_FNS[sortKey];
if (!fn) return dbInbounds;
const sorted = [...dbInbounds].sort((a, b) => fn(a, b, { nodesById, clientCount }));
return sortOrder === 'descend' ? sorted.reverse() : sorted;
}, [dbInbounds, sortKey, sortOrder, nodesById, clientCount]);
const hasAnyRemark = useMemo(
() => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
[dbInbounds],
);
const sorterFor = (key: SortKey) => ({
sorter: true as const,
showSorterTooltip: false,
sortOrder: sortKey === key ? sortOrder : null,
sortDirections: ['ascend' as const, 'descend' as const],
});
const columns: TableColumnType<DBInboundRecord>[] = useMemo(() => {
const cols: TableColumnType<DBInboundRecord>[] = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
align: 'right',
width: 30,
...sorterFor('id'),
},
{
title: t('pages.inbounds.operate'),
key: 'action',
align: 'center',
width: 60,
render: (_, record) => (
<RowActionsCell
record={record}
subEnable={subEnable}
onClick={(key) => onRowAction({ key, dbInbound: record })}
/>
),
},
{
title: t('pages.inbounds.enable'),
key: 'enable',
align: 'center',
width: 35,
...sorterFor('enable'),
render: (_, record) => (
<Switch
checked={record.enable}
onChange={(next) => onSwitchEnable(record, next)}
/>
),
},
];
if (hasAnyRemark) {
cols.push({
title: t('pages.inbounds.remark'),
dataIndex: 'remark',
key: 'remark',
align: 'center',
width: 60,
...sorterFor('remark'),
});
}
if (hasActiveNode) {
cols.push({
title: t('pages.inbounds.node'),
key: 'node',
align: 'center',
width: 60,
...sorterFor('node'),
render: (_, record) => {
if (record.nodeId == null) {
return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
}
const node = nodesById.get(record.nodeId);
if (!node) {
return <Tag color="orange">node #{record.nodeId}</Tag>;
}
return (
<Tag color={node.status === 'online' ? 'blue' : 'red'}>{node.name}</Tag>
);
},
});
}
cols.push(
{
title: t('pages.inbounds.port'),
dataIndex: 'port',
key: 'port',
align: 'center',
width: 40,
...sorterFor('port'),
},
{
title: t('pages.inbounds.protocol'),
key: 'protocol',
align: 'left',
width: 130,
...sorterFor('protocol'),
render: (_, record) => {
const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) {
const stream = record.toInbound().stream;
tags.push(
<Tag key="n" color="green">
{record.isHysteria ? 'UDP' : stream?.network}
</Tag>,
);
if (stream?.isTls) tags.push(<Tag key="tls" color="blue">TLS</Tag>);
if (stream?.isReality) tags.push(<Tag key="reality" color="blue">Reality</Tag>);
}
return <div className="protocol-tags">{tags}</div>;
},
},
{
title: t('clients'),
key: 'clients',
align: 'left',
width: 50,
...sorterFor('clients'),
render: (_, record) => {
const cc = clientCount[record.id];
if (!cc) return null;
return (
<>
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>
{cc.clients}
</Tag>
{cc.deactive.length > 0 && (
<Popover
title={t('disabled')}
content={(
<div className="client-email-list">
{cc.deactive.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
</Popover>
)}
{cc.depleted.length > 0 && (
<Popover
title={t('depleted')}
content={(
<div className="client-email-list">
{cc.depleted.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
</Popover>
)}
{cc.expiring.length > 0 && (
<Popover
title={t('depletingSoon')}
content={(
<div className="client-email-list">
{cc.expiring.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="orange" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.expiring.length}</Tag>
</Popover>
)}
{cc.online.length > 0 && (
<Popover
title={t('online')}
content={(
<div className="client-email-list">
{cc.online.map((e) => <div key={e}>{e}</div>)}
</div>
)}
>
<Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.online.length}</Tag>
</Popover>
)}
</>
);
},
},
{
title: t('pages.inbounds.traffic'),
key: 'traffic',
align: 'center',
width: 90,
...sorterFor('traffic'),
render: (_, record) => (
<Popover
content={(
<table cellPadding={2}>
<tbody>
<tr>
<td> {SizeFormatter.sizeFormat(record.up)}</td>
<td> {SizeFormatter.sizeFormat(record.down)}</td>
</tr>
{record.total > 0 && record.up + record.down < record.total && (
<tr>
<td>{t('remained')}</td>
<td>{SizeFormatter.sizeFormat(record.total - record.up - record.down)}</td>
</tr>
)}
</tbody>
</table>
)}
>
<Tag color={ColorUtils.usageColor(record.up + record.down, trafficDiff, record.total)}>
{SizeFormatter.sizeFormat(record.up + record.down)} /
{' '}
{record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
</Tag>
</Popover>
),
},
{
title: t('pages.inbounds.expireDate'),
key: 'expiryTime',
align: 'center',
width: 40,
...sorterFor('expiryTime'),
render: (_, record) => {
if (record.expiryTime > 0) {
return (
<Popover content={IntlUtil.formatDate(record.expiryTime, datepicker)}>
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)} style={{ minWidth: 50 }}>
{IntlUtil.formatRelativeTime(record.expiryTime)}
</Tag>
</Popover>
);
}
return <Tag color="purple"><InfinityIcon /></Tag>;
},
},
);
return cols;
}, [t, hasAnyRemark, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
const paginationFor = (rows: DBInboundRecord[]) => {
const size = pageSize > 0 ? pageSize : rows.length || 1;
return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
};
const generalActionsMenu: MenuProps = {
items: [
{ key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
{ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
...(subEnable
? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')}${t('pages.settings.subSettings')}` }]
: []),
{ key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
],
onClick: ({ key }) => onGeneralAction(key as GeneralAction),
};
return (
<Card
hoverable
title={(
<Space direction="horizontal">
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
{!isMobile && t('pages.inbounds.addInbound')}
</Button>
<Dropdown trigger={['click']} menu={generalActionsMenu}>
<Button type="primary" icon={<MenuOutlined />}>
{!isMobile && t('pages.inbounds.generalActions')}
</Button>
</Dropdown>
</Space>
)}
>
<Space direction="vertical" style={{ width: '100%' }}>
{isMobile ? (
<div className="inbound-cards">
{sortedInbounds.length === 0 ? (
<div className="card-empty"></div>
) : (
sortedInbounds.map((record) => (
<div key={record.id} className="inbound-card">
<div className="card-head">
<span className="card-id">#{record.id}</span>
<span className="tag-name">{record.remark}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<Tooltip title={t('info')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
</Tooltip>
<Switch
checked={record.enable}
size="small"
onChange={(next) => onSwitchEnable(record, next)}
/>
<Dropdown
trigger={['click']}
placement="bottomRight"
menu={{
items: buildRowActionsMenu({ record, subEnable, t, isMobile: true }),
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
}}
>
<MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
</Dropdown>
</div>
</div>
</div>
))
)}
</div>
) : (
<Table
columns={columns}
dataSource={sortedInbounds}
rowKey={(r) => r.id}
pagination={paginationFor(sortedInbounds)}
scroll={{ x: 1000 }}
style={{ marginTop: 10 }}
size="small"
onChange={(_p, _f, sorter) => {
const single = Array.isArray(sorter) ? sorter[0] : sorter;
const colKey = (single?.columnKey || single?.field) as SortKey | undefined;
setSortKey(colKey || null);
setSortOrder((single?.order as SortOrder) || null);
}}
/>
)}
</Space>
<Modal
open={isMobile && !!statsRecord}
footer={null}
width={360}
centered
title={statsRecord ? `#${statsRecord.id} ${statsRecord.remark || ''}`.trim() : ''}
onCancel={() => setStatsRecord(null)}
destroyOnClose
>
{statsRecord && (
<div className="card-stats">
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.protocol')}</span>
<Tag color="purple">{statsRecord.protocol}</Tag>
{(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && (
<>
<Tag color="green">
{statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream?.network}
</Tag>
{statsRecord.toInbound().stream?.isTls && <Tag color="blue">TLS</Tag>}
{statsRecord.toInbound().stream?.isReality && <Tag color="blue">Reality</Tag>}
</>
)}
</div>
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.port')}</span>
<Tag>{statsRecord.port}</Tag>
</div>
{hasActiveNode && (
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.node')}</span>
{statsRecord.nodeId == null ? (
<Tag color="default">{t('pages.inbounds.localPanel')}</Tag>
) : nodesById.get(statsRecord.nodeId) ? (
<Tag color={nodesById.get(statsRecord.nodeId)!.status === 'online' ? 'blue' : 'red'}>
{nodesById.get(statsRecord.nodeId)!.name}
</Tag>
) : (
<Tag color="orange">#{statsRecord.nodeId}</Tag>
)}
</div>
)}
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.traffic')}</span>
<Tag color={ColorUtils.usageColor(statsRecord.up + statsRecord.down, trafficDiff, statsRecord.total)}>
{SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} /
{' '}
{statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : <InfinityIcon />}
</Tag>
</div>
{clientCount[statsRecord.id] && (
<div className="stat-row">
<span className="stat-label">{t('clients')}</span>
<Tag color="green" className="client-count-tag">{clientCount[statsRecord.id].clients}</Tag>
{clientCount[statsRecord.id].online.length > 0 && (
<Tag color="blue">{clientCount[statsRecord.id].online.length} {t('online')}</Tag>
)}
{clientCount[statsRecord.id].depleted.length > 0 && (
<Tag color="red">{clientCount[statsRecord.id].depleted.length} {t('depleted')}</Tag>
)}
{clientCount[statsRecord.id].expiring.length > 0 && (
<Tag color="orange">{clientCount[statsRecord.id].expiring.length} {t('depletingSoon')}</Tag>
)}
</div>
)}
<div className="stat-row">
<span className="stat-label">{t('pages.inbounds.expireDate')}</span>
{statsRecord.expiryTime > 0 ? (
<Tag color={ColorUtils.usageColor(Date.now(), expireDiff, statsRecord._expiryTime)}>
{IntlUtil.formatRelativeTime(statsRecord.expiryTime)}
</Tag>
) : (
<Tag color="purple"><InfinityIcon /></Tag>
)}
</div>
</div>
)}
</Modal>
</Card>
);
}

View file

@ -1,680 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MenuOutlined,
MoreOutlined,
EditOutlined,
QrcodeOutlined,
CopyOutlined,
ExportOutlined,
ImportOutlined,
ReloadOutlined,
RetweetOutlined,
BlockOutlined,
DeleteOutlined,
InfoCircleOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, SizeFormatter, IntlUtil, ColorUtils } from '@/utils';
import InfinityIcon from '@/components/InfinityIcon.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',
]);
// ============ 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),
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 props.dbInbounds;
const fn = sortFns[column];
if (!fn) return props.dbInbounds;
const sorted = [...props.dbInbounds].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,
};
}
// ============ 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.expireDate'), key: 'expiryTime', align: 'center', width: 40 }, 'expiryTime'),
);
return cols;
});
const columns = computed(() => desktopColumns.value);
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>
</template>
</a-dropdown>
</a-space>
</template>
<a-space direction="vertical" :style="{ width: '100%' }">
<!-- ====================== Mobile: card list ======================= -->
<div v-if="isMobile" class="inbound-cards">
<div v-if="sortedInbounds.length === 0" class="card-empty"></div>
<div v-for="record in sortedInbounds" :key="record.id" class="inbound-card">
<!-- Header: id + remark + info + enable + actions -->
<div class="card-head">
<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="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>
</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>
</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 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"
@change="onTableChange">
<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="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>
</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>
<!-- ============== 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>
.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;
}
/* 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-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-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;
}
.row-action-trigger {
font-size: 22px;
padding: 4px;
}
}
</style>

View file

@ -0,0 +1,50 @@
.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 .ant-layout,
.inbounds-page .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;
}
}

View file

@ -0,0 +1,547 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Card,
Col,
ConfigProvider,
Layout,
Modal,
Row,
Spin,
message,
} from 'antd';
import {
SwapOutlined,
PieChartOutlined,
BarsOutlined,
} from '@ant-design/icons';
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
import { Inbound } from '@/models/inbound.js';
import { coerceInboundJsonField } from '@/models/dbinbound.js';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useWebSocket } from '@/hooks/useWebSocket';
import { useNodes } from '@/hooks/useNodes';
import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
import TextModal from '@/components/TextModal';
import PromptModal from '@/components/PromptModal';
import { useInbounds } from './useInbounds';
import InboundList from './InboundList';
import InboundFormModal from './InboundFormModal';
import InboundInfoModal from './InboundInfoModal';
import QrCodeModal from './QrCodeModal';
import './InboundsPage.css';
type RowAction =
| 'edit'
| 'showInfo'
| 'qrcode'
| 'export'
| 'subs'
| 'clipboard'
| 'delete'
| 'resetTraffic'
| 'clone';
type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
export default function InboundsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const {
fetched,
dbInbounds,
clientCount,
onlineClients,
lastOnlineMap,
totals,
expireDiff,
trafficDiff,
pageSize,
subSettings,
tgBotEnable,
ipLimitEnable,
remarkModel,
refresh,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
} = useInbounds();
const { nodes: nodesList } = useNodes();
const nodesById = useMemo(() => {
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
for (const n of nodesList || []) map.set(n.id, n);
return map;
}, [nodesList]);
const hasActiveNode = useMemo(
() => (nodesList || []).some((n) => n.enable && n.status === 'online'),
[nodesList],
);
const hasNodeAttachedInbound = useMemo(
() => (dbInbounds || []).some((ib: any) => ib?.nodeId != null),
[dbInbounds],
);
const showNodeInfo = hasNodeAttachedInbound || hasActiveNode;
useWebSocket({
traffic: applyTrafficEvent,
client_stats: applyClientStatsEvent,
invalidate: applyInvalidate,
inbounds: applyInboundsEvent,
});
useEffect(() => {
fetchDefaultSettings().then(() => refresh());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [formOpen, setFormOpen] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
const [formDbInbound, setFormDbInbound] = useState<any>(null);
const [infoOpen, setInfoOpen] = useState(false);
const [infoDbInbound, setInfoDbInbound] = useState<any>(null);
const [infoClientIndex, setInfoClientIndex] = useState(0);
const [qrOpen, setQrOpen] = useState(false);
const [qrDbInbound, setQrDbInbound] = useState<any>(null);
const [textOpen, setTextOpen] = useState(false);
const [textTitle, setTextTitle] = useState('');
const [textContent, setTextContent] = useState('');
const [textFileName, setTextFileName] = useState('');
const [promptOpen, setPromptOpen] = useState(false);
const [promptTitle, setPromptTitle] = useState('');
const [promptOkText, setPromptOkText] = useState('OK');
const [promptType, setPromptType] = useState<'textarea' | 'input'>('textarea');
const [promptInitial, setPromptInitial] = useState('');
const [promptLoading, setPromptLoading] = useState(false);
const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
const hostOverrideFor = useCallback((dbInbound: any) => {
if (!dbInbound || dbInbound.nodeId == null) return '';
return nodesById.get(dbInbound.nodeId)?.address || '';
}, [nodesById]);
const infoNodeAddress = useMemo(() => hostOverrideFor(infoDbInbound), [infoDbInbound, hostOverrideFor]);
const qrNodeAddress = useMemo(() => hostOverrideFor(qrDbInbound), [qrDbInbound, hostOverrideFor]);
const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
setTextTitle(opts.title);
setTextContent(opts.content);
setTextFileName(opts.fileName || '');
setTextOpen(true);
}, []);
const openPrompt = useCallback((opts: {
title: string;
okText?: string;
type?: 'textarea' | 'input';
value?: string;
confirm: (value: string) => Promise<boolean | void> | boolean | void;
}) => {
setPromptTitle(opts.title);
setPromptOkText(opts.okText || 'OK');
setPromptType(opts.type || 'textarea');
setPromptInitial(opts.value || '');
setPromptHandler(() => opts.confirm);
setPromptOpen(true);
}, []);
const onPromptConfirm = useCallback(async (value: string) => {
if (!promptHandler) {
setPromptOpen(false);
return;
}
setPromptLoading(true);
try {
const ok = await promptHandler(value);
if (ok !== false) setPromptOpen(false);
} finally {
setPromptLoading(false);
}
}, [promptHandler]);
const projectChildThroughMaster = useCallback((child: any, master: any) => {
const projected = JSON.parse(JSON.stringify(child));
projected.listen = master.listen;
projected.port = master.port;
const masterStream = master.toInbound().stream;
const childInbound = child.toInbound();
childInbound.stream.security = masterStream.security;
childInbound.stream.tls = masterStream.tls;
childInbound.stream.reality = masterStream.reality;
childInbound.stream.externalProxy = masterStream.externalProxy;
projected.streamSettings = childInbound.stream.toString();
return new child.constructor(projected);
}, []);
const checkFallback = useCallback((dbInbound: any) => {
const parent = dbInbound?.fallbackParent;
if (parent?.masterId) {
const master = (dbInbounds as any[]).find((ib: any) => ib.id === parent.masterId);
if (master) return projectChildThroughMaster(dbInbound, master);
}
if (!(dbInbound?.listen as string | undefined)?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds as any[]) {
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: { dest?: string }) => f.dest === dbInbound.listen)) continue;
return projectChildThroughMaster(dbInbound, candidate);
}
return dbInbound;
}, [dbInbounds, projectChildThroughMaster]);
const findClientIndex = useCallback((dbInbound: any, client: any) => {
if (!client) return 0;
const inbound = dbInbound.toInbound();
const clients = inbound?.clients || [];
const idx = clients.findIndex((c: any) => {
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;
}, []);
const exportInboundLinks = useCallback((dbInbound: any) => {
const projected = checkFallback(dbInbound);
openText({
title: 'Export inbound links',
content: projected.genInboundLinks(remarkModel, hostOverrideFor(dbInbound)),
fileName: projected.remark || 'inbound',
});
}, [checkFallback, remarkModel, hostOverrideFor, openText]);
const exportInboundClipboard = useCallback((dbInbound: any) => {
openText({ title: 'Inbound JSON', content: JSON.stringify(dbInbound, null, 2) });
}, [openText]);
const exportInboundSubs = useCallback((dbInbound: any) => {
const inbound = dbInbound.toInbound();
const clients = inbound?.clients || [];
const subLinks: string[] = [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
subLinks.push(subSettings.subURI + c.subId);
}
}
openText({
title: 'Export subscription links',
content: [...new Set(subLinks)].join('\n'),
fileName: `${dbInbound.remark || 'inbound'}-Subs`,
});
}, [subSettings, openText]);
const exportAllLinks = useCallback(() => {
const out: string[] = [];
for (const ib of dbInbounds as any[]) {
const projected = checkFallback(ib);
out.push(projected.genInboundLinks(remarkModel, hostOverrideFor(ib)));
}
openText({ title: 'Export all inbound links', content: out.join('\r\n'), fileName: 'All-Inbounds' });
}, [dbInbounds, checkFallback, remarkModel, hostOverrideFor, openText]);
const exportAllSubs = useCallback(() => {
const out: string[] = [];
for (const ib of dbInbounds as any[]) {
const inbound = ib.toInbound();
const clients = inbound?.clients || [];
for (const c of clients) {
if (c.subId && subSettings.subURI) {
out.push(subSettings.subURI + c.subId);
}
}
}
openText({ title: 'Export all subscription links', content: [...new Set(out)].join('\r\n'), fileName: 'All-Inbounds-Subs' });
}, [dbInbounds, subSettings, openText]);
const importInbound = useCallback(() => {
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;
},
});
}, [openPrompt, refresh]);
const onAddInbound = useCallback(() => {
setFormMode('add');
setFormDbInbound(null);
setFormOpen(true);
}, []);
const openEdit = useCallback((dbInbound: any) => {
setFormMode('edit');
setFormDbInbound(dbInbound);
setFormOpen(true);
}, []);
const confirmDelete = useCallback((dbInbound: any) => {
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();
},
});
}, [refresh]);
const confirmResetTraffic = useCallback((dbInbound: any) => {
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();
},
});
}, [refresh]);
const confirmClone = useCallback((dbInbound: any) => {
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();
let clonedSettings: string;
try {
const raw = coerceInboundJsonField(dbInbound.settings);
raw.clients = [];
clonedSettings = JSON.stringify(raw);
} catch {
clonedSettings = (Inbound as any).Settings.getSettings(baseInbound.protocol).toString();
}
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: clonedSettings,
streamSettings: baseInbound.stream.toString(),
sniffing: baseInbound.sniffing.toString(),
};
const msg = await HttpUtil.post('/panel/api/inbounds/add', data);
if (msg?.success) await refresh();
},
});
}, [refresh]);
const onGeneralAction = useCallback((key: GeneralAction) => {
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;
default:
message.info(`General action "${key}" — coming in a later 5f subphase`);
}
}, [importInbound, exportAllLinks, exportAllSubs, refresh]);
const onRowAction = useCallback(({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
switch (key) {
case 'edit':
openEdit(dbInbound);
break;
case 'showInfo':
setInfoDbInbound(checkFallback(dbInbound));
setInfoClientIndex(findClientIndex(dbInbound, null));
setInfoOpen(true);
break;
case 'qrcode':
setQrDbInbound(checkFallback(dbInbound));
setQrOpen(true);
break;
case 'export':
exportInboundLinks(dbInbound);
break;
case 'subs':
exportInboundSubs(dbInbound);
break;
case 'clipboard':
exportInboundClipboard(dbInbound);
break;
case 'delete':
confirmDelete(dbInbound);
break;
case 'resetTraffic':
confirmResetTraffic(dbInbound);
break;
case 'clone':
confirmClone(dbInbound);
break;
default:
message.info(`Action "${key}" — coming in a later 5f subphase`);
}
}, [openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone]);
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
<Layout className="content-shell">
<Layout.Content id="content-layout" className="content-area">
<Spin spinning={!fetched} delay={200} tip="Loading…" size="large">
{!fetched ? (
<div className="loading-spacer" />
) : (
<Row gutter={[isMobile ? 8 : 16, 12]}>
<Col span={24}>
<Card size="small" hoverable className="summary-card">
<Row gutter={[16, 12]}>
<Col xs={12} sm={12} md={8}>
<CustomStatistic
title={t('pages.inbounds.totalDownUp')}
value={`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`}
prefix={<SwapOutlined />}
/>
</Col>
<Col xs={12} sm={12} md={8}>
<CustomStatistic
title={t('pages.inbounds.totalUsage')}
value={SizeFormatter.sizeFormat(totals.up + totals.down)}
prefix={<PieChartOutlined />}
/>
</Col>
<Col xs={24} sm={24} md={8}>
<CustomStatistic
title={t('pages.inbounds.inboundCount')}
value={String(dbInbounds.length)}
prefix={<BarsOutlined />}
/>
</Col>
</Row>
</Card>
</Col>
<Col span={24}>
<InboundList
dbInbounds={dbInbounds as any}
clientCount={clientCount}
onlineClients={onlineClients}
lastOnlineMap={lastOnlineMap}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
pageSize={pageSize}
isMobile={isMobile}
subEnable={subSettings.enable}
nodesById={nodesById}
hasActiveNode={showNodeInfo}
onAddInbound={onAddInbound}
onGeneralAction={onGeneralAction}
onRowAction={onRowAction}
/>
</Col>
</Row>
)}
</Spin>
</Layout.Content>
</Layout>
<InboundFormModal
open={formOpen}
onClose={() => setFormOpen(false)}
onSaved={refresh}
mode={formMode}
dbInbound={formDbInbound}
dbInbounds={dbInbounds as any[]}
/>
<InboundInfoModal
open={infoOpen}
onClose={() => setInfoOpen(false)}
dbInbound={infoDbInbound}
clientIndex={infoClientIndex}
remarkModel={remarkModel}
expireDiff={expireDiff}
trafficDiff={trafficDiff}
ipLimitEnable={ipLimitEnable}
tgBotEnable={tgBotEnable}
subSettings={subSettings}
lastOnlineMap={lastOnlineMap}
nodeAddress={infoNodeAddress}
/>
<QrCodeModal
open={qrOpen}
onClose={() => setQrOpen(false)}
dbInbound={qrDbInbound}
client={null}
remarkModel={remarkModel}
nodeAddress={qrNodeAddress}
subSettings={subSettings}
/>
<TextModal
open={textOpen}
onClose={() => setTextOpen(false)}
title={textTitle}
content={textContent}
fileName={textFileName}
/>
<PromptModal
open={promptOpen}
onClose={() => setPromptOpen(false)}
title={promptTitle}
okText={promptOkText}
type={promptType}
initialValue={promptInitial}
loading={promptLoading}
onConfirm={onPromptConfirm}
/>
</Layout>
</ConfigProvider>
);
}

View file

@ -1,559 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
SwapOutlined,
PieChartOutlined,
BarsOutlined,
} from '@ant-design/icons-vue';
import { HttpUtil, SizeFormatter, RandomUtil } from '@/utils';
import { Inbound } from '@/models/inbound.js';
import { coerceInboundJsonField } from '@/models/dbinbound.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 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();
const { byId: nodesById, hasActive: hasActiveNode } = useNodeList();
const hasNodeAttachedInbound = computed(() =>
(dbInbounds.value || []).some((ib) => ib?.nodeId != null),
);
const showNodeInfo = computed(() => hasNodeAttachedInbound.value || hasActiveNode.value);
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);
// === 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) {
const projected = checkFallback(ib);
out.push(projected.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) {
// Path 1: panel-tracked fallback relationship (inbound_fallbacks row).
// The backend annotates each child inbound with fallbackParent so the
// child's client-share link advertises the master's reachable endpoint
// and inherits its TLS / Reality state.
const parent = dbInbound.fallbackParent;
if (parent?.masterId) {
const master = dbInbounds.value.find((ib) => ib.id === parent.masterId);
if (master) return projectChildThroughMaster(dbInbound, master);
}
// Path 2: legacy unix-socket convention (`@vless-ws` etc.) walk the
// VLESS/Trojan TCP inbounds and look for one whose settings.fallbacks
// references this child's listen address.
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;
return projectChildThroughMaster(dbInbound, candidate);
}
return dbInbound;
}
// projectChildThroughMaster returns a one-off DBInbound copy whose
// listen/port + TLS/Reality state come from the master, while the
// protocol/transport/clients stay the child's. This is what makes a
// `vless://uuid@server:443?type=ws&path=/vlws&security=tls` link work
// for a child VLESS-WS bound to 127.0.0.1.
function projectChildThroughMaster(child, master) {
const projected = JSON.parse(JSON.stringify(child));
projected.listen = master.listen;
projected.port = master.port;
const masterStream = master.toInbound().stream;
const childInbound = child.toInbound();
childInbound.stream.security = masterStream.security;
childInbound.stream.tls = masterStream.tls;
childInbound.stream.reality = masterStream.reality;
childInbound.stream.externalProxy = masterStream.externalProxy;
projected.streamSettings = childInbound.stream.toString();
return new child.constructor(projected);
}
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 onAddInbound() {
formMode.value = 'add';
formDbInbound.value = null;
formOpen.value = true;
}
function openEdit(dbInbound) {
formMode.value = 'edit';
formDbInbound.value = dbInbound;
formOpen.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();
},
});
}
// 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();
let clonedSettings;
try {
const raw = coerceInboundJsonField(dbInbound.settings);
raw.clients = [];
clonedSettings = JSON.stringify(raw);
} catch (_e) {
clonedSettings = Inbound.Settings.getSettings(baseInbound.protocol).toString();
}
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: clonedSettings,
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;
default:
message.info(`General action "${key}" — coming in a later 5f subphase`);
}
}
function onRowAction({ key, dbInbound }) {
switch (key) {
case 'edit':
openEdit(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 'delete':
confirmDelete(dbInbound);
break;
case 'resetTraffic':
confirmResetTraffic(dbInbound);
break;
case 'clone':
confirmClone(dbInbound);
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="8">
<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="8">
<CustomStatistic :title="t('pages.inbounds.totalUsage')"
:value="SizeFormatter.sizeFormat(totals.up + totals.down)">
<template #prefix>
<PieChartOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :xs="24" :sm="24" :md="8">
<CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
<template #prefix>
<BarsOutlined />
</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="showNodeInfo"
:stats-version="statsVersion" @refresh="refresh" @add-inbound="onAddInbound"
@general-action="onGeneralAction" @row-action="onRowAction" />
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<InboundFormModal v-model:open="formOpen" :mode="formMode" :db-inbound="formDbInbound" :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>

View file

@ -0,0 +1,139 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapse, Modal } from 'antd';
import { Protocols } from '@/models/inbound.js';
import QrPanel from './QrPanel';
import type { SubSettings } from './useInbounds';
interface ClientSetting {
email?: string;
subId?: string;
[k: string]: unknown;
}
interface DBInboundLike {
remark?: string;
toInbound: () => InboundLike;
}
interface InboundLike {
protocol: string;
genWireguardConfigs: (remark: string, model: string, host: string) => string;
genWireguardLinks: (remark: string, model: string, host: string) => string;
genAllLinks: (remark: string, model: string, client: ClientSetting | null, host: string) => { remark?: string; link: string }[];
}
interface QrCodeModalProps {
open: boolean;
onClose: () => void;
dbInbound: DBInboundLike | null;
client?: ClientSetting | null;
remarkModel?: string;
nodeAddress?: string;
subSettings?: SubSettings;
}
interface QrItem {
key: string;
header: string;
value: string;
downloadName?: string;
}
export default function QrCodeModal({
open,
onClose,
dbInbound,
client = null,
remarkModel = '-ieo',
nodeAddress = '',
subSettings,
}: QrCodeModalProps) {
const { t } = useTranslation();
const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]);
const [wireguardConfigs, setWireguardConfigs] = useState<string[]>([]);
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState('');
const [activeKeys, setActiveKeys] = useState<string[]>([]);
useEffect(() => {
if (!open || !dbInbound) return;
const inbound = dbInbound.toInbound();
if (inbound.protocol === Protocols.WIREGUARD) {
const peerRemark = client?.email
? `${dbInbound.remark}-${client.email}`
: dbInbound.remark || '';
setWireguardConfigs(inbound.genWireguardConfigs(peerRemark, '-ieo', nodeAddress).split('\r\n'));
setWireguardLinks(inbound.genWireguardLinks(peerRemark, '-ieo', nodeAddress).split('\r\n'));
setLinks([]);
} else {
setLinks(inbound.genAllLinks(dbInbound.remark || '', remarkModel, client, nodeAddress) as { remark?: string; link: string }[]);
setWireguardConfigs([]);
setWireguardLinks([]);
}
const subId = client?.subId;
let nextSub = '';
let nextSubJson = '';
if (subSettings?.enable && subId) {
nextSub = (subSettings.subURI || '') + subId;
nextSubJson = subSettings.subJsonEnable ? (subSettings.subJsonURI || '') + subId : '';
}
setSubLink(nextSub);
setSubJsonLink(nextSubJson);
setActiveKeys(nextSub ? ['sub'] : []);
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
const qrItems = useMemo<QrItem[]>(() => {
const items: QrItem[] = [];
if (subLink) {
items.push({ key: 'sub', header: t('subscription.title'), value: subLink });
}
if (subJsonLink) {
items.push({ key: 'sub-json', header: `${t('subscription.title')} (JSON)`, value: subJsonLink });
}
links.forEach((link, idx) => {
items.push({ key: `l${idx}`, header: link.remark || `Link ${idx + 1}`, value: link.link });
});
wireguardConfigs.forEach((cfg, idx) => {
items.push({
key: `wc${idx}`,
header: `Peer ${idx + 1} config`,
value: cfg,
downloadName: `peer-${idx + 1}.conf`,
});
if (wireguardLinks[idx]) {
items.push({ key: `wl${idx}`, header: `Peer ${idx + 1} link`, value: wireguardLinks[idx] });
}
});
return items;
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
const collapseItems = qrItems.map((item) => ({
key: item.key,
label: item.header,
children: (
<QrPanel
value={item.value}
remark={item.header}
downloadName={item.downloadName || ''}
showQr={!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')}
/>
),
}));
return (
<Modal open={open} onCancel={onClose} title={t('qrCode')} footer={null} width={420} destroyOnClose>
{dbInbound && (
<Collapse
ghost
activeKey={activeKeys}
onChange={(keys) => setActiveKeys(Array.isArray(keys) ? keys : [keys])}
items={collapseItems}
/>
)}
</Modal>
);
}

View file

@ -1,126 +0,0 @@
<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 || ''"
:show-qr="!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')" />
</a-collapse-panel>
</a-collapse>
</template>
</a-modal>
</template>
<style scoped>
.qr-collapse :deep(.ant-collapse-content-box) {
padding: 8px 0 0;
}
</style>

View file

@ -1,157 +0,0 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { CopyOutlined, DownloadOutlined, PictureOutlined } 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 },
});
const qrRef = ref(null);
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);
}
function svgToPngBlob(size = 360) {
const svgEl = qrRef.value?.querySelector('svg');
if (!svgEl) return Promise.resolve(null);
const svgData = new XMLSerializer().serializeToString(svgEl);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
canvas.toBlob(resolve, 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
async function copyImage() {
const blob = await svgToPngBlob(props.size);
if (!blob) return;
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
message.success(t('copied'));
} catch {
downloadImageBlob(blob);
}
}
function downloadImageBlob(blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${props.remark || 'qrcode'}.png`;
link.click();
URL.revokeObjectURL(url);
}
async function downloadImage() {
const blob = await svgToPngBlob(props.size);
if (blob) downloadImageBlob(blob);
}
</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="showQr" :title="t('downloadImage', 'Download Image')">
<a-button size="small" @click="downloadImage">
<template #icon>
<PictureOutlined />
</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" ref="qrRef" class="qr-panel-canvas">
<a-tooltip :title="t('copy')">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false" color="#000000"
bg-color="#ffffff" @click="copyImage" />
</a-tooltip>
</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>

View file

@ -1,329 +0,0 @@
// 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 allClients = inbound?.clients || [];
const statsEmails = new Set();
for (const s of clientStats) {
if (s && s.email) statsEmails.add(s.email);
}
const clients = clientStats.length > 0
? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
: allClients;
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/clients/onlines');
if (msg?.success) onlineClients.value = msg.obj || [];
}
async function fetchLastOnlineMap() {
const msg = await HttpUtil.post('/panel/api/clients/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.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.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);
}
}
const totals = computed(() => {
let up = 0;
let down = 0;
for (const ib of dbInbounds.value) {
up += ib.up || 0;
down += ib.down || 0;
}
return { up, down };
});
// 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,
};
}

View file

@ -0,0 +1,349 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { HttpUtil } from '@/utils';
import { DBInbound } from '@/models/dbinbound.js';
import { Protocols } from '@/models/inbound.js';
import { setDatepicker } from '@/hooks/useDatepicker';
export interface SubSettings {
enable: boolean;
subTitle: string;
subURI: string;
subJsonURI: string;
subJsonEnable: boolean;
}
type DBInboundInstance = InstanceType<typeof DBInbound>;
interface ClientRollup {
clients: number;
active: string[];
deactive: string[];
depleted: string[];
expiring: string[];
online: string[];
comments: Map<string, string>;
}
const TRACKED_PROTOCOLS = [
Protocols.VMESS,
Protocols.VLESS,
Protocols.TROJAN,
Protocols.SHADOWSOCKS,
Protocols.HYSTERIA,
];
export function useInbounds() {
const [fetched, setFetched] = useState(false);
const refreshingRef = useRef(false);
const [dbInbounds, setDbInbounds] = useState<DBInboundInstance[]>([]);
const dbInboundsRef = useRef<DBInboundInstance[]>([]);
dbInboundsRef.current = dbInbounds;
const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
const [onlineClients, setOnlineClients] = useState<string[]>([]);
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
const [statsVersion, setStatsVersion] = useState(0);
const [expireDiff, setExpireDiff] = useState(0);
const expireDiffRef = useRef(0);
expireDiffRef.current = expireDiff;
const [trafficDiff, setTrafficDiff] = useState(0);
const trafficDiffRef = useRef(0);
trafficDiffRef.current = trafficDiff;
const [subSettings, setSubSettings] = useState<SubSettings>({
enable: false,
subTitle: '',
subURI: '',
subJsonURI: '',
subJsonEnable: false,
});
const [remarkModel, setRemarkModel] = useState('-ieo');
const [datepicker, setDatepickerState] = useState('gregorian');
const [tgBotEnable, setTgBotEnable] = useState(false);
const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [pageSize, setPageSize] = useState(0);
const rollupClients = useCallback(
(dbInbound: DBInboundInstance, inbound: { clients?: { email?: string; enable?: boolean; comment?: string }[] }): ClientRollup => {
const clientStats = Array.isArray((dbInbound as { clientStats?: unknown }).clientStats)
? (dbInbound as unknown as { clientStats: { email: string; total: number; up: number; down: number; expiryTime: number }[] }).clientStats
: [];
const allClients = inbound?.clients || [];
const statsEmails = new Set<string>();
for (const s of clientStats) {
if (s && s.email) statsEmails.add(s.email);
}
const clients = clientStats.length > 0
? allClients.filter((c) => c && c.email && statsEmails.has(c.email))
: allClients;
const active: string[] = [];
const deactive: string[] = [];
const depleted: string[] = [];
const expiring: string[] = [];
const online: string[] = [];
const comments = new Map<string, string>();
const now = Date.now();
if (dbInbound.enable) {
for (const client of clients) {
if (client.comment && client.email) comments.set(client.email, client.comment);
if (client.enable) {
if (client.email) active.push(client.email);
if (client.email && onlineClientsRef.current.includes(client.email)) online.push(client.email);
} else if (client.email) {
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 < expireDiffRef.current) ||
(stats.total > 0 && stats.total - (stats.up + stats.down) < trafficDiffRef.current);
if (expiringSoon) expiring.push(stats.email);
}
}
} else {
for (const client of clients) {
if (client.email) deactive.push(client.email);
}
}
return {
clients: clients.length,
active,
deactive,
depleted,
expiring,
online,
comments,
};
},
[],
);
const setInbounds = useCallback(
(rows: unknown[]) => {
const next: DBInboundInstance[] = [];
const counts: Record<number, ClientRollup> = {};
for (const row of rows as { protocol: string; id: number }[]) {
const dbInbound = new DBInbound(row) as DBInboundInstance;
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean } }).toInbound();
next.push(dbInbound);
if (TRACKED_PROTOCOLS.includes(row.protocol)) {
if ((dbInbound as unknown as { isSS: boolean }).isSS && !parsed.isSSMultiUser) continue;
counts[row.id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
}
}
dbInboundsRef.current = next;
setDbInbounds(next);
setClientCount(counts);
setFetched(true);
},
[rollupClients],
);
const rebuildClientCount = useCallback(() => {
const counts: Record<number, ClientRollup> = {};
for (const dbInbound of dbInboundsRef.current) {
const parsed = (dbInbound as unknown as { toInbound: () => { clients?: unknown[]; isSSMultiUser?: boolean }; isSS: boolean; protocol: string }).toInbound();
const protocol = (dbInbound as unknown as { protocol: string }).protocol;
if (!TRACKED_PROTOCOLS.includes(protocol)) continue;
const isSS = (dbInbound as unknown as { isSS: boolean }).isSS;
if (isSS && !parsed.isSSMultiUser) continue;
counts[(dbInbound as unknown as { id: number }).id] = rollupClients(dbInbound, parsed as { clients?: { email?: string; enable?: boolean; comment?: string }[] });
}
setClientCount(counts);
}, [rollupClients]);
const fetchOnlineUsers = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/onlines');
if (msg?.success) {
const list = (msg.obj || []) as string[];
onlineClientsRef.current = list;
setOnlineClients(list);
}
}, []);
const fetchLastOnlineMap = useCallback(async () => {
const msg = await HttpUtil.post('/panel/api/clients/lastOnline');
if (msg?.success && msg.obj) {
setLastOnlineMap(msg.obj as Record<string, number>);
}
}, []);
const fetchDefaultSettings = useCallback(async () => {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
if (!msg?.success) return;
const s = (msg.obj || {}) as Record<string, unknown>;
setExpireDiff((s.expireDiff as number ?? 0) * 86400000);
setTrafficDiff((s.trafficDiff as number ?? 0) * 1073741824);
setTgBotEnable(!!s.tgBotEnable);
setSubSettings({
enable: !!s.subEnable,
subTitle: (s.subTitle as string) || '',
subURI: (s.subURI as string) || '',
subJsonURI: (s.subJsonURI as string) || '',
subJsonEnable: !!s.subJsonEnable,
});
setPageSize((s.pageSize as number) ?? 0);
setRemarkModel((s.remarkModel as string) || '-ieo');
const dp = ((s.datepicker as string) || 'gregorian') as 'gregorian' | 'jalalian';
setDatepickerState(dp);
setDatepicker(dp);
setIpLimitEnable(!!s.ipLimitEnable);
}, []);
const refresh = useCallback(async () => {
if (refreshingRef.current) return;
refreshingRef.current = 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 {
window.setTimeout(() => { refreshingRef.current = false; }, 500);
}
}, [fetchLastOnlineMap, fetchOnlineUsers, setInbounds]);
const applyTrafficEvent = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[]; lastOnlineMap?: Record<string, number> };
if (Array.isArray(p.onlineClients)) {
onlineClientsRef.current = p.onlineClients;
setOnlineClients(p.onlineClients);
}
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
}
rebuildClientCount();
},
[rebuildClientCount],
);
const applyClientStatsEvent = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as {
inbounds?: { id: number; up?: number; down?: number; total?: number; enable?: boolean }[];
clients?: { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }[];
};
let touched = false;
if (Array.isArray(p.inbounds) && p.inbounds.length > 0) {
const byId = new Map<number, { id: number; up?: number; down?: number; total?: number; enable?: boolean }>();
for (const row of p.inbounds) {
if (row && row.id != null) byId.set(row.id, row);
}
for (const ib of dbInboundsRef.current) {
const upd = byId.get((ib as unknown as { id: number }).id);
if (!upd) continue;
const ibRec = ib as unknown as { up: number; down: number; total: number; enable: boolean };
if (typeof upd.up === 'number') ibRec.up = upd.up;
if (typeof upd.down === 'number') ibRec.down = upd.down;
if (typeof upd.total === 'number') ibRec.total = upd.total;
if (typeof upd.enable === 'boolean') ibRec.enable = upd.enable;
touched = true;
}
}
if (Array.isArray(p.clients) && p.clients.length > 0) {
const byEmail = new Map<string, { email: string; up?: number; down?: number; total?: number; expiryTime?: number; enable?: boolean }>();
for (const row of p.clients) {
if (row && row.email) byEmail.set(row.email, row);
}
for (const ib of dbInboundsRef.current) {
const stats = (ib as unknown as { clientStats: { email: string; up: number; down: number; total: number; expiryTime: number; enable: boolean }[] }).clientStats;
if (!Array.isArray(stats)) continue;
for (let i = 0; i < stats.length; i++) {
const stat = stats[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.expiryTime === 'number') stat.expiryTime = upd.expiryTime;
if (typeof upd.enable === 'boolean') stat.enable = upd.enable;
touched = true;
}
}
}
if (touched) {
setStatsVersion((v) => v + 1);
setDbInbounds((prev) => {
const next = [...prev];
dbInboundsRef.current = next;
return next;
});
rebuildClientCount();
}
},
[rebuildClientCount],
);
const applyInvalidate = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { type?: string };
if (p.type === 'inbounds') {
refresh();
}
},
[refresh],
);
const applyInboundsEvent = useCallback(
(payload: unknown) => {
if (!Array.isArray(payload)) return;
setInbounds(payload);
},
[setInbounds],
);
const totals = useMemo(() => {
let up = 0;
let down = 0;
for (const ib of dbInbounds) {
const rec = ib as unknown as { up?: number; down?: number };
up += rec.up || 0;
down += rec.down || 0;
}
return { up, down };
}, [dbInbounds]);
return {
fetched,
dbInbounds,
clientCount,
onlineClients,
lastOnlineMap,
statsVersion,
totals,
expireDiff,
trafficDiff,
subSettings,
remarkModel,
datepicker,
tgBotEnable,
ipLimitEnable,
pageSize,
refresh,
fetchDefaultSettings,
applyTrafficEvent,
applyClientStatsEvent,
applyInvalidate,
applyInboundsEvent,
};
}

View file

@ -1,5 +1,5 @@
import axios from 'axios';
import { message as antMessage } from 'ant-design-vue';
import { message as antMessage } from 'antd';
export class Msg {
constructor(success = false, msg = "", obj = null) {

View file

@ -1,5 +1,4 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import react from '@vitejs/plugin-react';
import fs from 'node:fs';
import path from 'node:path';
@ -137,7 +136,7 @@ function makeBackendProxy(target) {
}
export default defineConfig({
plugins: [vue(), react(), injectBasePathPlugin()],
plugins: [react(), injectBasePathPlugin()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
@ -164,15 +163,8 @@ export default defineConfig({
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return undefined;
if (id.includes('ant-design-vue')) return 'vendor-antd';
if (id.includes('@ant-design/icons-vue')) return 'vendor-icons';
if (id.includes('vue-i18n')) return 'vendor-i18n';
if (
id.includes('/node_modules/vue/')
|| id.includes('/node_modules/@vue/')
) return 'vendor-vue';
if (id.includes('/node_modules/antd/')) return 'vendor-antd-react';
if (id.includes('/@ant-design/icons/')) return 'vendor-icons-react';
if (id.includes('/node_modules/antd/')) return 'vendor-antd';
if (id.includes('/@ant-design/icons/')) return 'vendor-icons';
if (
id.includes('/node_modules/react-i18next/')
|| id.includes('/node_modules/i18next/')
@ -184,12 +176,6 @@ export default defineConfig({
) return 'vendor-react';
if (id.includes('dayjs')) return 'vendor-dayjs';
if (id.includes('axios')) return 'vendor-axios';
if (
id.includes('vue3-persian-datetime-picker')
|| id.includes('moment-jalaali')
|| id.includes('jalaali-js')
|| id.includes('/node_modules/moment/')
) return 'vendor-jalali';
return 'vendor';
},
},