diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 07da9e79..1b66c511 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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) => ({ diff --git a/frontend/inbounds.html b/frontend/inbounds.html index 9e8861fc..52bb49c0 100644 --- a/frontend/inbounds.html +++ b/frontend/inbounds.html @@ -8,6 +8,6 @@
- + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fcff10e3..0f436a95 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index b0ed2e27..70cae164 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue deleted file mode 100644 index 1fd4dfc9..00000000 --- a/frontend/src/components/AppSidebar.vue +++ /dev/null @@ -1,432 +0,0 @@ - - - - - - - diff --git a/frontend/src/components/CustomStatistic.vue b/frontend/src/components/CustomStatistic.vue deleted file mode 100644 index 9d59e37c..00000000 --- a/frontend/src/components/CustomStatistic.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - - - diff --git a/frontend/src/components/DateTimePicker.tsx b/frontend/src/components/DateTimePicker.tsx index 3692eeb5..b5ff13f2 100644 --- a/frontend/src/components/DateTimePicker.tsx +++ b/frontend/src/components/DateTimePicker.tsx @@ -1,10 +1,3 @@ -// React port of DateTimePicker.vue. For now this delegates to AntD's -// ; 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'; diff --git a/frontend/src/components/DateTimePicker.vue b/frontend/src/components/DateTimePicker.vue deleted file mode 100644 index 750d2594..00000000 --- a/frontend/src/components/DateTimePicker.vue +++ /dev/null @@ -1,366 +0,0 @@ - - - - - - - - diff --git a/frontend/src/components/FinalMaskForm.vue b/frontend/src/components/FinalMaskForm.vue deleted file mode 100644 index f4a53225..00000000 --- a/frontend/src/components/FinalMaskForm.vue +++ /dev/null @@ -1,510 +0,0 @@ - - - diff --git a/frontend/src/components/InfinityIcon.tsx b/frontend/src/components/InfinityIcon.tsx new file mode 100644 index 00000000..8a7038d8 --- /dev/null +++ b/frontend/src/components/InfinityIcon.tsx @@ -0,0 +1,19 @@ +interface InfinityIconProps { + width?: number | string; + height?: number | string; +} + +export default function InfinityIcon({ width = 14, height = 10 }: InfinityIconProps) { + return ( + + ); +} diff --git a/frontend/src/components/InfinityIcon.vue b/frontend/src/components/InfinityIcon.vue deleted file mode 100644 index effe0187..00000000 --- a/frontend/src/components/InfinityIcon.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/frontend/src/components/JsonEditor.vue b/frontend/src/components/JsonEditor.vue deleted file mode 100644 index 2092fe91..00000000 --- a/frontend/src/components/JsonEditor.vue +++ /dev/null @@ -1,185 +0,0 @@ - - - - - diff --git a/frontend/src/components/PromptModal.tsx b/frontend/src/components/PromptModal.tsx new file mode 100644 index 00000000..36d488ad --- /dev/null +++ b/frontend/src/components/PromptModal.tsx @@ -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(null); + const inputRef = useRef(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) { + 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 ( + onConfirm(value)} + onCancel={onClose} + destroyOnClose + > + {type === 'textarea' ? ( + { 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} + /> + ) : ( + setValue(e.target.value)} + onKeyDown={onKeydown} + /> + )} + + ); +} diff --git a/frontend/src/components/PromptModal.vue b/frontend/src/components/PromptModal.vue deleted file mode 100644 index a2d52024..00000000 --- a/frontend/src/components/PromptModal.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/frontend/src/components/SettingListItem.vue b/frontend/src/components/SettingListItem.vue deleted file mode 100644 index a8a1e8f5..00000000 --- a/frontend/src/components/SettingListItem.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/frontend/src/components/Sparkline.vue b/frontend/src/components/Sparkline.vue deleted file mode 100644 index 679e92f7..00000000 --- a/frontend/src/components/Sparkline.vue +++ /dev/null @@ -1,297 +0,0 @@ - - - - - - - - diff --git a/frontend/src/components/TableSortable.vue b/frontend/src/components/TableSortable.vue deleted file mode 100644 index 2b3a39a1..00000000 --- a/frontend/src/components/TableSortable.vue +++ /dev/null @@ -1,311 +0,0 @@ - - - diff --git a/frontend/src/components/TextModal.tsx b/frontend/src/components/TextModal.tsx new file mode 100644 index 00000000..50752a52 --- /dev/null +++ b/frontend/src/components/TextModal.tsx @@ -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 ( + + {fileName && ( + + )} + + + )} + > + + + ); +} diff --git a/frontend/src/components/TextModal.vue b/frontend/src/components/TextModal.vue deleted file mode 100644 index 2e9bfe6d..00000000 --- a/frontend/src/components/TextModal.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - - - diff --git a/frontend/src/composables/useDatepicker.js b/frontend/src/composables/useDatepicker.js deleted file mode 100644 index 03eba91c..00000000 --- a/frontend/src/composables/useDatepicker.js +++ /dev/null @@ -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) }; -} diff --git a/frontend/src/composables/useMediaQuery.js b/frontend/src/composables/useMediaQuery.js deleted file mode 100644 index a1861c93..00000000 --- a/frontend/src/composables/useMediaQuery.js +++ /dev/null @@ -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 - - - - diff --git a/frontend/src/pages/inbounds/InboundInfoModal.css b/frontend/src/pages/inbounds/InboundInfoModal.css new file mode 100644 index 00000000..44dce4e2 --- /dev/null +++ b/frontend/src/pages/inbounds/InboundInfoModal.css @@ -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); +} diff --git a/frontend/src/pages/inbounds/InboundInfoModal.tsx b/frontend/src/pages/inbounds/InboundInfoModal.tsx new file mode 100644 index 00000000..27641f58 --- /dev/null +++ b/frontend/src/pages/inbounds/InboundInfoModal.tsx @@ -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; + 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; +} + +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(null); + const [clientSettings, setClientSettings] = useState(null); + const [clientStats, setClientStats] = useState(null); + const [links, setLinks] = useState<{ remark?: string; link: string }[]>([]); + const [wireguardConfigs, setWireguardConfigs] = useState([]); + const [wireguardLinks, setWireguardLinks] = useState([]); + const [subLink, setSubLink] = useState(''); + const [subJsonLink, setSubJsonLink] = useState(''); + const [refreshing, setRefreshing] = useState(false); + const [clientIpsArray, setClientIpsArray] = useState([]); + 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 ( + + ); + } + + const clientTab = ( + <> + + + + + + + {clientSettings?.id && ( + + )} + {dbInbound.isVMess && ( + + )} + {inbound.canEnableTlsFlow?.() && ( + + + + + )} + {clientSettings?.password && ( + + + + + )} + + + + + {clientStats && ( + + + + + )} + + + + + + + + + + + + + {clientSettings?.comment && ( + + )} + {ipLimitEnable && ( + + )} + {ipLimitEnable && (clientSettings?.limitIp ?? 0) > 0 && ( + + + + + )} + +
{t('pages.inbounds.email')} + {clientSettings?.email ? ( + {clientSettings.email} + ) : ( + {t('none')} + )} +
ID{clientSettings.id}
{t('security')}{clientSettings?.security}
Flow + {clientSettings?.flow ? {clientSettings.flow} : {t('none')}} +
{t('password')}{clientSettings.password}
{t('status')} + {isDepleted ? ( + {t('depleted')} + ) : isEnable ? ( + {t('enabled')} + ) : ( + {t('disabled')} + )} +
{t('usage')} + {SizeFormatter.sizeFormat(clientStats.up + clientStats.down)} + + ↑ {SizeFormatter.sizeFormat(clientStats.up)} / + {' '}{SizeFormatter.sizeFormat(clientStats.down)} ↓ + +
{t('pages.inbounds.createdAt')} + {clientSettings?.created_at ? ( + {IntlUtil.formatDate(clientSettings.created_at, datepicker)} + ) : -} +
{t('pages.inbounds.updatedAt')} + {clientSettings?.updated_at ? ( + {IntlUtil.formatDate(clientSettings.updated_at, datepicker)} + ) : -} +
{t('lastOnline')}{formatLastOnline(clientSettings?.email || '')}
{t('comment')}{clientSettings.comment}
{t('pages.inbounds.IPLimit')}{clientSettings?.limitIp ?? 0}
{t('pages.inbounds.IPLimitlog')} +
+ {clientIpsArray.length > 0 ? ( +
+ {clientIpsArray.map((item, idx) => ( + {item} + ))} +
+ ) : ( + {clientIpsText || t('tgbot.noIpRecord')} + )} +
+
+ loadClientIps()} /> + + clearClientIps()} /> + +
+
+ + + + + + + + + + + + + + + + +
{t('remained')}{t('pages.inbounds.totalUsage')}{t('pages.inbounds.expireDate')}
+ {clientStats && (clientSettings?.totalGB ?? 0) > 0 ? ( + {remainingStats} + ) : !clientSettings?.totalGB || clientSettings.totalGB <= 0 ? ( + + ) : null} + + {(clientSettings?.totalGB ?? 0) > 0 ? ( + + {SizeFormatter.sizeFormat(clientSettings!.totalGB!)} + + ) : ( + + )} + + {(clientSettings?.expiryTime ?? 0) > 0 ? ( + + {IntlUtil.formatDate(clientSettings!.expiryTime!, datepicker)} + + ) : (clientSettings?.expiryTime ?? 0) < 0 ? ( + {clientSettings!.expiryTime! / -86400000} {t('day')} + ) : ( + + )} +
+ + {tgBotEnable && clientSettings?.tgId && ( + <> + Telegram +
+ {clientSettings.tgId} + +
+ + )} + + {dbInbound.hasLink() && links.length > 0 && ( + <> + {t('pages.inbounds.copyLink')} + {links.map((link, idx) => ( +
+
+ {link.remark || `Link ${idx + 1}`} + +
+ {link.link} +
+ ))} + + )} + + {showSubscriptionTab && ( + <> + {t('subscription.title')} +
+
+ {t('subscription.title')} + +
+ {subLink} +
+ {subSettings?.subJsonEnable && subJsonLink && ( +
+
+ JSON + +
+ {subJsonLink} +
+ )} + + )} + + ); + + const inboundTab = ( + <> +
+
+
{t('pages.inbounds.protocol')}
+
{dbInbound.protocol}
+
+
+
{t('pages.inbounds.address')}
+
{dbInbound.address}
+
+
+
{t('pages.inbounds.port')}
+
{dbInbound.port}
+
+ + {(dbInbound.isVMess || dbInbound.isVLess || dbInbound.isTrojan || dbInbound.isSS) && ( + <> +
+
{t('transmission')}
+
{networkLabel}
+
+ {(inbound.isTcp || inbound.isWs || inbound.isHttpupgrade || inbound.isXHTTP) && ( + <> +
+
{t('host')}
+
{inbound.host ? {inbound.host} : {t('none')}}
+
+
+
{t('path')}
+
{inbound.path ? {inbound.path} : {t('none')}}
+
+ + )} + {inbound.isXHTTP && ( +
+
Mode
+
{inbound.stream?.xhttp?.mode}
+
+ )} + {inbound.isGrpc && ( + <> +
+
grpc serviceName
+
{inbound.serviceName}
+
+
+
grpc multiMode
+
{String(inbound.stream?.grpc?.multiMode)}
+
+ + )} + + )} + + {dbInbound.hasLink() && ( + <> +
+
{t('security')}
+
{securityLabel}
+
+ {encryptionLabel && ( +
+
{t('encryption')}
+
+ {encryptionLabel} + +
+
+ )} + {securityLabel !== 'none' && ( +
+
{t('domainName')}
+
+ {serverNameLabel ? ( + {serverNameLabel} + ) : ( + {t('none')} + )} +
+
+ )} + + )} +
+ + {dbInbound.isSS && inbound.settings && ( + + + + + + + {inbound.isSS2022 && ( + + + + + )} + + + + + +
{t('encryption')}{inbound.settings.method as string}
{t('password')}{inbound.settings.password as string}
{t('pages.inbounds.network')}{inbound.settings.network as string}
+ )} + + {inbound.protocol === Protocols.TUN && inbound.settings && ( +
+
+
Interface name
+
{inbound.settings.name as string}
+
+
+
MTU
+
{inbound.settings.mtu as number}
+
+ {Array.isArray(inbound.settings.gateway) && (inbound.settings.gateway as string[]).length > 0 && ( +
+
Gateway
+
+ {(inbound.settings.gateway as string[]).map((ip, j) => ( + {ip} + ))} +
+
+ )} + {Array.isArray(inbound.settings.dns) && (inbound.settings.dns as string[]).length > 0 && ( +
+
DNS
+
+ {(inbound.settings.dns as string[]).map((ip, j) => ( + {ip} + ))} +
+
+ )} +
+
Outbounds interface
+
{(inbound.settings.autoOutboundsInterface as string) || 'auto'}
+
+ {Array.isArray(inbound.settings.autoSystemRoutingTable) && (inbound.settings.autoSystemRoutingTable as string[]).length > 0 && ( +
+
Auto system routes
+
+ {(inbound.settings.autoSystemRoutingTable as string[]).map((cidr, j) => ( + {cidr} + ))} +
+
+ )} +
+ )} + + {inbound.protocol === Protocols.TUNNEL && inbound.settings && ( +
+
+
{t('pages.inbounds.targetAddress')}
+
{inbound.settings.rewriteAddress as string}
+
+
+
{t('pages.inbounds.destinationPort')}
+
{inbound.settings.rewritePort as number}
+
+
+
{t('pages.inbounds.network')}
+
{inbound.settings.allowedNetwork as string}
+
+
+
FollowRedirect
+
+ + {inbound.settings.followRedirect ? t('enabled') : t('disabled')} + +
+
+
+ )} + + {dbInbound.isMixed && inbound.settings && ( +
+
+
Auth
+
+ + {inbound.settings.auth as string} + +
+
+
+
UDP
+
+ + {inbound.settings.udp ? t('enabled') : t('disabled')} + +
+
+ {(inbound.settings.ip as string) && ( +
+
IP
+
{inbound.settings.ip as string}
+
+ )} + {inbound.settings.auth === 'password' && Array.isArray(inbound.settings.accounts) && ( + <> + {(inbound.settings.accounts as { user: string; pass: string }[]).map((account, idx) => ( +
+
{t('username')} #{idx + 1}
+
+ {account.user} + : + {account.pass} + + + + + + + + + + +
+
+ ))} + + )} + {inbound.settings.auth === 'noauth' && ( +
+
{t('copy')}
+
+ + + + + + + + + + + +
+
+ )} +
+ )} + + {dbInbound.isHTTP && Array.isArray(inbound.settings?.accounts) && (inbound.settings!.accounts as unknown[]).length > 0 && ( +
+ {(inbound.settings!.accounts as { user: string; pass: string }[]).map((account, idx) => ( +
+
{t('username')} #{idx + 1}
+
+ {account.user} + : + {account.pass} + +
+
+ ))} +
+ )} + + {dbInbound.isWireguard && inbound.settings && ( + + + + + + + {Array.isArray(inbound.settings.peers) && (inbound.settings.peers as { privateKey: string; publicKey: string; psk: string; allowedIPs?: string[]; keepAlive?: number }[]).map((peer, idx) => ( + <> + + + + + + + + + {wireguardConfigs[idx] && ( + + + + )} + {wireguardLinks[idx] && ( + + + + )} + + ))} + +
Secret key{inbound.settings.secretKey as string}
Public key{inbound.settings.pubKey as string}
MTU{inbound.settings.mtu as number}
No-kernel TUN{String(inbound.settings.noKernelTun)}
Peer {idx + 1}
Secret key{peer.privateKey}
Public key{peer.publicKey}
PSK{peer.psk}
Allowed IPs{(peer.allowedIPs || []).join(',')}
Keep alive{peer.keepAlive}
+
+
+ Peer {idx + 1} config + +
+ {wireguardConfigs[idx]} +
+
+
+
+ Peer {idx + 1} link + +
+ {wireguardLinks[idx]} +
+
+ )} + + {dbInbound.isSS && !inbound.isSSMultiUser && links.length > 0 && ( + <> + {t('pages.inbounds.copyLink')} + {links.map((link, idx) => ( +
+
+ {link.remark || `Link ${idx + 1}`} + +
+ {link.link} +
+ ))} + + )} + + ); + + 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 ( + + + + ); +} diff --git a/frontend/src/pages/inbounds/InboundInfoModal.vue b/frontend/src/pages/inbounds/InboundInfoModal.vue deleted file mode 100644 index 4bfef885..00000000 --- a/frontend/src/pages/inbounds/InboundInfoModal.vue +++ /dev/null @@ -1,1134 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/InboundList.css b/frontend/src/pages/inbounds/InboundList.css new file mode 100644 index 00000000..2a9f28ba --- /dev/null +++ b/frontend/src/pages/inbounds/InboundList.css @@ -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; + } +} diff --git a/frontend/src/pages/inbounds/InboundList.tsx b/frontend/src/pages/inbounds/InboundList.tsx new file mode 100644 index 00000000..baeb5850 --- /dev/null +++ b/frontend/src/pages/inbounds/InboundList.tsx @@ -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; + onlineClients: string[]; + lastOnlineMap: Record; + expireDiff: number; + trafficDiff: number; + pageSize: number; + isMobile: boolean; + subEnable: boolean; + nodesById: Map; + 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; clientCount: Record }) => 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: , label: t('edit') }); + } + if (showQrCodeMenu(record)) { + items.push({ key: 'qrcode', icon: , label: t('qrCode') }); + } + if (record.isMultiUser()) { + items.push({ key: 'export', icon: , label: t('pages.inbounds.export') }); + if (subEnable) { + items.push({ + key: 'subs', + icon: , + label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}`, + }); + } + } else { + items.push({ key: 'showInfo', icon: , label: t('info') }); + } + items.push({ key: 'clipboard', icon: , label: t('pages.inbounds.exportInbound') }); + items.push({ key: 'resetTraffic', icon: , label: t('pages.inbounds.resetTraffic') }); + items.push({ key: 'clone', icon: , label: t('pages.inbounds.clone') }); + items.push({ key: 'delete', icon: , danger: true, label: t('delete') }); + return items; +} + +function RowActionsCell({ record, subEnable, onClick }: RowActionsMenuProps) { + const { t } = useTranslation(); + return ( +
+
+ ); +} + +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(null); + const [sortOrder, setSortOrder] = useState(null); + const [statsRecord, setStatsRecord] = useState(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[] = useMemo(() => { + const cols: TableColumnType[] = [ + { + 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) => ( + onRowAction({ key, dbInbound: record })} + /> + ), + }, + { + title: t('pages.inbounds.enable'), + key: 'enable', + align: 'center', + width: 35, + ...sorterFor('enable'), + render: (_, record) => ( + 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 {t('pages.inbounds.localPanel')}; + } + const node = nodesById.get(record.nodeId); + if (!node) { + return node #{record.nodeId}; + } + return ( + {node.name} + ); + }, + }); + } + + 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[] = [{record.protocol}]; + if (record.isVMess || record.isVLess || record.isTrojan || record.isSS || record.isHysteria) { + const stream = record.toInbound().stream; + tags.push( + + {record.isHysteria ? 'UDP' : stream?.network} + , + ); + if (stream?.isTls) tags.push(TLS); + if (stream?.isReality) tags.push(Reality); + } + return
{tags}
; + }, + }, + { + title: t('clients'), + key: 'clients', + align: 'left', + width: 50, + ...sorterFor('clients'), + render: (_, record) => { + const cc = clientCount[record.id]; + if (!cc) return null; + return ( + <> + + {cc.clients} + + {cc.deactive.length > 0 && ( + + {cc.deactive.map((e) =>
{e}
)} + + )} + > + {cc.deactive.length} +
+ )} + {cc.depleted.length > 0 && ( + + {cc.depleted.map((e) =>
{e}
)} + + )} + > + {cc.depleted.length} +
+ )} + {cc.expiring.length > 0 && ( + + {cc.expiring.map((e) =>
{e}
)} + + )} + > + {cc.expiring.length} +
+ )} + {cc.online.length > 0 && ( + + {cc.online.map((e) =>
{e}
)} + + )} + > + {cc.online.length} +
+ )} + + ); + }, + }, + { + title: t('pages.inbounds.traffic'), + key: 'traffic', + align: 'center', + width: 90, + ...sorterFor('traffic'), + render: (_, record) => ( + + + + ↑ {SizeFormatter.sizeFormat(record.up)} + ↓ {SizeFormatter.sizeFormat(record.down)} + + {record.total > 0 && record.up + record.down < record.total && ( + + {t('remained')} + {SizeFormatter.sizeFormat(record.total - record.up - record.down)} + + )} + + + )} + > + + {SizeFormatter.sizeFormat(record.up + record.down)} / + {' '} + {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : } + + + ), + }, + { + title: t('pages.inbounds.expireDate'), + key: 'expiryTime', + align: 'center', + width: 40, + ...sorterFor('expiryTime'), + render: (_, record) => { + if (record.expiryTime > 0) { + return ( + + + {IntlUtil.formatRelativeTime(record.expiryTime)} + + + ); + } + return ; + }, + }, + ); + + 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: , label: t('pages.inbounds.importInbound') }, + { key: 'export', icon: , label: t('pages.inbounds.export') }, + ...(subEnable + ? [{ key: 'subs', icon: , label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }] + : []), + { key: 'resetInbounds', icon: , label: t('pages.inbounds.resetAllTraffic') }, + ], + onClick: ({ key }) => onGeneralAction(key as GeneralAction), + }; + + return ( + + + + + + + )} + > + + {isMobile ? ( +
+ {sortedInbounds.length === 0 ? ( +
+ ) : ( + sortedInbounds.map((record) => ( +
+
+ #{record.id} + {record.remark} +
e.stopPropagation()}> + + setStatsRecord(record)} /> + + onSwitchEnable(record, next)} + /> + onRowAction({ key: key as RowAction, dbInbound: record }), + }} + > + e.preventDefault()} /> + +
+
+
+ )) + )} +
+ ) : ( + 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); + }} + /> + )} + + + setStatsRecord(null)} + destroyOnClose + > + {statsRecord && ( +
+
+ {t('pages.inbounds.protocol')} + {statsRecord.protocol} + {(statsRecord.isVMess || statsRecord.isVLess || statsRecord.isTrojan || statsRecord.isSS || statsRecord.isHysteria) && ( + <> + + {statsRecord.isHysteria ? 'UDP' : statsRecord.toInbound().stream?.network} + + {statsRecord.toInbound().stream?.isTls && TLS} + {statsRecord.toInbound().stream?.isReality && Reality} + + )} +
+
+ {t('pages.inbounds.port')} + {statsRecord.port} +
+ {hasActiveNode && ( +
+ {t('pages.inbounds.node')} + {statsRecord.nodeId == null ? ( + {t('pages.inbounds.localPanel')} + ) : nodesById.get(statsRecord.nodeId) ? ( + + {nodesById.get(statsRecord.nodeId)!.name} + + ) : ( + #{statsRecord.nodeId} + )} +
+ )} +
+ {t('pages.inbounds.traffic')} + + {SizeFormatter.sizeFormat(statsRecord.up + statsRecord.down)} / + {' '} + {statsRecord.total > 0 ? SizeFormatter.sizeFormat(statsRecord.total) : } + +
+ {clientCount[statsRecord.id] && ( +
+ {t('clients')} + {clientCount[statsRecord.id].clients} + {clientCount[statsRecord.id].online.length > 0 && ( + {clientCount[statsRecord.id].online.length} {t('online')} + )} + {clientCount[statsRecord.id].depleted.length > 0 && ( + {clientCount[statsRecord.id].depleted.length} {t('depleted')} + )} + {clientCount[statsRecord.id].expiring.length > 0 && ( + {clientCount[statsRecord.id].expiring.length} {t('depletingSoon')} + )} +
+ )} +
+ {t('pages.inbounds.expireDate')} + {statsRecord.expiryTime > 0 ? ( + + {IntlUtil.formatRelativeTime(statsRecord.expiryTime)} + + ) : ( + + )} +
+
+ )} +
+ + ); +} diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue deleted file mode 100644 index 68687e6d..00000000 --- a/frontend/src/pages/inbounds/InboundList.vue +++ /dev/null @@ -1,680 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/InboundsPage.css b/frontend/src/pages/inbounds/InboundsPage.css new file mode 100644 index 00000000..82e8ff82 --- /dev/null +++ b/frontend/src/pages/inbounds/InboundsPage.css @@ -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; + } +} diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx new file mode 100644 index 00000000..95fe1b06 --- /dev/null +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -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['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(null); + + const [infoOpen, setInfoOpen] = useState(false); + const [infoDbInbound, setInfoDbInbound] = useState(null); + const [infoClientIndex, setInfoClientIndex] = useState(0); + + const [qrOpen, setQrOpen] = useState(false); + const [qrDbInbound, setQrDbInbound] = useState(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) | 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; + }) => { + 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 ( + + + + + + + + {!fetched ? ( +
+ ) : ( + +
+ + + + } + /> + + + } + /> + + + } + /> + + + + + + + + + + )} + + + + + setFormOpen(false)} + onSaved={refresh} + mode={formMode} + dbInbound={formDbInbound} + dbInbounds={dbInbounds as any[]} + /> + setInfoOpen(false)} + dbInbound={infoDbInbound} + clientIndex={infoClientIndex} + remarkModel={remarkModel} + expireDiff={expireDiff} + trafficDiff={trafficDiff} + ipLimitEnable={ipLimitEnable} + tgBotEnable={tgBotEnable} + subSettings={subSettings} + lastOnlineMap={lastOnlineMap} + nodeAddress={infoNodeAddress} + /> + setQrOpen(false)} + dbInbound={qrDbInbound} + client={null} + remarkModel={remarkModel} + nodeAddress={qrNodeAddress} + subSettings={subSettings} + /> + + setTextOpen(false)} + title={textTitle} + content={textContent} + fileName={textFileName} + /> + setPromptOpen(false)} + title={promptTitle} + okText={promptOkText} + type={promptType} + initialValue={promptInitial} + loading={promptLoading} + onConfirm={onPromptConfirm} + /> + + + ); +} diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue deleted file mode 100644 index 3f8917c1..00000000 --- a/frontend/src/pages/inbounds/InboundsPage.vue +++ /dev/null @@ -1,559 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/QrCodeModal.tsx b/frontend/src/pages/inbounds/QrCodeModal.tsx new file mode 100644 index 00000000..f817ef3c --- /dev/null +++ b/frontend/src/pages/inbounds/QrCodeModal.tsx @@ -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([]); + const [wireguardLinks, setWireguardLinks] = useState([]); + const [subLink, setSubLink] = useState(''); + const [subJsonLink, setSubJsonLink] = useState(''); + const [activeKeys, setActiveKeys] = useState([]); + + 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(() => { + 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: ( + + ), + })); + + return ( + + {dbInbound && ( + setActiveKeys(Array.isArray(keys) ? keys : [keys])} + items={collapseItems} + /> + )} + + ); +} diff --git a/frontend/src/pages/inbounds/QrCodeModal.vue b/frontend/src/pages/inbounds/QrCodeModal.vue deleted file mode 100644 index 6e01f8d7..00000000 --- a/frontend/src/pages/inbounds/QrCodeModal.vue +++ /dev/null @@ -1,126 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/QrPanel.vue b/frontend/src/pages/inbounds/QrPanel.vue deleted file mode 100644 index dae967f9..00000000 --- a/frontend/src/pages/inbounds/QrPanel.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - - - diff --git a/frontend/src/pages/inbounds/useInbounds.js b/frontend/src/pages/inbounds/useInbounds.js deleted file mode 100644 index 41fda433..00000000 --- a/frontend/src/pages/inbounds/useInbounds.js +++ /dev/null @@ -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, - }; -} diff --git a/frontend/src/pages/inbounds/useInbounds.ts b/frontend/src/pages/inbounds/useInbounds.ts new file mode 100644 index 00000000..59a12472 --- /dev/null +++ b/frontend/src/pages/inbounds/useInbounds.ts @@ -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; + +interface ClientRollup { + clients: number; + active: string[]; + deactive: string[]; + depleted: string[]; + expiring: string[]; + online: string[]; + comments: Map; +} + +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([]); + const dbInboundsRef = useRef([]); + dbInboundsRef.current = dbInbounds; + + const [clientCount, setClientCount] = useState>({}); + const [onlineClients, setOnlineClients] = useState([]); + const onlineClientsRef = useRef([]); + onlineClientsRef.current = onlineClients; + + const [lastOnlineMap, setLastOnlineMap] = useState>({}); + 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({ + 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(); + 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(); + 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 = {}; + 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 = {}; + 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); + } + }, []); + + const fetchDefaultSettings = useCallback(async () => { + const msg = await HttpUtil.post('/panel/setting/defaultSettings'); + if (!msg?.success) return; + const s = (msg.obj || {}) as Record; + 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 }; + 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(); + 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(); + 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, + }; +} diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js index 792574e0..b2741280 100644 --- a/frontend/src/utils/index.js +++ b/frontend/src/utils/index.js @@ -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) { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index ef0b8d4b..b0db7012 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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'; }, },