mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
23542e9e8d
commit
d6f42b3395
47 changed files with 5389 additions and 8673 deletions
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
695
frontend/package-lock.json
generated
695
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
19
frontend/src/components/InfinityIcon.tsx
Normal file
19
frontend/src/components/InfinityIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
82
frontend/src/components/PromptModal.tsx
Normal file
82
frontend/src/components/PromptModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
56
frontend/src/components/TextModal.tsx
Normal file
56
frontend/src/components/TextModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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';
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
28
frontend/src/entries/inbounds.tsx
Normal file
28
frontend/src/entries/inbounds.tsx
Normal 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>,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
87
frontend/src/pages/inbounds/InboundFormModal.css
Normal file
87
frontend/src/pages/inbounds/InboundFormModal.css
Normal 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);
|
||||
}
|
||||
2097
frontend/src/pages/inbounds/InboundFormModal.tsx
Normal file
2097
frontend/src/pages/inbounds/InboundFormModal.tsx
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
215
frontend/src/pages/inbounds/InboundInfoModal.css
Normal file
215
frontend/src/pages/inbounds/InboundInfoModal.css
Normal 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);
|
||||
}
|
||||
889
frontend/src/pages/inbounds/InboundInfoModal.tsx
Normal file
889
frontend/src/pages/inbounds/InboundInfoModal.tsx
Normal 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
163
frontend/src/pages/inbounds/InboundList.css
Normal file
163
frontend/src/pages/inbounds/InboundList.css
Normal 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;
|
||||
}
|
||||
}
|
||||
647
frontend/src/pages/inbounds/InboundList.tsx
Normal file
647
frontend/src/pages/inbounds/InboundList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
50
frontend/src/pages/inbounds/InboundsPage.css
Normal file
50
frontend/src/pages/inbounds/InboundsPage.css
Normal 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;
|
||||
}
|
||||
}
|
||||
547
frontend/src/pages/inbounds/InboundsPage.tsx
Normal file
547
frontend/src/pages/inbounds/InboundsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
139
frontend/src/pages/inbounds/QrCodeModal.tsx
Normal file
139
frontend/src/pages/inbounds/QrCodeModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
349
frontend/src/pages/inbounds/useInbounds.ts
Normal file
349
frontend/src/pages/inbounds/useInbounds.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue