mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-18 18:57:27 +00:00
Merge branches 'main' and 'main' of github.com:danding5/new-api
# Conflicts: # common/api_type.go # constant/api_type.go # constant/channel.go # relay/relay_adaptor.go # web/src/constants/channel.constants.js
This commit is contained in:
42
web/.eslintrc.cjs
Normal file
42
web/.eslintrc.cjs
Normal file
@@ -0,0 +1,42 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2021: true, node: true },
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['header', 'react-hooks'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
rules: {
|
||||
'header/header': [
|
||||
2,
|
||||
'block',
|
||||
[
|
||||
'',
|
||||
'Copyright (C) 2025 QuantumNous',
|
||||
'',
|
||||
'This program is free software: you can redistribute it and/or modify',
|
||||
'it under the terms of the GNU Affero General Public License as',
|
||||
'published by the Free Software Foundation, either version 3 of the',
|
||||
'License, or (at your option) any later version.',
|
||||
'',
|
||||
'This program is distributed in the hope that it will be useful,',
|
||||
'but WITHOUT ANY WARRANTY; without even the implied warranty of',
|
||||
'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the',
|
||||
'GNU Affero General Public License for more details.',
|
||||
'',
|
||||
'You should have received a copy of the GNU Affero General Public License',
|
||||
'along with this program. If not, see <https://www.gnu.org/licenses/>.',
|
||||
'',
|
||||
'For commercial licensing, please contact support@quantumnous.com',
|
||||
'',
|
||||
],
|
||||
],
|
||||
'no-multiple-empty-lines': ['error', { max: 1 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
172
web/bun.lock
172
web/bun.lock
@@ -21,6 +21,7 @@
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
@@ -46,6 +47,9 @@
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3",
|
||||
@@ -237,6 +241,14 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@8.57.0", "", {}, "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="],
|
||||
@@ -249,6 +261,12 @@
|
||||
|
||||
"@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="],
|
||||
|
||||
"@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.11.14", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="],
|
||||
@@ -629,15 +647,17 @@
|
||||
|
||||
"abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="],
|
||||
|
||||
"acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ahooks": ["ahooks@3.8.5", "", { "dependencies": { "@babel/runtime": "^7.21.0", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y+MLoJpBXVdjsnnBjE5rOSPkQ4DK+8i5aPDzLJdIOsCpo/fiAeXcBY1Y7oWgtOK0TpOz0gFa/XcyO1UGdoqLcw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"antd": ["antd@5.25.2", "", { "dependencies": { "@ant-design/colors": "^7.2.0", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.0.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.2.6", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.2.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.8", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.50.5", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.9.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-7R2nUvlHhey7Trx64+hCtGXOiy+DTUs1Lv5bwbV1LzEIZIhWb0at1AM6V3K108a5lyoR9n7DX3ptlLF7uYV/DQ=="],
|
||||
|
||||
@@ -649,6 +669,8 @@
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"array-source": ["array-source@0.0.4", "", {}, "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw=="],
|
||||
|
||||
"assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="],
|
||||
@@ -699,6 +721,8 @@
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
@@ -851,6 +875,8 @@
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
@@ -865,6 +891,8 @@
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
||||
|
||||
"dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
@@ -887,7 +915,25 @@
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.0", "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ=="],
|
||||
|
||||
"eslint-plugin-header": ["eslint-plugin-header@3.1.1", "", { "peerDependencies": { "eslint": ">=7.7.0" } }, "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="],
|
||||
|
||||
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
|
||||
|
||||
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||
|
||||
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||
|
||||
"estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="],
|
||||
|
||||
@@ -903,6 +949,8 @@
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="],
|
||||
@@ -917,8 +965,14 @@
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="],
|
||||
|
||||
"file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="],
|
||||
|
||||
"file-source": ["file-source@0.6.1", "", { "dependencies": { "stream-source": "0.3" } }, "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA=="],
|
||||
@@ -929,6 +983,12 @@
|
||||
|
||||
"find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="],
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
|
||||
|
||||
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
|
||||
@@ -969,12 +1029,16 @@
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
|
||||
"globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="],
|
||||
@@ -1025,12 +1089,16 @@
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="],
|
||||
|
||||
"immutable": ["immutable@5.1.2", "", {}, "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
@@ -1065,6 +1133,8 @@
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"is-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
|
||||
@@ -1083,10 +1153,18 @@
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||
|
||||
"json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
@@ -1097,6 +1175,8 @@
|
||||
|
||||
"katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
|
||||
|
||||
"kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="],
|
||||
@@ -1107,6 +1187,8 @@
|
||||
|
||||
"leva": ["leva@0.10.0", "", { "dependencies": { "@radix-ui/react-portal": "1.0.2", "@radix-ui/react-tooltip": "1.0.5", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-RiNJWmeqQdKIeHuVXgshmxIHu144a2AMYtLxKf8Nm1j93pisDPexuQDHKNdQlbo37wdyDQibLjY9JKGIiD7gaw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
@@ -1119,12 +1201,16 @@
|
||||
|
||||
"local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="],
|
||||
|
||||
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
@@ -1285,6 +1371,8 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
||||
@@ -1307,6 +1395,12 @@
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="],
|
||||
|
||||
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||
|
||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||
|
||||
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||
|
||||
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="],
|
||||
@@ -1327,6 +1421,8 @@
|
||||
|
||||
"path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
@@ -1375,6 +1471,8 @@
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="],
|
||||
|
||||
"prettier-package-json": ["prettier-package-json@2.8.0", "", { "dependencies": { "@types/parse-author": "^2.0.0", "commander": "^4.0.1", "cosmiconfig": "^7.0.0", "fs-extra": "^10.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4", "parse-author": "^2.0.0", "sort-object-keys": "^1.1.3", "sort-order": "^1.0.1" }, "bin": { "prettier-package-json": "bin/prettier-package-json" } }, "sha512-WxtodH/wWavfw3MR7yK/GrS4pASEQ+iSTkdtSxPJWvqzG55ir5nvbLt9rw5AOiEcqqPCRM92WCtR1rk3TG3JSQ=="],
|
||||
@@ -1393,6 +1491,10 @@
|
||||
|
||||
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="],
|
||||
|
||||
"quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="],
|
||||
|
||||
"query-string": ["query-string@9.2.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ=="],
|
||||
@@ -1403,7 +1505,7 @@
|
||||
|
||||
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
||||
|
||||
"rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||
"rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
||||
|
||||
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
||||
|
||||
@@ -1577,6 +1679,8 @@
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="],
|
||||
|
||||
"robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="],
|
||||
|
||||
"rollup": ["rollup@4.30.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.30.0", "@rollup/rollup-android-arm64": "4.30.0", "@rollup/rollup-darwin-arm64": "4.30.0", "@rollup/rollup-darwin-x64": "4.30.0", "@rollup/rollup-freebsd-arm64": "4.30.0", "@rollup/rollup-freebsd-x64": "4.30.0", "@rollup/rollup-linux-arm-gnueabihf": "4.30.0", "@rollup/rollup-linux-arm-musleabihf": "4.30.0", "@rollup/rollup-linux-arm64-gnu": "4.30.0", "@rollup/rollup-linux-arm64-musl": "4.30.0", "@rollup/rollup-linux-loongarch64-gnu": "4.30.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.30.0", "@rollup/rollup-linux-riscv64-gnu": "4.30.0", "@rollup/rollup-linux-s390x-gnu": "4.30.0", "@rollup/rollup-linux-x64-gnu": "4.30.0", "@rollup/rollup-linux-x64-musl": "4.30.0", "@rollup/rollup-win32-arm64-msvc": "4.30.0", "@rollup/rollup-win32-ia32-msvc": "4.30.0", "@rollup/rollup-win32-x64-msvc": "4.30.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-sDnr1pcjTgUT69qBksNF1N1anwfbyYG6TBQ22b03bII8EdiUQ7J0TlozVaTMjT/eEJAO49e1ndV7t+UZfL1+vA=="],
|
||||
@@ -1655,10 +1759,12 @@
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="],
|
||||
|
||||
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
|
||||
@@ -1667,6 +1773,8 @@
|
||||
|
||||
"suf-log": ["suf-log@2.5.3", "", { "dependencies": { "s.color": "0.0.15" } }, "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="],
|
||||
@@ -1677,6 +1785,8 @@
|
||||
|
||||
"text-encoding": ["text-encoding@0.6.4", "", {}, "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg=="],
|
||||
|
||||
"text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
@@ -1705,6 +1815,10 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="],
|
||||
|
||||
"typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="],
|
||||
|
||||
"typescript": ["typescript@4.4.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ=="],
|
||||
@@ -1733,6 +1847,8 @@
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
|
||||
|
||||
"use-debounce": ["use-debounce@10.0.4", "", { "peerDependencies": { "react": "*" } }, "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw=="],
|
||||
@@ -1777,6 +1893,8 @@
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
@@ -1787,6 +1905,8 @@
|
||||
|
||||
"yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
@@ -1807,8 +1927,6 @@
|
||||
|
||||
"@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="],
|
||||
|
||||
"@emotion/babel-plugin/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="],
|
||||
|
||||
"@emotion/babel-plugin/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
|
||||
@@ -1819,6 +1937,10 @@
|
||||
|
||||
"@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
|
||||
|
||||
"@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
|
||||
|
||||
"@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
|
||||
@@ -1827,8 +1949,6 @@
|
||||
|
||||
"@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="],
|
||||
|
||||
"@lobehub/ui/rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
|
||||
|
||||
"@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="],
|
||||
@@ -1845,6 +1965,8 @@
|
||||
|
||||
"@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="],
|
||||
|
||||
"antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||
|
||||
"antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -1867,6 +1989,8 @@
|
||||
|
||||
"d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
|
||||
|
||||
"esast-util-from-js/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
|
||||
"extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
@@ -1887,8 +2011,14 @@
|
||||
|
||||
"leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"mermaid/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
|
||||
"mlly/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="],
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
@@ -1909,6 +2039,8 @@
|
||||
|
||||
"react-toastify/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="],
|
||||
|
||||
"rimraf/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
|
||||
"sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="],
|
||||
@@ -1921,12 +2053,10 @@
|
||||
|
||||
"string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
@@ -1935,12 +2065,12 @@
|
||||
|
||||
"vite/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
"wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="],
|
||||
|
||||
"wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001690", "", {}, "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w=="],
|
||||
|
||||
"@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.76", "", {}, "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ=="],
|
||||
@@ -1951,6 +2081,8 @@
|
||||
|
||||
"@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@0.5.4", "", { "dependencies": { "@floating-ui/core": "^0.7.3" } }, "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="],
|
||||
@@ -1981,11 +2113,11 @@
|
||||
|
||||
"simplify-geojson/concat-stream/typedarray": ["typedarray@0.0.7", "", {}, "sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
"@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="],
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@
|
||||
/>
|
||||
<title>New API</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.js"></script>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
@@ -46,6 +47,8 @@
|
||||
"build": "vite build",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"eslintConfig": {
|
||||
@@ -71,6 +74,9 @@
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"tailwindcss": "^3",
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
BIN
web/public/cover-4.webp
Normal file
BIN
web/public/cover-4.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -1,38 +1,82 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { lazy, Suspense, useContext, useMemo } from 'react';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import Loading from './components/common/Loading.js';
|
||||
import Loading from './components/common/ui/Loading';
|
||||
import User from './pages/User';
|
||||
import { AuthRedirect, PrivateRoute } from './helpers';
|
||||
import RegisterForm from './components/auth/RegisterForm.js';
|
||||
import LoginForm from './components/auth/LoginForm.js';
|
||||
import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers';
|
||||
import RegisterForm from './components/auth/RegisterForm';
|
||||
import LoginForm from './components/auth/LoginForm';
|
||||
import NotFound from './pages/NotFound';
|
||||
import Forbidden from './pages/Forbidden';
|
||||
import Setting from './pages/Setting';
|
||||
import EditUser from './pages/User/EditUser';
|
||||
import PasswordResetForm from './components/auth/PasswordResetForm.js';
|
||||
import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js';
|
||||
import { StatusContext } from './context/Status';
|
||||
|
||||
import PasswordResetForm from './components/auth/PasswordResetForm';
|
||||
import PasswordResetConfirm from './components/auth/PasswordResetConfirm';
|
||||
import Channel from './pages/Channel';
|
||||
import Token from './pages/Token';
|
||||
import EditChannel from './pages/Channel/EditChannel';
|
||||
import Redemption from './pages/Redemption';
|
||||
import TopUp from './pages/TopUp';
|
||||
import Log from './pages/Log';
|
||||
import Chat from './pages/Chat';
|
||||
import Chat2Link from './pages/Chat2Link';
|
||||
import Midjourney from './pages/Midjourney';
|
||||
import Pricing from './pages/Pricing/index.js';
|
||||
import Task from './pages/Task/index.js';
|
||||
import Playground from './pages/Playground/index.js';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback.js';
|
||||
import PersonalSetting from './components/settings/PersonalSetting.js';
|
||||
import Setup from './pages/Setup/index.js';
|
||||
import SetupCheck from './components/layout/SetupCheck.js';
|
||||
import Pricing from './pages/Pricing';
|
||||
import Task from './pages/Task';
|
||||
import ModelPage from './pages/Model';
|
||||
import Playground from './pages/Playground';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||
import PersonalSetting from './components/settings/PersonalSetting';
|
||||
import Setup from './pages/Setup';
|
||||
import SetupCheck from './components/layout/SetupCheck';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Detail = lazy(() => import('./pages/Detail'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
// 获取模型广场权限配置
|
||||
const pricingRequireAuth = useMemo(() => {
|
||||
const headerNavModulesConfig = statusState?.status?.HeaderNavModules;
|
||||
if (headerNavModulesConfig) {
|
||||
try {
|
||||
const modules = JSON.parse(headerNavModulesConfig);
|
||||
|
||||
// 处理向后兼容性:如果pricing是boolean,默认不需要登录
|
||||
if (typeof modules.pricing === 'boolean') {
|
||||
return false; // 默认不需要登录鉴权
|
||||
}
|
||||
|
||||
// 如果是对象格式,使用requireAuth配置
|
||||
return modules.pricing?.requireAuth === true;
|
||||
} catch (error) {
|
||||
console.error('解析顶栏模块配置失败:', error);
|
||||
return false; // 默认不需要登录
|
||||
}
|
||||
}
|
||||
return false; // 默认不需要登录
|
||||
}, [statusState?.status?.HeaderNavModules]);
|
||||
|
||||
return (
|
||||
<SetupCheck>
|
||||
@@ -53,28 +97,21 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path='/forbidden' element={<Forbidden />} />
|
||||
<Route
|
||||
path='/console/models'
|
||||
element={
|
||||
<AdminRoute>
|
||||
<ModelPage />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AdminRoute>
|
||||
<Channel />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditChannel />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel/add'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditChannel />
|
||||
</Suspense>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -96,33 +133,17 @@ function App() {
|
||||
<Route
|
||||
path='/console/redemption'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AdminRoute>
|
||||
<Redemption />
|
||||
</PrivateRoute>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/user'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AdminRoute>
|
||||
<User />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/user/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditUser />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/user/edit'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<EditUser />
|
||||
</Suspense>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -188,11 +209,11 @@ function App() {
|
||||
<Route
|
||||
path='/console/setting'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AdminRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Setting />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -228,7 +249,7 @@ function App() {
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Detail />
|
||||
<Dashboard />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
}
|
||||
@@ -256,9 +277,20 @@ function App() {
|
||||
<Route
|
||||
path='/pricing'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
pricingRequireAuth ? (
|
||||
<PrivateRoute>
|
||||
<Suspense
|
||||
fallback={<Loading></Loading>}
|
||||
key={location.pathname}
|
||||
>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
</PrivateRoute>
|
||||
) : (
|
||||
<Suspense fallback={<Loading></Loading>} key={location.pathname}>
|
||||
<Pricing />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { UserContext } from '../../context/User';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
@@ -12,25 +31,19 @@ import {
|
||||
setUserData,
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked
|
||||
} from '../../helpers/index.js';
|
||||
onLinuxDOOAuthClicked,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
|
||||
import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon.js';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon.js';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
import TwoFAVerification from './TwoFAVerification';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const LoginForm = () => {
|
||||
@@ -57,8 +70,10 @@ const LoginForm = () => {
|
||||
const [emailLoginLoading, setEmailLoginLoading] = useState(false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false);
|
||||
const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [showTwoFA, setShowTwoFA] = useState(false);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -143,6 +158,13 @@ const LoginForm = () => {
|
||||
);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 检查是否需要2FA验证
|
||||
if (data && data.require_2fa) {
|
||||
setShowTwoFA(true);
|
||||
setLoginLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
@@ -219,10 +241,7 @@ const LoginForm = () => {
|
||||
const handleOIDCClick = () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id
|
||||
);
|
||||
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
|
||||
} finally {
|
||||
// 由于重定向,这里不会执行到,但为了完整性添加
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
@@ -261,79 +280,104 @@ const LoginForm = () => {
|
||||
setOtherLoginOptionsLoading(false);
|
||||
};
|
||||
|
||||
// 2FA验证成功处理
|
||||
const handle2FASuccess = (data) => {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
setUserData(data);
|
||||
updateAPI();
|
||||
showSuccess('登录成功!');
|
||||
navigate('/console');
|
||||
};
|
||||
|
||||
// 返回登录页面
|
||||
const handleBackToLogin = () => {
|
||||
setShowTwoFA(false);
|
||||
setInputs({ username: '', password: '', wechat_verification_code: '' });
|
||||
};
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<div className='space-y-3'>
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
|
||||
size="large"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
||||
}
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 微信 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<IconGithubLogo size="large" />}
|
||||
size="large"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconGithubLogo size='large' />}
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 GitHub 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
size="large"
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 OIDC 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
|
||||
size="large"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<LinuxDoIcon
|
||||
style={{
|
||||
color: '#E95420',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className="flex justify-center my-2">
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
@@ -346,25 +390,24 @@ const LoginForm = () => {
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
|
||||
icon={<IconMail size="large" />}
|
||||
size="large"
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
||||
icon={<IconMail size='large' />}
|
||||
onClick={handleEmailLoginClick}
|
||||
loading={emailLoginLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 邮箱或用户名 登录')}</span>
|
||||
<span className='ml-3'>{t('使用 邮箱或用户名 登录')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
@@ -380,47 +423,46 @@ const LoginForm = () => {
|
||||
|
||||
const renderEmailLoginForm = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3}>{systemName}</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('登 录')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('登 录')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field="username"
|
||||
field='username'
|
||||
label={t('用户名或邮箱')}
|
||||
placeholder={t('请输入您的用户名或邮箱地址')}
|
||||
name="username"
|
||||
size="large"
|
||||
name='username'
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password"
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('请输入您的密码')}
|
||||
name="password"
|
||||
mode="password"
|
||||
size="large"
|
||||
name='password'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
>
|
||||
@@ -428,10 +470,9 @@ const LoginForm = () => {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
theme="borderless"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className="w-full !rounded-full"
|
||||
size="large"
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleResetPasswordClick}
|
||||
loading={resetPasswordLoading}
|
||||
>
|
||||
@@ -440,18 +481,21 @@ const LoginForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
|
||||
{(status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div className='mt-4 text-center'>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
className="w-full !rounded-full"
|
||||
size="large"
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleOtherLoginOptionsClick}
|
||||
loading={otherLoginOptionsLoading}
|
||||
>
|
||||
@@ -462,12 +506,12 @@ const LoginForm = () => {
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('没有账户?')}{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
to='/register'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
@@ -491,46 +535,100 @@ const LoginForm = () => {
|
||||
onOk={onSubmitWeChatVerificationCode}
|
||||
onCancel={() => setShowWeChatLoginModal(false)}
|
||||
okText={t('登录')}
|
||||
size="small"
|
||||
centered={true}
|
||||
okButtonProps={{
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
|
||||
<div className='flex flex-col items-center'>
|
||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
|
||||
<div className='text-center mb-4'>
|
||||
<p>
|
||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form size="large">
|
||||
<Form>
|
||||
<Form.Input
|
||||
field="wechat_verification_code"
|
||||
field='wechat_verification_code'
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) => handleChange('wechat_verification_code', value)}
|
||||
onChange={(value) =>
|
||||
handleChange('wechat_verification_code', value)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 2FA验证弹窗
|
||||
const render2FAModal = () => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<div className='w-8 h-8 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center mr-3'>
|
||||
<svg
|
||||
className='w-4 h-4 text-green-600 dark:text-green-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M6 8a2 2 0 11-4 0 2 2 0 014 0zM8 7a1 1 0 100 2h8a1 1 0 100-2H8zM6 14a2 2 0 11-4 0 2 2 0 014 0zM8 13a1 1 0 100 2h8a1 1 0 100-2H8z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
两步验证
|
||||
</div>
|
||||
}
|
||||
visible={showTwoFA}
|
||||
onCancel={handleBackToLogin}
|
||||
footer={null}
|
||||
width={450}
|
||||
centered
|
||||
>
|
||||
<TwoFAVerification
|
||||
onSuccess={handle2FASuccess}
|
||||
onBack={handleBackToLogin}
|
||||
isModal={true}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
{showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailLogin ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
? renderEmailLoginForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
{render2FAModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
@@ -1,9 +1,34 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
showError,
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import Loading from '../common/Loading';
|
||||
import Loading from '../common/ui/Loading';
|
||||
|
||||
const OAuth2Callback = (props) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -1,5 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
copy,
|
||||
showError,
|
||||
showNotice,
|
||||
getLogo,
|
||||
getSystemName,
|
||||
} from '../../helpers';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons';
|
||||
@@ -36,7 +62,7 @@ const PasswordResetConfirm = () => {
|
||||
if (formApi) {
|
||||
formApi.setValues({
|
||||
email: email || '',
|
||||
newPassword: newPassword || ''
|
||||
newPassword: newPassword || '',
|
||||
});
|
||||
}
|
||||
}, [searchParams, newPassword, formApi]);
|
||||
@@ -78,41 +104,53 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置确认')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('密码重置确认')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className='px-2 py-8'>
|
||||
{!isValidResetLink && (
|
||||
<Banner
|
||||
type="danger"
|
||||
type='danger'
|
||||
description={t('无效的重置链接,请重新发起密码重置请求')}
|
||||
className="mb-4 !rounded-lg"
|
||||
className='mb-4 !rounded-lg'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
getFormApi={(api) => setFormApi(api)}
|
||||
initValues={{ email: email || '', newPassword: newPassword || '' }}
|
||||
className="space-y-4"
|
||||
initValues={{
|
||||
email: email || '',
|
||||
newPassword: newPassword || '',
|
||||
}}
|
||||
className='space-y-4'
|
||||
>
|
||||
<Form.Input
|
||||
field="email"
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
name="email"
|
||||
size="large"
|
||||
name='email'
|
||||
disabled={true}
|
||||
prefix={<IconMail />}
|
||||
placeholder={email ? '' : t('等待获取邮箱信息...')}
|
||||
@@ -120,20 +158,21 @@ const PasswordResetConfirm = () => {
|
||||
|
||||
{newPassword && (
|
||||
<Form.Input
|
||||
field="newPassword"
|
||||
field='newPassword'
|
||||
label={t('新密码')}
|
||||
name="newPassword"
|
||||
size="large"
|
||||
name='newPassword'
|
||||
disabled={true}
|
||||
prefix={<IconLock />}
|
||||
suffix={
|
||||
<Button
|
||||
icon={<IconCopy />}
|
||||
type="tertiary"
|
||||
theme="borderless"
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
onClick={async () => {
|
||||
await copy(newPassword);
|
||||
showNotice(`${t('密码已复制到剪贴板:')} ${newPassword}`);
|
||||
showNotice(
|
||||
`${t('密码已复制到剪贴板:')} ${newPassword}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('复制')}
|
||||
@@ -142,24 +181,32 @@ const PasswordResetConfirm = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={disableButton || newPassword || !isValidResetLink}
|
||||
disabled={
|
||||
disableButton || newPassword || !isValidResetLink
|
||||
}
|
||||
>
|
||||
{newPassword ? t('密码重置完成') : t('确认重置密码')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text><Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('返回登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('返回登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1,5 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
showError,
|
||||
showInfo,
|
||||
showSuccess,
|
||||
getSystemName,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Form, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconMail } from '@douyinfe/semi-icons';
|
||||
@@ -78,59 +104,77 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('密码重置')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('密码重置')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field="email"
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
placeholder={t('请输入您的邮箱地址')}
|
||||
name="email"
|
||||
size="large"
|
||||
name='email'
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={disableButton}
|
||||
>
|
||||
{disableButton ? `${t('重试')} (${countdown})` : t('提交')}
|
||||
{disableButton
|
||||
? `${t('重试')} (${countdown})`
|
||||
: t('提交')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('想起来了?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('想起来了?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
@@ -8,30 +27,29 @@ import {
|
||||
showSuccess,
|
||||
updateAPI,
|
||||
getSystemName,
|
||||
setUserData
|
||||
} from '../../helpers/index.js';
|
||||
setUserData,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Button, Card, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IconGithubLogo,
|
||||
IconMail,
|
||||
IconUser,
|
||||
IconLock,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
onOIDCClicked,
|
||||
} from '../../helpers/index.js';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon.js';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon.js';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon.js';
|
||||
} from '../../helpers';
|
||||
import OIDCIcon from '../common/logo/OIDCIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import TelegramLoginButton from 'react-telegram-login/src';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const RegisterForm = () => {
|
||||
@@ -59,8 +77,11 @@ const RegisterForm = () => {
|
||||
const [emailRegisterLoading, setEmailRegisterLoading] = useState(false);
|
||||
const [registerLoading, setRegisterLoading] = useState(false);
|
||||
const [verificationCodeLoading, setVerificationCodeLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false);
|
||||
const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] =
|
||||
useState(false);
|
||||
const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
|
||||
const logo = getLogo();
|
||||
const systemName = getSystemName();
|
||||
@@ -87,6 +108,19 @@ const RegisterForm = () => {
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
if (disableButton && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0) {
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
return () => clearInterval(countdownInterval); // Clean up on unmount
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
const onWeChatLoginClicked = () => {
|
||||
setWechatLoading(true);
|
||||
setShowWeChatLoginModal(true);
|
||||
@@ -179,6 +213,7 @@ const RegisterForm = () => {
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('验证码发送成功,请检查你的邮箱!');
|
||||
setDisableButton(true); // 发送成功后禁用按钮,开始倒计时
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
@@ -201,10 +236,7 @@ const RegisterForm = () => {
|
||||
const handleOIDCClick = () => {
|
||||
setOidcLoading(true);
|
||||
try {
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id
|
||||
);
|
||||
onOIDCClicked(status.oidc_authorization_endpoint, status.oidc_client_id);
|
||||
} finally {
|
||||
setTimeout(() => setOidcLoading(false), 3000);
|
||||
}
|
||||
@@ -268,77 +300,87 @@ const RegisterForm = () => {
|
||||
|
||||
const renderOAuthOptions = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('注 册')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<div className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<div className='space-y-3'>
|
||||
{status.wechat_login && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />}
|
||||
size="large"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<Icon svg={<WeChatIcon />} style={{ color: '#07C160' }} />
|
||||
}
|
||||
onClick={onWeChatLoginClicked}
|
||||
loading={wechatLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 微信 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 微信 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.github_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<IconGithubLogo size="large" />}
|
||||
size="large"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<IconGithubLogo size='large' />}
|
||||
onClick={handleGitHubClick}
|
||||
loading={githubLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 GitHub 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 GitHub 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.oidc_enabled && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<OIDCIcon style={{ color: '#1877F2' }} />}
|
||||
size="large"
|
||||
onClick={handleOIDCClick}
|
||||
loading={oidcLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 OIDC 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 OIDC 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.linuxdo_oauth && (
|
||||
<Button
|
||||
theme='outline'
|
||||
className="w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
type="tertiary"
|
||||
icon={<LinuxDoIcon style={{ color: '#E95420', width: '20px', height: '20px' }} />}
|
||||
size="large"
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<LinuxDoIcon
|
||||
style={{
|
||||
color: '#E95420',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleLinuxDOClick}
|
||||
loading={linuxdoLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 LinuxDO 继续')}</span>
|
||||
<span className='ml-3'>{t('使用 LinuxDO 继续')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{status.telegram_oauth && (
|
||||
<div className="flex justify-center my-2">
|
||||
<div className='flex justify-center my-2'>
|
||||
<TelegramLoginButton
|
||||
dataOnauth={onTelegramLoginClicked}
|
||||
botName={status.telegram_bot_name}
|
||||
@@ -351,20 +393,27 @@ const RegisterForm = () => {
|
||||
</Divider>
|
||||
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className="w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors"
|
||||
icon={<IconMail size="large" />}
|
||||
size="large"
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className='w-full h-12 flex items-center justify-center bg-black text-white !rounded-full hover:bg-gray-800 transition-colors'
|
||||
icon={<IconMail size='large' />}
|
||||
onClick={handleEmailRegisterClick}
|
||||
loading={emailRegisterLoading}
|
||||
>
|
||||
<span className="ml-3">{t('使用 用户名 注册')}</span>
|
||||
<span className='ml-3'>{t('使用 用户名 注册')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('已有账户?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -375,47 +424,48 @@ const RegisterForm = () => {
|
||||
|
||||
const renderEmailRegisterForm = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="flex items-center justify-center mb-6 gap-2">
|
||||
<img src={logo} alt="Logo" className="h-10 rounded-full" />
|
||||
<Title heading={3} className='!text-gray-800'>{systemName}</Title>
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='w-full max-w-md'>
|
||||
<div className='flex items-center justify-center mb-6 gap-2'>
|
||||
<img src={logo} alt='Logo' className='h-10 rounded-full' />
|
||||
<Title heading={3} className='!text-gray-800'>
|
||||
{systemName}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-xl border-0 !rounded-2xl overflow-hidden">
|
||||
<div className="flex justify-center pt-6 pb-2">
|
||||
<Title heading={3} className="text-gray-800 dark:text-gray-200">{t('注 册')}</Title>
|
||||
<Card className='border-0 !rounded-2xl overflow-hidden'>
|
||||
<div className='flex justify-center pt-6 pb-2'>
|
||||
<Title heading={3} className='text-gray-800 dark:text-gray-200'>
|
||||
{t('注 册')}
|
||||
</Title>
|
||||
</div>
|
||||
<div className="px-2 py-8">
|
||||
<Form className="space-y-3">
|
||||
<div className='px-2 py-8'>
|
||||
<Form className='space-y-3'>
|
||||
<Form.Input
|
||||
field="username"
|
||||
field='username'
|
||||
label={t('用户名')}
|
||||
placeholder={t('请输入用户名')}
|
||||
name="username"
|
||||
size="large"
|
||||
name='username'
|
||||
onChange={(value) => handleChange('username', value)}
|
||||
prefix={<IconUser />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password"
|
||||
field='password'
|
||||
label={t('密码')}
|
||||
placeholder={t('输入密码,最短 8 位,最长 20 位')}
|
||||
name="password"
|
||||
mode="password"
|
||||
size="large"
|
||||
name='password'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field="password2"
|
||||
field='password2'
|
||||
label={t('确认密码')}
|
||||
placeholder={t('确认密码')}
|
||||
name="password2"
|
||||
mode="password"
|
||||
size="large"
|
||||
name='password2'
|
||||
mode='password'
|
||||
onChange={(value) => handleChange('password2', value)}
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
@@ -423,43 +473,44 @@ const RegisterForm = () => {
|
||||
{showEmailVerification && (
|
||||
<>
|
||||
<Form.Input
|
||||
field="email"
|
||||
field='email'
|
||||
label={t('邮箱')}
|
||||
placeholder={t('输入邮箱地址')}
|
||||
name="email"
|
||||
type="email"
|
||||
size="large"
|
||||
name='email'
|
||||
type='email'
|
||||
onChange={(value) => handleChange('email', value)}
|
||||
prefix={<IconMail />}
|
||||
suffix={
|
||||
<Button
|
||||
onClick={sendVerificationCode}
|
||||
loading={verificationCodeLoading}
|
||||
size="small"
|
||||
disabled={disableButton || verificationCodeLoading}
|
||||
>
|
||||
{t('获取验证码')}
|
||||
{disableButton
|
||||
? `${t('重新发送')} (${countdown})`
|
||||
: t('获取验证码')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Form.Input
|
||||
field="verification_code"
|
||||
field='verification_code'
|
||||
label={t('验证码')}
|
||||
placeholder={t('输入验证码')}
|
||||
name="verification_code"
|
||||
size="large"
|
||||
onChange={(value) => handleChange('verification_code', value)}
|
||||
name='verification_code'
|
||||
onChange={(value) =>
|
||||
handleChange('verification_code', value)
|
||||
}
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className='space-y-2 pt-2'>
|
||||
<Button
|
||||
theme="solid"
|
||||
className="w-full !rounded-full"
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
theme='solid'
|
||||
className='w-full !rounded-full'
|
||||
type='primary'
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={registerLoading}
|
||||
>
|
||||
@@ -468,18 +519,21 @@ const RegisterForm = () => {
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && (
|
||||
{(status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth) && (
|
||||
<>
|
||||
<Divider margin='12px' align='center'>
|
||||
{t('或')}
|
||||
</Divider>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<div className='mt-4 text-center'>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
className="w-full !rounded-full"
|
||||
size="large"
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
className='w-full !rounded-full'
|
||||
onClick={handleOtherRegisterOptionsClick}
|
||||
loading={otherRegisterOptionsLoading}
|
||||
>
|
||||
@@ -489,8 +543,16 @@ const RegisterForm = () => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Text>{t('已有账户?')} <Link to="/login" className="text-blue-600 hover:text-blue-800 font-medium">{t('登录')}</Link></Text>
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
<Text>
|
||||
{t('已有账户?')}{' '}
|
||||
<Link
|
||||
to='/login'
|
||||
className='text-blue-600 hover:text-blue-800 font-medium'
|
||||
>
|
||||
{t('登录')}
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -508,27 +570,30 @@ const RegisterForm = () => {
|
||||
onOk={onSubmitWeChatVerificationCode}
|
||||
onCancel={() => setShowWeChatLoginModal(false)}
|
||||
okText={t('登录')}
|
||||
size="small"
|
||||
centered={true}
|
||||
okButtonProps={{
|
||||
loading: wechatCodeSubmitLoading,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={status.wechat_qrcode} alt="微信二维码" className="mb-4" />
|
||||
<div className='flex flex-col items-center'>
|
||||
<img src={status.wechat_qrcode} alt='微信二维码' className='mb-4' />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-4">
|
||||
<p>{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}</p>
|
||||
<div className='text-center mb-4'>
|
||||
<p>
|
||||
{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form size="large">
|
||||
<Form>
|
||||
<Form.Input
|
||||
field="wechat_verification_code"
|
||||
field='wechat_verification_code'
|
||||
placeholder={t('验证码')}
|
||||
label={t('验证码')}
|
||||
value={inputs.wechat_verification_code}
|
||||
onChange={(value) => handleChange('wechat_verification_code', value)}
|
||||
onChange={(value) =>
|
||||
handleChange('wechat_verification_code', value)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -536,18 +601,31 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className="blur-ball blur-ball-indigo" style={{ top: '-80px', right: '-80px', transform: 'none' }} />
|
||||
<div className="blur-ball blur-ball-teal" style={{ top: '50%', left: '-120px' }} />
|
||||
<div className="w-full max-w-sm mt-[64px]">
|
||||
{showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth)
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
style={{ top: '-80px', right: '-80px', transform: 'none' }}
|
||||
/>
|
||||
<div
|
||||
className='blur-ball blur-ball-teal'
|
||||
style={{ top: '50%', left: '-120px' }}
|
||||
/>
|
||||
<div className='w-full max-w-sm mt-[60px]'>
|
||||
{showEmailRegister ||
|
||||
!(
|
||||
status.github_oauth ||
|
||||
status.oidc_enabled ||
|
||||
status.wechat_login ||
|
||||
status.linuxdo_oauth ||
|
||||
status.telegram_oauth
|
||||
)
|
||||
? renderEmailRegisterForm()
|
||||
: renderOAuthOptions()}
|
||||
{renderWeChatLoginModal()}
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className="flex justify-center mt-6">
|
||||
<div className='flex justify-center mt-6'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
244
web/src/components/auth/TwoFAVerification.jsx
Normal file
244
web/src/components/auth/TwoFAVerification.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [useBackupCode, setUseBackupCode] = useState(false);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!verificationCode) {
|
||||
showError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
// Validate code format
|
||||
if (useBackupCode && verificationCode.length !== 8) {
|
||||
showError('备用码必须是8位');
|
||||
return;
|
||||
} else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) {
|
||||
showError('验证码必须是6位数字');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/login/2fa', {
|
||||
code: verificationCode,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess('登录成功');
|
||||
// 保存用户信息到本地存储
|
||||
localStorage.setItem('user', JSON.stringify(res.data.data));
|
||||
if (onSuccess) {
|
||||
onSuccess(res.data.data);
|
||||
}
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError('验证失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (isModal) {
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
<Paragraph className='text-gray-600 dark:text-gray-300'>
|
||||
请输入认证器应用显示的验证码完成登录
|
||||
</Paragraph>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Input
|
||||
field='code'
|
||||
label={useBackupCode ? '备用码' : '验证码'}
|
||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
onKeyPress={handleKeyPress}
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
htmlType='submit'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
block
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
验证并登录
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setVerificationCode('');
|
||||
}}
|
||||
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
||||
</Button>
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={onBack}
|
||||
style={{ color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
返回登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3'>
|
||||
<Text size='small' type='secondary'>
|
||||
<strong>提示:</strong>
|
||||
<br />
|
||||
• 验证码每30秒更新一次
|
||||
<br />
|
||||
• 如果无法获取验证码,请使用备用码
|
||||
<br />• 每个备用码只能使用一次
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '60vh',
|
||||
}}
|
||||
>
|
||||
<Card style={{ width: 400, padding: 24 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title heading={3}>两步验证</Title>
|
||||
<Paragraph type='secondary'>
|
||||
请输入认证器应用显示的验证码完成登录
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Form onSubmit={handleSubmit}>
|
||||
<Form.Input
|
||||
field='code'
|
||||
label={useBackupCode ? '备用码' : '验证码'}
|
||||
placeholder={useBackupCode ? '请输入8位备用码' : '请输入6位验证码'}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
onKeyPress={handleKeyPress}
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<Button
|
||||
htmlType='submit'
|
||||
type='primary'
|
||||
loading={loading}
|
||||
block
|
||||
size='large'
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
验证并登录
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={() => {
|
||||
setUseBackupCode(!useBackupCode);
|
||||
setVerificationCode('');
|
||||
}}
|
||||
style={{ marginRight: 16, color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
{useBackupCode ? '使用认证器验证码' : '使用备用码'}
|
||||
</Button>
|
||||
|
||||
{onBack && (
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
onClick={onBack}
|
||||
style={{ color: '#1890ff', padding: 0 }}
|
||||
>
|
||||
返回登录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
background: '#f6f8fa',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Text size='small' type='secondary'>
|
||||
<strong>提示:</strong>
|
||||
<br />
|
||||
• 验证码每30秒更新一次
|
||||
<br />
|
||||
• 如果无法获取验证码,请使用备用码
|
||||
<br />• 每个备用码只能使用一次
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFAVerification;
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
const Loading = ({ size = 'small' }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 w-screen h-screen flex items-center justify-center">
|
||||
<Spin
|
||||
size={size}
|
||||
spinning={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Icon } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import 'highlight.js/styles/github.css';
|
||||
@@ -141,7 +160,7 @@ export function PreCode(props) {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="copy-code-button"
|
||||
className='copy-code-button'
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
@@ -155,14 +174,15 @@ export function PreCode(props) {
|
||||
>
|
||||
<Tooltip content={t('复制代码')}>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
icon={<IconCopy />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
const code = ref.current.querySelector('code')?.innerText ?? '';
|
||||
const code =
|
||||
ref.current.querySelector('code')?.innerText ?? '';
|
||||
copy(code).then((success) => {
|
||||
if (success) {
|
||||
Toast.success(t('代码已复制到剪贴板'));
|
||||
@@ -198,7 +218,13 @@ export function PreCode(props) {
|
||||
backgroundColor: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: '8px', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
HTML预览:
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: htmlCode }} />
|
||||
@@ -239,7 +265,7 @@ function CustomCode(props) {
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Button size="small" onClick={toggleCollapsed} theme="solid">
|
||||
<Button size='small' onClick={toggleCollapsed} theme='solid'>
|
||||
{t('显示更多')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -348,7 +374,16 @@ function _MarkdownContent(props) {
|
||||
components={{
|
||||
pre: PreCode,
|
||||
code: CustomCode,
|
||||
p: (pProps) => <p {...pProps} dir="auto" style={{ lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
p: (pProps) => (
|
||||
<p
|
||||
{...pProps}
|
||||
dir='auto'
|
||||
style={{
|
||||
lineHeight: '1.6',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
a: (aProps) => {
|
||||
const href = aProps.href || '';
|
||||
if (/\.(aac|mp3|opus|wav)$/.test(href)) {
|
||||
@@ -360,13 +395,16 @@ function _MarkdownContent(props) {
|
||||
}
|
||||
if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) {
|
||||
return (
|
||||
<video controls style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}>
|
||||
<video
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '100%', margin: '12px 0' }}
|
||||
>
|
||||
<source src={href} />
|
||||
</video>
|
||||
);
|
||||
}
|
||||
const isInternal = /^\/#/i.test(href);
|
||||
const target = isInternal ? '_self' : aProps.target ?? '_blank';
|
||||
const target = isInternal ? '_self' : (aProps.target ?? '_blank');
|
||||
return (
|
||||
<a
|
||||
{...aProps}
|
||||
@@ -384,20 +422,84 @@ function _MarkdownContent(props) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
h1: (props) => <h1 {...props} style={{ fontSize: '24px', fontWeight: 'bold', margin: '20px 0 12px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h2: (props) => <h2 {...props} style={{ fontSize: '20px', fontWeight: 'bold', margin: '18px 0 10px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h3: (props) => <h3 {...props} style={{ fontSize: '18px', fontWeight: 'bold', margin: '16px 0 8px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h4: (props) => <h4 {...props} style={{ fontSize: '16px', fontWeight: 'bold', margin: '14px 0 6px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h5: (props) => <h5 {...props} style={{ fontSize: '14px', fontWeight: 'bold', margin: '12px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h6: (props) => <h6 {...props} style={{ fontSize: '13px', fontWeight: 'bold', margin: '10px 0 4px 0', color: isUserMessage ? 'white' : 'var(--semi-color-text-0)' }} />,
|
||||
h1: (props) => (
|
||||
<h1
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
margin: '20px 0 12px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h2: (props) => (
|
||||
<h2
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
margin: '18px 0 10px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h3: (props) => (
|
||||
<h3
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
margin: '16px 0 8px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h4: (props) => (
|
||||
<h4
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
margin: '14px 0 6px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h5: (props) => (
|
||||
<h5
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
margin: '12px 0 4px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
h6: (props) => (
|
||||
<h6
|
||||
{...props}
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: 'bold',
|
||||
margin: '10px 0 4px 0',
|
||||
color: isUserMessage ? 'white' : 'var(--semi-color-text-0)',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
blockquote: (props) => (
|
||||
<blockquote
|
||||
{...props}
|
||||
style={{
|
||||
borderLeft: isUserMessage ? '4px solid rgba(255, 255, 255, 0.5)' : '4px solid var(--semi-color-primary)',
|
||||
borderLeft: isUserMessage
|
||||
? '4px solid rgba(255, 255, 255, 0.5)'
|
||||
: '4px solid var(--semi-color-primary)',
|
||||
paddingLeft: '16px',
|
||||
margin: '12px 0',
|
||||
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.1)' : 'var(--semi-color-fill-0)',
|
||||
backgroundColor: isUserMessage
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'var(--semi-color-fill-0)',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '0 4px 4px 0',
|
||||
fontStyle: 'italic',
|
||||
@@ -405,9 +507,36 @@ function _MarkdownContent(props) {
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ul: (props) => <ul {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
ol: (props) => <ol {...props} style={{ margin: '8px 0', paddingLeft: '20px', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
li: (props) => <li {...props} style={{ margin: '4px 0', lineHeight: '1.6', color: isUserMessage ? 'white' : 'inherit' }} />,
|
||||
ul: (props) => (
|
||||
<ul
|
||||
{...props}
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
paddingLeft: '20px',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
ol: (props) => (
|
||||
<ol
|
||||
{...props}
|
||||
style={{
|
||||
margin: '8px 0',
|
||||
paddingLeft: '20px',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
li: (props) => (
|
||||
<li
|
||||
{...props}
|
||||
style={{
|
||||
margin: '4px 0',
|
||||
lineHeight: '1.6',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
table: (props) => (
|
||||
<div style={{ overflow: 'auto', margin: '12px 0' }}>
|
||||
<table
|
||||
@@ -415,7 +544,9 @@ function _MarkdownContent(props) {
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
@@ -427,8 +558,12 @@ function _MarkdownContent(props) {
|
||||
{...props}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
backgroundColor: isUserMessage ? 'rgba(255, 255, 255, 0.2)' : 'var(--semi-color-fill-1)',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
backgroundColor: isUserMessage
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'var(--semi-color-fill-1)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'left',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
@@ -440,7 +575,9 @@ function _MarkdownContent(props) {
|
||||
{...props}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: isUserMessage ? '1px solid rgba(255, 255, 255, 0.3)' : '1px solid var(--semi-color-border)',
|
||||
border: isUserMessage
|
||||
? '1px solid rgba(255, 255, 255, 0.3)'
|
||||
: '1px solid var(--semi-color-border)',
|
||||
color: isUserMessage ? 'white' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
@@ -477,25 +614,29 @@ export function MarkdownRenderer(props) {
|
||||
color: 'var(--semi-color-text-0)',
|
||||
...style,
|
||||
}}
|
||||
dir="auto"
|
||||
dir='auto'
|
||||
{...otherProps}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid var(--semi-color-border)',
|
||||
borderTop: '2px solid var(--semi-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '16px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid var(--semi-color-border)',
|
||||
borderTop: '2px solid var(--semi-color-primary)',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
正在渲染...
|
||||
</div>
|
||||
) : (
|
||||
@@ -510,4 +651,4 @@ export function MarkdownRenderer(props) {
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownRenderer;
|
||||
export default MarkdownRenderer;
|
||||
@@ -59,12 +59,12 @@
|
||||
}
|
||||
|
||||
.user-message a {
|
||||
color: #87CEEB !important;
|
||||
color: #87ceeb !important;
|
||||
/* 浅蓝色链接 */
|
||||
}
|
||||
|
||||
.user-message a:hover {
|
||||
color: #B0E0E6 !important;
|
||||
color: #b0e0e6 !important;
|
||||
/* hover时更浅的蓝色 */
|
||||
}
|
||||
|
||||
@@ -298,7 +298,12 @@ pre:hover .copy-code-button {
|
||||
.markdown-body hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, var(--semi-color-border), transparent);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--semi-color-border),
|
||||
transparent
|
||||
);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
@@ -332,7 +337,7 @@ pre:hover .copy-code-button {
|
||||
}
|
||||
|
||||
/* 任务列表样式 */
|
||||
.markdown-body input[type="checkbox"] {
|
||||
.markdown-body input[type='checkbox'] {
|
||||
margin-right: 8px;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
@@ -441,4 +446,4 @@ pre:hover .copy-code-button {
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
}
|
||||
|
||||
146
web/src/components/common/modals/TwoFactorAuthModal.jsx
Normal file
146
web/src/components/common/modals/TwoFactorAuthModal.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui';
|
||||
|
||||
/**
|
||||
* 可复用的两步验证模态框组件
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - 是否显示模态框
|
||||
* @param {string} props.code - 验证码值
|
||||
* @param {boolean} props.loading - 是否正在验证
|
||||
* @param {Function} props.onCodeChange - 验证码变化回调
|
||||
* @param {Function} props.onVerify - 验证回调
|
||||
* @param {Function} props.onCancel - 取消回调
|
||||
* @param {string} props.title - 模态框标题
|
||||
* @param {string} props.description - 验证描述文本
|
||||
* @param {string} props.placeholder - 输入框占位文本
|
||||
*/
|
||||
const TwoFactorAuthModal = ({
|
||||
visible,
|
||||
code,
|
||||
loading,
|
||||
onCodeChange,
|
||||
onVerify,
|
||||
onCancel,
|
||||
title,
|
||||
description,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && code && !loading) {
|
||||
onVerify();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<div className='w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center mr-3'>
|
||||
<svg
|
||||
className='w-4 h-4 text-blue-600 dark:text-blue-400'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{title || t('安全验证')}
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel}>{t('取消')}</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
loading={loading}
|
||||
disabled={!code || loading}
|
||||
onClick={onVerify}
|
||||
>
|
||||
{t('验证')}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{/* 安全提示 */}
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-4'>
|
||||
<div className='flex items-start'>
|
||||
<svg
|
||||
className='w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 mr-3 flex-shrink-0'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='text-blue-800 dark:text-blue-200'
|
||||
>
|
||||
{t('安全验证')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className='block text-blue-700 dark:text-blue-300 text-sm mt-1'>
|
||||
{description || t('为了保护账户安全,请验证您的两步验证码。')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证码输入 */}
|
||||
<div>
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('验证身份')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder={placeholder || t('请输入认证器验证码或备用码')}
|
||||
value={code}
|
||||
onChange={onCodeChange}
|
||||
size='large'
|
||||
maxLength={8}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
<Typography.Text type='tertiary' size='small' className='mt-2 block'>
|
||||
{t('支持6位TOTP验证码或8位备用码')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFactorAuthModal;
|
||||
200
web/src/components/common/ui/CardPro.jsx
Normal file
200
web/src/components/common/ui/CardPro.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Divider, Typography, Button } from '@douyinfe/semi-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { IconEyeOpened, IconEyeClosed } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* CardPro 高级卡片组件
|
||||
*
|
||||
* 布局分为6个区域:
|
||||
* 1. 统计信息区域 (statsArea)
|
||||
* 2. 描述信息区域 (descriptionArea)
|
||||
* 3. 类型切换/标签区域 (tabsArea)
|
||||
* 4. 操作按钮区域 (actionsArea)
|
||||
* 5. 搜索表单区域 (searchArea)
|
||||
* 6. 分页区域 (paginationArea) - 固定在卡片底部
|
||||
*
|
||||
* 支持三种布局类型:
|
||||
* - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单
|
||||
* - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单
|
||||
* - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单
|
||||
*/
|
||||
const CardPro = ({
|
||||
type = 'type1',
|
||||
className = '',
|
||||
children,
|
||||
// 各个区域的内容
|
||||
statsArea,
|
||||
descriptionArea,
|
||||
tabsArea,
|
||||
actionsArea,
|
||||
searchArea,
|
||||
paginationArea, // 新增分页区域
|
||||
// 卡片属性
|
||||
shadows = '',
|
||||
bordered = true,
|
||||
// 自定义样式
|
||||
style,
|
||||
// 国际化函数
|
||||
t = (key) => key,
|
||||
...props
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [showMobileActions, setShowMobileActions] = useState(false);
|
||||
|
||||
const toggleMobileActions = () => {
|
||||
setShowMobileActions(!showMobileActions);
|
||||
};
|
||||
|
||||
const hasMobileHideableContent = actionsArea || searchArea;
|
||||
|
||||
const renderHeader = () => {
|
||||
const hasContent =
|
||||
statsArea || descriptionArea || tabsArea || actionsArea || searchArea;
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-full'>
|
||||
{/* 统计信息区域 - 用于type2 */}
|
||||
{type === 'type2' && statsArea && <>{statsArea}</>}
|
||||
|
||||
{/* 描述信息区域 - 用于type1和type3 */}
|
||||
{(type === 'type1' || type === 'type3') && descriptionArea && (
|
||||
<>{descriptionArea}</>
|
||||
)}
|
||||
|
||||
{/* 第一个分隔线 - 在描述信息或统计信息后面 */}
|
||||
{((type === 'type1' || type === 'type3') && descriptionArea) ||
|
||||
(type === 'type2' && statsArea) ? (
|
||||
<Divider margin='12px' />
|
||||
) : null}
|
||||
|
||||
{/* 类型切换/标签区域 - 主要用于type3 */}
|
||||
{type === 'type3' && tabsArea && <>{tabsArea}</>}
|
||||
|
||||
{/* 移动端操作切换按钮 */}
|
||||
{isMobile && hasMobileHideableContent && (
|
||||
<>
|
||||
<div className='w-full mb-2'>
|
||||
<Button
|
||||
onClick={toggleMobileActions}
|
||||
icon={showMobileActions ? <IconEyeClosed /> : <IconEyeOpened />}
|
||||
type='tertiary'
|
||||
size='small'
|
||||
theme='outline'
|
||||
block
|
||||
>
|
||||
{showMobileActions ? t('隐藏操作项') : t('显示操作项')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 操作按钮和搜索表单的容器 */}
|
||||
<div
|
||||
className={`flex flex-col gap-2 ${isMobile && !showMobileActions ? 'hidden' : ''}`}
|
||||
>
|
||||
{/* 操作按钮区域 - 用于type1和type3 */}
|
||||
{(type === 'type1' || type === 'type3') &&
|
||||
actionsArea &&
|
||||
(Array.isArray(actionsArea) ? (
|
||||
actionsArea.map((area, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{idx !== 0 && <Divider />}
|
||||
<div className='w-full'>{area}</div>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className='w-full'>{actionsArea}</div>
|
||||
))}
|
||||
|
||||
{/* 当同时存在操作区和搜索区时,插入分隔线 */}
|
||||
{actionsArea && searchArea && <Divider />}
|
||||
|
||||
{/* 搜索表单区域 - 所有类型都可能有 */}
|
||||
{searchArea && <div className='w-full'>{searchArea}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const headerContent = renderHeader();
|
||||
|
||||
// 渲染分页区域
|
||||
const renderFooter = () => {
|
||||
if (!paginationArea) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex w-full pt-4 border-t ${isMobile ? 'justify-center' : 'justify-between items-center'}`}
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
{paginationArea}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const footerContent = renderFooter();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`table-scroll-card !rounded-2xl ${className}`}
|
||||
title={headerContent}
|
||||
footer={footerContent}
|
||||
shadows={shadows}
|
||||
bordered={bordered}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
CardPro.propTypes = {
|
||||
// 布局类型
|
||||
type: PropTypes.oneOf(['type1', 'type2', 'type3']),
|
||||
// 样式相关
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
bordered: PropTypes.bool,
|
||||
// 内容区域
|
||||
statsArea: PropTypes.node,
|
||||
descriptionArea: PropTypes.node,
|
||||
tabsArea: PropTypes.node,
|
||||
actionsArea: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.arrayOf(PropTypes.node),
|
||||
]),
|
||||
searchArea: PropTypes.node,
|
||||
paginationArea: PropTypes.node,
|
||||
// 表格内容
|
||||
children: PropTypes.node,
|
||||
// 国际化函数
|
||||
t: PropTypes.func,
|
||||
};
|
||||
|
||||
export default CardPro;
|
||||
242
web/src/components/common/ui/CardTable.jsx
Normal file
242
web/src/components/common/ui/CardTable.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
Skeleton,
|
||||
Pagination,
|
||||
Empty,
|
||||
Button,
|
||||
Collapsible,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
|
||||
/**
|
||||
* CardTable 响应式表格组件
|
||||
*
|
||||
* 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。
|
||||
* 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。
|
||||
*/
|
||||
const CardTable = ({
|
||||
columns = [],
|
||||
dataSource = [],
|
||||
loading = false,
|
||||
rowKey = 'key',
|
||||
hidePagination = false,
|
||||
...tableProps
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
const getRowKey = (record, index) => {
|
||||
if (typeof rowKey === 'function') return rowKey(record);
|
||||
return record[rowKey] !== undefined ? record[rowKey] : index;
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
const finalTableProps = hidePagination
|
||||
? { ...tableProps, pagination: false }
|
||||
: tableProps;
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={loading}
|
||||
rowKey={rowKey}
|
||||
{...finalTableProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showSkeleton) {
|
||||
const visibleCols = columns.filter((col) => {
|
||||
if (tableProps?.visibleColumns && col.key) {
|
||||
return tableProps.visibleColumns[col.key];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const renderSkeletonCard = (key) => {
|
||||
const placeholder = (
|
||||
<div className='p-2'>
|
||||
{visibleCols.map((col, idx) => {
|
||||
if (!col.title) {
|
||||
return (
|
||||
<div key={idx} className='mt-2 flex justify-end'>
|
||||
<Skeleton.Title active style={{ width: 100, height: 24 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className='flex justify-between items-center py-1 border-b last:border-b-0 border-dashed'
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{
|
||||
width: `${50 + (idx % 3) * 10}%`,
|
||||
maxWidth: 180,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={key} className='!rounded-2xl shadow-sm'>
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{[1, 2, 3].map((i) => renderSkeletonCard(i))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0);
|
||||
|
||||
const MobileRowCard = ({ record, index }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const rowKeyVal = getRowKey(record, index);
|
||||
|
||||
const hasDetails =
|
||||
tableProps.expandedRowRender &&
|
||||
(!tableProps.rowExpandable || tableProps.rowExpandable(record));
|
||||
|
||||
return (
|
||||
<Card key={rowKeyVal} className='!rounded-2xl shadow-sm'>
|
||||
{columns.map((col, colIdx) => {
|
||||
if (
|
||||
tableProps?.visibleColumns &&
|
||||
!tableProps.visibleColumns[col.key]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = col.title;
|
||||
const cellContent = col.render
|
||||
? col.render(record[col.dataIndex], record, index)
|
||||
: record[col.dataIndex];
|
||||
|
||||
if (!title) {
|
||||
return (
|
||||
<div key={col.key || colIdx} className='mt-2 flex justify-end'>
|
||||
{cellContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.key || colIdx}
|
||||
className='flex justify-between items-start py-1 border-b last:border-b-0 border-dashed'
|
||||
style={{ borderColor: 'var(--semi-color-border)' }}
|
||||
>
|
||||
<span className='font-medium text-gray-600 mr-2 whitespace-nowrap select-none'>
|
||||
{title}
|
||||
</span>
|
||||
<div className='flex-1 break-all flex justify-end items-center gap-1'>
|
||||
{cellContent !== undefined && cellContent !== null
|
||||
? cellContent
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{hasDetails && (
|
||||
<>
|
||||
<Button
|
||||
theme='borderless'
|
||||
size='small'
|
||||
className='w-full flex justify-center mt-2'
|
||||
icon={showDetails ? <IconChevronUp /> : <IconChevronDown />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDetails(!showDetails);
|
||||
}}
|
||||
>
|
||||
{showDetails ? t('收起') : t('详情')}
|
||||
</Button>
|
||||
<Collapsible isOpen={showDetails} keepDOM>
|
||||
<div className='pt-2'>
|
||||
{tableProps.expandedRowRender(record, index)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
if (isEmpty) {
|
||||
if (tableProps.empty) return tableProps.empty;
|
||||
return (
|
||||
<div className='flex justify-center p-4'>
|
||||
<Empty description='No Data' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-2'>
|
||||
{dataSource.map((record, index) => (
|
||||
<MobileRowCard
|
||||
key={getRowKey(record, index)}
|
||||
record={record}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
{!hidePagination && tableProps.pagination && dataSource.length > 0 && (
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Pagination {...tableProps.pagination} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CardTable.propTypes = {
|
||||
columns: PropTypes.array.isRequired,
|
||||
dataSource: PropTypes.array,
|
||||
loading: PropTypes.bool,
|
||||
rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
hidePagination: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CardTable;
|
||||
280
web/src/components/common/ui/ChannelKeyDisplay.jsx
Normal file
280
web/src/components/common/ui/ChannelKeyDisplay.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui';
|
||||
import { copy, showSuccess } from '../../../helpers';
|
||||
|
||||
/**
|
||||
* 解析密钥数据,支持多种格式
|
||||
* @param {string} keyData - 密钥数据
|
||||
* @param {Function} t - 翻译函数
|
||||
* @returns {Array} 解析后的密钥数组
|
||||
*/
|
||||
const parseChannelKeys = (keyData, t) => {
|
||||
if (!keyData) return [];
|
||||
|
||||
const trimmed = keyData.trim();
|
||||
|
||||
// 检查是否是JSON数组格式(如Vertex AI)
|
||||
if (trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((item, index) => ({
|
||||
id: index,
|
||||
content:
|
||||
typeof item === 'string' ? item : JSON.stringify(item, null, 2),
|
||||
type: typeof item === 'string' ? 'text' : 'json',
|
||||
label: `${t('密钥')} ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,按普通文本处理
|
||||
console.warn('Failed to parse JSON keys:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否是多行密钥(按换行符分割)
|
||||
const lines = trimmed.split('\n').filter((line) => line.trim());
|
||||
if (lines.length > 1) {
|
||||
return lines.map((line, index) => ({
|
||||
id: index,
|
||||
content: line.trim(),
|
||||
type: 'text',
|
||||
label: `${t('密钥')} ${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// 单个密钥
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
content: trimmed,
|
||||
type: trimmed.startsWith('{') ? 'json' : 'text',
|
||||
label: t('密钥'),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* 可复用的密钥显示组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.keyData - 密钥数据
|
||||
* @param {boolean} props.showSuccessIcon - 是否显示成功图标
|
||||
* @param {string} props.successText - 成功文本
|
||||
* @param {boolean} props.showWarning - 是否显示安全警告
|
||||
* @param {string} props.warningText - 警告文本
|
||||
*/
|
||||
const ChannelKeyDisplay = ({
|
||||
keyData,
|
||||
showSuccessIcon = true,
|
||||
successText,
|
||||
showWarning = true,
|
||||
warningText,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const parsedKeys = parseChannelKeys(keyData, t);
|
||||
const isMultipleKeys = parsedKeys.length > 1;
|
||||
|
||||
const handleCopyAll = () => {
|
||||
copy(keyData);
|
||||
showSuccess(t('所有密钥已复制到剪贴板'));
|
||||
};
|
||||
|
||||
const handleCopyKey = (content) => {
|
||||
copy(content);
|
||||
showSuccess(t('密钥已复制到剪贴板'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* 成功状态 */}
|
||||
{showSuccessIcon && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<svg
|
||||
className='w-5 h-5 text-green-600'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<Typography.Text strong className='text-green-700'>
|
||||
{successText || t('验证成功')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 密钥内容 */}
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text strong>
|
||||
{isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')}
|
||||
</Typography.Text>
|
||||
{isMultipleKeys && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<Typography.Text type='tertiary' size='small'>
|
||||
{t('共 {{count}} 个密钥', { count: parsedKeys.length })}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleCopyAll}
|
||||
>
|
||||
{t('复制全部')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-3 max-h-80 overflow-auto'>
|
||||
{parsedKeys.map((keyItem) => (
|
||||
<Card
|
||||
key={keyItem.id}
|
||||
className='!rounded-lg !border !border-gray-200 dark:!border-gray-700'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text
|
||||
strong
|
||||
size='small'
|
||||
className='text-gray-700 dark:text-gray-300'
|
||||
>
|
||||
{keyItem.label}
|
||||
</Typography.Text>
|
||||
<div className='flex items-center gap-2'>
|
||||
{keyItem.type === 'json' && (
|
||||
<Tag size='small' color='blue'>
|
||||
{t('JSON')}
|
||||
</Tag>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
icon={
|
||||
<svg
|
||||
className='w-3 h-3'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path d='M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z' />
|
||||
<path d='M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z' />
|
||||
</svg>
|
||||
}
|
||||
onClick={() => handleCopyKey(keyItem.content)}
|
||||
>
|
||||
{t('复制')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-lg p-3 max-h-40 overflow-auto'>
|
||||
<Typography.Text
|
||||
code
|
||||
className='text-xs font-mono break-all whitespace-pre-wrap text-gray-800 dark:text-gray-200'
|
||||
>
|
||||
{keyItem.content}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{keyItem.type === 'json' && (
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='block'
|
||||
>
|
||||
{t('JSON格式密钥,请确保格式正确')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isMultipleKeys && (
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
|
||||
<Typography.Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='text-blue-700 dark:text-blue-300'
|
||||
>
|
||||
<svg
|
||||
className='w-4 h-4 inline mr-1'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
{t(
|
||||
'检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 安全警告 */}
|
||||
{showWarning && (
|
||||
<div className='bg-yellow-50 dark:bg-yellow-900 rounded-lg p-4'>
|
||||
<div className='flex items-start'>
|
||||
<svg
|
||||
className='w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 mr-3 flex-shrink-0'
|
||||
fill='currentColor'
|
||||
viewBox='0 0 20 20'
|
||||
>
|
||||
<path
|
||||
fillRule='evenodd'
|
||||
d='M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z'
|
||||
clipRule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<Typography.Text
|
||||
strong
|
||||
className='text-yellow-800 dark:text-yellow-200'
|
||||
>
|
||||
{t('安全提醒')}
|
||||
</Typography.Text>
|
||||
<Typography.Text className='block text-yellow-700 dark:text-yellow-300 text-sm mt-1'>
|
||||
{warningText ||
|
||||
t(
|
||||
'请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelKeyDisplay;
|
||||
68
web/src/components/common/ui/CompactModeToggle.jsx
Normal file
68
web/src/components/common/ui/CompactModeToggle.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
|
||||
/**
|
||||
* 紧凑模式切换按钮组件
|
||||
* 用于在自适应列表和紧凑列表之间切换
|
||||
* 在移动端时自动隐藏,因为移动端使用"显示操作项"按钮来控制内容显示
|
||||
*/
|
||||
const CompactModeToggle = ({
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
size = 'small',
|
||||
type = 'tertiary',
|
||||
className = '',
|
||||
...props
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 在移动端隐藏紧凑列表切换按钮
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
size={size}
|
||||
className={`w-full md:w-auto ${className}`}
|
||||
onClick={() => setCompactMode(!compactMode)}
|
||||
{...props}
|
||||
>
|
||||
{compactMode ? t('自适应列表') : t('紧凑列表')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
CompactModeToggle.propTypes = {
|
||||
compactMode: PropTypes.bool.isRequired,
|
||||
setCompactMode: PropTypes.func.isRequired,
|
||||
t: PropTypes.func.isRequired,
|
||||
size: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
};
|
||||
|
||||
export default CompactModeToggle;
|
||||
714
web/src/components/common/ui/JSONEditor.jsx
Normal file
714
web/src/components/common/ui/JSONEditor.jsx
Normal file
@@ -0,0 +1,714 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Typography,
|
||||
Banner,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Card,
|
||||
Input,
|
||||
InputNumber,
|
||||
Switch,
|
||||
TextArea,
|
||||
Row,
|
||||
Col,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 唯一 ID 生成器,确保在组件生命周期内稳定且递增
|
||||
const generateUniqueId = (() => {
|
||||
let counter = 0;
|
||||
return () => `kv_${counter++}`;
|
||||
})();
|
||||
|
||||
const JSONEditor = ({
|
||||
value = '',
|
||||
onChange,
|
||||
field,
|
||||
label,
|
||||
placeholder,
|
||||
extraText,
|
||||
extraFooter,
|
||||
showClear = true,
|
||||
template,
|
||||
templateLabel,
|
||||
editorType = 'keyValue',
|
||||
rules = [],
|
||||
formApi = null,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 将对象转换为键值对数组(包含唯一ID)
|
||||
const objectToKeyValueArray = useCallback((obj, prevPairs = []) => {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
|
||||
const entries = Object.entries(obj);
|
||||
return entries.map(([key, value], index) => {
|
||||
// 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定
|
||||
const prev = prevPairs[index];
|
||||
const shouldReuseId = prev && prev.key === key;
|
||||
return {
|
||||
id: shouldReuseId ? prev.id : generateUniqueId(),
|
||||
key,
|
||||
value,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 将键值对数组转换为对象(重复键时后面的会覆盖前面的)
|
||||
const keyValueArrayToObject = useCallback((arr) => {
|
||||
const result = {};
|
||||
arr.forEach((item) => {
|
||||
if (item.key) {
|
||||
result[item.key] = item.value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
// 初始化键值对数组
|
||||
const [keyValuePairs, setKeyValuePairs] = useState(() => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return objectToKeyValueArray(parsed);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
return objectToKeyValueArray(value);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// 手动模式下的本地文本缓冲
|
||||
const [manualText, setManualText] = useState(() => {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object')
|
||||
return JSON.stringify(value, null, 2);
|
||||
return '';
|
||||
});
|
||||
|
||||
// 根据键数量决定默认编辑模式
|
||||
const [editMode, setEditMode] = useState(() => {
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const keyCount = Object.keys(parsed).length;
|
||||
return keyCount > 10 ? 'manual' : 'visual';
|
||||
} catch (error) {
|
||||
return 'manual';
|
||||
}
|
||||
}
|
||||
return 'visual';
|
||||
});
|
||||
|
||||
const [jsonError, setJsonError] = useState('');
|
||||
|
||||
// 计算重复的键
|
||||
const duplicateKeys = useMemo(() => {
|
||||
const keyCount = {};
|
||||
const duplicates = new Set();
|
||||
|
||||
keyValuePairs.forEach((pair) => {
|
||||
if (pair.key) {
|
||||
keyCount[pair.key] = (keyCount[pair.key] || 0) + 1;
|
||||
if (keyCount[pair.key] > 1) {
|
||||
duplicates.add(pair.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return duplicates;
|
||||
}, [keyValuePairs]);
|
||||
|
||||
// 数据同步 - 当value变化时更新键值对数组
|
||||
useEffect(() => {
|
||||
try {
|
||||
let parsed = {};
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
parsed = JSON.parse(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
|
||||
// 只在外部值真正改变时更新,避免循环更新
|
||||
const currentObj = keyValueArrayToObject(keyValuePairs);
|
||||
if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) {
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
}
|
||||
setJsonError('');
|
||||
} catch (error) {
|
||||
console.log('JSON解析失败:', error.message);
|
||||
setJsonError(error.message);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
// 外部 value 变化时,若不在手动模式,则同步手动文本
|
||||
useEffect(() => {
|
||||
if (editMode !== 'manual') {
|
||||
if (typeof value === 'string') setManualText(value);
|
||||
else if (value && typeof value === 'object')
|
||||
setManualText(JSON.stringify(value, null, 2));
|
||||
else setManualText('');
|
||||
}
|
||||
}, [value, editMode]);
|
||||
|
||||
// 处理可视化编辑的数据变化
|
||||
const handleVisualChange = useCallback(
|
||||
(newPairs) => {
|
||||
setKeyValuePairs(newPairs);
|
||||
const jsonObject = keyValueArrayToObject(newPairs);
|
||||
const jsonString =
|
||||
Object.keys(jsonObject).length === 0
|
||||
? ''
|
||||
: JSON.stringify(jsonObject, null, 2);
|
||||
|
||||
setJsonError('');
|
||||
|
||||
// 通过formApi设置值
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, jsonString);
|
||||
}
|
||||
|
||||
onChange?.(jsonString);
|
||||
},
|
||||
[onChange, formApi, field, keyValueArrayToObject],
|
||||
);
|
||||
|
||||
// 处理手动编辑的数据变化
|
||||
const handleManualChange = useCallback(
|
||||
(newValue) => {
|
||||
setManualText(newValue);
|
||||
if (newValue && newValue.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(newValue);
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
setJsonError('');
|
||||
onChange?.(newValue);
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
}
|
||||
} else {
|
||||
setKeyValuePairs([]);
|
||||
setJsonError('');
|
||||
onChange?.('');
|
||||
}
|
||||
},
|
||||
[onChange, objectToKeyValueArray, keyValuePairs],
|
||||
);
|
||||
|
||||
// 切换编辑模式
|
||||
const toggleEditMode = useCallback(() => {
|
||||
if (editMode === 'visual') {
|
||||
const jsonObject = keyValueArrayToObject(keyValuePairs);
|
||||
setManualText(
|
||||
Object.keys(jsonObject).length === 0
|
||||
? ''
|
||||
: JSON.stringify(jsonObject, null, 2),
|
||||
);
|
||||
setEditMode('manual');
|
||||
} else {
|
||||
try {
|
||||
let parsed = {};
|
||||
if (manualText && manualText.trim()) {
|
||||
parsed = JSON.parse(manualText);
|
||||
} else if (typeof value === 'string' && value.trim()) {
|
||||
parsed = JSON.parse(value);
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
parsed = value;
|
||||
}
|
||||
setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs));
|
||||
setJsonError('');
|
||||
setEditMode('visual');
|
||||
} catch (error) {
|
||||
setJsonError(error.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [
|
||||
editMode,
|
||||
value,
|
||||
manualText,
|
||||
keyValuePairs,
|
||||
keyValueArrayToObject,
|
||||
objectToKeyValueArray,
|
||||
]);
|
||||
|
||||
// 添加键值对
|
||||
const addKeyValue = useCallback(() => {
|
||||
const newPairs = [...keyValuePairs];
|
||||
const existingKeys = newPairs.map((p) => p.key);
|
||||
let counter = 1;
|
||||
let newKey = `field_${counter}`;
|
||||
while (existingKeys.includes(newKey)) {
|
||||
counter += 1;
|
||||
newKey = `field_${counter}`;
|
||||
}
|
||||
newPairs.push({
|
||||
id: generateUniqueId(),
|
||||
key: newKey,
|
||||
value: '',
|
||||
});
|
||||
handleVisualChange(newPairs);
|
||||
}, [keyValuePairs, handleVisualChange]);
|
||||
|
||||
// 删除键值对
|
||||
const removeKeyValue = useCallback(
|
||||
(id) => {
|
||||
const newPairs = keyValuePairs.filter((pair) => pair.id !== id);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 更新键名
|
||||
const updateKey = useCallback(
|
||||
(id, newKey) => {
|
||||
const newPairs = keyValuePairs.map((pair) =>
|
||||
pair.id === id ? { ...pair, key: newKey } : pair,
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 更新值
|
||||
const updateValue = useCallback(
|
||||
(id, newValue) => {
|
||||
const newPairs = keyValuePairs.map((pair) =>
|
||||
pair.id === id ? { ...pair, value: newValue } : pair,
|
||||
);
|
||||
handleVisualChange(newPairs);
|
||||
},
|
||||
[keyValuePairs, handleVisualChange],
|
||||
);
|
||||
|
||||
// 填入模板
|
||||
const fillTemplate = useCallback(() => {
|
||||
if (template) {
|
||||
const templateString = JSON.stringify(template, null, 2);
|
||||
|
||||
if (formApi && field) {
|
||||
formApi.setValue(field, templateString);
|
||||
}
|
||||
|
||||
setManualText(templateString);
|
||||
setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs));
|
||||
onChange?.(templateString);
|
||||
setJsonError('');
|
||||
}
|
||||
}, [
|
||||
template,
|
||||
onChange,
|
||||
formApi,
|
||||
field,
|
||||
objectToKeyValueArray,
|
||||
keyValuePairs,
|
||||
]);
|
||||
|
||||
// 渲染值输入控件(支持嵌套)
|
||||
const renderValueInput = (pairId, value) => {
|
||||
const valueType = typeof value;
|
||||
|
||||
if (valueType === 'boolean') {
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<Switch
|
||||
checked={value}
|
||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||
/>
|
||||
<Text type='tertiary' className='ml-2'>
|
||||
{value ? t('true') : t('false')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'number') {
|
||||
return (
|
||||
<InputNumber
|
||||
value={value}
|
||||
onChange={(newValue) => updateValue(pairId, newValue)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('输入数字')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (valueType === 'object' && value !== null) {
|
||||
// 简化嵌套对象的处理,使用TextArea
|
||||
return (
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
onChange={(txt) => {
|
||||
try {
|
||||
const obj = txt.trim() ? JSON.parse(txt) : {};
|
||||
updateValue(pairId, obj);
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}}
|
||||
placeholder={t('输入JSON对象')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 字符串或其他原始类型
|
||||
return (
|
||||
<Input
|
||||
placeholder={t('参数值')}
|
||||
value={String(value)}
|
||||
onChange={(newValue) => {
|
||||
let convertedValue = newValue;
|
||||
if (newValue === 'true') convertedValue = true;
|
||||
else if (newValue === 'false') convertedValue = false;
|
||||
else if (!isNaN(newValue) && newValue !== '') {
|
||||
const num = Number(newValue);
|
||||
// 检查是否为整数
|
||||
if (Number.isInteger(num)) {
|
||||
convertedValue = num;
|
||||
}
|
||||
}
|
||||
updateValue(pairId, convertedValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染键值对编辑器
|
||||
const renderKeyValueEditor = () => {
|
||||
return (
|
||||
<div className='space-y-1'>
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
{keyValuePairs.length === 0 && (
|
||||
<div className='text-center py-6 px-4'>
|
||||
<Text type='tertiary' className='text-gray-500 text-sm'>
|
||||
{t('暂无数据,点击下方按钮添加键值对')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keyValuePairs.map((pair, index) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
const isLastDuplicate =
|
||||
isDuplicate &&
|
||||
keyValuePairs.slice(index + 1).every((p) => p.key !== pair.key);
|
||||
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align='middle'>
|
||||
<Col span={10}>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
placeholder={t('键名')}
|
||||
value={pair.key}
|
||||
onChange={(newKey) => updateKey(pair.id, newKey)}
|
||||
status={isDuplicate ? 'warning' : undefined}
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<Tooltip
|
||||
content={
|
||||
isLastDuplicate
|
||||
? t('这是重复键中的最后一个,其值将被使用')
|
||||
: t('重复的键名,此值将被后面的同名键覆盖')
|
||||
}
|
||||
>
|
||||
<IconAlertTriangle
|
||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
||||
style={{
|
||||
color: isLastDuplicate ? '#ff7d00' : '#faad14',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>{renderValueInput(pair.id, pair.value)}</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={addKeyValue}
|
||||
>
|
||||
{t('添加键值对')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染区域编辑器(特殊格式)- 也需要改造以支持重复键
|
||||
const renderRegionEditor = () => {
|
||||
const defaultPair = keyValuePairs.find((pair) => pair.key === 'default');
|
||||
const modelPairs = keyValuePairs.filter((pair) => pair.key !== 'default');
|
||||
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
{/* 重复键警告 */}
|
||||
{duplicateKeys.size > 0 && (
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<IconAlertTriangle />}
|
||||
description={
|
||||
<div>
|
||||
<Text strong>{t('存在重复的键名:')}</Text>
|
||||
<Text>{Array.from(duplicateKeys).join(', ')}</Text>
|
||||
<br />
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('注意:JSON中重复的键只会保留最后一个同名键的值')}
|
||||
</Text>
|
||||
</div>
|
||||
}
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 默认区域 */}
|
||||
<Form.Slot label={t('默认区域')}>
|
||||
<Input
|
||||
placeholder={t('默认区域,如: us-central1')}
|
||||
value={defaultPair ? defaultPair.value : ''}
|
||||
onChange={(value) => {
|
||||
if (defaultPair) {
|
||||
updateValue(defaultPair.id, value);
|
||||
} else {
|
||||
const newPairs = [
|
||||
...keyValuePairs,
|
||||
{
|
||||
id: generateUniqueId(),
|
||||
key: 'default',
|
||||
value: value,
|
||||
},
|
||||
];
|
||||
handleVisualChange(newPairs);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Slot>
|
||||
|
||||
{/* 模型专用区域 */}
|
||||
<Form.Slot label={t('模型专用区域')}>
|
||||
<div>
|
||||
{modelPairs.map((pair) => {
|
||||
const isDuplicate = duplicateKeys.has(pair.key);
|
||||
return (
|
||||
<Row key={pair.id} gutter={8} align='middle' className='mb-2'>
|
||||
<Col span={10}>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
placeholder={t('模型名称')}
|
||||
value={pair.key}
|
||||
onChange={(newKey) => updateKey(pair.id, newKey)}
|
||||
status={isDuplicate ? 'warning' : undefined}
|
||||
/>
|
||||
{isDuplicate && (
|
||||
<Tooltip content={t('重复的键名')}>
|
||||
<IconAlertTriangle
|
||||
className='absolute right-2 top-1/2 transform -translate-y-1/2'
|
||||
style={{ color: '#faad14', fontSize: '14px' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Input
|
||||
placeholder={t('区域')}
|
||||
value={pair.value}
|
||||
onChange={(newValue) => updateValue(pair.id, newValue)}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={2}>
|
||||
<Button
|
||||
icon={<IconDelete />}
|
||||
type='danger'
|
||||
theme='borderless'
|
||||
onClick={() => removeKeyValue(pair.id)}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<Button
|
||||
icon={<IconPlus />}
|
||||
onClick={addKeyValue}
|
||||
type='primary'
|
||||
theme='outline'
|
||||
>
|
||||
{t('添加模型区域')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Slot>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染可视化编辑器
|
||||
const renderVisualEditor = () => {
|
||||
switch (editorType) {
|
||||
case 'region':
|
||||
return renderRegionEditor();
|
||||
case 'object':
|
||||
case 'keyValue':
|
||||
default:
|
||||
return renderKeyValueEditor();
|
||||
}
|
||||
};
|
||||
|
||||
const hasJsonError = jsonError && jsonError.trim() !== '';
|
||||
|
||||
return (
|
||||
<Form.Slot label={label}>
|
||||
<Card
|
||||
header={
|
||||
<div className='flex justify-between items-center'>
|
||||
<Tabs
|
||||
type='slash'
|
||||
activeKey={editMode}
|
||||
onChange={(key) => {
|
||||
if (key === 'manual' && editMode === 'visual') {
|
||||
setEditMode('manual');
|
||||
} else if (key === 'visual' && editMode === 'manual') {
|
||||
toggleEditMode();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabPane tab={t('可视化')} itemKey='visual' />
|
||||
<TabPane tab={t('手动编辑')} itemKey='manual' />
|
||||
</Tabs>
|
||||
|
||||
{template && templateLabel && (
|
||||
<Button type='tertiary' onClick={fillTemplate} size='small'>
|
||||
{templateLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
headerStyle={{ padding: '12px 16px' }}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
className='!rounded-2xl'
|
||||
>
|
||||
{/* JSON错误提示 */}
|
||||
{hasJsonError && (
|
||||
<Banner
|
||||
type='danger'
|
||||
description={`JSON 格式错误: ${jsonError}`}
|
||||
className='mb-3'
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 编辑器内容 */}
|
||||
{editMode === 'visual' ? (
|
||||
<div>
|
||||
{renderVisualEditor()}
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<TextArea
|
||||
placeholder={placeholder}
|
||||
value={manualText}
|
||||
onChange={handleManualChange}
|
||||
showClear={showClear}
|
||||
rows={Math.max(8, manualText ? manualText.split('\n').length : 8)}
|
||||
/>
|
||||
{/* 隐藏的Form字段用于验证和数据绑定 */}
|
||||
<Form.Input
|
||||
field={field}
|
||||
value={value}
|
||||
rules={rules}
|
||||
style={{ display: 'none' }}
|
||||
noLabel={true}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 额外文本显示在卡片底部 */}
|
||||
{extraText && (
|
||||
<Divider margin='12px' align='center'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{extraText}
|
||||
</Text>
|
||||
</Divider>
|
||||
)}
|
||||
{extraFooter && <div className='mt-1'>{extraFooter}</div>}
|
||||
</Card>
|
||||
</Form.Slot>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
||||
31
web/src/components/common/ui/Loading.jsx
Normal file
31
web/src/components/common/ui/Loading.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
const Loading = ({ size = 'small' }) => {
|
||||
return (
|
||||
<div className='fixed inset-0 w-screen h-screen flex items-center justify-center'>
|
||||
<Spin size={size} spinning={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
60
web/src/components/common/ui/RenderUtils.jsx
Normal file
60
web/src/components/common/ui/RenderUtils.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Space, Tag, Typography, Popover } from '@douyinfe/semi-ui';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 通用渲染函数:限制项目数量显示,支持popover展开
|
||||
export function renderLimitedItems({ items, renderItem, maxDisplay = 3 }) {
|
||||
if (!items || items.length === 0) return '-';
|
||||
const displayItems = items.slice(0, maxDisplay);
|
||||
const remainingItems = items.slice(maxDisplay);
|
||||
return (
|
||||
<Space spacing={1} wrap>
|
||||
{displayItems.map((item, idx) => renderItem(item, idx))}
|
||||
{remainingItems.length > 0 && (
|
||||
<Popover
|
||||
content={
|
||||
<div className='p-2'>
|
||||
<Space spacing={1} wrap>
|
||||
{remainingItems.map((item, idx) => renderItem(item, idx))}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
position='top'
|
||||
>
|
||||
<Tag size='small' shape='circle' color='grey'>
|
||||
+{remainingItems.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染描述字段,长文本支持tooltip
|
||||
export const renderDescription = (text, maxWidth = 200) => {
|
||||
return (
|
||||
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth }}>
|
||||
{text || '-'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
242
web/src/components/common/ui/ScrollableContainer.jsx
Normal file
242
web/src/components/common/ui/ScrollableContainer.jsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
||||
/**
|
||||
* ScrollableContainer 可滚动容器组件
|
||||
*
|
||||
* 提供自动检测滚动状态和显示渐变指示器的功能
|
||||
* 当内容超出容器高度且未滚动到底部时,会显示底部渐变指示器
|
||||
*
|
||||
*/
|
||||
const ScrollableContainer = forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
maxHeight = '24rem',
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
fadeIndicatorClassName = '',
|
||||
checkInterval = 100,
|
||||
scrollThreshold = 5,
|
||||
debounceDelay = 16, // ~60fps
|
||||
onScroll,
|
||||
onScrollStateChange,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const scrollRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const debounceTimerRef = useRef(null);
|
||||
const resizeObserverRef = useRef(null);
|
||||
const onScrollStateChangeRef = useRef(onScrollStateChange);
|
||||
const onScrollRef = useRef(onScroll);
|
||||
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollStateChangeRef.current = onScrollStateChange;
|
||||
}, [onScrollStateChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onScrollRef.current = onScroll;
|
||||
}, [onScroll]);
|
||||
|
||||
const debounce = useCallback((func, delay) => {
|
||||
return (...args) => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkScrollable = useCallback(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const element = scrollRef.current;
|
||||
const isScrollable = element.scrollHeight > element.clientHeight;
|
||||
const isAtBottom =
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - scrollThreshold;
|
||||
const shouldShowHint = isScrollable && !isAtBottom;
|
||||
|
||||
setShowScrollHint(shouldShowHint);
|
||||
|
||||
if (onScrollStateChangeRef.current) {
|
||||
onScrollStateChangeRef.current({
|
||||
isScrollable,
|
||||
isAtBottom,
|
||||
showScrollHint: shouldShowHint,
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
});
|
||||
}
|
||||
}, [scrollThreshold]);
|
||||
|
||||
const debouncedCheckScrollable = useMemo(
|
||||
() => debounce(checkScrollable, debounceDelay),
|
||||
[debounce, checkScrollable, debounceDelay],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e) => {
|
||||
debouncedCheckScrollable();
|
||||
if (onScrollRef.current) {
|
||||
onScrollRef.current(e);
|
||||
}
|
||||
},
|
||||
[debouncedCheckScrollable],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
checkScrollable: () => {
|
||||
checkScrollable();
|
||||
},
|
||||
scrollToTop: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = 0;
|
||||
}
|
||||
},
|
||||
scrollToBottom: () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
},
|
||||
getScrollInfo: () => {
|
||||
if (!scrollRef.current) return null;
|
||||
const element = scrollRef.current;
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
isScrollable: element.scrollHeight > element.clientHeight,
|
||||
isAtBottom:
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - scrollThreshold,
|
||||
};
|
||||
},
|
||||
}),
|
||||
[checkScrollable, scrollThreshold],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkScrollable();
|
||||
}, checkInterval);
|
||||
return () => clearTimeout(timer);
|
||||
}, [checkScrollable, checkInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
debouncedCheckScrollable();
|
||||
});
|
||||
|
||||
observer.observe(scrollRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
resizeObserverRef.current = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
debouncedCheckScrollable();
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserverRef.current.observe(scrollRef.current);
|
||||
|
||||
return () => {
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
}
|
||||
};
|
||||
}, [debouncedCheckScrollable]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
maxHeight,
|
||||
}),
|
||||
[maxHeight],
|
||||
);
|
||||
|
||||
const fadeIndicatorStyle = useMemo(
|
||||
() => ({
|
||||
opacity: showScrollHint ? 1 : 0,
|
||||
}),
|
||||
[showScrollHint],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`card-content-container ${className}`}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className={`overflow-y-auto card-content-scroll ${contentClassName}`}
|
||||
style={containerStyle}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={`card-content-fade-indicator ${fadeIndicatorClassName}`}
|
||||
style={fadeIndicatorStyle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ScrollableContainer.displayName = 'ScrollableContainer';
|
||||
|
||||
export default ScrollableContainer;
|
||||
310
web/src/components/common/ui/SelectableButtonGroup.jsx
Normal file
310
web/src/components/common/ui/SelectableButtonGroup.jsx
Normal file
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime';
|
||||
import { useContainerWidth } from '../../../hooks/common/useContainerWidth';
|
||||
import {
|
||||
Divider,
|
||||
Button,
|
||||
Tag,
|
||||
Row,
|
||||
Col,
|
||||
Collapsible,
|
||||
Checkbox,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
|
||||
/**
|
||||
* 通用可选择按钮组组件
|
||||
*
|
||||
* @param {string} title 标题
|
||||
* @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 按钮项
|
||||
* @param {*|Array} activeValue 当前激活的值,可以是单个值或数组(多选)
|
||||
* @param {(value:any)=>void} onChange 选择改变回调
|
||||
* @param {function} t i18n
|
||||
* @param {object} style 额外样式
|
||||
* @param {boolean} collapsible 是否支持折叠,默认true
|
||||
* @param {number} collapseHeight 折叠时的高度,默认200
|
||||
* @param {boolean} withCheckbox 是否启用前缀 Checkbox 来控制激活状态
|
||||
* @param {boolean} loading 是否处于加载状态
|
||||
*/
|
||||
const SelectableButtonGroup = ({
|
||||
title,
|
||||
items = [],
|
||||
activeValue,
|
||||
onChange,
|
||||
t = (v) => v,
|
||||
style = {},
|
||||
collapsible = true,
|
||||
collapseHeight = 200,
|
||||
withCheckbox = false,
|
||||
loading = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [skeletonCount] = useState(12);
|
||||
const [containerRef, containerWidth] = useContainerWidth();
|
||||
|
||||
const ConditionalTooltipText = ({ text }) => {
|
||||
const textRef = useRef(null);
|
||||
const [isOverflowing, setIsOverflowing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = textRef.current;
|
||||
if (!el) return;
|
||||
setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, [text, containerWidth]);
|
||||
|
||||
const textElement = (
|
||||
<span ref={textRef} className='sbg-ellipsis'>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
return isOverflowing ? (
|
||||
<Tooltip content={text}>{textElement}</Tooltip>
|
||||
) : (
|
||||
textElement
|
||||
);
|
||||
};
|
||||
|
||||
// 基于容器宽度计算响应式列数和标签显示策略
|
||||
const getResponsiveConfig = () => {
|
||||
if (containerWidth <= 280) return { columns: 1, showTags: true }; // 极窄:1列+标签
|
||||
if (containerWidth <= 380) return { columns: 2, showTags: true }; // 窄屏:2列+标签
|
||||
if (containerWidth <= 460) return { columns: 3, showTags: false }; // 中等:3列不加标签
|
||||
return { columns: 3, showTags: true }; // 最宽:3列+标签
|
||||
};
|
||||
|
||||
const { columns: perRow, showTags: shouldShowTags } = getResponsiveConfig();
|
||||
const maxVisibleRows = Math.max(1, Math.floor(collapseHeight / 32)); // Approx row height 32
|
||||
const needCollapse = collapsible && items.length > perRow * maxVisibleRows;
|
||||
const showSkeleton = useMinimumLoadingTime(loading);
|
||||
|
||||
// 统一使用紧凑的网格间距
|
||||
const gutterSize = [4, 4];
|
||||
|
||||
// 计算 Semi UI Col 的 span 值
|
||||
const getColSpan = () => {
|
||||
return Math.floor(24 / perRow);
|
||||
};
|
||||
|
||||
const maskStyle = isOpen
|
||||
? {}
|
||||
: {
|
||||
WebkitMaskImage:
|
||||
'linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 1) 60%, rgba(0, 0, 0, 0.2) 80%, transparent 100%)',
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const linkStyle = {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
textAlign: 'center',
|
||||
bottom: -10,
|
||||
fontWeight: 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
};
|
||||
|
||||
const renderSkeletonButtons = () => {
|
||||
const placeholder = (
|
||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<Col span={getColSpan()} key={index}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
borderRadius: 'var(--semi-border-radius-medium)',
|
||||
padding: '0 12px',
|
||||
gap: '6px',
|
||||
}}
|
||||
>
|
||||
{withCheckbox && (
|
||||
<Skeleton.Title active style={{ width: 14, height: 14 }} />
|
||||
)}
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{
|
||||
width: `${60 + (index % 3) * 20}px`,
|
||||
height: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
|
||||
return (
|
||||
<Skeleton loading={true} active placeholder={placeholder}></Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const contentElement = showSkeleton ? (
|
||||
renderSkeletonButtons()
|
||||
) : (
|
||||
<Row gutter={gutterSize} style={{ lineHeight: '32px', ...style }}>
|
||||
{items.map((item) => {
|
||||
const isDisabled =
|
||||
item.disabled ||
|
||||
(typeof item.tagCount === 'number' && item.tagCount === 0);
|
||||
const isActive = Array.isArray(activeValue)
|
||||
? activeValue.includes(item.value)
|
||||
: activeValue === item.value;
|
||||
|
||||
if (withCheckbox) {
|
||||
return (
|
||||
<Col span={getColSpan()} key={item.value}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
/* disabled */
|
||||
}}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className='sbg-button'
|
||||
icon={
|
||||
<Checkbox
|
||||
checked={isActive}
|
||||
onChange={() => onChange(item.value)}
|
||||
disabled={isDisabled}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
/>
|
||||
}
|
||||
style={{ width: '100%', cursor: 'default' }}
|
||||
>
|
||||
<div className='sbg-content'>
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Col span={getColSpan()} key={item.value}>
|
||||
<Button
|
||||
onClick={() => onChange(item.value)}
|
||||
theme={isActive ? 'light' : 'outline'}
|
||||
type={isActive ? 'primary' : 'tertiary'}
|
||||
disabled={isDisabled}
|
||||
className='sbg-button'
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div className='sbg-content'>
|
||||
{item.icon && <span className='sbg-icon'>{item.icon}</span>}
|
||||
<ConditionalTooltipText text={item.label} />
|
||||
{item.tagCount !== undefined && shouldShowTags && (
|
||||
<Tag
|
||||
className='sbg-tag'
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='small'
|
||||
>
|
||||
{item.tagCount}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-8 ${containerWidth <= 400 ? 'sbg-compact' : ''}`}
|
||||
ref={containerRef}
|
||||
>
|
||||
{title && (
|
||||
<Divider margin='12px' align='left'>
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Title active style={{ width: 80, height: 14 }} />
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</Divider>
|
||||
)}
|
||||
{needCollapse && !showSkeleton ? (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Collapsible
|
||||
isOpen={isOpen}
|
||||
collapseHeight={collapseHeight}
|
||||
style={{ ...maskStyle }}
|
||||
>
|
||||
{contentElement}
|
||||
</Collapsible>
|
||||
{isOpen ? null : (
|
||||
<div onClick={toggle} style={{ ...linkStyle }}>
|
||||
<IconChevronDown size='small' />
|
||||
<span>{t('展开更多')}</span>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div
|
||||
onClick={toggle}
|
||||
style={{
|
||||
...linkStyle,
|
||||
position: 'static',
|
||||
marginTop: 8,
|
||||
bottom: 'auto',
|
||||
}}
|
||||
>
|
||||
<IconChevronUp size='small' />
|
||||
<span>{t('收起')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
contentElement
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableButtonGroup;
|
||||
126
web/src/components/dashboard/AnnouncementsPanel.jsx
Normal file
126
web/src/components/dashboard/AnnouncementsPanel.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
|
||||
import { Bell } from 'lucide-react';
|
||||
import { marked } from 'marked';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const AnnouncementsPanel = ({
|
||||
announcementData,
|
||||
announcementLegendData,
|
||||
CARD_PROPS,
|
||||
ILLUSTRATION_SIZE,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className='shadow-sm !rounded-2xl lg:col-span-2'
|
||||
title={
|
||||
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Bell size={16} />
|
||||
{t('系统公告')}
|
||||
<Tag color='white' shape='circle'>
|
||||
{t('显示最新20条')}
|
||||
</Tag>
|
||||
</div>
|
||||
{/* 图例 */}
|
||||
<div className='flex flex-wrap gap-3 text-xs'>
|
||||
{announcementLegendData.map((legend, index) => (
|
||||
<div key={index} className='flex items-center gap-1'>
|
||||
<div
|
||||
className='w-2 h-2 rounded-full'
|
||||
style={{
|
||||
backgroundColor:
|
||||
legend.color === 'grey'
|
||||
? '#8b9aa7'
|
||||
: legend.color === 'blue'
|
||||
? '#3b82f6'
|
||||
: legend.color === 'green'
|
||||
? '#10b981'
|
||||
: legend.color === 'orange'
|
||||
? '#f59e0b'
|
||||
: legend.color === 'red'
|
||||
? '#ef4444'
|
||||
: '#8b9aa7',
|
||||
}}
|
||||
/>
|
||||
<span className='text-gray-600'>{legend.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{announcementData.length > 0 ? (
|
||||
<Timeline mode='left'>
|
||||
{announcementData.map((item, idx) => {
|
||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={idx}
|
||||
type={item.type || 'default'}
|
||||
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
|
||||
extra={
|
||||
item.extra ? (
|
||||
<div
|
||||
className='text-xs text-gray-500'
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(item.content || ''),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
) : (
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无系统公告')}
|
||||
description={t('请联系管理员在系统设置中配置公告信息')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsPanel;
|
||||
119
web/src/components/dashboard/ApiInfoPanel.jsx
Normal file
119
web/src/components/dashboard/ApiInfoPanel.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Tag, Divider, Empty } from '@douyinfe/semi-ui';
|
||||
import { Server, Gauge, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const ApiInfoPanel = ({
|
||||
apiInfoData,
|
||||
handleCopyUrl,
|
||||
handleSpeedTest,
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className='bg-gray-50 border-0 !rounded-2xl'
|
||||
title={
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<Server size={16} />
|
||||
{t('API信息')}
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{apiInfoData.length > 0 ? (
|
||||
apiInfoData.map((api) => (
|
||||
<React.Fragment key={api.id}>
|
||||
<div className='flex p-2 hover:bg-white rounded-lg transition-colors cursor-pointer'>
|
||||
<div className='flex-shrink-0 mr-3'>
|
||||
<Avatar size='extra-small' color={api.color}>
|
||||
{api.route.substring(0, 2)}
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='flex flex-wrap items-center justify-between mb-1 w-full gap-2'>
|
||||
<span className='text-sm font-medium text-gray-900 !font-bold break-all'>
|
||||
{api.route}
|
||||
</span>
|
||||
<div className='flex items-center gap-1 mt-1 lg:mt-0'>
|
||||
<Tag
|
||||
prefixIcon={<Gauge size={12} />}
|
||||
size='small'
|
||||
color='white'
|
||||
shape='circle'
|
||||
onClick={() => handleSpeedTest(api.url)}
|
||||
className='cursor-pointer hover:opacity-80 text-xs'
|
||||
>
|
||||
{t('测速')}
|
||||
</Tag>
|
||||
<Tag
|
||||
prefixIcon={<ExternalLink size={12} />}
|
||||
size='small'
|
||||
color='white'
|
||||
shape='circle'
|
||||
onClick={() =>
|
||||
window.open(api.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
className='cursor-pointer hover:opacity-80 text-xs'
|
||||
>
|
||||
{t('跳转')}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='!text-semi-color-primary break-all cursor-pointer hover:underline mb-1'
|
||||
onClick={() => handleCopyUrl(api.url)}
|
||||
>
|
||||
{api.url}
|
||||
</div>
|
||||
<div className='text-gray-500'>{api.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className='flex justify-center items-center min-h-[20rem] w-full'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无API信息')}
|
||||
description={t('请联系管理员在系统设置中配置API信息')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiInfoPanel;
|
||||
80
web/src/components/dashboard/ChartsPanel.jsx
Normal file
80
web/src/components/dashboard/ChartsPanel.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||
import { PieChart } from 'lucide-react';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
const ChartsPanel = ({
|
||||
activeChartTab,
|
||||
setActiveChartTab,
|
||||
spec_line,
|
||||
spec_model_line,
|
||||
spec_pie,
|
||||
spec_rank_bar,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG,
|
||||
FLEX_CENTER_GAP2,
|
||||
hasApiInfoPanel,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className={`!rounded-2xl ${hasApiInfoPanel ? 'lg:col-span-3' : ''}`}
|
||||
title={
|
||||
<div className='flex flex-col lg:flex-row lg:items-center lg:justify-between w-full gap-3'>
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<PieChart size={16} />
|
||||
{t('模型数据分析')}
|
||||
</div>
|
||||
<Tabs
|
||||
type='slash'
|
||||
activeKey={activeChartTab}
|
||||
onChange={setActiveChartTab}
|
||||
>
|
||||
<TabPane tab={<span>{t('消耗分布')}</span>} itemKey='1' />
|
||||
<TabPane tab={<span>{t('消耗趋势')}</span>} itemKey='2' />
|
||||
<TabPane tab={<span>{t('调用次数分布')}</span>} itemKey='3' />
|
||||
<TabPane tab={<span>{t('调用次数排行')}</span>} itemKey='4' />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className='h-96 p-2'>
|
||||
{activeChartTab === '1' && (
|
||||
<VChart spec={spec_line} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '2' && (
|
||||
<VChart spec={spec_model_line} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '3' && (
|
||||
<VChart spec={spec_pie} option={CHART_CONFIG} />
|
||||
)}
|
||||
{activeChartTab === '4' && (
|
||||
<VChart spec={spec_rank_bar} option={CHART_CONFIG} />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartsPanel;
|
||||
61
web/src/components/dashboard/DashboardHeader.jsx
Normal file
61
web/src/components/dashboard/DashboardHeader.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { RefreshCw, Search } from 'lucide-react';
|
||||
|
||||
const DashboardHeader = ({
|
||||
getGreeting,
|
||||
greetingVisible,
|
||||
showSearchModal,
|
||||
refresh,
|
||||
loading,
|
||||
t,
|
||||
}) => {
|
||||
const ICON_BUTTON_CLASS = 'text-white hover:bg-opacity-80 !rounded-full';
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<h2
|
||||
className='text-2xl font-semibold text-gray-800 transition-opacity duration-1000 ease-in-out'
|
||||
style={{ opacity: greetingVisible ? 1 : 0 }}
|
||||
>
|
||||
{getGreeting}
|
||||
</h2>
|
||||
<div className='flex gap-3'>
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<Search size={16} />}
|
||||
onClick={showSearchModal}
|
||||
className={`bg-green-500 hover:bg-green-600 ${ICON_BUTTON_CLASS}`}
|
||||
/>
|
||||
<Button
|
||||
type='tertiary'
|
||||
icon={<RefreshCw size={16} />}
|
||||
onClick={refresh}
|
||||
loading={loading}
|
||||
className={`bg-blue-500 hover:bg-blue-600 ${ICON_BUTTON_CLASS}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardHeader;
|
||||
88
web/src/components/dashboard/FaqPanel.jsx
Normal file
88
web/src/components/dashboard/FaqPanel.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
|
||||
import { marked } from 'marked';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const FaqPanel = ({
|
||||
faqData,
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className='shadow-sm !rounded-2xl lg:col-span-1'
|
||||
title={
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<HelpCircle size={16} />
|
||||
{t('常见问答')}
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{faqData.length > 0 ? (
|
||||
<Collapse
|
||||
accordion
|
||||
expandIcon={<IconPlus />}
|
||||
collapseIcon={<IconMinus />}
|
||||
>
|
||||
{faqData.map((item, index) => (
|
||||
<Collapse.Panel
|
||||
key={index}
|
||||
header={item.question}
|
||||
itemKey={index.toString()}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(item.answer || ''),
|
||||
}}
|
||||
/>
|
||||
</Collapse.Panel>
|
||||
))}
|
||||
</Collapse>
|
||||
) : (
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无常见问答')}
|
||||
description={t('请联系管理员在系统设置中配置常见问答')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ScrollableContainer>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqPanel;
|
||||
116
web/src/components/dashboard/StatsCards.jsx
Normal file
116
web/src/components/dashboard/StatsCards.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Card, Avatar, Skeleton, Tag } from '@douyinfe/semi-ui';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const StatsCards = ({
|
||||
groupedStatsData,
|
||||
loading,
|
||||
getTrendSpec,
|
||||
CARD_PROPS,
|
||||
CHART_CONFIG,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='mb-4'>
|
||||
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4'>
|
||||
{groupedStatsData.map((group, idx) => (
|
||||
<Card
|
||||
key={idx}
|
||||
{...CARD_PROPS}
|
||||
className={`${group.color} border-0 !rounded-2xl w-full`}
|
||||
title={group.title}
|
||||
>
|
||||
<div className='space-y-4'>
|
||||
{group.items.map((item, itemIdx) => (
|
||||
<div
|
||||
key={itemIdx}
|
||||
className='flex items-center justify-between cursor-pointer'
|
||||
onClick={item.onClick}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<Avatar
|
||||
className='mr-3'
|
||||
size='small'
|
||||
color={item.avatarColor}
|
||||
>
|
||||
{item.icon}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className='text-xs text-gray-500'>{item.title}</div>
|
||||
<div className='text-lg font-semibold'>
|
||||
<Skeleton
|
||||
loading={loading}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Paragraph
|
||||
active
|
||||
rows={1}
|
||||
style={{
|
||||
width: '65px',
|
||||
height: '24px',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{item.value}
|
||||
</Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{item.title === t('当前余额') ? (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
size='large'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/console/topup');
|
||||
}}
|
||||
>
|
||||
{t('充值')}
|
||||
</Tag>
|
||||
) : (
|
||||
(loading ||
|
||||
(item.trendData && item.trendData.length > 0)) && (
|
||||
<div className='w-24 h-10'>
|
||||
<VChart
|
||||
spec={getTrendSpec(item.trendData, item.trendColor)}
|
||||
option={CHART_CONFIG}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCards;
|
||||
152
web/src/components/dashboard/UptimePanel.jsx
Normal file
152
web/src/components/dashboard/UptimePanel.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Spin,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Tag,
|
||||
Empty,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Gauge, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
IllustrationConstruction,
|
||||
IllustrationConstructionDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import ScrollableContainer from '../common/ui/ScrollableContainer';
|
||||
|
||||
const UptimePanel = ({
|
||||
uptimeData,
|
||||
uptimeLoading,
|
||||
activeUptimeTab,
|
||||
setActiveUptimeTab,
|
||||
loadUptimeData,
|
||||
uptimeLegendData,
|
||||
renderMonitorList,
|
||||
CARD_PROPS,
|
||||
ILLUSTRATION_SIZE,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className='shadow-sm !rounded-2xl lg:col-span-1'
|
||||
title={
|
||||
<div className='flex items-center justify-between w-full gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Gauge size={16} />
|
||||
{t('服务可用性')}
|
||||
</div>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={loadUptimeData}
|
||||
loading={uptimeLoading}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
{/* 内容区域 */}
|
||||
<div className='relative'>
|
||||
<Spin spinning={uptimeLoading}>
|
||||
{uptimeData.length > 0 ? (
|
||||
uptimeData.length === 1 ? (
|
||||
<ScrollableContainer maxHeight='24rem'>
|
||||
{renderMonitorList(uptimeData[0].monitors)}
|
||||
</ScrollableContainer>
|
||||
) : (
|
||||
<Tabs
|
||||
type='card'
|
||||
collapsible
|
||||
activeKey={activeUptimeTab}
|
||||
onChange={setActiveUptimeTab}
|
||||
size='small'
|
||||
>
|
||||
{uptimeData.map((group, groupIdx) => (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
<Gauge size={14} />
|
||||
{group.categoryName}
|
||||
<Tag
|
||||
color={
|
||||
activeUptimeTab === group.categoryName
|
||||
? 'red'
|
||||
: 'grey'
|
||||
}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{group.monitors ? group.monitors.length : 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={group.categoryName}
|
||||
key={groupIdx}
|
||||
>
|
||||
<ScrollableContainer maxHeight='21.5rem'>
|
||||
{renderMonitorList(group.monitors)}
|
||||
</ScrollableContainer>
|
||||
</TabPane>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
) : (
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={ILLUSTRATION_SIZE} />
|
||||
}
|
||||
title={t('暂无监控数据')}
|
||||
description={t('请联系管理员在系统设置中配置Uptime')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
{/* 图例 */}
|
||||
{uptimeData.length > 0 && (
|
||||
<div className='p-3 bg-gray-50 rounded-b-2xl'>
|
||||
<div className='flex flex-wrap gap-3 text-xs justify-center'>
|
||||
{uptimeLegendData.map((legend, index) => (
|
||||
<div key={index} className='flex items-center gap-1'>
|
||||
<div
|
||||
className='w-2 h-2 rounded-full'
|
||||
style={{ backgroundColor: legend.color }}
|
||||
/>
|
||||
<span className='text-gray-600'>{legend.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UptimePanel;
|
||||
271
web/src/components/dashboard/index.jsx
Normal file
271
web/src/components/dashboard/index.jsx
Normal file
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { getRelativeTime } from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
import DashboardHeader from './DashboardHeader';
|
||||
import StatsCards from './StatsCards';
|
||||
import ChartsPanel from './ChartsPanel';
|
||||
import ApiInfoPanel from './ApiInfoPanel';
|
||||
import AnnouncementsPanel from './AnnouncementsPanel';
|
||||
import FaqPanel from './FaqPanel';
|
||||
import UptimePanel from './UptimePanel';
|
||||
import SearchModal from './modals/SearchModal';
|
||||
|
||||
import { useDashboardData } from '../../hooks/dashboard/useDashboardData';
|
||||
import { useDashboardStats } from '../../hooks/dashboard/useDashboardStats';
|
||||
import { useDashboardCharts } from '../../hooks/dashboard/useDashboardCharts';
|
||||
|
||||
import {
|
||||
CHART_CONFIG,
|
||||
CARD_PROPS,
|
||||
FLEX_CENTER_GAP2,
|
||||
ILLUSTRATION_SIZE,
|
||||
ANNOUNCEMENT_LEGEND_DATA,
|
||||
UPTIME_STATUS_MAP,
|
||||
} from '../../constants/dashboard.constants';
|
||||
import {
|
||||
getTrendSpec,
|
||||
handleCopyUrl,
|
||||
handleSpeedTest,
|
||||
getUptimeStatusColor,
|
||||
getUptimeStatusText,
|
||||
renderMonitorList,
|
||||
} from '../../helpers/dashboard';
|
||||
|
||||
const Dashboard = () => {
|
||||
// ========== Context ==========
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
|
||||
// ========== 主要数据管理 ==========
|
||||
const dashboardData = useDashboardData(userState, userDispatch, statusState);
|
||||
|
||||
// ========== 图表管理 ==========
|
||||
const dashboardCharts = useDashboardCharts(
|
||||
dashboardData.dataExportDefaultTime,
|
||||
dashboardData.setTrendData,
|
||||
dashboardData.setConsumeQuota,
|
||||
dashboardData.setTimes,
|
||||
dashboardData.setConsumeTokens,
|
||||
dashboardData.setPieData,
|
||||
dashboardData.setLineData,
|
||||
dashboardData.setModelColors,
|
||||
dashboardData.t,
|
||||
);
|
||||
|
||||
// ========== 统计数据 ==========
|
||||
const { groupedStatsData } = useDashboardStats(
|
||||
userState,
|
||||
dashboardData.consumeQuota,
|
||||
dashboardData.consumeTokens,
|
||||
dashboardData.times,
|
||||
dashboardData.trendData,
|
||||
dashboardData.performanceMetrics,
|
||||
dashboardData.navigate,
|
||||
dashboardData.t,
|
||||
);
|
||||
|
||||
// ========== 数据处理 ==========
|
||||
const initChart = async () => {
|
||||
await dashboardData.loadQuotaData().then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
});
|
||||
await dashboardData.loadUptimeData();
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const data = await dashboardData.refresh();
|
||||
if (data && data.length > 0) {
|
||||
dashboardCharts.updateChartData(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchConfirm = async () => {
|
||||
await dashboardData.handleSearchConfirm(dashboardCharts.updateChartData);
|
||||
};
|
||||
|
||||
// ========== 数据准备 ==========
|
||||
const apiInfoData = statusState?.status?.api_info || [];
|
||||
const announcementData = (statusState?.status?.announcements || []).map(
|
||||
(item) => {
|
||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
||||
const absoluteTime =
|
||||
pubDate && !isNaN(pubDate.getTime())
|
||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
||||
: item?.publishDate || '';
|
||||
const relativeTime = getRelativeTime(item.publishDate);
|
||||
return {
|
||||
...item,
|
||||
time: absoluteTime,
|
||||
relative: relativeTime,
|
||||
};
|
||||
},
|
||||
);
|
||||
const faqData = statusState?.status?.faq || [];
|
||||
|
||||
const uptimeLegendData = Object.entries(UPTIME_STATUS_MAP).map(
|
||||
([status, info]) => ({
|
||||
status: Number(status),
|
||||
color: info.color,
|
||||
label: dashboardData.t(info.label),
|
||||
}),
|
||||
);
|
||||
|
||||
// ========== Effects ==========
|
||||
useEffect(() => {
|
||||
initChart();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='h-full'>
|
||||
<DashboardHeader
|
||||
getGreeting={dashboardData.getGreeting}
|
||||
greetingVisible={dashboardData.greetingVisible}
|
||||
showSearchModal={dashboardData.showSearchModal}
|
||||
refresh={handleRefresh}
|
||||
loading={dashboardData.loading}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
|
||||
<SearchModal
|
||||
searchModalVisible={dashboardData.searchModalVisible}
|
||||
handleSearchConfirm={handleSearchConfirm}
|
||||
handleCloseModal={dashboardData.handleCloseModal}
|
||||
isMobile={dashboardData.isMobile}
|
||||
isAdminUser={dashboardData.isAdminUser}
|
||||
inputs={dashboardData.inputs}
|
||||
dataExportDefaultTime={dashboardData.dataExportDefaultTime}
|
||||
timeOptions={dashboardData.timeOptions}
|
||||
handleInputChange={dashboardData.handleInputChange}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
|
||||
<StatsCards
|
||||
groupedStatsData={groupedStatsData}
|
||||
loading={dashboardData.loading}
|
||||
getTrendSpec={getTrendSpec}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
/>
|
||||
|
||||
{/* API信息和图表面板 */}
|
||||
<div className='mb-4'>
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-4 ${dashboardData.hasApiInfoPanel ? 'lg:grid-cols-4' : ''}`}
|
||||
>
|
||||
<ChartsPanel
|
||||
activeChartTab={dashboardData.activeChartTab}
|
||||
setActiveChartTab={dashboardData.setActiveChartTab}
|
||||
spec_line={dashboardCharts.spec_line}
|
||||
spec_model_line={dashboardCharts.spec_model_line}
|
||||
spec_pie={dashboardCharts.spec_pie}
|
||||
spec_rank_bar={dashboardCharts.spec_rank_bar}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
CHART_CONFIG={CHART_CONFIG}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
hasApiInfoPanel={dashboardData.hasApiInfoPanel}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
|
||||
{dashboardData.hasApiInfoPanel && (
|
||||
<ApiInfoPanel
|
||||
apiInfoData={apiInfoData}
|
||||
handleCopyUrl={(url) => handleCopyUrl(url, dashboardData.t)}
|
||||
handleSpeedTest={handleSpeedTest}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统公告和常见问答卡片 */}
|
||||
{dashboardData.hasInfoPanels && (
|
||||
<div className='mb-4'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-4 gap-4'>
|
||||
{/* 公告卡片 */}
|
||||
{dashboardData.announcementsEnabled && (
|
||||
<AnnouncementsPanel
|
||||
announcementData={announcementData}
|
||||
announcementLegendData={ANNOUNCEMENT_LEGEND_DATA.map(
|
||||
(item) => ({
|
||||
...item,
|
||||
label: dashboardData.t(item.label),
|
||||
}),
|
||||
)}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 常见问答卡片 */}
|
||||
{dashboardData.faqEnabled && (
|
||||
<FaqPanel
|
||||
faqData={faqData}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
FLEX_CENTER_GAP2={FLEX_CENTER_GAP2}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 服务可用性卡片 */}
|
||||
{dashboardData.uptimeEnabled && (
|
||||
<UptimePanel
|
||||
uptimeData={dashboardData.uptimeData}
|
||||
uptimeLoading={dashboardData.uptimeLoading}
|
||||
activeUptimeTab={dashboardData.activeUptimeTab}
|
||||
setActiveUptimeTab={dashboardData.setActiveUptimeTab}
|
||||
loadUptimeData={dashboardData.loadUptimeData}
|
||||
uptimeLegendData={uptimeLegendData}
|
||||
renderMonitorList={(monitors) =>
|
||||
renderMonitorList(
|
||||
monitors,
|
||||
(status) => getUptimeStatusColor(status, UPTIME_STATUS_MAP),
|
||||
(status) =>
|
||||
getUptimeStatusText(
|
||||
status,
|
||||
UPTIME_STATUS_MAP,
|
||||
dashboardData.t,
|
||||
),
|
||||
dashboardData.t,
|
||||
)
|
||||
}
|
||||
CARD_PROPS={CARD_PROPS}
|
||||
ILLUSTRATION_SIZE={ILLUSTRATION_SIZE}
|
||||
t={dashboardData.t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
103
web/src/components/dashboard/modals/SearchModal.jsx
Normal file
103
web/src/components/dashboard/modals/SearchModal.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import { Modal, Form } from '@douyinfe/semi-ui';
|
||||
|
||||
const SearchModal = ({
|
||||
searchModalVisible,
|
||||
handleSearchConfirm,
|
||||
handleCloseModal,
|
||||
isMobile,
|
||||
isAdminUser,
|
||||
inputs,
|
||||
dataExportDefaultTime,
|
||||
timeOptions,
|
||||
handleInputChange,
|
||||
t,
|
||||
}) => {
|
||||
const formRef = useRef();
|
||||
|
||||
const FORM_FIELD_PROPS = {
|
||||
className: 'w-full mb-2 !rounded-lg',
|
||||
};
|
||||
|
||||
const createFormField = (Component, props) => (
|
||||
<Component {...FORM_FIELD_PROPS} {...props} />
|
||||
);
|
||||
|
||||
const { start_timestamp, end_timestamp, username } = inputs;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('搜索条件')}
|
||||
visible={searchModalVisible}
|
||||
onOk={handleSearchConfirm}
|
||||
onCancel={handleCloseModal}
|
||||
closeOnEsc={true}
|
||||
size={isMobile ? 'full-width' : 'small'}
|
||||
centered
|
||||
>
|
||||
<Form ref={formRef} layout='vertical' className='w-full'>
|
||||
{createFormField(Form.DatePicker, {
|
||||
field: 'start_timestamp',
|
||||
label: t('起始时间'),
|
||||
initValue: start_timestamp,
|
||||
value: start_timestamp,
|
||||
type: 'dateTime',
|
||||
name: 'start_timestamp',
|
||||
onChange: (value) => handleInputChange(value, 'start_timestamp'),
|
||||
})}
|
||||
|
||||
{createFormField(Form.DatePicker, {
|
||||
field: 'end_timestamp',
|
||||
label: t('结束时间'),
|
||||
initValue: end_timestamp,
|
||||
value: end_timestamp,
|
||||
type: 'dateTime',
|
||||
name: 'end_timestamp',
|
||||
onChange: (value) => handleInputChange(value, 'end_timestamp'),
|
||||
})}
|
||||
|
||||
{createFormField(Form.Select, {
|
||||
field: 'data_export_default_time',
|
||||
label: t('时间粒度'),
|
||||
initValue: dataExportDefaultTime,
|
||||
placeholder: t('时间粒度'),
|
||||
name: 'data_export_default_time',
|
||||
optionList: timeOptions,
|
||||
onChange: (value) =>
|
||||
handleInputChange(value, 'data_export_default_time'),
|
||||
})}
|
||||
|
||||
{isAdminUser &&
|
||||
createFormField(Form.Input, {
|
||||
field: 'username',
|
||||
label: t('用户名称'),
|
||||
value: username,
|
||||
placeholder: t('可选值'),
|
||||
name: 'username',
|
||||
onChange: (value) => handleInputChange(value, 'username'),
|
||||
})}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchModal;
|
||||
@@ -1,112 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
const FooterBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const [footer, setFooter] = useState(getFooterHTML());
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const loadFooter = () => {
|
||||
let footer_html = localStorage.getItem('footer_html');
|
||||
if (footer_html) {
|
||||
setFooter(footer_html);
|
||||
}
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const customFooter = useMemo(() => (
|
||||
<footer className="relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden">
|
||||
<div className="absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]"></div>
|
||||
<div className="absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60"></div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className="flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8">
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
src={logo}
|
||||
alt={systemName}
|
||||
className="w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full">
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('关于我们')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/wiki/project-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('关于项目')}</a>
|
||||
<a href="https://docs.newapi.pro/support/community-interaction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('联系我们')}</a>
|
||||
<a href="https://docs.newapi.pro/wiki/features-introduction/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('功能特性')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('文档')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://docs.newapi.pro/getting-started/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('快速开始')}</a>
|
||||
<a href="https://docs.newapi.pro/installation/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('安装指南')}</a>
|
||||
<a href="https://docs.newapi.pro/api/" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">{t('API 文档')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('相关项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">One API</a>
|
||||
<a href="https://github.com/novicezk/midjourney-proxy" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">Midjourney-Proxy</a>
|
||||
<a href="https://github.com/Deeptrain-Community/chatnio" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">chatnio</a>
|
||||
<a href="https://github.com/Calcium-Ion/neko-api-key-tool" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">neko-api-key-tool</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
<p className="!text-semi-color-text-0 font-semibold mb-5">{t('基于New API的项目')}</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<a href="https://github.com/Calcium-Ion/new-api-horizon" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">new-api-horizon</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Typography.Text className="text-sm !text-semi-color-text-1">© {currentYear} {systemName}. {t('版权所有')}</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="!text-semi-color-text-1">{t('设计与开发由')} </span>
|
||||
<a href="https://github.com/QuantumNous/new-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">New API</a>
|
||||
<span className="!text-semi-color-text-1"> & </span>
|
||||
<a href="https://github.com/songquanpeng/one-api" target="_blank" rel="noopener noreferrer" className="!text-semi-color-primary font-medium">One API</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
), [logo, systemName, t, currentYear, isDemoSiteMode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFooter();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{footer ? (
|
||||
<div
|
||||
className="custom-footer"
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
) : (
|
||||
customFooter
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBar;
|
||||
237
web/src/components/layout/Footer.jsx
Normal file
237
web/src/components/layout/Footer.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
|
||||
const FooterBar = () => {
|
||||
const { t } = useTranslation();
|
||||
const [footer, setFooter] = useState(getFooterHTML());
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const loadFooter = () => {
|
||||
let footer_html = localStorage.getItem('footer_html');
|
||||
if (footer_html) {
|
||||
setFooter(footer_html);
|
||||
}
|
||||
};
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const customFooter = useMemo(
|
||||
() => (
|
||||
<footer className='relative h-auto py-16 px-6 md:px-24 w-full flex flex-col items-center justify-between overflow-hidden'>
|
||||
<div className='absolute hidden md:block top-[204px] left-[-100px] w-[151px] h-[151px] rounded-full bg-[#FFD166]'></div>
|
||||
<div className='absolute md:hidden bottom-[20px] left-[-50px] w-[80px] h-[80px] rounded-full bg-[#FFD166] opacity-60'></div>
|
||||
|
||||
{isDemoSiteMode && (
|
||||
<div className='flex flex-col md:flex-row justify-between w-full max-w-[1110px] mb-10 gap-8'>
|
||||
<div className='flex-shrink-0'>
|
||||
<img
|
||||
src={logo}
|
||||
alt={systemName}
|
||||
className='w-16 h-16 rounded-full bg-gray-800 p-1.5 object-contain'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8 w-full'>
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('关于我们')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://docs.newapi.pro/wiki/project-introduction/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('关于项目')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/support/community-interaction/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('联系我们')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/wiki/features-introduction/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('功能特性')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('文档')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://docs.newapi.pro/getting-started/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('快速开始')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/installation/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('安装指南')}
|
||||
</a>
|
||||
<a
|
||||
href='https://docs.newapi.pro/api/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
{t('API 文档')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('相关项目')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
One API
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/novicezk/midjourney-proxy'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
Midjourney-Proxy
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Deeptrain-Community/chatnio'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
chatnio
|
||||
</a>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/neko-api-key-tool'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
neko-api-key-tool
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='text-left'>
|
||||
<p className='!text-semi-color-text-0 font-semibold mb-5'>
|
||||
{t('基于New API的项目')}
|
||||
</p>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<a
|
||||
href='https://github.com/Calcium-Ion/new-api-horizon'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-text-1'
|
||||
>
|
||||
new-api-horizon
|
||||
</a>
|
||||
{/* <a href="https://github.com/VoAPI/VoAPI" target="_blank" rel="noopener noreferrer" className="!text-semi-color-text-1">VoAPI</a> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex flex-col md:flex-row items-center justify-between w-full max-w-[1110px] gap-6'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Typography.Text className='text-sm !text-semi-color-text-1'>
|
||||
© {currentYear} {systemName}. {t('版权所有')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className='text-sm'>
|
||||
<span className='!text-semi-color-text-1'>
|
||||
{t('设计与开发由')}{' '}
|
||||
</span>
|
||||
<a
|
||||
href='https://github.com/QuantumNous/new-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
New API
|
||||
</a>
|
||||
<span className='!text-semi-color-text-1'> & </span>
|
||||
<a
|
||||
href='https://github.com/songquanpeng/one-api'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='!text-semi-color-primary font-medium'
|
||||
>
|
||||
One API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
),
|
||||
[logo, systemName, t, currentYear, isDemoSiteMode],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadFooter();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
{footer ? (
|
||||
<div
|
||||
className='custom-footer'
|
||||
dangerouslySetInnerHTML={{ __html: footer }}
|
||||
></div>
|
||||
) : (
|
||||
customFooter
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterBar;
|
||||
@@ -1,646 +0,0 @@
|
||||
import React, { useContext, useEffect, useState, useRef } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { useSetTheme, useTheme } from '../../context/Theme/index.js';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js';
|
||||
import fireworks from 'react-fireworks';
|
||||
import { CN, GB } from 'country-flag-icons/react/3x2';
|
||||
import NoticeModal from './NoticeModal.js';
|
||||
|
||||
import {
|
||||
IconClose,
|
||||
IconMenu,
|
||||
IconLanguage,
|
||||
IconChevronDown,
|
||||
IconSun,
|
||||
IconMoon,
|
||||
IconExit,
|
||||
IconUserSetting,
|
||||
IconCreditCard,
|
||||
IconKey,
|
||||
IconBell,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Dropdown,
|
||||
Tag,
|
||||
Typography,
|
||||
Skeleton,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
|
||||
|
||||
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
const [statusState, statusDispatch] = useContext(StatusContext);
|
||||
const isMobile = useIsMobile();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
let navigate = useNavigate();
|
||||
const [currentLang, setCurrentLang] = useState(i18n.language);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
const [noticeVisible, setNoticeVisible] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const loadingStartRef = useRef(Date.now());
|
||||
|
||||
const systemName = getSystemName();
|
||||
const logo = getLogo();
|
||||
const currentDate = new Date();
|
||||
const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1;
|
||||
|
||||
const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false;
|
||||
const docsLink = statusState?.status?.docs_link || '';
|
||||
const isDemoSiteMode = statusState?.status?.demo_site_enabled || false;
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
|
||||
const theme = useTheme();
|
||||
const setTheme = useSetTheme();
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`;
|
||||
|
||||
const calculateUnreadCount = () => {
|
||||
if (!announcements.length) return 0;
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length;
|
||||
};
|
||||
|
||||
const getUnreadKeys = () => {
|
||||
if (!announcements.length) return [];
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const readSet = new Set(readKeys);
|
||||
return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setUnreadCount(calculateUnreadCount());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [announcements]);
|
||||
|
||||
const mainNavLinks = [
|
||||
{
|
||||
text: t('首页'),
|
||||
itemKey: 'home',
|
||||
to: '/',
|
||||
},
|
||||
{
|
||||
text: t('控制台'),
|
||||
itemKey: 'console',
|
||||
to: '/console',
|
||||
},
|
||||
{
|
||||
text: t('定价'),
|
||||
itemKey: 'pricing',
|
||||
to: '/pricing',
|
||||
},
|
||||
...(docsLink
|
||||
? [
|
||||
{
|
||||
text: t('文档'),
|
||||
itemKey: 'docs',
|
||||
isExternal: true,
|
||||
externalLink: docsLink,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: t('关于'),
|
||||
itemKey: 'about',
|
||||
to: '/about',
|
||||
},
|
||||
];
|
||||
|
||||
async function logout() {
|
||||
await API.get('/api/user/logout');
|
||||
showSuccess(t('注销成功!'));
|
||||
userDispatch({ type: 'logout' });
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
setMobileMenuOpen(false);
|
||||
}
|
||||
|
||||
const handleNewYearClick = () => {
|
||||
fireworks.init('root', {});
|
||||
fireworks.start();
|
||||
setTimeout(() => {
|
||||
fireworks.stop();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleNoticeOpen = () => {
|
||||
setNoticeVisible(true);
|
||||
};
|
||||
|
||||
const handleNoticeClose = () => {
|
||||
setNoticeVisible(false);
|
||||
if (announcements.length) {
|
||||
let readKeys = [];
|
||||
try {
|
||||
readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || [];
|
||||
} catch (_) {
|
||||
readKeys = [];
|
||||
}
|
||||
const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)]));
|
||||
localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys));
|
||||
}
|
||||
setUnreadCount(0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === 'dark') {
|
||||
document.body.setAttribute('theme-mode', 'dark');
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.body.removeAttribute('theme-mode');
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.contentWindow.postMessage({ themeMode: theme }, '*');
|
||||
}
|
||||
|
||||
}, [theme, isNewYear]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng) => {
|
||||
setCurrentLang(lng);
|
||||
const iframe = document.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.contentWindow.postMessage({ lang: lng }, '*');
|
||||
}
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', handleLanguageChanged);
|
||||
return () => {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
};
|
||||
}, [i18n]);
|
||||
|
||||
useEffect(() => {
|
||||
if (statusState?.status !== undefined) {
|
||||
const elapsed = Date.now() - loadingStartRef.current;
|
||||
const remaining = Math.max(0, 500 - elapsed);
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, remaining);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [statusState?.status]);
|
||||
|
||||
const handleLanguageChange = (lang) => {
|
||||
i18n.changeLanguage(lang);
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleNavLinkClick = (itemKey) => {
|
||||
if (itemKey === 'home') {
|
||||
// styleDispatch(styleActions.setSider(false)); // This line is removed
|
||||
}
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const renderNavLinks = (isMobileView = false, isLoading = false) => {
|
||||
if (isLoading) {
|
||||
const skeletonLinkClasses = isMobileView
|
||||
? 'flex items-center gap-1 p-3 w-full rounded-md'
|
||||
: 'flex items-center gap-1 p-2 rounded-md';
|
||||
return Array(4)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div key={index} className={skeletonLinkClasses}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobileView ? 100 : 60, height: 16 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return mainNavLinks.map((link) => {
|
||||
const commonLinkClasses = isMobileView
|
||||
? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold'
|
||||
: 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold';
|
||||
|
||||
const linkContent = (
|
||||
<span>{link.text}</span>
|
||||
);
|
||||
|
||||
if (link.isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={link.itemKey}
|
||||
href={link.externalLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={commonLinkClasses}
|
||||
onClick={() => handleNavLinkClick(link.itemKey)}
|
||||
>
|
||||
{linkContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
let targetPath = link.to;
|
||||
if (link.itemKey === 'console' && !userState.user) {
|
||||
targetPath = '/login';
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={link.itemKey}
|
||||
to={targetPath}
|
||||
className={commonLinkClasses}
|
||||
onClick={() => handleNavLinkClick(link.itemKey)}
|
||||
>
|
||||
{linkContent}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const renderUserArea = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1">
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Avatar active size="extra-small" className="shadow-sm" />}
|
||||
/>
|
||||
<div className="ml-1.5 mr-1">
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 15 : 50, height: 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (userState.user) {
|
||||
return (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/personal');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconUserSetting size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('个人设置')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/token');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconKey size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('API令牌')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/topup');
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconCreditCard size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('钱包')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={logout} className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconExit size="small" className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('退出')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
>
|
||||
<Avatar
|
||||
size="extra-small"
|
||||
color={stringToColor(userState.user.username)}
|
||||
className="mr-1"
|
||||
>
|
||||
{userState.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<span className="hidden md:inline">
|
||||
<Typography.Text className="!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1">
|
||||
{userState.user.username}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<IconChevronDown className="text-xs text-semi-color-text-2 dark:text-gray-400" />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
const showRegisterButton = !isSelfUseMode;
|
||||
|
||||
const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5";
|
||||
|
||||
const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors";
|
||||
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
|
||||
|
||||
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
|
||||
|
||||
const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5";
|
||||
const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5";
|
||||
|
||||
if (showRegisterButton) {
|
||||
if (isMobile) {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-l-full !rounded-r-none";
|
||||
}
|
||||
registerButtonClasses += " !rounded-r-full !rounded-l-none";
|
||||
} else {
|
||||
loginButtonClasses += " !rounded-full";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link to="/login" onClick={() => handleNavLinkClick('login')} className="flex">
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className={loginButtonClasses}
|
||||
>
|
||||
<span className={loginButtonTextSpanClass}>
|
||||
{t('登录')}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
{showRegisterButton && (
|
||||
<div className="hidden md:block">
|
||||
<Link to="/register" onClick={() => handleNavLinkClick('register')} className="flex -ml-px">
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
className={registerButtonClasses}
|
||||
>
|
||||
<span className={registerButtonTextSpanClass}>
|
||||
{t('注册')}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg">
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={handleNoticeClose}
|
||||
isMobile={isMobile}
|
||||
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
|
||||
unreadKeys={getUnreadKeys()}
|
||||
/>
|
||||
<div className="w-full px-2">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
icon={
|
||||
isConsoleRoute
|
||||
? ((isMobile ? drawerOpen : collapsed) ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
|
||||
: (mobileMenuOpen ? <IconClose className="text-lg" /> : <IconMenu className="text-lg" />)
|
||||
}
|
||||
aria-label={
|
||||
isConsoleRoute
|
||||
? ((isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏'))
|
||||
: (mobileMenuOpen ? t('关闭菜单') : t('打开菜单'))
|
||||
}
|
||||
onClick={() => {
|
||||
if (isConsoleRoute) {
|
||||
// 控制侧边栏的显示/隐藏,无论是否移动设备
|
||||
isMobile ? onMobileMenuToggle() : toggleCollapsed();
|
||||
} else {
|
||||
// 控制HeaderBar自己的移动菜单
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
}
|
||||
}}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<Link to="/" onClick={() => handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2">
|
||||
<Skeleton
|
||||
loading={isLoading}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Image
|
||||
active
|
||||
className="h-7 md:h-8 !rounded-full"
|
||||
style={{ width: 32, height: 32 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<img src={logo} alt="logo" className="h-7 md:h-8 transition-transform duration-300 ease-in-out group-hover:scale-105 rounded-full" />
|
||||
</Skeleton>
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton
|
||||
loading={isLoading}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: 120, height: 24 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Typography.Title heading={4} className="!text-lg !font-semibold !mb-0">
|
||||
{systemName}
|
||||
</Typography.Title>
|
||||
</Skeleton>
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className="text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm"
|
||||
size="small"
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<div className="md:hidden">
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className="ml-2 text-xs px-1 py-0.5 rounded whitespace-nowrap shadow-sm"
|
||||
size="small"
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 lg:gap-2 ml-6">
|
||||
{renderNavLinks(false, isLoading)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
{isNewYear && (
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item onClick={handleNewYearClick} className="!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600">
|
||||
Happy New Year!!! 🎉
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
icon={<span className="text-xl">🎉</span>}
|
||||
aria-label="New Year"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{unreadCount > 0 ? (
|
||||
<Badge count={unreadCount} type="danger" overflowCount={99}>
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={handleNoticeOpen}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
</Badge>
|
||||
) : (
|
||||
<Button
|
||||
icon={<IconBell className="text-lg" />}
|
||||
aria-label={t('系统公告')}
|
||||
onClick={handleNoticeOpen}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
icon={theme === 'dark' ? <IconSun size="large" className="text-yellow-500" /> : <IconMoon size="large" className="text-gray-300" />}
|
||||
aria-label={t('切换主题')}
|
||||
onClick={() => setTheme(theme === 'dark' ? false : true)}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu className="!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600">
|
||||
<Dropdown.Item
|
||||
onClick={() => handleLanguageChange('zh')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<CN title="中文" className="!w-5 !h-auto" />
|
||||
<span>中文</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => handleLanguageChange('en')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<GB title="English" className="!w-5 !h-auto" />
|
||||
<span>English</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<IconLanguage className="text-lg" />}
|
||||
aria-label={t('切换语言')}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
className="!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2"
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
{renderUserArea()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={`
|
||||
absolute top-16 left-0 right-0 bg-semi-color-bg-0
|
||||
shadow-lg p-3
|
||||
transform transition-all duration-300 ease-in-out
|
||||
${(!isConsoleRoute && mobileMenuOpen) ? 'translate-y-0 opacity-100 visible' : '-translate-y-4 opacity-0 invisible'}
|
||||
`}
|
||||
>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{renderNavLinks(true, isLoading)}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
@@ -1,184 +0,0 @@
|
||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||
import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, getRelativeTime } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { Bell, Megaphone } from 'lucide-react';
|
||||
|
||||
const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => {
|
||||
const { t } = useTranslation();
|
||||
const [noticeContent, setNoticeContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
|
||||
|
||||
const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
||||
|
||||
const processedAnnouncements = useMemo(() => {
|
||||
return (announcements || []).slice(0, 20).map(item => ({
|
||||
key: getKeyForItem(item),
|
||||
type: item.type || 'default',
|
||||
time: getRelativeTime(item.publishDate),
|
||||
content: item.content,
|
||||
extra: item.extra,
|
||||
isUnread: unreadSet.has(getKeyForItem(item))
|
||||
}));
|
||||
}, [announcements, unreadSet]);
|
||||
|
||||
const handleCloseTodayNotice = () => {
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('notice_close_date', today);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const displayNotice = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data !== '') {
|
||||
const htmlNotice = marked.parse(data);
|
||||
setNoticeContent(htmlNotice);
|
||||
} else {
|
||||
setNoticeContent('');
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
displayNotice();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setActiveTab(defaultTab);
|
||||
}
|
||||
}, [defaultTab, visible]);
|
||||
|
||||
const renderMarkdownNotice = () => {
|
||||
if (loading) {
|
||||
return <div className="py-12"><Empty description={t('加载中...')} /></div>;
|
||||
}
|
||||
|
||||
if (!noticeContent) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||
className="notice-content-scroll max-h-[55vh] overflow-y-auto pr-2"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAnnouncementTimeline = () => {
|
||||
if (processedAnnouncements.length === 0) {
|
||||
return (
|
||||
<div className="py-12">
|
||||
<Empty
|
||||
image={<IllustrationNoContent style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoContentDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('暂无系统公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-[55vh] overflow-y-auto pr-2 card-content-scroll">
|
||||
<Timeline mode="alternate">
|
||||
{processedAnnouncements.map((item, idx) => {
|
||||
const htmlContent = marked.parse(item.content || '');
|
||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={idx}
|
||||
type={item.type}
|
||||
time={item.time}
|
||||
className={item.isUnread ? '' : ''}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={item.isUnread ? 'shine-text' : ''}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
{item.extra && (
|
||||
<div
|
||||
className="text-xs text-gray-500"
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (activeTab === 'inApp') {
|
||||
return renderMarkdownNotice();
|
||||
}
|
||||
return renderAnnouncementTimeline();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>{t('系统公告')}</span>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
type='card'
|
||||
size='small'
|
||||
>
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Bell size={14} /> {t('通知')}</span>} itemKey='inApp' />
|
||||
<TabPane tab={<span className="flex items-center gap-1"><Megaphone size={14} /> {t('系统公告')}</span>} itemKey='system' />
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={(
|
||||
<div className="flex justify-end">
|
||||
<Button type='secondary' onClick={handleCloseTodayNotice}>{t('今日关闭')}</Button>
|
||||
<Button type="primary" onClick={onClose}>{t('关闭公告')}</Button>
|
||||
</div>
|
||||
)}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{renderBody()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoticeModal;
|
||||
255
web/src/components/layout/NoticeModal.jsx
Normal file
255
web/src/components/layout/NoticeModal.jsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useContext, useMemo } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Empty,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Timeline,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, getRelativeTime } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import {
|
||||
IllustrationNoContent,
|
||||
IllustrationNoContentDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { Bell, Megaphone } from 'lucide-react';
|
||||
|
||||
const NoticeModal = ({
|
||||
visible,
|
||||
onClose,
|
||||
isMobile,
|
||||
defaultTab = 'inApp',
|
||||
unreadKeys = [],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [noticeContent, setNoticeContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
||||
|
||||
const [statusState] = useContext(StatusContext);
|
||||
|
||||
const announcements = statusState?.status?.announcements || [];
|
||||
|
||||
const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]);
|
||||
|
||||
const getKeyForItem = (item) =>
|
||||
`${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`;
|
||||
|
||||
const processedAnnouncements = useMemo(() => {
|
||||
return (announcements || []).slice(0, 20).map((item) => {
|
||||
const pubDate = item?.publishDate ? new Date(item.publishDate) : null;
|
||||
const absoluteTime =
|
||||
pubDate && !isNaN(pubDate.getTime())
|
||||
? `${pubDate.getFullYear()}-${String(pubDate.getMonth() + 1).padStart(2, '0')}-${String(pubDate.getDate()).padStart(2, '0')} ${String(pubDate.getHours()).padStart(2, '0')}:${String(pubDate.getMinutes()).padStart(2, '0')}`
|
||||
: item?.publishDate || '';
|
||||
return {
|
||||
key: getKeyForItem(item),
|
||||
type: item.type || 'default',
|
||||
time: absoluteTime,
|
||||
content: item.content,
|
||||
extra: item.extra,
|
||||
relative: getRelativeTime(item.publishDate),
|
||||
isUnread: unreadSet.has(getKeyForItem(item)),
|
||||
};
|
||||
});
|
||||
}, [announcements, unreadSet]);
|
||||
|
||||
const handleCloseTodayNotice = () => {
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('notice_close_date', today);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const displayNotice = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/notice');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (data !== '') {
|
||||
const htmlNotice = marked.parse(data);
|
||||
setNoticeContent(htmlNotice);
|
||||
} else {
|
||||
setNoticeContent('');
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
displayNotice();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setActiveTab(defaultTab);
|
||||
}
|
||||
}, [defaultTab, visible]);
|
||||
|
||||
const renderMarkdownNotice = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='py-12'>
|
||||
<Empty description={t('加载中...')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!noticeContent) {
|
||||
return (
|
||||
<div className='py-12'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: noticeContent }}
|
||||
className='notice-content-scroll max-h-[55vh] overflow-y-auto pr-2'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAnnouncementTimeline = () => {
|
||||
if (processedAnnouncements.length === 0) {
|
||||
return (
|
||||
<div className='py-12'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无系统公告')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='max-h-[55vh] overflow-y-auto pr-2 card-content-scroll'>
|
||||
<Timeline mode='left'>
|
||||
{processedAnnouncements.map((item, idx) => {
|
||||
const htmlContent = marked.parse(item.content || '');
|
||||
const htmlExtra = item.extra ? marked.parse(item.extra) : '';
|
||||
return (
|
||||
<Timeline.Item
|
||||
key={idx}
|
||||
type={item.type}
|
||||
time={`${item.relative ? item.relative + ' ' : ''}${item.time}`}
|
||||
extra={
|
||||
item.extra ? (
|
||||
<div
|
||||
className='text-xs text-gray-500'
|
||||
dangerouslySetInnerHTML={{ __html: htmlExtra }}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
className={item.isUnread ? '' : ''}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={item.isUnread ? 'shine-text' : ''}
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
</Timeline.Item>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = () => {
|
||||
if (activeTab === 'inApp') {
|
||||
return renderMarkdownNotice();
|
||||
}
|
||||
return renderAnnouncementTimeline();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center justify-between w-full'>
|
||||
<span>{t('系统公告')}</span>
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} type='button'>
|
||||
<TabPane
|
||||
tab={
|
||||
<span className='flex items-center gap-1'>
|
||||
<Bell size={14} /> {t('通知')}
|
||||
</span>
|
||||
}
|
||||
itemKey='inApp'
|
||||
/>
|
||||
<TabPane
|
||||
tab={
|
||||
<span className='flex items-center gap-1'>
|
||||
<Megaphone size={14} /> {t('系统公告')}
|
||||
</span>
|
||||
}
|
||||
itemKey='system'
|
||||
/>
|
||||
</Tabs>
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
<Button type='secondary' onClick={handleCloseTodayNotice}>
|
||||
{t('今日关闭')}
|
||||
</Button>
|
||||
<Button type='primary' onClick={onClose}>
|
||||
{t('关闭公告')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
>
|
||||
{renderBody()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoticeModal;
|
||||
@@ -1,16 +1,41 @@
|
||||
import HeaderBar from './HeaderBar.js';
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import HeaderBar from './headerbar';
|
||||
import { Layout } from '@douyinfe/semi-ui';
|
||||
import SiderBar from './SiderBar.js';
|
||||
import App from '../../App.js';
|
||||
import FooterBar from './Footer.js';
|
||||
import SiderBar from './SiderBar';
|
||||
import App from '../../App';
|
||||
import FooterBar from './Footer';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js';
|
||||
import { UserContext } from '../../context/User/index.js';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import {
|
||||
API,
|
||||
getLogo,
|
||||
getSystemName,
|
||||
showError,
|
||||
setStatusData,
|
||||
} from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
const { Sider, Content, Header } = Layout;
|
||||
|
||||
@@ -23,9 +48,12 @@ const PageLayout = () => {
|
||||
const { i18n } = useTranslation();
|
||||
const location = useLocation();
|
||||
|
||||
const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat');
|
||||
const shouldHideFooter =
|
||||
location.pathname.startsWith('/console') ||
|
||||
location.pathname === '/pricing';
|
||||
|
||||
const shouldInnerPadding = location.pathname.includes('/console') &&
|
||||
const shouldInnerPadding =
|
||||
location.pathname.includes('/console') &&
|
||||
!location.pathname.startsWith('/console/chat') &&
|
||||
location.pathname !== '/console/playground';
|
||||
|
||||
@@ -101,7 +129,10 @@ const PageLayout = () => {
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<HeaderBar onMobileMenuToggle={() => setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} />
|
||||
<HeaderBar
|
||||
onMobileMenuToggle={() => setDrawerOpen((prev) => !prev)}
|
||||
drawerOpen={drawerOpen}
|
||||
/>
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
@@ -123,12 +154,20 @@ const PageLayout = () => {
|
||||
width: 'var(--sidebar-current-width)',
|
||||
}}
|
||||
>
|
||||
<SiderBar onNavigate={() => { if (isMobile) setDrawerOpen(false); }} />
|
||||
<SiderBar
|
||||
onNavigate={() => {
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Sider>
|
||||
)}
|
||||
<Layout
|
||||
style={{
|
||||
marginLeft: isMobile ? '0' : showSider ? 'var(--sidebar-current-width)' : '0',
|
||||
marginLeft: isMobile
|
||||
? '0'
|
||||
: showSider
|
||||
? 'var(--sidebar-current-width)'
|
||||
: '0',
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
@@ -7,7 +26,10 @@ const SetupCheck = ({ children }) => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (statusState?.status?.setup === false && location.pathname !== '/setup') {
|
||||
if (
|
||||
statusState?.status?.setup === false &&
|
||||
location.pathname !== '/setup'
|
||||
) {
|
||||
window.location.href = '/setup';
|
||||
}
|
||||
}, [statusState?.status?.setup, location.pathname]);
|
||||
@@ -15,4 +37,4 @@ const SetupCheck = ({ children }) => {
|
||||
return children;
|
||||
};
|
||||
|
||||
export default SetupCheck;
|
||||
export default SetupCheck;
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js';
|
||||
import {
|
||||
isAdmin,
|
||||
isRoot,
|
||||
showError
|
||||
} from '../../helpers/index.js';
|
||||
|
||||
import {
|
||||
Nav,
|
||||
Divider,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
|
||||
const routerMap = {
|
||||
home: '/',
|
||||
channel: '/console/channel',
|
||||
token: '/console/token',
|
||||
redemption: '/console/redemption',
|
||||
topup: '/console/topup',
|
||||
user: '/console/user',
|
||||
log: '/console/log',
|
||||
midjourney: '/console/midjourney',
|
||||
setting: '/console/setting',
|
||||
about: '/about',
|
||||
detail: '/console',
|
||||
pricing: '/pricing',
|
||||
task: '/console/task',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
};
|
||||
|
||||
const SiderBar = ({ onNavigate = () => { } }) => {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||
const [chatItems, setChatItems] = useState([]);
|
||||
const [openedKeys, setOpenedKeys] = useState([]);
|
||||
const location = useLocation();
|
||||
const [routerMapState, setRouterMapState] = useState(routerMap);
|
||||
|
||||
const workspaceItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('数据看板'),
|
||||
itemKey: 'detail',
|
||||
to: '/detail',
|
||||
className:
|
||||
localStorage.getItem('enable_data_export') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('API令牌'),
|
||||
itemKey: 'token',
|
||||
to: '/token',
|
||||
},
|
||||
{
|
||||
text: t('使用日志'),
|
||||
itemKey: 'log',
|
||||
to: '/log',
|
||||
},
|
||||
{
|
||||
text: t('绘图日志'),
|
||||
itemKey: 'midjourney',
|
||||
to: '/midjourney',
|
||||
className:
|
||||
localStorage.getItem('enable_drawing') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('任务日志'),
|
||||
itemKey: 'task',
|
||||
to: '/task',
|
||||
className:
|
||||
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
|
||||
},
|
||||
],
|
||||
[
|
||||
localStorage.getItem('enable_data_export'),
|
||||
localStorage.getItem('enable_drawing'),
|
||||
localStorage.getItem('enable_task'),
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const financeItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('钱包'),
|
||||
itemKey: 'topup',
|
||||
to: '/topup',
|
||||
},
|
||||
{
|
||||
text: t('个人设置'),
|
||||
itemKey: 'personal',
|
||||
to: '/personal',
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const adminItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('渠道'),
|
||||
itemKey: 'channel',
|
||||
to: '/channel',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('兑换码'),
|
||||
itemKey: 'redemption',
|
||||
to: '/redemption',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('用户管理'),
|
||||
itemKey: 'user',
|
||||
to: '/user',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('系统设置'),
|
||||
itemKey: 'setting',
|
||||
to: '/setting',
|
||||
className: isRoot() ? '' : 'tableHiddle',
|
||||
},
|
||||
],
|
||||
[isAdmin(), isRoot(), t],
|
||||
);
|
||||
|
||||
const chatMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
text: t('操练场'),
|
||||
itemKey: 'playground',
|
||||
to: '/playground',
|
||||
},
|
||||
{
|
||||
text: t('聊天'),
|
||||
itemKey: 'chat',
|
||||
items: chatItems,
|
||||
},
|
||||
],
|
||||
[chatItems, t],
|
||||
);
|
||||
|
||||
// 更新路由映射,添加聊天路由
|
||||
const updateRouterMapWithChats = (chats) => {
|
||||
const newRouterMap = { ...routerMap };
|
||||
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
newRouterMap['chat' + i] = '/console/chat/' + i;
|
||||
}
|
||||
}
|
||||
|
||||
setRouterMapState(newRouterMap);
|
||||
return newRouterMap;
|
||||
};
|
||||
|
||||
// 加载聊天项
|
||||
useEffect(() => {
|
||||
let chats = localStorage.getItem('chats');
|
||||
if (chats) {
|
||||
try {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats)) {
|
||||
let chatItems = [];
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
let chat = {};
|
||||
for (let key in chats[i]) {
|
||||
chat.text = key;
|
||||
chat.itemKey = 'chat' + i;
|
||||
chat.to = '/console/chat/' + i;
|
||||
}
|
||||
chatItems.push(chat);
|
||||
}
|
||||
setChatItems(chatItems);
|
||||
updateRouterMapWithChats(chats);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showError('聊天数据解析失败');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据当前路径设置选中的菜单项
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
let matchingKey = Object.keys(routerMapState).find(
|
||||
(key) => routerMapState[key] === currentPath,
|
||||
);
|
||||
|
||||
// 处理聊天路由
|
||||
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
|
||||
const chatIndex = currentPath.split('/').pop();
|
||||
if (!isNaN(chatIndex)) {
|
||||
matchingKey = 'chat' + chatIndex;
|
||||
} else {
|
||||
matchingKey = 'chat';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到匹配的键,更新选中的键
|
||||
if (matchingKey) {
|
||||
setSelectedKeys([matchingKey]);
|
||||
}
|
||||
}, [location.pathname, routerMapState]);
|
||||
|
||||
// 监控折叠状态变化以更新 body class
|
||||
useEffect(() => {
|
||||
if (collapsed) {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
} else {
|
||||
document.body.classList.remove('sidebar-collapsed');
|
||||
}
|
||||
}, [collapsed]);
|
||||
|
||||
// 获取菜单项对应的颜色
|
||||
const getItemColor = (itemKey) => {
|
||||
switch (itemKey) {
|
||||
case 'detail': return sidebarIconColors.dashboard;
|
||||
case 'playground': return sidebarIconColors.terminal;
|
||||
case 'chat': return sidebarIconColors.message;
|
||||
case 'token': return sidebarIconColors.key;
|
||||
case 'log': return sidebarIconColors.chart;
|
||||
case 'midjourney': return sidebarIconColors.image;
|
||||
case 'task': return sidebarIconColors.check;
|
||||
case 'topup': return sidebarIconColors.credit;
|
||||
case 'channel': return sidebarIconColors.layers;
|
||||
case 'redemption': return sidebarIconColors.gift;
|
||||
case 'user':
|
||||
case 'personal': return sidebarIconColors.user;
|
||||
case 'setting': return sidebarIconColors.settings;
|
||||
default:
|
||||
// 处理聊天项
|
||||
if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message;
|
||||
return 'currentColor';
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染自定义菜单项
|
||||
const renderNavItem = (item) => {
|
||||
// 跳过隐藏的项目
|
||||
if (item.className === 'tableHiddle') return null;
|
||||
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<div className="flex items-center">
|
||||
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<div className="sidebar-icon-container flex-shrink-0">
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
className={item.className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染子菜单项
|
||||
const renderSubItem = (item) => {
|
||||
if (item.items && item.items.length > 0) {
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Sub
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<div className="flex items-center">
|
||||
<span className="truncate font-medium text-sm" style={{ color: textColor }}>
|
||||
{item.text}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
icon={
|
||||
<div className="sidebar-icon-container flex-shrink-0">
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{item.items.map((subItem) => {
|
||||
const isSubSelected = selectedKeys.includes(subItem.itemKey);
|
||||
const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
key={subItem.itemKey}
|
||||
itemKey={subItem.itemKey}
|
||||
text={
|
||||
<span className="truncate font-medium text-sm" style={{ color: subTextColor }}>
|
||||
{subItem.text}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Nav.Sub>
|
||||
);
|
||||
} else {
|
||||
return renderNavItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sidebar-container"
|
||||
style={{ width: 'var(--sidebar-current-width)' }}
|
||||
>
|
||||
<Nav
|
||||
className="sidebar-nav"
|
||||
defaultIsCollapsed={collapsed}
|
||||
isCollapsed={collapsed}
|
||||
onCollapseChange={toggleCollapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
itemStyle="sidebar-nav-item"
|
||||
hoverStyle="sidebar-nav-item:hover"
|
||||
selectedStyle="sidebar-nav-item-selected"
|
||||
renderWrapper={({ itemElement, props }) => {
|
||||
const to = routerMapState[props.itemKey] || routerMap[props.itemKey];
|
||||
|
||||
// 如果没有路由,直接返回元素
|
||||
if (!to) return itemElement;
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={to}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{itemElement}
|
||||
</Link>
|
||||
);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
||||
if (openedKeys.includes(key.itemKey)) {
|
||||
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
||||
}
|
||||
|
||||
setSelectedKeys([key.itemKey]);
|
||||
}}
|
||||
openKeys={openedKeys}
|
||||
onOpenChange={(data) => {
|
||||
setOpenedKeys(data.openKeys);
|
||||
}}
|
||||
>
|
||||
{/* 聊天区域 */}
|
||||
<div className="sidebar-section">
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('聊天')}</div>
|
||||
)}
|
||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
||||
</div>
|
||||
|
||||
{/* 控制台区域 */}
|
||||
<Divider className="sidebar-divider" />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('控制台')}</div>
|
||||
)}
|
||||
{workspaceItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
|
||||
{/* 管理员区域 - 只在管理员时显示 */}
|
||||
{isAdmin() && (
|
||||
<>
|
||||
<Divider className="sidebar-divider" />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('管理员')}</div>
|
||||
)}
|
||||
{adminItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 个人中心区域 */}
|
||||
<Divider className="sidebar-divider" />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className="sidebar-group-label">{t('个人中心')}</div>
|
||||
)}
|
||||
{financeItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</Nav>
|
||||
|
||||
{/* 底部折叠按钮 */}
|
||||
<div className="sidebar-collapse-button">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={
|
||||
<ChevronLeft
|
||||
size={16}
|
||||
strokeWidth={2.5}
|
||||
color="var(--semi-color-text-2)"
|
||||
style={{ transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)' }}
|
||||
/>
|
||||
}
|
||||
onClick={toggleCollapsed}
|
||||
iconOnly={collapsed}
|
||||
style={collapsed ? { padding: '4px', width: '100%' } : { padding: '4px 12px', width: '100%' }}
|
||||
>
|
||||
{!collapsed ? t('收起侧边栏') : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiderBar;
|
||||
519
web/src/components/layout/SiderBar.jsx
Normal file
519
web/src/components/layout/SiderBar.jsx
Normal file
@@ -0,0 +1,519 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLucideIcon } from '../../helpers/render';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useSidebarCollapsed } from '../../hooks/common/useSidebarCollapsed';
|
||||
import { useSidebar } from '../../hooks/common/useSidebar';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||
import { isAdmin, isRoot, showError } from '../../helpers';
|
||||
import SkeletonWrapper from './components/SkeletonWrapper';
|
||||
|
||||
import { Nav, Divider, Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const routerMap = {
|
||||
home: '/',
|
||||
channel: '/console/channel',
|
||||
token: '/console/token',
|
||||
redemption: '/console/redemption',
|
||||
topup: '/console/topup',
|
||||
user: '/console/user',
|
||||
log: '/console/log',
|
||||
midjourney: '/console/midjourney',
|
||||
setting: '/console/setting',
|
||||
about: '/about',
|
||||
detail: '/console',
|
||||
pricing: '/pricing',
|
||||
task: '/console/task',
|
||||
models: '/console/models',
|
||||
playground: '/console/playground',
|
||||
personal: '/console/personal',
|
||||
};
|
||||
|
||||
const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
const { t } = useTranslation();
|
||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||
const {
|
||||
isModuleVisible,
|
||||
hasSectionVisibleModules,
|
||||
loading: sidebarLoading,
|
||||
} = useSidebar();
|
||||
|
||||
const showSkeleton = useMinimumLoadingTime(sidebarLoading);
|
||||
|
||||
const [selectedKeys, setSelectedKeys] = useState(['home']);
|
||||
const [chatItems, setChatItems] = useState([]);
|
||||
const [openedKeys, setOpenedKeys] = useState([]);
|
||||
const location = useLocation();
|
||||
const [routerMapState, setRouterMapState] = useState(routerMap);
|
||||
|
||||
const workspaceItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('数据看板'),
|
||||
itemKey: 'detail',
|
||||
to: '/detail',
|
||||
className:
|
||||
localStorage.getItem('enable_data_export') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('令牌管理'),
|
||||
itemKey: 'token',
|
||||
to: '/token',
|
||||
},
|
||||
{
|
||||
text: t('使用日志'),
|
||||
itemKey: 'log',
|
||||
to: '/log',
|
||||
},
|
||||
{
|
||||
text: t('绘图日志'),
|
||||
itemKey: 'midjourney',
|
||||
to: '/midjourney',
|
||||
className:
|
||||
localStorage.getItem('enable_drawing') === 'true'
|
||||
? ''
|
||||
: 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('任务日志'),
|
||||
itemKey: 'task',
|
||||
to: '/task',
|
||||
className:
|
||||
localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle',
|
||||
},
|
||||
];
|
||||
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('console', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [
|
||||
localStorage.getItem('enable_data_export'),
|
||||
localStorage.getItem('enable_drawing'),
|
||||
localStorage.getItem('enable_task'),
|
||||
t,
|
||||
isModuleVisible,
|
||||
]);
|
||||
|
||||
const financeItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('钱包管理'),
|
||||
itemKey: 'topup',
|
||||
to: '/topup',
|
||||
},
|
||||
{
|
||||
text: t('个人设置'),
|
||||
itemKey: 'personal',
|
||||
to: '/personal',
|
||||
},
|
||||
];
|
||||
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('personal', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [t, isModuleVisible]);
|
||||
|
||||
const adminItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('渠道管理'),
|
||||
itemKey: 'channel',
|
||||
to: '/channel',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('模型管理'),
|
||||
itemKey: 'models',
|
||||
to: '/console/models',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('兑换码管理'),
|
||||
itemKey: 'redemption',
|
||||
to: '/redemption',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('用户管理'),
|
||||
itemKey: 'user',
|
||||
to: '/user',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('系统设置'),
|
||||
itemKey: 'setting',
|
||||
to: '/setting',
|
||||
className: isRoot() ? '' : 'tableHiddle',
|
||||
},
|
||||
];
|
||||
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('admin', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [isAdmin(), isRoot(), t, isModuleVisible]);
|
||||
|
||||
const chatMenuItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
text: t('操练场'),
|
||||
itemKey: 'playground',
|
||||
to: '/playground',
|
||||
},
|
||||
{
|
||||
text: t('聊天'),
|
||||
itemKey: 'chat',
|
||||
items: chatItems,
|
||||
},
|
||||
];
|
||||
|
||||
// 根据配置过滤项目
|
||||
const filteredItems = items.filter((item) => {
|
||||
const configVisible = isModuleVisible('chat', item.itemKey);
|
||||
return configVisible;
|
||||
});
|
||||
|
||||
return filteredItems;
|
||||
}, [chatItems, t, isModuleVisible]);
|
||||
|
||||
// 更新路由映射,添加聊天路由
|
||||
const updateRouterMapWithChats = (chats) => {
|
||||
const newRouterMap = { ...routerMap };
|
||||
|
||||
if (Array.isArray(chats) && chats.length > 0) {
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
newRouterMap['chat' + i] = '/console/chat/' + i;
|
||||
}
|
||||
}
|
||||
|
||||
setRouterMapState(newRouterMap);
|
||||
return newRouterMap;
|
||||
};
|
||||
|
||||
// 加载聊天项
|
||||
useEffect(() => {
|
||||
let chats = localStorage.getItem('chats');
|
||||
if (chats) {
|
||||
try {
|
||||
chats = JSON.parse(chats);
|
||||
if (Array.isArray(chats)) {
|
||||
let chatItems = [];
|
||||
for (let i = 0; i < chats.length; i++) {
|
||||
let shouldSkip = false;
|
||||
let chat = {};
|
||||
for (let key in chats[i]) {
|
||||
let link = chats[i][key];
|
||||
if (typeof link !== 'string') continue; // 确保链接是字符串
|
||||
if (link.startsWith('fluent')) {
|
||||
shouldSkip = true;
|
||||
break; // 跳过 Fluent Read
|
||||
}
|
||||
chat.text = key;
|
||||
chat.itemKey = 'chat' + i;
|
||||
chat.to = '/console/chat/' + i;
|
||||
}
|
||||
if (shouldSkip || !chat.text) continue; // 避免推入空项
|
||||
chatItems.push(chat);
|
||||
}
|
||||
setChatItems(chatItems);
|
||||
updateRouterMapWithChats(chats);
|
||||
}
|
||||
} catch (e) {
|
||||
showError('聊天数据解析失败');
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 根据当前路径设置选中的菜单项
|
||||
useEffect(() => {
|
||||
const currentPath = location.pathname;
|
||||
let matchingKey = Object.keys(routerMapState).find(
|
||||
(key) => routerMapState[key] === currentPath,
|
||||
);
|
||||
|
||||
// 处理聊天路由
|
||||
if (!matchingKey && currentPath.startsWith('/console/chat/')) {
|
||||
const chatIndex = currentPath.split('/').pop();
|
||||
if (!isNaN(chatIndex)) {
|
||||
matchingKey = 'chat' + chatIndex;
|
||||
} else {
|
||||
matchingKey = 'chat';
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找到匹配的键,更新选中的键
|
||||
if (matchingKey) {
|
||||
setSelectedKeys([matchingKey]);
|
||||
}
|
||||
}, [location.pathname, routerMapState]);
|
||||
|
||||
// 监控折叠状态变化以更新 body class
|
||||
useEffect(() => {
|
||||
if (collapsed) {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
} else {
|
||||
document.body.classList.remove('sidebar-collapsed');
|
||||
}
|
||||
}, [collapsed]);
|
||||
|
||||
// 选中高亮颜色(统一)
|
||||
const SELECTED_COLOR = 'var(--semi-color-primary)';
|
||||
|
||||
// 渲染自定义菜单项
|
||||
const renderNavItem = (item) => {
|
||||
// 跳过隐藏的项目
|
||||
if (item.className === 'tableHiddle') return null;
|
||||
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
className={item.className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染子菜单项
|
||||
const renderSubItem = (item) => {
|
||||
if (item.items && item.items.length > 0) {
|
||||
const isSelected = selectedKeys.includes(item.itemKey);
|
||||
const textColor = isSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Sub
|
||||
key={item.itemKey}
|
||||
itemKey={item.itemKey}
|
||||
text={
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: textColor }}
|
||||
>
|
||||
{item.text}
|
||||
</span>
|
||||
}
|
||||
icon={
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
{getLucideIcon(item.itemKey, isSelected)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{item.items.map((subItem) => {
|
||||
const isSubSelected = selectedKeys.includes(subItem.itemKey);
|
||||
const subTextColor = isSubSelected ? SELECTED_COLOR : 'inherit';
|
||||
|
||||
return (
|
||||
<Nav.Item
|
||||
key={subItem.itemKey}
|
||||
itemKey={subItem.itemKey}
|
||||
text={
|
||||
<span
|
||||
className='truncate font-medium text-sm'
|
||||
style={{ color: subTextColor }}
|
||||
>
|
||||
{subItem.text}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Nav.Sub>
|
||||
);
|
||||
} else {
|
||||
return renderNavItem(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='sidebar-container'
|
||||
style={{
|
||||
width: 'var(--sidebar-current-width)',
|
||||
background: 'var(--semi-color-bg-0)',
|
||||
}}
|
||||
>
|
||||
<SkeletonWrapper
|
||||
loading={showSkeleton}
|
||||
type='sidebar'
|
||||
className=''
|
||||
collapsed={collapsed}
|
||||
showAdmin={isAdmin()}
|
||||
>
|
||||
<Nav
|
||||
className='sidebar-nav'
|
||||
defaultIsCollapsed={collapsed}
|
||||
isCollapsed={collapsed}
|
||||
onCollapseChange={toggleCollapsed}
|
||||
selectedKeys={selectedKeys}
|
||||
itemStyle='sidebar-nav-item'
|
||||
hoverStyle='sidebar-nav-item:hover'
|
||||
selectedStyle='sidebar-nav-item-selected'
|
||||
renderWrapper={({ itemElement, props }) => {
|
||||
const to =
|
||||
routerMapState[props.itemKey] || routerMap[props.itemKey];
|
||||
|
||||
// 如果没有路由,直接返回元素
|
||||
if (!to) return itemElement;
|
||||
|
||||
return (
|
||||
<Link
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={to}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{itemElement}
|
||||
</Link>
|
||||
);
|
||||
}}
|
||||
onSelect={(key) => {
|
||||
// 如果点击的是已经展开的子菜单的父项,则收起子菜单
|
||||
if (openedKeys.includes(key.itemKey)) {
|
||||
setOpenedKeys(openedKeys.filter((k) => k !== key.itemKey));
|
||||
}
|
||||
|
||||
setSelectedKeys([key.itemKey]);
|
||||
}}
|
||||
openKeys={openedKeys}
|
||||
onOpenChange={(data) => {
|
||||
setOpenedKeys(data.openKeys);
|
||||
}}
|
||||
>
|
||||
{/* 聊天区域 */}
|
||||
{hasSectionVisibleModules('chat') && (
|
||||
<div className='sidebar-section'>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('聊天')}</div>
|
||||
)}
|
||||
{chatMenuItems.map((item) => renderSubItem(item))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制台区域 */}
|
||||
{hasSectionVisibleModules('console') && (
|
||||
<>
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('控制台')}</div>
|
||||
)}
|
||||
{workspaceItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 个人中心区域 */}
|
||||
{hasSectionVisibleModules('personal') && (
|
||||
<>
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('个人中心')}</div>
|
||||
)}
|
||||
{financeItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 管理员区域 - 只在管理员时显示且配置允许时显示 */}
|
||||
{isAdmin() && hasSectionVisibleModules('admin') && (
|
||||
<>
|
||||
<Divider className='sidebar-divider' />
|
||||
<div>
|
||||
{!collapsed && (
|
||||
<div className='sidebar-group-label'>{t('管理员')}</div>
|
||||
)}
|
||||
{adminItems.map((item) => renderNavItem(item))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Nav>
|
||||
</SkeletonWrapper>
|
||||
|
||||
{/* 底部折叠按钮 */}
|
||||
<div className='sidebar-collapse-button'>
|
||||
<SkeletonWrapper
|
||||
loading={showSkeleton}
|
||||
type='button'
|
||||
width={collapsed ? 36 : 156}
|
||||
height={24}
|
||||
className='w-full'
|
||||
>
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={
|
||||
<ChevronLeft
|
||||
size={16}
|
||||
strokeWidth={2.5}
|
||||
color='var(--semi-color-text-2)'
|
||||
style={{
|
||||
transform: collapsed ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={toggleCollapsed}
|
||||
icononly={collapsed}
|
||||
style={
|
||||
collapsed
|
||||
? { width: 36, height: 24, padding: 0 }
|
||||
: { padding: '4px 12px', width: '100%' }
|
||||
}
|
||||
>
|
||||
{!collapsed ? t('收起侧边栏') : null}
|
||||
</Button>
|
||||
</SkeletonWrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiderBar;
|
||||
394
web/src/components/layout/components/SkeletonWrapper.jsx
Normal file
394
web/src/components/layout/components/SkeletonWrapper.jsx
Normal file
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@douyinfe/semi-ui';
|
||||
|
||||
const SkeletonWrapper = ({
|
||||
loading = false,
|
||||
type = 'text',
|
||||
count = 1,
|
||||
width = 60,
|
||||
height = 16,
|
||||
isMobile = false,
|
||||
className = '',
|
||||
collapsed = false,
|
||||
showAdmin = true,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
if (!loading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 导航链接骨架屏
|
||||
const renderNavigationSkeleton = () => {
|
||||
const skeletonLinkClasses = isMobile
|
||||
? 'flex items-center gap-1 p-1 w-full rounded-md'
|
||||
: 'flex items-center gap-1 p-2 rounded-md';
|
||||
|
||||
return Array(count)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div key={index} className={skeletonLinkClasses}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 40 : width, height }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 用户区域骨架屏 (头像 + 文本)
|
||||
const renderUserAreaSkeleton = () => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center p-1 rounded-full bg-semi-color-fill-0 dark:bg-semi-color-fill-1 ${className}`}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar active size='extra-small' className='shadow-sm' />
|
||||
}
|
||||
/>
|
||||
<div className='ml-1.5 mr-1'>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: isMobile ? 15 : width, height: 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Logo图片骨架屏
|
||||
const renderImageSkeleton = () => {
|
||||
return (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Image
|
||||
active
|
||||
className={`absolute inset-0 !rounded-full ${className}`}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 系统名称骨架屏
|
||||
const renderTitleSkeleton = () => {
|
||||
return (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height: 24 }} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 通用文本骨架屏
|
||||
const renderTextSkeleton = () => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={<Skeleton.Title active style={{ width, height }} />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 按钮骨架屏(支持圆角)
|
||||
const renderButtonSkeleton = () => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width, height, borderRadius: 9999 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 侧边栏导航项骨架屏 (图标 + 文本)
|
||||
const renderSidebarNavItemSkeleton = () => {
|
||||
return Array(count)
|
||||
.fill(null)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center p-2 mb-1 rounded-md ${className}`}
|
||||
>
|
||||
{/* 图标骨架屏 */}
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar active size='extra-small' shape='square' />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* 文本骨架屏 */}
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: width || 80, height: height || 14 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
// 侧边栏组标题骨架屏
|
||||
const renderSidebarGroupTitleSkeleton = () => {
|
||||
return (
|
||||
<div className={`mb-2 ${className}`}>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: width || 60, height: height || 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 完整侧边栏骨架屏 - 1:1 还原,去重实现
|
||||
const renderSidebarSkeleton = () => {
|
||||
const NAV_WIDTH = 164;
|
||||
const NAV_HEIGHT = 30;
|
||||
const COLLAPSED_WIDTH = 44;
|
||||
const COLLAPSED_HEIGHT = 44;
|
||||
const ICON_SIZE = 16;
|
||||
const TITLE_HEIGHT = 12;
|
||||
const TEXT_HEIGHT = 16;
|
||||
|
||||
const renderIcon = () => (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
shape='square'
|
||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderLabel = (labelWidth) => (
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: labelWidth, height: TEXT_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const NavRow = ({ labelWidth }) => (
|
||||
<div
|
||||
className='flex items-center p-2 mb-1 rounded-md'
|
||||
style={{
|
||||
width: `${NAV_WIDTH}px`,
|
||||
height: `${NAV_HEIGHT}px`,
|
||||
margin: '3px 8px',
|
||||
}}
|
||||
>
|
||||
<div className='sidebar-icon-container flex-shrink-0'>
|
||||
{renderIcon()}
|
||||
</div>
|
||||
{renderLabel(labelWidth)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const CollapsedRow = ({ keyPrefix, index }) => (
|
||||
<div
|
||||
key={`${keyPrefix}-${index}`}
|
||||
className='flex items-center justify-center'
|
||||
style={{
|
||||
width: `${COLLAPSED_WIDTH}px`,
|
||||
height: `${COLLAPSED_HEIGHT}px`,
|
||||
margin: '0 8px 4px 8px',
|
||||
}}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Avatar
|
||||
active
|
||||
shape='square'
|
||||
style={{ width: ICON_SIZE, height: ICON_SIZE }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
|
||||
{Array(2)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-chat' index={i} />
|
||||
))}
|
||||
{Array(5)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-console' index={i} />
|
||||
))}
|
||||
{Array(2)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-personal' index={i} />
|
||||
))}
|
||||
{Array(5)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<CollapsedRow keyPrefix='c-admin' index={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ key: 'chat', titleWidth: 32, itemWidths: [54, 32], wrapper: 'section' },
|
||||
{ key: 'console', titleWidth: 48, itemWidths: [64, 64, 64, 64, 64] },
|
||||
{ key: 'personal', titleWidth: 64, itemWidths: [64, 64] },
|
||||
...(showAdmin
|
||||
? [{ key: 'admin', titleWidth: 48, itemWidths: [64, 64, 80, 64, 64] }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`} style={{ paddingTop: '12px' }}>
|
||||
{sections.map((sec, idx) => (
|
||||
<React.Fragment key={sec.key}>
|
||||
{sec.wrapper === 'section' ? (
|
||||
<div className='sidebar-section'>
|
||||
<div
|
||||
className='sidebar-group-label'
|
||||
style={{ padding: '4px 15px 8px' }}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{sec.itemWidths.map((w, i) => (
|
||||
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
className='sidebar-group-label'
|
||||
style={{ padding: '4px 15px 8px' }}
|
||||
>
|
||||
<Skeleton
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: sec.titleWidth, height: TITLE_HEIGHT }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{sec.itemWidths.map((w, i) => (
|
||||
<NavRow key={`${sec.key}-${i}`} labelWidth={w} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据类型渲染不同的骨架屏
|
||||
switch (type) {
|
||||
case 'navigation':
|
||||
return renderNavigationSkeleton();
|
||||
case 'userArea':
|
||||
return renderUserAreaSkeleton();
|
||||
case 'image':
|
||||
return renderImageSkeleton();
|
||||
case 'title':
|
||||
return renderTitleSkeleton();
|
||||
case 'sidebarNavItem':
|
||||
return renderSidebarNavItemSkeleton();
|
||||
case 'sidebarGroupTitle':
|
||||
return renderSidebarGroupTitleSkeleton();
|
||||
case 'sidebar':
|
||||
return renderSidebarSkeleton();
|
||||
case 'button':
|
||||
return renderButtonSkeleton();
|
||||
case 'text':
|
||||
default:
|
||||
return renderTextSkeleton();
|
||||
}
|
||||
};
|
||||
|
||||
export default SkeletonWrapper;
|
||||
74
web/src/components/layout/headerbar/ActionButtons.jsx
Normal file
74
web/src/components/layout/headerbar/ActionButtons.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import NewYearButton from './NewYearButton';
|
||||
import NotificationButton from './NotificationButton';
|
||||
import ThemeToggle from './ThemeToggle';
|
||||
import LanguageSelector from './LanguageSelector';
|
||||
import UserArea from './UserArea';
|
||||
|
||||
const ActionButtons = ({
|
||||
isNewYear,
|
||||
unreadCount,
|
||||
onNoticeOpen,
|
||||
theme,
|
||||
onThemeToggle,
|
||||
currentLang,
|
||||
onLanguageChange,
|
||||
userState,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isSelfUseMode,
|
||||
logout,
|
||||
navigate,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex items-center gap-2 md:gap-3'>
|
||||
<NewYearButton isNewYear={isNewYear} />
|
||||
|
||||
<NotificationButton
|
||||
unreadCount={unreadCount}
|
||||
onNoticeOpen={onNoticeOpen}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<ThemeToggle theme={theme} onThemeToggle={onThemeToggle} t={t} />
|
||||
|
||||
<LanguageSelector
|
||||
currentLang={currentLang}
|
||||
onLanguageChange={onLanguageChange}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<UserArea
|
||||
userState={userState}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
isSelfUseMode={isSelfUseMode}
|
||||
logout={logout}
|
||||
navigate={navigate}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButtons;
|
||||
81
web/src/components/layout/headerbar/HeaderLogo.jsx
Normal file
81
web/src/components/layout/headerbar/HeaderLogo.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Typography, Tag } from '@douyinfe/semi-ui';
|
||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||
|
||||
const HeaderLogo = ({
|
||||
isMobile,
|
||||
isConsoleRoute,
|
||||
logo,
|
||||
logoLoaded,
|
||||
isLoading,
|
||||
systemName,
|
||||
isSelfUseMode,
|
||||
isDemoSiteMode,
|
||||
t,
|
||||
}) => {
|
||||
if (isMobile && isConsoleRoute) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to='/' className='group flex items-center gap-2'>
|
||||
<div className='relative w-8 h-8 md:w-8 md:h-8'>
|
||||
<SkeletonWrapper loading={isLoading || !logoLoaded} type='image' />
|
||||
<img
|
||||
src={logo}
|
||||
alt='logo'
|
||||
className={`absolute inset-0 w-full h-full transition-all duration-200 group-hover:scale-110 rounded-full ${!isLoading && logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className='hidden md:flex items-center gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<SkeletonWrapper
|
||||
loading={isLoading}
|
||||
type='title'
|
||||
width={120}
|
||||
height={24}
|
||||
>
|
||||
<Typography.Title
|
||||
heading={4}
|
||||
className='!text-lg !font-semibold !mb-0'
|
||||
>
|
||||
{systemName}
|
||||
</Typography.Title>
|
||||
</SkeletonWrapper>
|
||||
{(isSelfUseMode || isDemoSiteMode) && !isLoading && (
|
||||
<Tag
|
||||
color={isSelfUseMode ? 'purple' : 'blue'}
|
||||
className='text-xs px-1.5 py-0.5 rounded whitespace-nowrap shadow-sm'
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{isSelfUseMode ? t('自用模式') : t('演示站点')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderLogo;
|
||||
59
web/src/components/layout/headerbar/LanguageSelector.jsx
Normal file
59
web/src/components/layout/headerbar/LanguageSelector.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
||||
import { Languages } from 'lucide-react';
|
||||
import { CN, GB } from 'country-flag-icons/react/3x2';
|
||||
|
||||
const LanguageSelector = ({ currentLang, onLanguageChange, t }) => {
|
||||
return (
|
||||
<Dropdown
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={() => onLanguageChange('zh')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'zh' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<CN title='中文' className='!w-5 !h-auto' />
|
||||
<span>中文</span>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => onLanguageChange('en')}
|
||||
className={`!flex !items-center !gap-2 !px-3 !py-1.5 !text-sm !text-semi-color-text-0 dark:!text-gray-200 ${currentLang === 'en' ? '!bg-semi-color-primary-light-default dark:!bg-blue-600 !font-semibold' : 'hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-600'}`}
|
||||
>
|
||||
<GB title='English' className='!w-5 !h-auto' />
|
||||
<span>English</span>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<Languages size={18} />}
|
||||
aria-label={t('切换语言')}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSelector;
|
||||
56
web/src/components/layout/headerbar/MobileMenuButton.jsx
Normal file
56
web/src/components/layout/headerbar/MobileMenuButton.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import { IconClose, IconMenu } from '@douyinfe/semi-icons';
|
||||
|
||||
const MobileMenuButton = ({
|
||||
isConsoleRoute,
|
||||
isMobile,
|
||||
drawerOpen,
|
||||
collapsed,
|
||||
onToggle,
|
||||
t,
|
||||
}) => {
|
||||
if (!isConsoleRoute || !isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon={
|
||||
(isMobile ? drawerOpen : collapsed) ? (
|
||||
<IconClose className='text-lg' />
|
||||
) : (
|
||||
<IconMenu className='text-lg' />
|
||||
)
|
||||
}
|
||||
aria-label={
|
||||
(isMobile ? drawerOpen : collapsed) ? t('关闭侧边栏') : t('打开侧边栏')
|
||||
}
|
||||
onClick={onToggle}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-2 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileMenuButton;
|
||||
88
web/src/components/layout/headerbar/Navigation.jsx
Normal file
88
web/src/components/layout/headerbar/Navigation.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||
|
||||
const Navigation = ({
|
||||
mainNavLinks,
|
||||
isMobile,
|
||||
isLoading,
|
||||
userState,
|
||||
pricingRequireAuth,
|
||||
}) => {
|
||||
const renderNavLinks = () => {
|
||||
const baseClasses =
|
||||
'flex-shrink-0 flex items-center gap-1 font-semibold rounded-md transition-all duration-200 ease-in-out';
|
||||
const hoverClasses = 'hover:text-semi-color-primary';
|
||||
const spacingClasses = isMobile ? 'p-1' : 'p-2';
|
||||
|
||||
const commonLinkClasses = `${baseClasses} ${spacingClasses} ${hoverClasses}`;
|
||||
|
||||
return mainNavLinks.map((link) => {
|
||||
const linkContent = <span>{link.text}</span>;
|
||||
|
||||
if (link.isExternal) {
|
||||
return (
|
||||
<a
|
||||
key={link.itemKey}
|
||||
href={link.externalLink}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={commonLinkClasses}
|
||||
>
|
||||
{linkContent}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
let targetPath = link.to;
|
||||
if (link.itemKey === 'console' && !userState.user) {
|
||||
targetPath = '/login';
|
||||
}
|
||||
if (link.itemKey === 'pricing' && pricingRequireAuth && !userState.user) {
|
||||
targetPath = '/login';
|
||||
}
|
||||
|
||||
return (
|
||||
<Link key={link.itemKey} to={targetPath} className={commonLinkClasses}>
|
||||
{linkContent}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className='flex flex-1 items-center gap-1 lg:gap-2 mx-2 md:mx-4 overflow-x-auto whitespace-nowrap scrollbar-hide'>
|
||||
<SkeletonWrapper
|
||||
loading={isLoading}
|
||||
type='navigation'
|
||||
count={4}
|
||||
width={60}
|
||||
height={16}
|
||||
isMobile={isMobile}
|
||||
>
|
||||
{renderNavLinks()}
|
||||
</SkeletonWrapper>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
62
web/src/components/layout/headerbar/NewYearButton.jsx
Normal file
62
web/src/components/layout/headerbar/NewYearButton.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
||||
import fireworks from 'react-fireworks';
|
||||
|
||||
const NewYearButton = ({ isNewYear }) => {
|
||||
if (!isNewYear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleNewYearClick = () => {
|
||||
fireworks.init('root', {});
|
||||
fireworks.start();
|
||||
setTimeout(() => {
|
||||
fireworks.stop();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={handleNewYearClick}
|
||||
className='!text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-gray-600'
|
||||
>
|
||||
Happy New Year!!! 🎉
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
icon={<span className='text-xl'>🎉</span>}
|
||||
aria-label='New Year'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 rounded-full'
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewYearButton;
|
||||
46
web/src/components/layout/headerbar/NotificationButton.jsx
Normal file
46
web/src/components/layout/headerbar/NotificationButton.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Badge } from '@douyinfe/semi-ui';
|
||||
import { Bell } from 'lucide-react';
|
||||
|
||||
const NotificationButton = ({ unreadCount, onNoticeOpen, t }) => {
|
||||
const buttonProps = {
|
||||
icon: <Bell size={18} />,
|
||||
'aria-label': t('系统公告'),
|
||||
onClick: onNoticeOpen,
|
||||
theme: 'borderless',
|
||||
type: 'tertiary',
|
||||
className:
|
||||
'!p-1.5 !text-current focus:!bg-semi-color-fill-1 dark:focus:!bg-gray-700 !rounded-full !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2',
|
||||
};
|
||||
|
||||
if (unreadCount > 0) {
|
||||
return (
|
||||
<Badge count={unreadCount} type='danger' overflowCount={99}>
|
||||
<Button {...buttonProps} />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return <Button {...buttonProps} />;
|
||||
};
|
||||
|
||||
export default NotificationButton;
|
||||
109
web/src/components/layout/headerbar/ThemeToggle.jsx
Normal file
109
web/src/components/layout/headerbar/ThemeToggle.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Button, Dropdown } from '@douyinfe/semi-ui';
|
||||
import { Sun, Moon, Monitor } from 'lucide-react';
|
||||
import { useActualTheme } from '../../../context/Theme';
|
||||
|
||||
const ThemeToggle = ({ theme, onThemeToggle, t }) => {
|
||||
const actualTheme = useActualTheme();
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'light',
|
||||
icon: <Sun size={18} />,
|
||||
buttonIcon: <Sun size={18} />,
|
||||
label: t('浅色模式'),
|
||||
description: t('始终使用浅色主题'),
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
icon: <Moon size={18} />,
|
||||
buttonIcon: <Moon size={18} />,
|
||||
label: t('深色模式'),
|
||||
description: t('始终使用深色主题'),
|
||||
},
|
||||
{
|
||||
key: 'auto',
|
||||
icon: <Monitor size={18} />,
|
||||
buttonIcon: <Monitor size={18} />,
|
||||
label: t('自动模式'),
|
||||
description: t('跟随系统主题设置'),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const getItemClassName = (isSelected) =>
|
||||
isSelected
|
||||
? '!bg-semi-color-primary-light-default !font-semibold'
|
||||
: 'hover:!bg-semi-color-fill-1';
|
||||
|
||||
const currentButtonIcon = useMemo(() => {
|
||||
const currentOption = themeOptions.find((option) => option.key === theme);
|
||||
return currentOption?.buttonIcon || themeOptions[2].buttonIcon;
|
||||
}, [theme, themeOptions]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{themeOptions.map((option) => (
|
||||
<Dropdown.Item
|
||||
key={option.key}
|
||||
icon={option.icon}
|
||||
onClick={() => onThemeToggle(option.key)}
|
||||
className={getItemClassName(theme === option.key)}
|
||||
>
|
||||
<div className='flex flex-col'>
|
||||
<span>{option.label}</span>
|
||||
<span className='text-xs text-semi-color-text-2'>
|
||||
{option.description}
|
||||
</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
|
||||
{theme === 'auto' && (
|
||||
<>
|
||||
<Dropdown.Divider />
|
||||
<div className='px-3 py-2 text-xs text-semi-color-text-2'>
|
||||
{t('当前跟随系统')}:
|
||||
{actualTheme === 'dark' ? t('深色') : t('浅色')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={currentButtonIcon}
|
||||
aria-label={t('切换主题')}
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='!p-1.5 !text-current focus:!bg-semi-color-fill-1 !rounded-full !bg-semi-color-fill-0 hover:!bg-semi-color-fill-1'
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
196
web/src/components/layout/headerbar/UserArea.jsx
Normal file
196
web/src/components/layout/headerbar/UserArea.jsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
IconExit,
|
||||
IconUserSetting,
|
||||
IconCreditCard,
|
||||
IconKey,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { stringToColor } from '../../../helpers';
|
||||
import SkeletonWrapper from '../components/SkeletonWrapper';
|
||||
|
||||
const UserArea = ({
|
||||
userState,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isSelfUseMode,
|
||||
logout,
|
||||
navigate,
|
||||
t,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SkeletonWrapper
|
||||
loading={true}
|
||||
type='userArea'
|
||||
width={50}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (userState.user) {
|
||||
return (
|
||||
<Dropdown
|
||||
position='bottomRight'
|
||||
render={
|
||||
<Dropdown.Menu className='!bg-semi-color-bg-overlay !border-semi-color-border !shadow-lg !rounded-lg dark:!bg-gray-700 dark:!border-gray-600'>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/personal');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconUserSetting
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('个人设置')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/token');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconKey
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('令牌管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={() => {
|
||||
navigate('/console/topup');
|
||||
}}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconCreditCard
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('钱包管理')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
onClick={logout}
|
||||
className='!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-red-500 dark:hover:!text-white'
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<IconExit
|
||||
size='small'
|
||||
className='text-gray-500 dark:text-gray-400'
|
||||
/>
|
||||
<span>{t('退出')}</span>
|
||||
</div>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className='flex items-center gap-1.5 !p-1 !rounded-full hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 !bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 dark:hover:!bg-semi-color-fill-2'
|
||||
>
|
||||
<Avatar
|
||||
size='extra-small'
|
||||
color={stringToColor(userState.user.username)}
|
||||
className='mr-1'
|
||||
>
|
||||
{userState.user.username[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<span className='hidden md:inline'>
|
||||
<Typography.Text className='!text-xs !font-medium !text-semi-color-text-1 dark:!text-gray-300 mr-1'>
|
||||
{userState.user.username}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className='text-xs text-semi-color-text-2 dark:text-gray-400'
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
} else {
|
||||
const showRegisterButton = !isSelfUseMode;
|
||||
|
||||
const commonSizingAndLayoutClass =
|
||||
'flex items-center justify-center !py-[10px] !px-1.5';
|
||||
|
||||
const loginButtonSpecificStyling =
|
||||
'!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors';
|
||||
let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`;
|
||||
|
||||
let registerButtonClasses = `${commonSizingAndLayoutClass}`;
|
||||
|
||||
const loginButtonTextSpanClass =
|
||||
'!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5';
|
||||
const registerButtonTextSpanClass = '!text-xs !text-white !p-1.5';
|
||||
|
||||
if (showRegisterButton) {
|
||||
if (isMobile) {
|
||||
loginButtonClasses += ' !rounded-full';
|
||||
} else {
|
||||
loginButtonClasses += ' !rounded-l-full !rounded-r-none';
|
||||
}
|
||||
registerButtonClasses += ' !rounded-r-full !rounded-l-none';
|
||||
} else {
|
||||
loginButtonClasses += ' !rounded-full';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<Link to='/login' className='flex'>
|
||||
<Button
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
className={loginButtonClasses}
|
||||
>
|
||||
<span className={loginButtonTextSpanClass}>{t('登录')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
{showRegisterButton && (
|
||||
<div className='hidden md:block'>
|
||||
<Link to='/register' className='flex -ml-px'>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className={registerButtonClasses}
|
||||
>
|
||||
<span className={registerButtonTextSpanClass}>{t('注册')}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default UserArea;
|
||||
132
web/src/components/layout/headerbar/index.jsx
Normal file
132
web/src/components/layout/headerbar/index.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useHeaderBar } from '../../../hooks/common/useHeaderBar';
|
||||
import { useNotifications } from '../../../hooks/common/useNotifications';
|
||||
import { useNavigation } from '../../../hooks/common/useNavigation';
|
||||
import NoticeModal from '../NoticeModal';
|
||||
import MobileMenuButton from './MobileMenuButton';
|
||||
import HeaderLogo from './HeaderLogo';
|
||||
import Navigation from './Navigation';
|
||||
import ActionButtons from './ActionButtons';
|
||||
|
||||
const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => {
|
||||
const {
|
||||
userState,
|
||||
statusState,
|
||||
isMobile,
|
||||
collapsed,
|
||||
logoLoaded,
|
||||
currentLang,
|
||||
isLoading,
|
||||
systemName,
|
||||
logo,
|
||||
isNewYear,
|
||||
isSelfUseMode,
|
||||
docsLink,
|
||||
isDemoSiteMode,
|
||||
isConsoleRoute,
|
||||
theme,
|
||||
headerNavModules,
|
||||
pricingRequireAuth,
|
||||
logout,
|
||||
handleLanguageChange,
|
||||
handleThemeToggle,
|
||||
handleMobileMenuToggle,
|
||||
navigate,
|
||||
t,
|
||||
} = useHeaderBar({ onMobileMenuToggle, drawerOpen });
|
||||
|
||||
const {
|
||||
noticeVisible,
|
||||
unreadCount,
|
||||
handleNoticeOpen,
|
||||
handleNoticeClose,
|
||||
getUnreadKeys,
|
||||
} = useNotifications(statusState);
|
||||
|
||||
const { mainNavLinks } = useNavigation(t, docsLink, headerNavModules);
|
||||
|
||||
return (
|
||||
<header className='text-semi-color-text-0 sticky top-0 z-50 transition-colors duration-300 bg-white/75 dark:bg-zinc-900/75 backdrop-blur-lg'>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={handleNoticeClose}
|
||||
isMobile={isMobile}
|
||||
defaultTab={unreadCount > 0 ? 'system' : 'inApp'}
|
||||
unreadKeys={getUnreadKeys()}
|
||||
/>
|
||||
|
||||
<div className='w-full px-2'>
|
||||
<div className='flex items-center justify-between h-16'>
|
||||
<div className='flex items-center'>
|
||||
<MobileMenuButton
|
||||
isConsoleRoute={isConsoleRoute}
|
||||
isMobile={isMobile}
|
||||
drawerOpen={drawerOpen}
|
||||
collapsed={collapsed}
|
||||
onToggle={handleMobileMenuToggle}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<HeaderLogo
|
||||
isMobile={isMobile}
|
||||
isConsoleRoute={isConsoleRoute}
|
||||
logo={logo}
|
||||
logoLoaded={logoLoaded}
|
||||
isLoading={isLoading}
|
||||
systemName={systemName}
|
||||
isSelfUseMode={isSelfUseMode}
|
||||
isDemoSiteMode={isDemoSiteMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Navigation
|
||||
mainNavLinks={mainNavLinks}
|
||||
isMobile={isMobile}
|
||||
isLoading={isLoading}
|
||||
userState={userState}
|
||||
pricingRequireAuth={pricingRequireAuth}
|
||||
/>
|
||||
|
||||
<ActionButtons
|
||||
isNewYear={isNewYear}
|
||||
unreadCount={unreadCount}
|
||||
onNoticeOpen={handleNoticeOpen}
|
||||
theme={theme}
|
||||
onThemeToggle={handleThemeToggle}
|
||||
currentLang={currentLang}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
userState={userState}
|
||||
isLoading={isLoading}
|
||||
isMobile={isMobile}
|
||||
isSelfUseMode={isSelfUseMode}
|
||||
logout={logout}
|
||||
navigate={navigate}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeaderBar;
|
||||
@@ -1,15 +1,25 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Chat,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
MessageSquare,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Card, Chat, Typography, Button } from '@douyinfe/semi-ui';
|
||||
import { MessageSquare, Eye, EyeOff } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CustomInputRender from './CustomInputRender';
|
||||
|
||||
@@ -38,37 +48,43 @@ const ChatArea = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
bordered={false}
|
||||
bodyStyle={{ padding: 0, height: 'calc(100vh - 66px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}
|
||||
bodyStyle={{
|
||||
padding: 0,
|
||||
height: 'calc(100vh - 66px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 聊天头部 */}
|
||||
{styleState.isMobile ? (
|
||||
<div className="pt-4"></div>
|
||||
<div className='pt-4'></div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center">
|
||||
<MessageSquare size={20} className="text-white" />
|
||||
<div className='px-6 py-4 bg-gradient-to-r from-purple-500 to-blue-500 rounded-t-2xl'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='w-10 h-10 rounded-full bg-white/20 backdrop-blur flex items-center justify-center'>
|
||||
<MessageSquare size={20} className='text-white' />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={5} className="!text-white mb-0">
|
||||
<Typography.Title heading={5} className='!text-white mb-0'>
|
||||
{t('AI 对话')}
|
||||
</Typography.Title>
|
||||
<Typography.Text className="!text-white/80 text-sm hidden sm:inline">
|
||||
<Typography.Text className='!text-white/80 text-sm hidden sm:inline'>
|
||||
{inputs.model || t('选择模型开始对话')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
icon={showDebugPanel ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
onClick={onToggleDebugPanel}
|
||||
theme="borderless"
|
||||
type="primary"
|
||||
size="small"
|
||||
className="!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10"
|
||||
theme='borderless'
|
||||
type='primary'
|
||||
size='small'
|
||||
className='!rounded-lg !text-white/80 hover:!text-white hover:!bg-white/10'
|
||||
>
|
||||
{showDebugPanel ? t('隐藏调试') : t('显示调试')}
|
||||
</Button>
|
||||
@@ -78,7 +94,7 @@ const ChatArea = ({
|
||||
)}
|
||||
|
||||
{/* 聊天内容区域 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className='flex-1 overflow-hidden'>
|
||||
<Chat
|
||||
ref={chatRef}
|
||||
chatBoxRenderConfig={{
|
||||
@@ -91,7 +107,7 @@ const ChatArea = ({
|
||||
style={{
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden'
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
chats={message}
|
||||
onMessageSend={onMessageSend}
|
||||
@@ -102,7 +118,7 @@ const ChatArea = ({
|
||||
showStopGenerate
|
||||
onStopGenerator={onStopGenerator}
|
||||
onClear={onClearMessages}
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
placeholder={t('请输入您的问题...')}
|
||||
/>
|
||||
</div>
|
||||
@@ -110,4 +126,4 @@ const ChatArea = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatArea;
|
||||
export default ChatArea;
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Button, Tooltip, Toast } from '@douyinfe/semi-ui';
|
||||
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
@@ -83,15 +102,17 @@ const highlightJson = (str) => {
|
||||
color = '#569cd6';
|
||||
}
|
||||
return `<span style="color: ${color}">${match}</span>`;
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const isJsonLike = (content, language) => {
|
||||
if (language === 'json') return true;
|
||||
const trimmed = content.trim();
|
||||
return (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'));
|
||||
return (
|
||||
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
||||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
|
||||
);
|
||||
};
|
||||
|
||||
const formatContent = (content) => {
|
||||
@@ -129,7 +150,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
const contentMetrics = useMemo(() => {
|
||||
const length = formattedContent.length;
|
||||
const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH;
|
||||
const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
|
||||
const isVeryLarge =
|
||||
length >
|
||||
PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH *
|
||||
PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER;
|
||||
return { length, isLarge, isVeryLarge };
|
||||
}, [formattedContent.length]);
|
||||
|
||||
@@ -137,8 +161,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
if (!contentMetrics.isLarge || isExpanded) {
|
||||
return formattedContent;
|
||||
}
|
||||
return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
|
||||
'\n\n// ... 内容被截断以提升性能 ...';
|
||||
return (
|
||||
formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) +
|
||||
'\n\n// ... 内容被截断以提升性能 ...'
|
||||
);
|
||||
}, [formattedContent, contentMetrics.isLarge, isExpanded]);
|
||||
|
||||
const highlightedContent = useMemo(() => {
|
||||
@@ -155,9 +181,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
const textToCopy = typeof content === 'object' && content !== null
|
||||
? JSON.stringify(content, null, 2)
|
||||
: content;
|
||||
const textToCopy =
|
||||
typeof content === 'object' && content !== null
|
||||
? JSON.stringify(content, null, 2)
|
||||
: content;
|
||||
|
||||
const success = await copy(textToCopy);
|
||||
setCopied(true);
|
||||
@@ -186,11 +213,12 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
}, [isExpanded, contentMetrics.isVeryLarge]);
|
||||
|
||||
if (!content) {
|
||||
const placeholderText = {
|
||||
preview: t('正在构造请求体预览...'),
|
||||
request: t('暂无请求数据'),
|
||||
response: t('暂无响应数据')
|
||||
}[title] || t('暂无数据');
|
||||
const placeholderText =
|
||||
{
|
||||
preview: t('正在构造请求体预览...'),
|
||||
request: t('暂无请求数据'),
|
||||
response: t('暂无响应数据'),
|
||||
}[title] || t('暂无数据');
|
||||
|
||||
return (
|
||||
<div style={codeThemeStyles.noContent}>
|
||||
@@ -203,7 +231,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
const contentPadding = contentMetrics.isLarge ? '52px' : '16px';
|
||||
|
||||
return (
|
||||
<div style={codeThemeStyles.container} className="h-full">
|
||||
<div style={codeThemeStyles.container} className='h-full'>
|
||||
{/* 性能警告 */}
|
||||
{contentMetrics.isLarge && (
|
||||
<div style={codeThemeStyles.performanceWarning}>
|
||||
@@ -231,8 +259,8 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
<Button
|
||||
icon={<Copy size={14} />}
|
||||
onClick={handleCopy}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
@@ -249,25 +277,29 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
...codeThemeStyles.content,
|
||||
paddingTop: contentPadding,
|
||||
}}
|
||||
className="model-settings-scroll"
|
||||
className='model-settings-scroll'
|
||||
>
|
||||
{isProcessing ? (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '200px',
|
||||
color: '#888'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #444',
|
||||
borderTop: '2px solid #888',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginRight: '8px'
|
||||
}} />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '200px',
|
||||
color: '#888',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #444',
|
||||
borderTop: '2px solid #888',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
marginRight: '8px',
|
||||
}}
|
||||
/>
|
||||
{t('正在处理大内容...')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -277,18 +309,22 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
|
||||
{/* 展开/收起按钮 */}
|
||||
{contentMetrics.isLarge && !isProcessing && (
|
||||
<div style={{
|
||||
...codeThemeStyles.actionButton,
|
||||
bottom: '12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
...codeThemeStyles.actionButton,
|
||||
bottom: '12px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<Tooltip content={isExpanded ? t('收起内容') : t('显示完整内容')}>
|
||||
<Button
|
||||
icon={isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
icon={
|
||||
isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />
|
||||
}
|
||||
onClick={handleToggleExpand}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
@@ -298,8 +334,16 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
>
|
||||
{isExpanded ? t('收起') : t('展开')}
|
||||
{!isExpanded && (
|
||||
<span style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}>
|
||||
(+{Math.round((contentMetrics.length - PERFORMANCE_CONFIG.PREVIEW_LENGTH) / 1000)}K)
|
||||
<span
|
||||
style={{ fontSize: '11px', opacity: 0.7, marginLeft: '4px' }}
|
||||
>
|
||||
(+
|
||||
{Math.round(
|
||||
(contentMetrics.length -
|
||||
PERFORMANCE_CONFIG.PREVIEW_LENGTH) /
|
||||
1000,
|
||||
)}
|
||||
K)
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
@@ -310,4 +354,4 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default CodeViewer;
|
||||
export default CodeViewer;
|
||||
@@ -1,19 +1,33 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Toast,
|
||||
Modal,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Download,
|
||||
Upload,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { Button, Typography, Toast, Modal, Dropdown } from '@douyinfe/semi-ui';
|
||||
import { Download, Upload, RotateCcw, Settings2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage';
|
||||
import {
|
||||
exportConfig,
|
||||
importConfig,
|
||||
clearConfig,
|
||||
hasStoredConfig,
|
||||
getConfigTimestamp,
|
||||
} from './configStorage';
|
||||
|
||||
const ConfigManager = ({
|
||||
currentConfig,
|
||||
@@ -32,7 +46,10 @@ const ConfigManager = ({
|
||||
...currentConfig,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp));
|
||||
localStorage.setItem(
|
||||
'playground_config',
|
||||
JSON.stringify(configWithTimestamp),
|
||||
);
|
||||
|
||||
exportConfig(currentConfig, messages);
|
||||
Toast.success({
|
||||
@@ -85,7 +102,9 @@ const ConfigManager = ({
|
||||
const handleReset = () => {
|
||||
Modal.confirm({
|
||||
title: t('重置配置'),
|
||||
content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'),
|
||||
content: t(
|
||||
'将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?',
|
||||
),
|
||||
okText: t('确定重置'),
|
||||
cancelText: t('取消'),
|
||||
okButtonProps: {
|
||||
@@ -95,7 +114,9 @@ const ConfigManager = ({
|
||||
// 询问是否同时重置消息
|
||||
Modal.confirm({
|
||||
title: t('重置选项'),
|
||||
content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'),
|
||||
content: t(
|
||||
'是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。',
|
||||
),
|
||||
okText: t('同时重置消息'),
|
||||
cancelText: t('仅重置配置'),
|
||||
okButtonProps: {
|
||||
@@ -140,7 +161,7 @@ const ConfigManager = ({
|
||||
name: 'export',
|
||||
onClick: handleExport,
|
||||
children: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Download size={14} />
|
||||
{t('导出配置')}
|
||||
</div>
|
||||
@@ -151,7 +172,7 @@ const ConfigManager = ({
|
||||
name: 'import',
|
||||
onClick: handleImportClick,
|
||||
children: (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Upload size={14} />
|
||||
{t('导入配置')}
|
||||
</div>
|
||||
@@ -165,7 +186,7 @@ const ConfigManager = ({
|
||||
name: 'reset',
|
||||
onClick: handleReset,
|
||||
children: (
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<div className='flex items-center gap-2 text-red-600'>
|
||||
<RotateCcw size={14} />
|
||||
{t('重置配置')}
|
||||
</div>
|
||||
@@ -178,24 +199,24 @@ const ConfigManager = ({
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomLeft"
|
||||
trigger='click'
|
||||
position='bottomLeft'
|
||||
showTick
|
||||
menu={dropdownItems}
|
||||
>
|
||||
<Button
|
||||
icon={<Settings2 size={14} />}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
className="!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='!rounded-lg !text-gray-600 hover:!text-blue-600 hover:!bg-blue-50'
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
type='file'
|
||||
accept='.json'
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
@@ -205,42 +226,42 @@ const ConfigManager = ({
|
||||
|
||||
// 桌面端显示紧凑的按钮组
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{/* 配置状态信息和重置按钮 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.Text className="text-xs text-gray-500">
|
||||
<div className='flex items-center justify-between'>
|
||||
<Typography.Text className='text-xs text-gray-500'>
|
||||
{getConfigStatus()}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
icon={<RotateCcw size={12} />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
size='small'
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
onClick={handleReset}
|
||||
className="!rounded-full !text-xs !px-2"
|
||||
className='!rounded-full !text-xs !px-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 导出和导入按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
icon={<Download size={12} />}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
size='small'
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={handleExport}
|
||||
className="!rounded-lg flex-1 !text-xs !h-7"
|
||||
className='!rounded-lg flex-1 !text-xs !h-7'
|
||||
>
|
||||
{t('导出')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon={<Upload size={12} />}
|
||||
size="small"
|
||||
theme="outline"
|
||||
type="primary"
|
||||
size='small'
|
||||
theme='outline'
|
||||
type='primary'
|
||||
onClick={handleImportClick}
|
||||
className="!rounded-lg flex-1 !text-xs !h-7"
|
||||
className='!rounded-lg flex-1 !text-xs !h-7'
|
||||
>
|
||||
{t('导入')}
|
||||
</Button>
|
||||
@@ -248,8 +269,8 @@ const ConfigManager = ({
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
type='file'
|
||||
accept='.json'
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
@@ -257,4 +278,4 @@ const ConfigManager = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigManager;
|
||||
export default ConfigManager;
|
||||
@@ -1,58 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
const CustomInputRender = (props) => {
|
||||
const { detailProps } = props;
|
||||
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps;
|
||||
|
||||
// 清空按钮
|
||||
const styledClearNode = clearContextNode
|
||||
? React.cloneElement(clearContextNode, {
|
||||
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
|
||||
style: {
|
||||
...clearContextNode.props.style,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
minWidth: '32px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
})
|
||||
: null;
|
||||
|
||||
// 发送按钮
|
||||
const styledSendNode = React.cloneElement(sendNode, {
|
||||
className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`,
|
||||
style: {
|
||||
...sendNode.props.style,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
minWidth: '32px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-2 sm:p-4">
|
||||
<div
|
||||
className="flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow"
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 清空对话按钮 - 左边 */}
|
||||
{styledClearNode}
|
||||
<div className="flex-1">
|
||||
{inputNode}
|
||||
</div>
|
||||
{/* 发送按钮 - 右边 */}
|
||||
{styledSendNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInputRender;
|
||||
76
web/src/components/playground/CustomInputRender.jsx
Normal file
76
web/src/components/playground/CustomInputRender.jsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
const CustomInputRender = (props) => {
|
||||
const { detailProps } = props;
|
||||
const { clearContextNode, uploadNode, inputNode, sendNode, onClick } =
|
||||
detailProps;
|
||||
|
||||
// 清空按钮
|
||||
const styledClearNode = clearContextNode
|
||||
? React.cloneElement(clearContextNode, {
|
||||
className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`,
|
||||
style: {
|
||||
...clearContextNode.props.style,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
minWidth: '32px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// 发送按钮
|
||||
const styledSendNode = React.cloneElement(sendNode, {
|
||||
className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`,
|
||||
style: {
|
||||
...sendNode.props.style,
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
minWidth: '32px',
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='p-2 sm:p-4'>
|
||||
<div
|
||||
className='flex items-center gap-2 sm:gap-3 p-2 bg-gray-50 rounded-xl sm:rounded-2xl shadow-sm hover:shadow-md transition-shadow'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 清空对话按钮 - 左边 */}
|
||||
{styledClearNode}
|
||||
<div className='flex-1'>{inputNode}</div>
|
||||
{/* 发送按钮 - 右边 */}
|
||||
{styledSendNode}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomInputRender;
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TextArea,
|
||||
@@ -6,13 +25,7 @@ import {
|
||||
Switch,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Code,
|
||||
Edit,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Code, Edit, Check, X, AlertTriangle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const CustomRequestEditor = ({
|
||||
@@ -29,12 +42,22 @@ const CustomRequestEditor = ({
|
||||
|
||||
// 当切换到自定义模式时,用默认payload初始化
|
||||
useEffect(() => {
|
||||
if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) {
|
||||
const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : '';
|
||||
if (
|
||||
customRequestMode &&
|
||||
(!customRequestBody || customRequestBody.trim() === '')
|
||||
) {
|
||||
const defaultJson = defaultPayload
|
||||
? JSON.stringify(defaultPayload, null, 2)
|
||||
: '';
|
||||
setLocalValue(defaultJson);
|
||||
onCustomRequestBodyChange(defaultJson);
|
||||
}
|
||||
}, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]);
|
||||
}, [
|
||||
customRequestMode,
|
||||
defaultPayload,
|
||||
customRequestBody,
|
||||
onCustomRequestBodyChange,
|
||||
]);
|
||||
|
||||
// 同步外部传入的customRequestBody到本地状态
|
||||
useEffect(() => {
|
||||
@@ -94,21 +117,21 @@ const CustomRequestEditor = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-4'>
|
||||
{/* 自定义模式开关 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Code size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
自定义请求体模式
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={customRequestMode}
|
||||
onChange={handleModeToggle}
|
||||
checkedText="开"
|
||||
uncheckedText="关"
|
||||
size="small"
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
size='small'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,43 +139,43 @@ const CustomRequestEditor = ({
|
||||
<>
|
||||
{/* 提示信息 */}
|
||||
<Banner
|
||||
type="warning"
|
||||
description="启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。"
|
||||
type='warning'
|
||||
description='启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。'
|
||||
icon={<AlertTriangle size={16} />}
|
||||
className="!rounded-lg"
|
||||
closable={false}
|
||||
className='!rounded-lg'
|
||||
closeIcon={null}
|
||||
/>
|
||||
|
||||
{/* JSON编辑器 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<Typography.Text strong className='text-sm'>
|
||||
请求体 JSON
|
||||
</Typography.Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className='flex items-center gap-2'>
|
||||
{isValid ? (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<div className='flex items-center gap-1 text-green-600'>
|
||||
<Check size={14} />
|
||||
<Typography.Text className="text-xs">
|
||||
<Typography.Text className='text-xs'>
|
||||
格式正确
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<div className='flex items-center gap-1 text-red-600'>
|
||||
<X size={14} />
|
||||
<Typography.Text className="text-xs">
|
||||
<Typography.Text className='text-xs'>
|
||||
格式错误
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Edit size={14} />}
|
||||
onClick={formatJson}
|
||||
disabled={!isValid}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
>
|
||||
格式化
|
||||
</Button>
|
||||
@@ -172,12 +195,12 @@ const CustomRequestEditor = ({
|
||||
/>
|
||||
|
||||
{!isValid && errorMessage && (
|
||||
<Typography.Text type="danger" className="text-xs mt-1 block">
|
||||
<Typography.Text type='danger' className='text-xs mt-1 block'>
|
||||
{errorMessage}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<Typography.Text className="text-xs text-gray-500 mt-2 block">
|
||||
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
|
||||
请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -187,4 +210,4 @@ const CustomRequestEditor = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomRequestEditor;
|
||||
export default CustomRequestEditor;
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
@@ -7,14 +26,7 @@ import {
|
||||
Button,
|
||||
Dropdown,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Code,
|
||||
Zap,
|
||||
Clock,
|
||||
X,
|
||||
Eye,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { Code, Zap, Clock, X, Eye, Send } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CodeViewer from './CodeViewer';
|
||||
|
||||
@@ -57,7 +69,7 @@ const DebugPanel = ({
|
||||
<Dropdown
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
{items.map(item => {
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<Dropdown.Item
|
||||
key={item.itemKey}
|
||||
@@ -85,21 +97,21 @@ const DebugPanel = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full flex flex-col"
|
||||
className='h-full flex flex-col'
|
||||
bordered={false}
|
||||
bodyStyle={{
|
||||
padding: styleState.isMobile ? '16px' : '24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3">
|
||||
<Code size={20} className="text-white" />
|
||||
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
|
||||
<div className='flex items-center'>
|
||||
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-green-500 to-blue-500 flex items-center justify-center mr-3'>
|
||||
<Code size={20} className='text-white' />
|
||||
</div>
|
||||
<Typography.Title heading={5} className="mb-0">
|
||||
<Typography.Title heading={5} className='mb-0'>
|
||||
{t('调试信息')}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
@@ -108,75 +120,84 @@ const DebugPanel = ({
|
||||
<Button
|
||||
icon={<X size={16} />}
|
||||
onClick={onCloseDebugPanel}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
className="!rounded-lg"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden debug-panel">
|
||||
<div className='flex-1 overflow-hidden debug-panel'>
|
||||
<Tabs
|
||||
renderArrow={renderArrow}
|
||||
type="card"
|
||||
type='card'
|
||||
collapsible
|
||||
className="h-full"
|
||||
className='h-full'
|
||||
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||
activeKey={activeKey}
|
||||
onChange={handleTabChange}
|
||||
>
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye size={16} />
|
||||
{t('预览请求体')}
|
||||
{customRequestMode && (
|
||||
<span className="px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full">
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
} itemKey="preview">
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Eye size={16} />
|
||||
{t('预览请求体')}
|
||||
{customRequestMode && (
|
||||
<span className='px-1.5 py-0.5 text-xs bg-orange-100 text-orange-600 rounded-full'>
|
||||
自定义
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
itemKey='preview'
|
||||
>
|
||||
<CodeViewer
|
||||
content={debugData.previewRequest}
|
||||
title="preview"
|
||||
language="json"
|
||||
title='preview'
|
||||
language='json'
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Send size={16} />
|
||||
{t('实际请求体')}
|
||||
</div>
|
||||
} itemKey="request">
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Send size={16} />
|
||||
{t('实际请求体')}
|
||||
</div>
|
||||
}
|
||||
itemKey='request'
|
||||
>
|
||||
<CodeViewer
|
||||
content={debugData.request}
|
||||
title="request"
|
||||
language="json"
|
||||
title='request'
|
||||
language='json'
|
||||
/>
|
||||
</TabPane>
|
||||
|
||||
<TabPane tab={
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={16} />
|
||||
{t('响应')}
|
||||
</div>
|
||||
} itemKey="response">
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Zap size={16} />
|
||||
{t('响应')}
|
||||
</div>
|
||||
}
|
||||
itemKey='response'
|
||||
>
|
||||
<CodeViewer
|
||||
content={debugData.response}
|
||||
title="response"
|
||||
language="json"
|
||||
title='response'
|
||||
language='json'
|
||||
/>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-4 pt-4 flex-shrink-0">
|
||||
<div className='flex items-center justify-between mt-4 pt-4 flex-shrink-0'>
|
||||
{(debugData.timestamp || debugData.previewTimestamp) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} className="text-gray-500" />
|
||||
<Typography.Text className="text-xs text-gray-500">
|
||||
<div className='flex items-center gap-2'>
|
||||
<Clock size={14} className='text-gray-500' />
|
||||
<Typography.Text className='text-xs text-gray-500'>
|
||||
{activeKey === 'preview' && debugData.previewTimestamp
|
||||
? `${t('预览更新')}: ${new Date(debugData.previewTimestamp).toLocaleString()}`
|
||||
: debugData.timestamp
|
||||
@@ -190,4 +211,4 @@ const DebugPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DebugPanel;
|
||||
export default DebugPanel;
|
||||
@@ -1,10 +1,25 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Settings,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Settings, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const FloatingButtons = ({
|
||||
styleState,
|
||||
@@ -36,7 +51,7 @@ const FloatingButtons = ({
|
||||
onClick={onToggleSettings}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
className="lg:hidden"
|
||||
className='lg:hidden'
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -45,8 +60,8 @@ const FloatingButtons = ({
|
||||
<Button
|
||||
icon={showDebugPanel ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
onClick={onToggleDebugPanel}
|
||||
theme="solid"
|
||||
type={showDebugPanel ? "danger" : "primary"}
|
||||
theme='solid'
|
||||
type={showDebugPanel ? 'danger' : 'primary'}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: 16,
|
||||
@@ -61,11 +76,11 @@ const FloatingButtons = ({
|
||||
? 'linear-gradient(to right, #e11d48, #be123c)'
|
||||
: 'linear-gradient(to right, #4f46e5, #6366f1)',
|
||||
}}
|
||||
className="lg:hidden !rounded-full !p-0"
|
||||
className='lg:hidden'
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingButtons;
|
||||
export default FloatingButtons;
|
||||
@@ -1,113 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Input,
|
||||
Typography,
|
||||
Button,
|
||||
Switch,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconFile } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
X,
|
||||
Image,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ImageUrlInput = ({ imageUrls, imageEnabled, onImageUrlsChange, onImageEnabledChange, disabled = false }) => {
|
||||
const handleAddImageUrl = () => {
|
||||
const newUrls = [...imageUrls, ''];
|
||||
onImageUrlsChange(newUrls);
|
||||
};
|
||||
|
||||
const handleUpdateImageUrl = (index, value) => {
|
||||
const newUrls = [...imageUrls];
|
||||
newUrls[index] = value;
|
||||
onImageUrlsChange(newUrls);
|
||||
};
|
||||
|
||||
const handleRemoveImageUrl = (index) => {
|
||||
const newUrls = imageUrls.filter((_, i) => i !== index);
|
||||
onImageUrlsChange(newUrls);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={disabled ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image size={16} className={imageEnabled && !disabled ? "text-blue-500" : "text-gray-400"} />
|
||||
<Typography.Text strong className="text-sm">
|
||||
图片地址
|
||||
</Typography.Text>
|
||||
{disabled && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={imageEnabled}
|
||||
onChange={onImageEnabledChange}
|
||||
checkedText="启用"
|
||||
uncheckedText="停用"
|
||||
size="small"
|
||||
className="flex-shrink-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
icon={<Plus size={14} />}
|
||||
size="small"
|
||||
theme="solid"
|
||||
type="primary"
|
||||
onClick={handleAddImageUrl}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!imageEnabled ? (
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2 block">
|
||||
{disabled ? '图片功能在自定义请求体模式下不可用' : '启用后可添加图片URL进行多模态对话'}
|
||||
</Typography.Text>
|
||||
) : imageUrls.length === 0 ? (
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2 block">
|
||||
{disabled ? '图片功能在自定义请求体模式下不可用' : '点击 + 按钮添加图片URL进行多模态对话'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2 block">
|
||||
已添加 {imageUrls.length} 张图片{disabled ? ' (自定义模式下不可用)' : ''}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<div className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}>
|
||||
{imageUrls.map((url, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={`https://example.com/image${index + 1}.jpg`}
|
||||
value={url}
|
||||
onChange={(value) => handleUpdateImageUrl(index, value)}
|
||||
className="!rounded-lg"
|
||||
size="small"
|
||||
prefix={<IconFile size='small' />}
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<X size={12} />}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
type="danger"
|
||||
onClick={() => handleRemoveImageUrl(index)}
|
||||
className="!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0"
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUrlInput;
|
||||
140
web/src/components/playground/ImageUrlInput.jsx
Normal file
140
web/src/components/playground/ImageUrlInput.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Input, Typography, Button, Switch } from '@douyinfe/semi-ui';
|
||||
import { IconFile } from '@douyinfe/semi-icons';
|
||||
import { FileText, Plus, X, Image } from 'lucide-react';
|
||||
|
||||
const ImageUrlInput = ({
|
||||
imageUrls,
|
||||
imageEnabled,
|
||||
onImageUrlsChange,
|
||||
onImageEnabledChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const handleAddImageUrl = () => {
|
||||
const newUrls = [...imageUrls, ''];
|
||||
onImageUrlsChange(newUrls);
|
||||
};
|
||||
|
||||
const handleUpdateImageUrl = (index, value) => {
|
||||
const newUrls = [...imageUrls];
|
||||
newUrls[index] = value;
|
||||
onImageUrlsChange(newUrls);
|
||||
};
|
||||
|
||||
const handleRemoveImageUrl = (index) => {
|
||||
const newUrls = imageUrls.filter((_, i) => i !== index);
|
||||
onImageUrlsChange(newUrls);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={disabled ? 'opacity-50' : ''}>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Image
|
||||
size={16}
|
||||
className={
|
||||
imageEnabled && !disabled ? 'text-blue-500' : 'text-gray-400'
|
||||
}
|
||||
/>
|
||||
<Typography.Text strong className='text-sm'>
|
||||
图片地址
|
||||
</Typography.Text>
|
||||
{disabled && (
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Switch
|
||||
checked={imageEnabled}
|
||||
onChange={onImageEnabledChange}
|
||||
checkedText='启用'
|
||||
uncheckedText='停用'
|
||||
size='small'
|
||||
className='flex-shrink-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
icon={<Plus size={14} />}
|
||||
size='small'
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={handleAddImageUrl}
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!imageEnabled ? (
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
||||
{disabled
|
||||
? '图片功能在自定义请求体模式下不可用'
|
||||
: '启用后可添加图片URL进行多模态对话'}
|
||||
</Typography.Text>
|
||||
) : imageUrls.length === 0 ? (
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
||||
{disabled
|
||||
? '图片功能在自定义请求体模式下不可用'
|
||||
: '点击 + 按钮添加图片URL进行多模态对话'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2 block'>
|
||||
已添加 {imageUrls.length} 张图片
|
||||
{disabled ? ' (自定义模式下不可用)' : ''}
|
||||
</Typography.Text>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`space-y-2 max-h-32 overflow-y-auto image-list-scroll ${!imageEnabled || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
{imageUrls.map((url, index) => (
|
||||
<div key={index} className='flex items-center gap-2'>
|
||||
<div className='flex-1'>
|
||||
<Input
|
||||
placeholder={`https://example.com/image${index + 1}.jpg`}
|
||||
value={url}
|
||||
onChange={(value) => handleUpdateImageUrl(index, value)}
|
||||
className='!rounded-lg'
|
||||
size='small'
|
||||
prefix={<IconFile size='small' />}
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
icon={<X size={12} />}
|
||||
size='small'
|
||||
theme='borderless'
|
||||
type='danger'
|
||||
onClick={() => handleRemoveImageUrl(index)}
|
||||
className='!rounded-full !w-6 !h-6 !p-0 !min-w-0 !text-red-500 hover:!bg-red-50 flex-shrink-0'
|
||||
disabled={!imageEnabled || disabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUrlInput;
|
||||
@@ -1,15 +1,25 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
Edit,
|
||||
} from 'lucide-react';
|
||||
import { Button, Tooltip } from '@douyinfe/semi-ui';
|
||||
import { RefreshCw, Copy, Trash2, UserCheck, Edit } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MessageActions = ({
|
||||
@@ -21,23 +31,32 @@ const MessageActions = ({
|
||||
onRoleToggle,
|
||||
onMessageEdit,
|
||||
isAnyMessageGenerating = false,
|
||||
isEditing = false
|
||||
isEditing = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isLoading = message.status === 'loading' || message.status === 'incomplete';
|
||||
const isLoading =
|
||||
message.status === 'loading' || message.status === 'incomplete';
|
||||
const shouldDisableActions = isAnyMessageGenerating || isEditing;
|
||||
const canToggleRole = message.role === 'assistant' || message.role === 'system';
|
||||
const canEdit = !isLoading && message.content && typeof onMessageEdit === 'function' && !isEditing;
|
||||
const canToggleRole =
|
||||
message.role === 'assistant' || message.role === 'system';
|
||||
const canEdit =
|
||||
!isLoading &&
|
||||
message.content &&
|
||||
typeof onMessageEdit === 'function' &&
|
||||
!isEditing;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className='flex items-center gap-0.5'>
|
||||
{!isLoading && (
|
||||
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')} position="top">
|
||||
<Tooltip
|
||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('重试')}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<RefreshCw size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onMessageReset(message)}
|
||||
disabled={shouldDisableActions}
|
||||
@@ -48,11 +67,11 @@ const MessageActions = ({
|
||||
)}
|
||||
|
||||
{message.content && (
|
||||
<Tooltip content={t('复制')} position="top">
|
||||
<Tooltip content={t('复制')} position='top'>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Copy size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => onMessageCopy(message)}
|
||||
className={`!rounded-full !text-gray-400 hover:!text-green-600 hover:!bg-green-50 ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
||||
@@ -62,11 +81,14 @@ const MessageActions = ({
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')} position="top">
|
||||
<Tooltip
|
||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('编辑')}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Edit size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onMessageEdit(message)}
|
||||
disabled={shouldDisableActions}
|
||||
@@ -85,27 +107,36 @@ const MessageActions = ({
|
||||
? t('切换为System角色')
|
||||
: t('切换为Assistant角色')
|
||||
}
|
||||
position="top"
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<UserCheck size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onRoleToggle && onRoleToggle(message)}
|
||||
onClick={() =>
|
||||
!shouldDisableActions && onRoleToggle && onRoleToggle(message)
|
||||
}
|
||||
disabled={shouldDisableActions}
|
||||
className={`!rounded-full ${shouldDisableActions ? '!text-gray-300 !cursor-not-allowed' : message.role === 'system' ? '!text-purple-500 hover:!text-purple-700 hover:!bg-purple-50' : '!text-gray-400 hover:!text-purple-600 hover:!bg-purple-50'} ${styleState.isMobile ? '!w-6 !h-6' : '!w-7 !h-7'} !p-0 transition-all`}
|
||||
aria-label={message.role === 'assistant' ? t('切换为System角色') : t('切换为Assistant角色')}
|
||||
aria-label={
|
||||
message.role === 'assistant'
|
||||
? t('切换为System角色')
|
||||
: t('切换为Assistant角色')
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<Tooltip content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')} position="top">
|
||||
<Tooltip
|
||||
content={shouldDisableActions ? t('操作暂时被禁用') : t('删除')}
|
||||
position='top'
|
||||
>
|
||||
<Button
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
icon={<Trash2 size={styleState.isMobile ? 12 : 14} />}
|
||||
onClick={() => !shouldDisableActions && onMessageDelete(message)}
|
||||
disabled={shouldDisableActions}
|
||||
@@ -118,4 +149,4 @@ const MessageActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageActions;
|
||||
export default MessageActions;
|
||||
@@ -1,16 +1,27 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
Typography,
|
||||
TextArea,
|
||||
Button,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Typography, TextArea, Button } from '@douyinfe/semi-ui';
|
||||
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
|
||||
import ThinkingContent from './ThinkingContent';
|
||||
import {
|
||||
Loader2,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Loader2, Check, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MessageContent = ({
|
||||
@@ -22,13 +33,14 @@ const MessageContent = ({
|
||||
onEditSave,
|
||||
onEditCancel,
|
||||
editValue,
|
||||
onEditValueChange
|
||||
onEditValueChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const previousContentLengthRef = useRef(0);
|
||||
const lastContentRef = useRef('');
|
||||
|
||||
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
|
||||
const isThinkingStatus =
|
||||
message.status === 'loading' || message.status === 'incomplete';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinkingStatus) {
|
||||
@@ -41,10 +53,11 @@ const MessageContent = ({
|
||||
let errorText;
|
||||
|
||||
if (Array.isArray(message.content)) {
|
||||
const textContent = message.content.find(item => item.type === 'text');
|
||||
errorText = textContent && textContent.text && typeof textContent.text === 'string'
|
||||
? textContent.text
|
||||
: t('请求发生错误');
|
||||
const textContent = message.content.find((item) => item.type === 'text');
|
||||
errorText =
|
||||
textContent && textContent.text && typeof textContent.text === 'string'
|
||||
? textContent.text
|
||||
: t('请求发生错误');
|
||||
} else if (typeof message.content === 'string') {
|
||||
errorText = message.content;
|
||||
} else {
|
||||
@@ -52,22 +65,22 @@ const MessageContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className} flex items-center p-4 bg-red-50 rounded-xl`}>
|
||||
<Typography.Text type="danger" className="text-sm">
|
||||
{errorText}
|
||||
</Typography.Text>
|
||||
<div className={`${className}`}>
|
||||
<Typography.Text className='text-white'>{errorText}</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let currentExtractedThinkingContent = null;
|
||||
let currentDisplayableFinalContent = "";
|
||||
let currentDisplayableFinalContent = '';
|
||||
let thinkingSource = null;
|
||||
|
||||
const getTextContent = (content) => {
|
||||
if (Array.isArray(content)) {
|
||||
const textItem = content.find(item => item.type === 'text');
|
||||
return textItem && textItem.text && typeof textItem.text === 'string' ? textItem.text : '';
|
||||
const textItem = content.find((item) => item.type === 'text');
|
||||
return textItem && textItem.text && typeof textItem.text === 'string'
|
||||
? textItem.text
|
||||
: '';
|
||||
} else if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
@@ -78,7 +91,7 @@ const MessageContent = ({
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
let baseContentForDisplay = getTextContent(message.content);
|
||||
let combinedThinkingContent = "";
|
||||
let combinedThinkingContent = '';
|
||||
|
||||
if (message.reasoningContent) {
|
||||
combinedThinkingContent = message.reasoningContent;
|
||||
@@ -93,7 +106,9 @@ const MessageContent = ({
|
||||
let lastIndex = 0;
|
||||
|
||||
while ((match = thinkTagRegex.exec(baseContentForDisplay)) !== null) {
|
||||
replyParts.push(baseContentForDisplay.substring(lastIndex, match.index));
|
||||
replyParts.push(
|
||||
baseContentForDisplay.substring(lastIndex, match.index),
|
||||
);
|
||||
thoughtsFromPairedTags.push(match[1]);
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
@@ -106,7 +121,9 @@ const MessageContent = ({
|
||||
} else {
|
||||
combinedThinkingContent = pairedThoughtsStr;
|
||||
}
|
||||
thinkingSource = thinkingSource ? thinkingSource + ' & <think> tags' : '<think> tags';
|
||||
thinkingSource = thinkingSource
|
||||
? thinkingSource + ' & <think> tags'
|
||||
: '<think> tags';
|
||||
}
|
||||
|
||||
baseContentForDisplay = replyParts.join('');
|
||||
@@ -115,37 +132,55 @@ const MessageContent = ({
|
||||
if (isThinkingStatus) {
|
||||
const lastOpenThinkIndex = baseContentForDisplay.lastIndexOf('<think>');
|
||||
if (lastOpenThinkIndex !== -1) {
|
||||
const fragmentAfterLastOpen = baseContentForDisplay.substring(lastOpenThinkIndex);
|
||||
const fragmentAfterLastOpen =
|
||||
baseContentForDisplay.substring(lastOpenThinkIndex);
|
||||
if (!fragmentAfterLastOpen.includes('</think>')) {
|
||||
const unclosedThought = fragmentAfterLastOpen.substring('<think>'.length).trim();
|
||||
const unclosedThought = fragmentAfterLastOpen
|
||||
.substring('<think>'.length)
|
||||
.trim();
|
||||
if (unclosedThought) {
|
||||
if (combinedThinkingContent) {
|
||||
combinedThinkingContent += '\n\n---\n\n' + unclosedThought;
|
||||
} else {
|
||||
combinedThinkingContent = unclosedThought;
|
||||
}
|
||||
thinkingSource = thinkingSource ? thinkingSource + ' + streaming <think>' : 'streaming <think>';
|
||||
thinkingSource = thinkingSource
|
||||
? thinkingSource + ' + streaming <think>'
|
||||
: 'streaming <think>';
|
||||
}
|
||||
baseContentForDisplay = baseContentForDisplay.substring(0, lastOpenThinkIndex);
|
||||
baseContentForDisplay = baseContentForDisplay.substring(
|
||||
0,
|
||||
lastOpenThinkIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentExtractedThinkingContent = combinedThinkingContent || null;
|
||||
currentDisplayableFinalContent = baseContentForDisplay.replace(/<\/?think>/g, '').trim();
|
||||
currentDisplayableFinalContent = baseContentForDisplay
|
||||
.replace(/<\/?think>/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const finalExtractedThinkingContent = currentExtractedThinkingContent;
|
||||
const finalDisplayableFinalContent = currentDisplayableFinalContent;
|
||||
|
||||
if (message.role === 'assistant' &&
|
||||
if (
|
||||
message.role === 'assistant' &&
|
||||
isThinkingStatus &&
|
||||
!finalExtractedThinkingContent &&
|
||||
(!finalDisplayableFinalContent || finalDisplayableFinalContent.trim() === '')) {
|
||||
(!finalDisplayableFinalContent ||
|
||||
finalDisplayableFinalContent.trim() === '')
|
||||
) {
|
||||
return (
|
||||
<div className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}>
|
||||
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg">
|
||||
<Loader2 className="animate-spin text-white" size={styleState.isMobile ? 16 : 20} />
|
||||
<div
|
||||
className={`${className} flex items-center gap-2 sm:gap-4 bg-gradient-to-r from-purple-50 to-indigo-50`}
|
||||
>
|
||||
<div className='w-5 h-5 rounded-full bg-gradient-to-br from-purple-500 to-indigo-600 flex items-center justify-center shadow-lg'>
|
||||
<Loader2
|
||||
className='animate-spin text-white'
|
||||
size={styleState.isMobile ? 16 : 20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -154,12 +189,17 @@ const MessageContent = ({
|
||||
return (
|
||||
<div className={className}>
|
||||
{message.role === 'system' && (
|
||||
<div className="mb-2 sm:mb-4">
|
||||
<div className="flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg" style={{ border: '1px solid var(--semi-color-border)' }}>
|
||||
<div className="w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm">
|
||||
<Typography.Text className="text-white text-xs font-bold">S</Typography.Text>
|
||||
<div className='mb-2 sm:mb-4'>
|
||||
<div
|
||||
className='flex items-center gap-2 p-2 sm:p-3 bg-gradient-to-r from-amber-50 to-orange-50 rounded-lg'
|
||||
style={{ border: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-gradient-to-br from-amber-500 to-orange-600 flex items-center justify-center shadow-sm'>
|
||||
<Typography.Text className='text-white text-xs font-bold'>
|
||||
S
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text className="text-amber-700 text-xs sm:text-sm font-medium">
|
||||
<Typography.Text className='text-amber-700 text-xs sm:text-sm font-medium'>
|
||||
{t('系统消息')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
@@ -177,7 +217,7 @@ const MessageContent = ({
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
<TextArea
|
||||
value={editValue}
|
||||
onChange={(value) => onEditValueChange(value)}
|
||||
@@ -188,27 +228,27 @@ const MessageContent = ({
|
||||
fontSize: styleState.isMobile ? '14px' : '15px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
className="!border-blue-200 focus:!border-blue-400 !bg-blue-50/50"
|
||||
className='!border-blue-200 focus:!border-blue-400 !bg-blue-50/50'
|
||||
/>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className='flex items-center gap-2 w-full'>
|
||||
<Button
|
||||
size="small"
|
||||
type="danger"
|
||||
theme="light"
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='light'
|
||||
icon={<X size={14} />}
|
||||
onClick={onEditCancel}
|
||||
className="flex-1"
|
||||
className='flex-1'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="warning"
|
||||
theme="solid"
|
||||
size='small'
|
||||
type='warning'
|
||||
theme='solid'
|
||||
icon={<Check size={14} />}
|
||||
onClick={onEditSave}
|
||||
disabled={!editValue || editValue.trim() === ''}
|
||||
className="flex-1"
|
||||
className='flex-1'
|
||||
>
|
||||
{t('保存')}
|
||||
</Button>
|
||||
@@ -217,19 +257,23 @@ const MessageContent = ({
|
||||
) : (
|
||||
(() => {
|
||||
if (Array.isArray(message.content)) {
|
||||
const textContent = message.content.find(item => item.type === 'text');
|
||||
const imageContents = message.content.filter(item => item.type === 'image_url');
|
||||
const textContent = message.content.find(
|
||||
(item) => item.type === 'text',
|
||||
);
|
||||
const imageContents = message.content.filter(
|
||||
(item) => item.type === 'image_url',
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{imageContents.length > 0 && (
|
||||
<div className="mb-3 space-y-2">
|
||||
<div className='mb-3 space-y-2'>
|
||||
{imageContents.map((imgItem, index) => (
|
||||
<div key={index} className="max-w-sm">
|
||||
<div key={index} className='max-w-sm'>
|
||||
<img
|
||||
src={imgItem.image_url.url}
|
||||
alt={`用户上传的图片 ${index + 1}`}
|
||||
className="rounded-lg max-w-full h-auto shadow-sm border"
|
||||
className='rounded-lg max-w-full h-auto shadow-sm border'
|
||||
style={{ maxHeight: '300px' }}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
@@ -237,7 +281,7 @@ const MessageContent = ({
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200"
|
||||
className='text-red-500 text-sm p-2 bg-red-50 rounded-lg border border-red-200'
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
图片加载失败: {imgItem.image_url.url}
|
||||
@@ -247,28 +291,42 @@ const MessageContent = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{textContent && textContent.text && typeof textContent.text === 'string' && textContent.text.trim() !== '' && (
|
||||
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
|
||||
<MarkdownRenderer
|
||||
content={textContent.text}
|
||||
className={message.role === 'user' ? 'user-message' : ''}
|
||||
animated={false}
|
||||
previousContentLength={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{textContent &&
|
||||
textContent.text &&
|
||||
typeof textContent.text === 'string' &&
|
||||
textContent.text.trim() !== '' && (
|
||||
<div
|
||||
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={textContent.text}
|
||||
className={
|
||||
message.role === 'user' ? 'user-message' : ''
|
||||
}
|
||||
animated={false}
|
||||
previousContentLength={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
if (message.role === 'assistant') {
|
||||
if (finalDisplayableFinalContent && finalDisplayableFinalContent.trim() !== '') {
|
||||
if (
|
||||
finalDisplayableFinalContent &&
|
||||
finalDisplayableFinalContent.trim() !== ''
|
||||
) {
|
||||
// 获取上一次的内容长度
|
||||
let prevLength = 0;
|
||||
if (isThinkingStatus && lastContentRef.current) {
|
||||
// 只有当前内容包含上一次内容时,才使用上一次的长度
|
||||
if (finalDisplayableFinalContent.startsWith(lastContentRef.current)) {
|
||||
if (
|
||||
finalDisplayableFinalContent.startsWith(
|
||||
lastContentRef.current,
|
||||
)
|
||||
) {
|
||||
prevLength = lastContentRef.current.length;
|
||||
}
|
||||
}
|
||||
@@ -279,10 +337,10 @@ const MessageContent = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm">
|
||||
<div className='prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm'>
|
||||
<MarkdownRenderer
|
||||
content={finalDisplayableFinalContent}
|
||||
className=""
|
||||
className=''
|
||||
animated={isThinkingStatus}
|
||||
previousContentLength={prevLength}
|
||||
/>
|
||||
@@ -291,7 +349,9 @@ const MessageContent = ({
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<div className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}>
|
||||
<div
|
||||
className={`prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm ${message.role === 'user' ? 'user-message' : ''}`}
|
||||
>
|
||||
<MarkdownRenderer
|
||||
content={message.content}
|
||||
className={message.role === 'user' ? 'user-message' : ''}
|
||||
@@ -310,4 +370,4 @@ const MessageContent = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageContent;
|
||||
export default MessageContent;
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import MessageContent from './MessageContent';
|
||||
import MessageActions from './MessageActions';
|
||||
@@ -5,56 +24,74 @@ import SettingsPanel from './SettingsPanel';
|
||||
import DebugPanel from './DebugPanel';
|
||||
|
||||
// 优化的消息内容组件
|
||||
export const OptimizedMessageContent = React.memo(MessageContent, (prevProps, nextProps) => {
|
||||
// 只有这些属性变化时才重新渲染
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.message.reasoningContent === nextProps.message.reasoningContent &&
|
||||
prevProps.message.isReasoningExpanded === nextProps.message.isReasoningExpanded &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.editValue === nextProps.editValue &&
|
||||
prevProps.styleState.isMobile === nextProps.styleState.isMobile
|
||||
);
|
||||
});
|
||||
export const OptimizedMessageContent = React.memo(
|
||||
MessageContent,
|
||||
(prevProps, nextProps) => {
|
||||
// 只有这些属性变化时才重新渲染
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.status === nextProps.message.status &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.message.reasoningContent ===
|
||||
nextProps.message.reasoningContent &&
|
||||
prevProps.message.isReasoningExpanded ===
|
||||
nextProps.message.isReasoningExpanded &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.editValue === nextProps.editValue &&
|
||||
prevProps.styleState.isMobile === nextProps.styleState.isMobile
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 优化的消息操作组件
|
||||
export const OptimizedMessageActions = React.memo(MessageActions, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.onMessageReset === nextProps.onMessageReset
|
||||
);
|
||||
});
|
||||
export const OptimizedMessageActions = React.memo(
|
||||
MessageActions,
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.role === nextProps.message.role &&
|
||||
prevProps.isAnyMessageGenerating === nextProps.isAnyMessageGenerating &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.onMessageReset === nextProps.onMessageReset
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 优化的设置面板组件
|
||||
export const OptimizedSettingsPanel = React.memo(SettingsPanel, (prevProps, nextProps) => {
|
||||
return (
|
||||
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
|
||||
JSON.stringify(prevProps.parameterEnabled) === JSON.stringify(nextProps.parameterEnabled) &&
|
||||
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
|
||||
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.customRequestBody === nextProps.customRequestBody &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel &&
|
||||
prevProps.showSettings === nextProps.showSettings &&
|
||||
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
|
||||
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
|
||||
);
|
||||
});
|
||||
export const OptimizedSettingsPanel = React.memo(
|
||||
SettingsPanel,
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
JSON.stringify(prevProps.inputs) === JSON.stringify(nextProps.inputs) &&
|
||||
JSON.stringify(prevProps.parameterEnabled) ===
|
||||
JSON.stringify(nextProps.parameterEnabled) &&
|
||||
JSON.stringify(prevProps.models) === JSON.stringify(nextProps.models) &&
|
||||
JSON.stringify(prevProps.groups) === JSON.stringify(nextProps.groups) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.customRequestBody === nextProps.customRequestBody &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel &&
|
||||
prevProps.showSettings === nextProps.showSettings &&
|
||||
JSON.stringify(prevProps.previewPayload) ===
|
||||
JSON.stringify(nextProps.previewPayload) &&
|
||||
JSON.stringify(prevProps.messages) === JSON.stringify(nextProps.messages)
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 优化的调试面板组件
|
||||
export const OptimizedDebugPanel = React.memo(DebugPanel, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.show === nextProps.show &&
|
||||
prevProps.activeTab === nextProps.activeTab &&
|
||||
JSON.stringify(prevProps.debugData) === JSON.stringify(nextProps.debugData) &&
|
||||
JSON.stringify(prevProps.previewPayload) === JSON.stringify(nextProps.previewPayload) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel
|
||||
);
|
||||
});
|
||||
export const OptimizedDebugPanel = React.memo(
|
||||
DebugPanel,
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.show === nextProps.show &&
|
||||
prevProps.activeTab === nextProps.activeTab &&
|
||||
JSON.stringify(prevProps.debugData) ===
|
||||
JSON.stringify(nextProps.debugData) &&
|
||||
JSON.stringify(prevProps.previewPayload) ===
|
||||
JSON.stringify(nextProps.previewPayload) &&
|
||||
prevProps.customRequestMode === nextProps.customRequestMode &&
|
||||
prevProps.showDebugPanel === nextProps.showDebugPanel
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Input,
|
||||
Slider,
|
||||
Typography,
|
||||
Button,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Hash,
|
||||
Thermometer,
|
||||
Target,
|
||||
Repeat,
|
||||
Ban,
|
||||
Shuffle,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ParameterControl = ({
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
onInputChange,
|
||||
onParameterToggle,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Temperature */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Thermometer size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
Temperature
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
{inputs.temperature}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.temperature ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('temperature')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
控制输出的随机性和创造性
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={0.1}
|
||||
max={1}
|
||||
value={inputs.temperature}
|
||||
onChange={(value) => onInputChange('temperature', value)}
|
||||
className="mt-2"
|
||||
disabled={!parameterEnabled.temperature || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top P */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Target size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
Top P
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
{inputs.top_p}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('top_p')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
核采样,控制词汇选择的多样性
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={0.1}
|
||||
max={1}
|
||||
value={inputs.top_p}
|
||||
onChange={(value) => onInputChange('top_p', value)}
|
||||
className="mt-2"
|
||||
disabled={!parameterEnabled.top_p || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frequency Penalty */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Repeat size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
Frequency Penalty
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
{inputs.frequency_penalty}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.frequency_penalty ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('frequency_penalty')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
频率惩罚,减少重复词汇的出现
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={-2}
|
||||
max={2}
|
||||
value={inputs.frequency_penalty}
|
||||
onChange={(value) => onInputChange('frequency_penalty', value)}
|
||||
className="mt-2"
|
||||
disabled={!parameterEnabled.frequency_penalty || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Presence Penalty */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ban size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
Presence Penalty
|
||||
</Typography.Text>
|
||||
<Tag size="small" shape='circle'>
|
||||
{inputs.presence_penalty}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.presence_penalty ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('presence_penalty')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className="text-xs text-gray-500 mb-2">
|
||||
存在惩罚,鼓励讨论新话题
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={-2}
|
||||
max={2}
|
||||
value={inputs.presence_penalty}
|
||||
onChange={(value) => onInputChange('presence_penalty', value)}
|
||||
className="mt-2"
|
||||
disabled={!parameterEnabled.presence_penalty || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MaxTokens */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hash size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
Max Tokens
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.max_tokens ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('max_tokens')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder='MaxTokens'
|
||||
name='max_tokens'
|
||||
required
|
||||
autoComplete='new-password'
|
||||
defaultValue={0}
|
||||
value={inputs.max_tokens}
|
||||
onChange={(value) => onInputChange('max_tokens', value)}
|
||||
className="!rounded-lg"
|
||||
disabled={!parameterEnabled.max_tokens || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Seed */}
|
||||
<div className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
Seed
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-xs text-gray-400">
|
||||
(可选,用于复现结果)
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
|
||||
size="small"
|
||||
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('seed')}
|
||||
className="!rounded-full !w-4 !h-4 !p-0 !min-w-0"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder='随机种子 (留空为随机)'
|
||||
name='seed'
|
||||
autoComplete='new-password'
|
||||
value={inputs.seed || ''}
|
||||
onChange={(value) => onInputChange('seed', value === '' ? null : value)}
|
||||
className="!rounded-lg"
|
||||
disabled={!parameterEnabled.seed || disabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParameterControl;
|
||||
294
web/src/components/playground/ParameterControl.jsx
Normal file
294
web/src/components/playground/ParameterControl.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Input, Slider, Typography, Button, Tag } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Hash,
|
||||
Thermometer,
|
||||
Target,
|
||||
Repeat,
|
||||
Ban,
|
||||
Shuffle,
|
||||
Check,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
const ParameterControl = ({
|
||||
inputs,
|
||||
parameterEnabled,
|
||||
onInputChange,
|
||||
onParameterToggle,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Temperature */}
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.temperature || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Thermometer size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Temperature
|
||||
</Typography.Text>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.temperature}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.temperature ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.temperature ? 'primary' : 'tertiary'}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.temperature ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('temperature')}
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
控制输出的随机性和创造性
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={0.1}
|
||||
max={1}
|
||||
value={inputs.temperature}
|
||||
onChange={(value) => onInputChange('temperature', value)}
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.temperature || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top P */}
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.top_p || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Target size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Top P
|
||||
</Typography.Text>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.top_p}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.top_p ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.top_p ? 'primary' : 'tertiary'}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.top_p ? <Check size={10} /> : <X size={10} />
|
||||
}
|
||||
onClick={() => onParameterToggle('top_p')}
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
核采样,控制词汇选择的多样性
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={0.1}
|
||||
max={1}
|
||||
value={inputs.top_p}
|
||||
onChange={(value) => onInputChange('top_p', value)}
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.top_p || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Frequency Penalty */}
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.frequency_penalty || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Repeat size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Frequency Penalty
|
||||
</Typography.Text>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.frequency_penalty}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.frequency_penalty ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.frequency_penalty ? 'primary' : 'tertiary'}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.frequency_penalty ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('frequency_penalty')}
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
频率惩罚,减少重复词汇的出现
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={-2}
|
||||
max={2}
|
||||
value={inputs.frequency_penalty}
|
||||
onChange={(value) => onInputChange('frequency_penalty', value)}
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.frequency_penalty || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Presence Penalty */}
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.presence_penalty || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Ban size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Presence Penalty
|
||||
</Typography.Text>
|
||||
<Tag size='small' shape='circle'>
|
||||
{inputs.presence_penalty}
|
||||
</Tag>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.presence_penalty ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.presence_penalty ? 'primary' : 'tertiary'}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.presence_penalty ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('presence_penalty')}
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Typography.Text className='text-xs text-gray-500 mb-2'>
|
||||
存在惩罚,鼓励讨论新话题
|
||||
</Typography.Text>
|
||||
<Slider
|
||||
step={0.1}
|
||||
min={-2}
|
||||
max={2}
|
||||
value={inputs.presence_penalty}
|
||||
onChange={(value) => onInputChange('presence_penalty', value)}
|
||||
className='mt-2'
|
||||
disabled={!parameterEnabled.presence_penalty || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MaxTokens */}
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.max_tokens || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Hash size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Max Tokens
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.max_tokens ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.max_tokens ? 'primary' : 'tertiary'}
|
||||
size='small'
|
||||
icon={
|
||||
parameterEnabled.max_tokens ? (
|
||||
<Check size={10} />
|
||||
) : (
|
||||
<X size={10} />
|
||||
)
|
||||
}
|
||||
onClick={() => onParameterToggle('max_tokens')}
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder='MaxTokens'
|
||||
name='max_tokens'
|
||||
required
|
||||
autoComplete='new-password'
|
||||
defaultValue={0}
|
||||
value={inputs.max_tokens}
|
||||
onChange={(value) => onInputChange('max_tokens', value)}
|
||||
className='!rounded-lg'
|
||||
disabled={!parameterEnabled.max_tokens || disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Seed */}
|
||||
<div
|
||||
className={`transition-opacity duration-200 mb-4 ${!parameterEnabled.seed || disabled ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Shuffle size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
Seed
|
||||
</Typography.Text>
|
||||
<Typography.Text className='text-xs text-gray-400'>
|
||||
(可选,用于复现结果)
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
theme={parameterEnabled.seed ? 'solid' : 'borderless'}
|
||||
type={parameterEnabled.seed ? 'primary' : 'tertiary'}
|
||||
size='small'
|
||||
icon={parameterEnabled.seed ? <Check size={10} /> : <X size={10} />}
|
||||
onClick={() => onParameterToggle('seed')}
|
||||
className='!rounded-full !w-4 !h-4 !p-0 !min-w-0'
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder='随机种子 (留空为随机)'
|
||||
name='seed'
|
||||
autoComplete='new-password'
|
||||
value={inputs.seed || ''}
|
||||
onChange={(value) =>
|
||||
onInputChange('seed', value === '' ? null : value)
|
||||
}
|
||||
className='!rounded-lg'
|
||||
disabled={!parameterEnabled.seed || disabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ParameterControl;
|
||||
@@ -1,20 +1,27 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
Select,
|
||||
Typography,
|
||||
Button,
|
||||
Switch,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Sparkles,
|
||||
Users,
|
||||
ToggleLeft,
|
||||
X,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { Card, Select, Typography, Button, Switch } from '@douyinfe/semi-ui';
|
||||
import { Sparkles, Users, ToggleLeft, X, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { renderGroupOption } from '../../helpers';
|
||||
import { renderGroupOption, selectFilter } from '../../helpers';
|
||||
import ParameterControl from './ParameterControl';
|
||||
import ImageUrlInput from './ImageUrlInput';
|
||||
import ConfigManager from './ConfigManager';
|
||||
@@ -51,22 +58,22 @@ const SettingsPanel = ({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="h-full flex flex-col"
|
||||
className='h-full flex flex-col'
|
||||
bordered={false}
|
||||
bodyStyle={{
|
||||
padding: styleState.isMobile ? '16px' : '24px',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* 标题区域 - 与调试面板保持一致 */}
|
||||
<div className="flex items-center justify-between mb-6 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3">
|
||||
<Settings size={20} className="text-white" />
|
||||
<div className='flex items-center justify-between mb-6 flex-shrink-0'>
|
||||
<div className='flex items-center'>
|
||||
<div className='w-10 h-10 rounded-full bg-gradient-to-r from-purple-500 to-pink-500 flex items-center justify-center mr-3'>
|
||||
<Settings size={20} className='text-white' />
|
||||
</div>
|
||||
<Typography.Title heading={5} className="mb-0">
|
||||
<Typography.Title heading={5} className='mb-0'>
|
||||
{t('模型配置')}
|
||||
</Typography.Title>
|
||||
</div>
|
||||
@@ -75,17 +82,17 @@ const SettingsPanel = ({
|
||||
<Button
|
||||
icon={<X size={16} />}
|
||||
onClick={onCloseSettings}
|
||||
theme="borderless"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
className="!rounded-lg"
|
||||
theme='borderless'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 移动端配置管理 */}
|
||||
{styleState.isMobile && (
|
||||
<div className="mb-4 flex-shrink-0">
|
||||
<div className='mb-4 flex-shrink-0'>
|
||||
<ConfigManager
|
||||
currentConfig={currentConfig}
|
||||
onConfigImport={onConfigImport}
|
||||
@@ -96,7 +103,7 @@ const SettingsPanel = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll">
|
||||
<div className='space-y-6 overflow-y-auto flex-1 pr-2 model-settings-scroll'>
|
||||
{/* 自定义请求体编辑器 */}
|
||||
<CustomRequestEditor
|
||||
customRequestMode={customRequestMode}
|
||||
@@ -108,13 +115,13 @@ const SettingsPanel = ({
|
||||
|
||||
{/* 分组选择 */}
|
||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Users size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Users size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
{t('分组')}
|
||||
</Typography.Text>
|
||||
{customRequestMode && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -124,6 +131,8 @@ const SettingsPanel = ({
|
||||
name='group'
|
||||
required
|
||||
selection
|
||||
filter={selectFilter}
|
||||
autoClearSearchValue={false}
|
||||
onChange={(value) => onInputChange('group', value)}
|
||||
value={inputs.group}
|
||||
autoComplete='new-password'
|
||||
@@ -131,20 +140,20 @@ const SettingsPanel = ({
|
||||
renderOptionItem={renderGroupOption}
|
||||
style={{ width: '100%' }}
|
||||
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 模型选择 */}
|
||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Sparkles size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center gap-2 mb-2'>
|
||||
<Sparkles size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
{t('模型')}
|
||||
</Typography.Text>
|
||||
{customRequestMode && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -154,15 +163,15 @@ const SettingsPanel = ({
|
||||
name='model'
|
||||
required
|
||||
selection
|
||||
searchPosition='dropdown'
|
||||
filter
|
||||
filter={selectFilter}
|
||||
autoClearSearchValue={false}
|
||||
onChange={(value) => onInputChange('model', value)}
|
||||
value={inputs.model}
|
||||
autoComplete='new-password'
|
||||
optionList={models}
|
||||
style={{ width: '100%' }}
|
||||
dropdownStyle={{ width: '100%', maxWidth: '100%' }}
|
||||
className="!rounded-lg"
|
||||
className='!rounded-lg'
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -173,7 +182,9 @@ const SettingsPanel = ({
|
||||
imageUrls={inputs.imageUrls}
|
||||
imageEnabled={inputs.imageEnabled}
|
||||
onImageUrlsChange={(urls) => onInputChange('imageUrls', urls)}
|
||||
onImageEnabledChange={(enabled) => onInputChange('imageEnabled', enabled)}
|
||||
onImageEnabledChange={(enabled) =>
|
||||
onInputChange('imageEnabled', enabled)
|
||||
}
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -191,14 +202,14 @@ const SettingsPanel = ({
|
||||
|
||||
{/* 流式输出开关 */}
|
||||
<div className={customRequestMode ? 'opacity-50' : ''}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ToggleLeft size={16} className="text-gray-500" />
|
||||
<Typography.Text strong className="text-sm">
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ToggleLeft size={16} className='text-gray-500' />
|
||||
<Typography.Text strong className='text-sm'>
|
||||
流式输出
|
||||
</Typography.Text>
|
||||
{customRequestMode && (
|
||||
<Typography.Text className="text-xs text-orange-600">
|
||||
<Typography.Text className='text-xs text-orange-600'>
|
||||
(已在自定义模式中忽略)
|
||||
</Typography.Text>
|
||||
)}
|
||||
@@ -206,9 +217,9 @@ const SettingsPanel = ({
|
||||
<Switch
|
||||
checked={inputs.stream}
|
||||
onChange={(checked) => onInputChange('stream', checked)}
|
||||
checkedText="开"
|
||||
uncheckedText="关"
|
||||
size="small"
|
||||
checkedText='开'
|
||||
uncheckedText='关'
|
||||
size='small'
|
||||
disabled={customRequestMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -217,7 +228,7 @@ const SettingsPanel = ({
|
||||
|
||||
{/* 桌面端的配置管理放在底部 */}
|
||||
{!styleState.isMobile && (
|
||||
<div className="flex-shrink-0 pt-3">
|
||||
<div className='flex-shrink-0 pt-3'>
|
||||
<ConfigManager
|
||||
currentConfig={currentConfig}
|
||||
onConfigImport={onConfigImport}
|
||||
@@ -231,4 +242,4 @@ const SettingsPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPanel;
|
||||
export default SettingsPanel;
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
|
||||
import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ThinkingContent = ({
|
||||
message,
|
||||
finalExtractedThinkingContent,
|
||||
thinkingSource,
|
||||
styleState,
|
||||
onToggleReasoningExpansion
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef(null);
|
||||
const lastContentRef = useRef('');
|
||||
|
||||
const isThinkingStatus = message.status === 'loading' || message.status === 'incomplete';
|
||||
const headerText = (isThinkingStatus && !message.isThinkingComplete) ? t('思考中...') : t('思考过程');
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current && finalExtractedThinkingContent && message.isReasoningExpanded) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [finalExtractedThinkingContent, message.isReasoningExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinkingStatus) {
|
||||
lastContentRef.current = '';
|
||||
}
|
||||
}, [isThinkingStatus]);
|
||||
|
||||
if (!finalExtractedThinkingContent) return null;
|
||||
|
||||
let prevLength = 0;
|
||||
if (isThinkingStatus && lastContentRef.current) {
|
||||
if (finalExtractedThinkingContent.startsWith(lastContentRef.current)) {
|
||||
prevLength = lastContentRef.current.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (isThinkingStatus) {
|
||||
lastContentRef.current = finalExtractedThinkingContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={() => onToggleReasoningExpansion(message.id)}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full"></div>
|
||||
<div className="absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full"></div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-4 relative">
|
||||
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg">
|
||||
<Brain style={{ color: 'white' }} size={styleState.isMobile ? 12 : 16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<Typography.Text strong style={{ color: 'white' }} className="text-sm sm:text-base">
|
||||
{headerText}
|
||||
</Typography.Text>
|
||||
{thinkingSource && (
|
||||
<Typography.Text style={{ color: 'white' }} className="text-xs mt-0.5 opacity-80 hidden sm:block">
|
||||
来源: {thinkingSource}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 sm:gap-3 relative">
|
||||
{isThinkingStatus && !message.isThinkingComplete && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<Loader2 style={{ color: 'white' }} className="animate-spin" size={styleState.isMobile ? 14 : 18} />
|
||||
<Typography.Text style={{ color: 'white' }} className="text-xs sm:text-sm font-medium opacity-90">
|
||||
思考中
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{(!isThinkingStatus || message.isThinkingComplete) && (
|
||||
<div className="w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center">
|
||||
{message.isReasoningExpanded ?
|
||||
<ChevronUp size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} /> :
|
||||
<ChevronRight size={styleState.isMobile ? 12 : 16} style={{ color: 'white' }} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-500 ease-out ${message.isReasoningExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
|
||||
>
|
||||
{message.isReasoningExpanded && (
|
||||
<div className="p-3 sm:p-5 pt-2 sm:pt-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll"
|
||||
style={{
|
||||
maxHeight: '200px',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent'
|
||||
}}
|
||||
>
|
||||
<div className="prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm">
|
||||
<MarkdownRenderer
|
||||
content={finalExtractedThinkingContent}
|
||||
className=""
|
||||
animated={isThinkingStatus}
|
||||
previousContentLength={prevLength}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThinkingContent;
|
||||
180
web/src/components/playground/ThinkingContent.jsx
Normal file
180
web/src/components/playground/ThinkingContent.jsx
Normal file
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import MarkdownRenderer from '../common/markdown/MarkdownRenderer';
|
||||
import { ChevronRight, ChevronUp, Brain, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ThinkingContent = ({
|
||||
message,
|
||||
finalExtractedThinkingContent,
|
||||
thinkingSource,
|
||||
styleState,
|
||||
onToggleReasoningExpansion,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef(null);
|
||||
const lastContentRef = useRef('');
|
||||
|
||||
const isThinkingStatus =
|
||||
message.status === 'loading' || message.status === 'incomplete';
|
||||
const headerText =
|
||||
isThinkingStatus && !message.isThinkingComplete
|
||||
? t('思考中...')
|
||||
: t('思考过程');
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
scrollRef.current &&
|
||||
finalExtractedThinkingContent &&
|
||||
message.isReasoningExpanded
|
||||
) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [finalExtractedThinkingContent, message.isReasoningExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinkingStatus) {
|
||||
lastContentRef.current = '';
|
||||
}
|
||||
}, [isThinkingStatus]);
|
||||
|
||||
if (!finalExtractedThinkingContent) return null;
|
||||
|
||||
let prevLength = 0;
|
||||
if (isThinkingStatus && lastContentRef.current) {
|
||||
if (finalExtractedThinkingContent.startsWith(lastContentRef.current)) {
|
||||
prevLength = lastContentRef.current.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (isThinkingStatus) {
|
||||
lastContentRef.current = finalExtractedThinkingContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm'>
|
||||
<div
|
||||
className='flex items-center justify-between p-3 cursor-pointer hover:bg-gradient-to-r hover:from-white/20 hover:to-purple-50/30 transition-all'
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, #4c1d95 0%, #6d28d9 50%, #7c3aed 100%)',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => onToggleReasoningExpansion(message.id)}
|
||||
>
|
||||
<div className='absolute inset-0 overflow-hidden'>
|
||||
<div className='absolute -top-10 -right-10 w-40 h-40 bg-white opacity-5 rounded-full'></div>
|
||||
<div className='absolute -bottom-8 -left-8 w-24 h-24 bg-white opacity-10 rounded-full'></div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 sm:gap-4 relative'>
|
||||
<div className='w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-white/20 flex items-center justify-center shadow-lg'>
|
||||
<Brain
|
||||
style={{ color: 'white' }}
|
||||
size={styleState.isMobile ? 12 : 16}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col'>
|
||||
<Typography.Text
|
||||
strong
|
||||
style={{ color: 'white' }}
|
||||
className='text-sm sm:text-base'
|
||||
>
|
||||
{headerText}
|
||||
</Typography.Text>
|
||||
{thinkingSource && (
|
||||
<Typography.Text
|
||||
style={{ color: 'white' }}
|
||||
className='text-xs mt-0.5 opacity-80 hidden sm:block'
|
||||
>
|
||||
来源: {thinkingSource}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2 sm:gap-3 relative'>
|
||||
{isThinkingStatus && !message.isThinkingComplete && (
|
||||
<div className='flex items-center gap-1 sm:gap-2'>
|
||||
<Loader2
|
||||
style={{ color: 'white' }}
|
||||
className='animate-spin'
|
||||
size={styleState.isMobile ? 14 : 18}
|
||||
/>
|
||||
<Typography.Text
|
||||
style={{ color: 'white' }}
|
||||
className='text-xs sm:text-sm font-medium opacity-90'
|
||||
>
|
||||
思考中
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{(!isThinkingStatus || message.isThinkingComplete) && (
|
||||
<div className='w-5 h-5 sm:w-6 sm:h-6 rounded-full bg-white/20 flex items-center justify-center'>
|
||||
{message.isReasoningExpanded ? (
|
||||
<ChevronUp
|
||||
size={styleState.isMobile ? 12 : 16}
|
||||
style={{ color: 'white' }}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
size={styleState.isMobile ? 12 : 16}
|
||||
style={{ color: 'white' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-500 ease-out ${
|
||||
message.isReasoningExpanded
|
||||
? 'max-h-96 opacity-100'
|
||||
: 'max-h-0 opacity-0'
|
||||
} overflow-hidden bg-gradient-to-br from-purple-50 via-indigo-50 to-violet-50`}
|
||||
>
|
||||
{message.isReasoningExpanded && (
|
||||
<div className='p-3 sm:p-5 pt-2 sm:pt-4'>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className='bg-white/70 backdrop-blur-sm rounded-lg sm:rounded-xl p-2 shadow-inner overflow-x-auto overflow-y-auto thinking-content-scroll'
|
||||
style={{
|
||||
maxHeight: '200px',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: 'rgba(0, 0, 0, 0.3) transparent',
|
||||
}}
|
||||
>
|
||||
<div className='prose prose-xs sm:prose-sm prose-purple max-w-none text-xs sm:text-sm'>
|
||||
<MarkdownRenderer
|
||||
content={finalExtractedThinkingContent}
|
||||
className=''
|
||||
animated={isThinkingStatus}
|
||||
previousContentLength={prevLength}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThinkingContent;
|
||||
@@ -1,4 +1,26 @@
|
||||
import { STORAGE_KEYS, DEFAULT_CONFIG } from '../../constants/playground.constants';
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import {
|
||||
STORAGE_KEYS,
|
||||
DEFAULT_CONFIG,
|
||||
} from '../../constants/playground.constants';
|
||||
|
||||
const MESSAGES_STORAGE_KEY = 'playground_messages';
|
||||
|
||||
@@ -53,9 +75,12 @@ export const loadConfig = () => {
|
||||
...DEFAULT_CONFIG.parameterEnabled,
|
||||
...parsedConfig.parameterEnabled,
|
||||
},
|
||||
showDebugPanel: parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
|
||||
customRequestMode: parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
|
||||
customRequestBody: parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
|
||||
showDebugPanel:
|
||||
parsedConfig.showDebugPanel || DEFAULT_CONFIG.showDebugPanel,
|
||||
customRequestMode:
|
||||
parsedConfig.customRequestMode || DEFAULT_CONFIG.customRequestMode,
|
||||
customRequestBody:
|
||||
parsedConfig.customRequestBody || DEFAULT_CONFIG.customRequestBody,
|
||||
};
|
||||
|
||||
return mergedConfig;
|
||||
@@ -161,7 +186,6 @@ export const exportConfig = (config, messages = null) => {
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(link.href);
|
||||
|
||||
} catch (error) {
|
||||
console.error('导出配置失败:', error);
|
||||
}
|
||||
@@ -182,7 +206,10 @@ export const importConfig = (file) => {
|
||||
|
||||
if (importedConfig.inputs && importedConfig.parameterEnabled) {
|
||||
// 如果导入的配置包含消息,也一起导入
|
||||
if (importedConfig.messages && Array.isArray(importedConfig.messages)) {
|
||||
if (
|
||||
importedConfig.messages &&
|
||||
Array.isArray(importedConfig.messages)
|
||||
) {
|
||||
saveMessages(importedConfig.messages);
|
||||
}
|
||||
|
||||
@@ -200,4 +227,4 @@ export const importConfig = (file) => {
|
||||
reject(new Error('导入配置失败: ' + error.message));
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export { default as SettingsPanel } from './SettingsPanel';
|
||||
export { default as ChatArea } from './ChatArea';
|
||||
export { default as DebugPanel } from './DebugPanel';
|
||||
@@ -17,4 +36,4 @@ export {
|
||||
getConfigTimestamp,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
} from './configStorage';
|
||||
} from './configStorage';
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile.js';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Input,
|
||||
Space,
|
||||
Highlight,
|
||||
Select,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
import { CheckCircle, XCircle, AlertCircle, HelpCircle } from 'lucide-react';
|
||||
|
||||
const ChannelSelectorModal = forwardRef(({
|
||||
visible,
|
||||
onCancel,
|
||||
onOk,
|
||||
allChannels,
|
||||
selectedChannelIds,
|
||||
setSelectedChannelIds,
|
||||
channelEndpoints,
|
||||
updateChannelEndpoint,
|
||||
t,
|
||||
}, ref) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetPagination: () => {
|
||||
setCurrentPage(1);
|
||||
setSearchText('');
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!allChannels) return;
|
||||
|
||||
const searchLower = searchText.trim().toLowerCase();
|
||||
const matched = searchLower
|
||||
? allChannels.filter((item) => {
|
||||
const name = (item.label || '').toLowerCase();
|
||||
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
|
||||
return name.includes(searchLower) || baseUrl.includes(searchLower);
|
||||
})
|
||||
: allChannels;
|
||||
|
||||
setFilteredData(matched);
|
||||
}, [allChannels, searchText]);
|
||||
|
||||
const total = filteredData.length;
|
||||
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
|
||||
const updateEndpoint = (channelId, endpoint) => {
|
||||
if (typeof updateChannelEndpoint === 'function') {
|
||||
updateChannelEndpoint(channelId, endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEndpointCell = (text, record) => {
|
||||
const channelId = record.key || record.value;
|
||||
const currentEndpoint = channelEndpoints[channelId] || '';
|
||||
|
||||
const getEndpointType = (ep) => {
|
||||
if (ep === '/api/ratio_config') return 'ratio_config';
|
||||
if (ep === '/api/pricing') return 'pricing';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
const currentType = getEndpointType(currentEndpoint);
|
||||
|
||||
const handleTypeChange = (val) => {
|
||||
if (val === 'ratio_config') {
|
||||
updateEndpoint(channelId, '/api/ratio_config');
|
||||
} else if (val === 'pricing') {
|
||||
updateEndpoint(channelId, '/api/pricing');
|
||||
} else {
|
||||
if (currentType !== 'custom') {
|
||||
updateEndpoint(channelId, '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
size="small"
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
style={{ width: 120 }}
|
||||
optionList={[
|
||||
{ label: 'ratio_config', value: 'ratio_config' },
|
||||
{ label: 'pricing', value: 'pricing' },
|
||||
{ label: 'custom', value: 'custom' },
|
||||
]}
|
||||
/>
|
||||
{currentType === 'custom' && (
|
||||
<Input
|
||||
size="small"
|
||||
value={currentEndpoint}
|
||||
onChange={(val) => updateEndpoint(channelId, val)}
|
||||
placeholder="/your/endpoint"
|
||||
style={{ width: 160, fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusCell = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return (
|
||||
<Tag color='green' shape='circle' prefixIcon={<CheckCircle size={14} />}>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<Tag color='red' shape='circle' prefixIcon={<XCircle size={14} />}>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
case 3:
|
||||
return (
|
||||
<Tag color='yellow' shape='circle' prefixIcon={<AlertCircle size={14} />}>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tag color='grey' shape='circle' prefixIcon={<HelpCircle size={14} />}>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const renderNameCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const renderBaseUrlCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'label',
|
||||
render: renderNameCell,
|
||||
},
|
||||
{
|
||||
title: t('源地址'),
|
||||
dataIndex: '_originalData.base_url',
|
||||
render: (_, record) => renderBaseUrlCell(record._originalData?.base_url || ''),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: '_originalData.status',
|
||||
render: (_, record) => renderStatusCell(record._originalData?.status || 0),
|
||||
},
|
||||
{
|
||||
title: t('同步接口'),
|
||||
dataIndex: 'endpoint',
|
||||
fixed: 'right',
|
||||
render: renderEndpointCell,
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedChannelIds,
|
||||
onChange: (keys) => setSelectedChannelIds(keys),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
title={<span className="text-lg font-semibold">{t('选择同步渠道')}</span>}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
keepDOM
|
||||
lazyRender={false}
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索渠道名称或地址')}
|
||||
value={searchText}
|
||||
onChange={setSearchText}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={paginatedData}
|
||||
rowKey="key"
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
formatPageText: (page) => t('第 {{start}} - {{end}} 条,共 {{total}} 条', {
|
||||
start: page.currentStart,
|
||||
end: page.currentEnd,
|
||||
total: total,
|
||||
}),
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (curr, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChannelSelectorModal;
|
||||
296
web/src/components/settings/ChannelSelectorModal.jsx
Normal file
296
web/src/components/settings/ChannelSelectorModal.jsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useIsMobile } from '../../hooks/common/useIsMobile';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
Input,
|
||||
Space,
|
||||
Highlight,
|
||||
Select,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const ChannelSelectorModal = forwardRef(
|
||||
(
|
||||
{
|
||||
visible,
|
||||
onCancel,
|
||||
onOk,
|
||||
allChannels,
|
||||
selectedChannelIds,
|
||||
setSelectedChannelIds,
|
||||
channelEndpoints,
|
||||
updateChannelEndpoint,
|
||||
t,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [filteredData, setFilteredData] = useState([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
resetPagination: () => {
|
||||
setCurrentPage(1);
|
||||
setSearchText('');
|
||||
},
|
||||
}));
|
||||
|
||||
// 官方渠道识别
|
||||
const isOfficialChannel = (record) => {
|
||||
const id = record?.key ?? record?.value ?? record?._originalData?.id;
|
||||
const base = record?._originalData?.base_url || '';
|
||||
const name = record?.label || '';
|
||||
return (
|
||||
id === -100 ||
|
||||
base === 'https://basellm.github.io' ||
|
||||
name === '官方倍率预设'
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!allChannels) return;
|
||||
|
||||
const searchLower = searchText.trim().toLowerCase();
|
||||
const matched = searchLower
|
||||
? allChannels.filter((item) => {
|
||||
const name = (item.label || '').toLowerCase();
|
||||
const baseUrl = (item._originalData?.base_url || '').toLowerCase();
|
||||
return name.includes(searchLower) || baseUrl.includes(searchLower);
|
||||
})
|
||||
: allChannels;
|
||||
|
||||
const sorted = [...matched].sort((a, b) => {
|
||||
const wa = isOfficialChannel(a) ? 0 : 1;
|
||||
const wb = isOfficialChannel(b) ? 0 : 1;
|
||||
return wa - wb;
|
||||
});
|
||||
|
||||
setFilteredData(sorted);
|
||||
}, [allChannels, searchText]);
|
||||
|
||||
const total = filteredData.length;
|
||||
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * pageSize,
|
||||
currentPage * pageSize,
|
||||
);
|
||||
|
||||
const updateEndpoint = (channelId, endpoint) => {
|
||||
if (typeof updateChannelEndpoint === 'function') {
|
||||
updateChannelEndpoint(channelId, endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
const renderEndpointCell = (text, record) => {
|
||||
const channelId = record.key || record.value;
|
||||
const currentEndpoint = channelEndpoints[channelId] || '';
|
||||
|
||||
const getEndpointType = (ep) => {
|
||||
if (ep === '/api/ratio_config') return 'ratio_config';
|
||||
if (ep === '/api/pricing') return 'pricing';
|
||||
return 'custom';
|
||||
};
|
||||
|
||||
const currentType = getEndpointType(currentEndpoint);
|
||||
|
||||
const handleTypeChange = (val) => {
|
||||
if (val === 'ratio_config') {
|
||||
updateEndpoint(channelId, '/api/ratio_config');
|
||||
} else if (val === 'pricing') {
|
||||
updateEndpoint(channelId, '/api/pricing');
|
||||
} else {
|
||||
if (currentType !== 'custom') {
|
||||
updateEndpoint(channelId, '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Select
|
||||
size='small'
|
||||
value={currentType}
|
||||
onChange={handleTypeChange}
|
||||
style={{ width: 120 }}
|
||||
optionList={[
|
||||
{ label: 'ratio_config', value: 'ratio_config' },
|
||||
{ label: 'pricing', value: 'pricing' },
|
||||
{ label: 'custom', value: 'custom' },
|
||||
]}
|
||||
/>
|
||||
{currentType === 'custom' && (
|
||||
<Input
|
||||
size='small'
|
||||
value={currentEndpoint}
|
||||
onChange={(val) => updateEndpoint(channelId, val)}
|
||||
placeholder='/your/endpoint'
|
||||
style={{ width: 160, fontSize: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStatusCell = (record) => {
|
||||
const status = record?._originalData?.status || 0;
|
||||
const official = isOfficialChannel(record);
|
||||
let statusTag = null;
|
||||
switch (status) {
|
||||
case 1:
|
||||
statusTag = (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
statusTag = (
|
||||
<Tag color='red' shape='circle'>
|
||||
{t('已禁用')}
|
||||
</Tag>
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
statusTag = (
|
||||
<Tag color='yellow' shape='circle'>
|
||||
{t('自动禁用')}
|
||||
</Tag>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
statusTag = (
|
||||
<Tag color='grey' shape='circle'>
|
||||
{t('未知状态')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{statusTag}
|
||||
{official && (
|
||||
<Tag color='green' shape='circle' type='light'>
|
||||
{t('官方')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNameCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const renderBaseUrlCell = (text) => (
|
||||
<Highlight sourceString={text} searchWords={[searchText]} />
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('名称'),
|
||||
dataIndex: 'label',
|
||||
render: renderNameCell,
|
||||
},
|
||||
{
|
||||
title: t('源地址'),
|
||||
dataIndex: '_originalData.base_url',
|
||||
render: (_, record) =>
|
||||
renderBaseUrlCell(record._originalData?.base_url || ''),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: '_originalData.status',
|
||||
render: (_, record) => renderStatusCell(record),
|
||||
},
|
||||
{
|
||||
title: t('同步接口'),
|
||||
dataIndex: 'endpoint',
|
||||
fixed: 'right',
|
||||
render: renderEndpointCell,
|
||||
},
|
||||
];
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedChannelIds,
|
||||
onChange: (keys) => setSelectedChannelIds(keys),
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
title={
|
||||
<span className='text-lg font-semibold'>{t('选择同步渠道')}</span>
|
||||
}
|
||||
size={isMobile ? 'full-width' : 'large'}
|
||||
keepDOM
|
||||
lazyRender={false}
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<IconSearch size={14} />}
|
||||
placeholder={t('搜索渠道名称或地址')}
|
||||
value={searchText}
|
||||
onChange={setSearchText}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={paginatedData}
|
||||
rowKey='key'
|
||||
rowSelection={rowSelection}
|
||||
pagination={{
|
||||
currentPage: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
},
|
||||
onShowSizeChange: (curr, size) => {
|
||||
setCurrentPage(1);
|
||||
setPageSize(size);
|
||||
},
|
||||
}}
|
||||
size='small'
|
||||
/>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ChannelSelectorModal;
|
||||
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsChats from '../../pages/Setting/Chat/SettingsChats.js';
|
||||
import SettingsChats from '../../pages/Setting/Chat/SettingsChats';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
|
||||
const ChatsSetting = () => {
|
||||
@@ -60,4 +79,4 @@ const ChatsSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatsSetting;
|
||||
export default ChatsSetting;
|
||||
@@ -1,11 +1,30 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Card, Spin, Button, Modal } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess, toBoolean } from '../../helpers';
|
||||
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js';
|
||||
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js';
|
||||
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js';
|
||||
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js';
|
||||
import SettingsDataDashboard from '../../pages/Setting/Dashboard/SettingsDataDashboard.js';
|
||||
import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo';
|
||||
import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements';
|
||||
import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ';
|
||||
import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma';
|
||||
import SettingsDataDashboard from '../../pages/Setting/Dashboard/SettingsDataDashboard';
|
||||
|
||||
const DashboardSetting = () => {
|
||||
let [inputs, setInputs] = useState({
|
||||
@@ -43,8 +62,7 @@ const DashboardSetting = () => {
|
||||
if (item.key in inputs) {
|
||||
newInputs[item.key] = item.value;
|
||||
}
|
||||
if (item.key.endsWith('Enabled') &&
|
||||
(item.key === 'DataExportEnabled')) {
|
||||
if (item.key.endsWith('Enabled') && item.key === 'DataExportEnabled') {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
}
|
||||
});
|
||||
@@ -72,8 +90,14 @@ const DashboardSetting = () => {
|
||||
|
||||
// 用于迁移检测的旧键,下个版本会删除
|
||||
const hasLegacyData = useMemo(() => {
|
||||
const legacyKeys = ['ApiInfo', 'Announcements', 'FAQ', 'UptimeKumaUrl', 'UptimeKumaSlug'];
|
||||
return legacyKeys.some(k => inputs[k]);
|
||||
const legacyKeys = [
|
||||
'ApiInfo',
|
||||
'Announcements',
|
||||
'FAQ',
|
||||
'UptimeKumaUrl',
|
||||
'UptimeKumaSlug',
|
||||
];
|
||||
return legacyKeys.some((k) => inputs[k]);
|
||||
}, [inputs]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,17 +126,18 @@ const DashboardSetting = () => {
|
||||
<Spin spinning={loading} size='large'>
|
||||
{/* 用于迁移检测的旧键模态框,下个版本会删除 */}
|
||||
<Modal
|
||||
title="配置迁移确认"
|
||||
title='配置迁移确认'
|
||||
visible={showMigrateModal}
|
||||
onOk={handleMigrate}
|
||||
onCancel={() => setShowMigrateModal(false)}
|
||||
confirmLoading={loading}
|
||||
okText="确认迁移"
|
||||
cancelText="取消"
|
||||
okText='确认迁移'
|
||||
cancelText='取消'
|
||||
>
|
||||
<p>检测到旧版本的配置数据,是否要迁移到新的配置格式?</p>
|
||||
<p style={{ color: '#f57c00', marginTop: '10px' }}>
|
||||
<strong>注意:</strong>迁移过程中会自动处理数据格式转换,迁移完成后旧配置将被清除,请在迁移前在数据库中备份好旧配置。
|
||||
<strong>注意:</strong>
|
||||
迁移过程中会自动处理数据格式转换,迁移完成后旧配置将被清除,请在迁移前在数据库中备份好旧配置。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
@@ -145,4 +170,4 @@ const DashboardSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSetting;
|
||||
export default DashboardSetting;
|
||||
@@ -1,6 +1,25 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing.js';
|
||||
import SettingsDrawing from '../../pages/Setting/Drawing/SettingsDrawing';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
|
||||
const DrawingSetting = () => {
|
||||
@@ -62,4 +81,4 @@ const DrawingSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingSetting;
|
||||
export default DrawingSetting;
|
||||
@@ -1,11 +1,30 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
|
||||
import { API, showError, showSuccess, toBoolean } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel.js';
|
||||
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel.js';
|
||||
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel.js';
|
||||
import SettingGeminiModel from '../../pages/Setting/Model/SettingGeminiModel';
|
||||
import SettingClaudeModel from '../../pages/Setting/Model/SettingClaudeModel';
|
||||
import SettingGlobalModel from '../../pages/Setting/Model/SettingGlobalModel';
|
||||
|
||||
const ModelSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -1,10 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral.js';
|
||||
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords.js';
|
||||
import SettingsLog from '../../pages/Setting/Operation/SettingsLog.js';
|
||||
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring.js';
|
||||
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit.js';
|
||||
import SettingsGeneral from '../../pages/Setting/Operation/SettingsGeneral';
|
||||
import SettingsHeaderNavModules from '../../pages/Setting/Operation/SettingsHeaderNavModules';
|
||||
import SettingsSidebarModulesAdmin from '../../pages/Setting/Operation/SettingsSidebarModulesAdmin';
|
||||
import SettingsSensitiveWords from '../../pages/Setting/Operation/SettingsSensitiveWords';
|
||||
import SettingsLog from '../../pages/Setting/Operation/SettingsLog';
|
||||
import SettingsMonitoring from '../../pages/Setting/Operation/SettingsMonitoring';
|
||||
import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLimit';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
|
||||
const OperationSetting = () => {
|
||||
@@ -27,6 +48,12 @@ const OperationSetting = () => {
|
||||
DemoSiteEnabled: false,
|
||||
SelfUseModeEnabled: false,
|
||||
|
||||
/* 顶栏模块管理 */
|
||||
HeaderNavModules: '',
|
||||
|
||||
/* 左侧边栏模块管理(管理员) */
|
||||
SidebarModulesAdmin: '',
|
||||
|
||||
/* 敏感词设置 */
|
||||
CheckSensitiveEnabled: false,
|
||||
CheckSensitiveOnPromptEnabled: false,
|
||||
@@ -41,6 +68,8 @@ const OperationSetting = () => {
|
||||
AutomaticDisableChannelEnabled: false,
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
@@ -51,10 +80,7 @@ const OperationSetting = () => {
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.key.endsWith('Enabled') ||
|
||||
['DefaultCollapseSidebar'].includes(item.key)
|
||||
) {
|
||||
if (typeof inputs[item.key] === 'boolean') {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
} else {
|
||||
newInputs[item.key] = item.value;
|
||||
@@ -89,6 +115,14 @@ const OperationSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsGeneral options={inputs} refresh={onRefresh} />
|
||||
</Card>
|
||||
{/* 顶栏模块管理 */}
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<SettingsHeaderNavModules options={inputs} refresh={onRefresh} />
|
||||
</div>
|
||||
{/* 左侧边栏模块管理(管理员) */}
|
||||
<div style={{ marginTop: '10px' }}>
|
||||
<SettingsSidebarModulesAdmin options={inputs} refresh={onRefresh} />
|
||||
</div>
|
||||
{/* 屏蔽词过滤设置 */}
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<SettingsSensitiveWords options={inputs} refresh={onRefresh} />
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Banner,
|
||||
@@ -12,7 +31,7 @@ import {
|
||||
import { API, showError, showSuccess, timestamp2string } from '../../helpers';
|
||||
import { marked } from 'marked';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatusContext } from '../../context/Status/index.js';
|
||||
import { StatusContext } from '../../context/Status';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
|
||||
const OtherSetting = () => {
|
||||
@@ -1,8 +1,27 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
|
||||
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
|
||||
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe.js';
|
||||
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
|
||||
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
|
||||
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -37,7 +56,11 @@ const PaymentSetting = () => {
|
||||
switch (item.key) {
|
||||
case 'TopupGroupRatio':
|
||||
try {
|
||||
newInputs[item.key] = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
newInputs[item.key] = JSON.stringify(
|
||||
JSON.parse(item.value),
|
||||
null,
|
||||
2,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('解析TopupGroupRatio出错:', error);
|
||||
newInputs[item.key] = item.value;
|
||||
@@ -97,4 +120,4 @@ const PaymentSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentSetting;
|
||||
export default PaymentSetting;
|
||||
File diff suppressed because it is too large
Load Diff
394
web/src/components/settings/PersonalSetting.jsx
Normal file
394
web/src/components/settings/PersonalSetting.jsx
Normal file
@@ -0,0 +1,394 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../../helpers';
|
||||
import { UserContext } from '../../context/User';
|
||||
import { Modal } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// 导入子组件
|
||||
import UserInfoHeader from './personal/components/UserInfoHeader';
|
||||
import AccountManagement from './personal/cards/AccountManagement';
|
||||
import NotificationSettings from './personal/cards/NotificationSettings';
|
||||
import EmailBindModal from './personal/modals/EmailBindModal';
|
||||
import WeChatBindModal from './personal/modals/WeChatBindModal';
|
||||
import AccountDeleteModal from './personal/modals/AccountDeleteModal';
|
||||
import ChangePasswordModal from './personal/modals/ChangePasswordModal';
|
||||
|
||||
const PersonalSetting = () => {
|
||||
const [userState, userDispatch] = useContext(UserContext);
|
||||
let navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [inputs, setInputs] = useState({
|
||||
wechat_verification_code: '',
|
||||
email_verification_code: '',
|
||||
email: '',
|
||||
self_account_deletion_confirmation: '',
|
||||
original_password: '',
|
||||
set_new_password: '',
|
||||
set_new_password_confirmation: '',
|
||||
});
|
||||
const [status, setStatus] = useState({});
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
|
||||
const [showEmailBindModal, setShowEmailBindModal] = useState(false);
|
||||
const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
|
||||
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
||||
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
|
||||
const [turnstileToken, setTurnstileToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [disableButton, setDisableButton] = useState(false);
|
||||
const [countdown, setCountdown] = useState(30);
|
||||
const [systemToken, setSystemToken] = useState('');
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
warningType: 'email',
|
||||
warningThreshold: 100000,
|
||||
webhookUrl: '',
|
||||
webhookSecret: '',
|
||||
notificationEmail: '',
|
||||
barkUrl: '',
|
||||
acceptUnsetModelRatioModel: false,
|
||||
recordIpLog: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let status = localStorage.getItem('status');
|
||||
if (status) {
|
||||
status = JSON.parse(status);
|
||||
setStatus(status);
|
||||
if (status.turnstile_check) {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
}
|
||||
getUserData().then((res) => {
|
||||
console.log(userState);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let countdownInterval = null;
|
||||
if (disableButton && countdown > 0) {
|
||||
countdownInterval = setInterval(() => {
|
||||
setCountdown(countdown - 1);
|
||||
}, 1000);
|
||||
} else if (countdown === 0) {
|
||||
setDisableButton(false);
|
||||
setCountdown(30);
|
||||
}
|
||||
return () => clearInterval(countdownInterval); // Clean up on unmount
|
||||
}, [disableButton, countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userState?.user?.setting) {
|
||||
const settings = JSON.parse(userState.user.setting);
|
||||
setNotificationSettings({
|
||||
warningType: settings.notify_type || 'email',
|
||||
warningThreshold: settings.quota_warning_threshold || 500000,
|
||||
webhookUrl: settings.webhook_url || '',
|
||||
webhookSecret: settings.webhook_secret || '',
|
||||
notificationEmail: settings.notification_email || '',
|
||||
barkUrl: settings.bark_url || '',
|
||||
acceptUnsetModelRatioModel:
|
||||
settings.accept_unset_model_ratio_model || false,
|
||||
recordIpLog: settings.record_ip_log || false,
|
||||
});
|
||||
}
|
||||
}, [userState?.user?.setting]);
|
||||
|
||||
const handleInputChange = (name, value) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const generateAccessToken = async () => {
|
||||
const res = await API.get('/api/user/token');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setSystemToken(data);
|
||||
await copy(data);
|
||||
showSuccess(t('令牌已重置并已复制到剪贴板'));
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserData = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
userDispatch({ type: 'login', payload: data });
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSystemTokenClick = async (e) => {
|
||||
e.target.select();
|
||||
await copy(e.target.value);
|
||||
showSuccess(t('系统令牌已复制到剪切板'));
|
||||
};
|
||||
|
||||
const deleteAccount = async () => {
|
||||
if (inputs.self_account_deletion_confirmation !== userState.user.username) {
|
||||
showError(t('请输入你的账户名以确认删除!'));
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await API.delete('/api/user/self');
|
||||
const { success, message } = res.data;
|
||||
|
||||
if (success) {
|
||||
showSuccess(t('账户已删除!'));
|
||||
await API.get('/api/user/logout');
|
||||
userDispatch({ type: 'logout' });
|
||||
localStorage.removeItem('user');
|
||||
navigate('/login');
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const bindWeChat = async () => {
|
||||
if (inputs.wechat_verification_code === '') return;
|
||||
const res = await API.get(
|
||||
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('微信账户绑定成功!'));
|
||||
setShowWeChatBindModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (inputs.original_password === '') {
|
||||
showError(t('请输入原密码!'));
|
||||
return;
|
||||
}
|
||||
if (inputs.set_new_password === '') {
|
||||
showError(t('请输入新密码!'));
|
||||
return;
|
||||
}
|
||||
if (inputs.original_password === inputs.set_new_password) {
|
||||
showError(t('新密码需要和原密码不一致!'));
|
||||
return;
|
||||
}
|
||||
if (inputs.set_new_password !== inputs.set_new_password_confirmation) {
|
||||
showError(t('两次输入的密码不一致!'));
|
||||
return;
|
||||
}
|
||||
const res = await API.put(`/api/user/self`, {
|
||||
original_password: inputs.original_password,
|
||||
password: inputs.set_new_password,
|
||||
});
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('密码修改成功!'));
|
||||
setShowWeChatBindModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setShowChangePasswordModal(false);
|
||||
};
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (inputs.email === '') {
|
||||
showError(t('请输入邮箱!'));
|
||||
return;
|
||||
}
|
||||
setDisableButton(true);
|
||||
if (turnstileEnabled && turnstileToken === '') {
|
||||
showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('验证码发送成功,请检查邮箱!'));
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const bindEmail = async () => {
|
||||
if (inputs.email_verification_code === '') {
|
||||
showError(t('请输入邮箱验证码!'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const res = await API.get(
|
||||
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`,
|
||||
);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('邮箱账户绑定成功!'));
|
||||
setShowEmailBindModal(false);
|
||||
userState.user.email = inputs.email;
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyText = async (text) => {
|
||||
if (await copy(text)) {
|
||||
showSuccess(t('已复制:') + text);
|
||||
} else {
|
||||
// setSearchKeyword(text);
|
||||
Modal.error({ title: t('无法复制到剪贴板,请手动复制'), content: text });
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotificationSettingChange = (type, value) => {
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
[type]: value.target
|
||||
? value.target.value !== undefined
|
||||
? value.target.value
|
||||
: value.target.checked
|
||||
: value, // handle checkbox properly
|
||||
}));
|
||||
};
|
||||
|
||||
const saveNotificationSettings = async () => {
|
||||
try {
|
||||
const res = await API.put('/api/user/setting', {
|
||||
notify_type: notificationSettings.warningType,
|
||||
quota_warning_threshold: parseFloat(
|
||||
notificationSettings.warningThreshold,
|
||||
),
|
||||
webhook_url: notificationSettings.webhookUrl,
|
||||
webhook_secret: notificationSettings.webhookSecret,
|
||||
notification_email: notificationSettings.notificationEmail,
|
||||
bark_url: notificationSettings.barkUrl,
|
||||
accept_unset_model_ratio_model:
|
||||
notificationSettings.acceptUnsetModelRatioModel,
|
||||
record_ip_log: notificationSettings.recordIpLog,
|
||||
});
|
||||
|
||||
if (res.data.success) {
|
||||
showSuccess(t('设置保存成功'));
|
||||
await getUserData();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('设置保存失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mt-[60px]'>
|
||||
<div className='flex justify-center'>
|
||||
<div className='w-full max-w-7xl mx-auto px-2'>
|
||||
{/* 顶部用户信息区域 */}
|
||||
<UserInfoHeader t={t} userState={userState} />
|
||||
|
||||
{/* 账户管理和其他设置 */}
|
||||
<div className='grid grid-cols-1 xl:grid-cols-2 items-start gap-4 md:gap-6 mt-4 md:mt-6'>
|
||||
{/* 左侧:账户管理设置 */}
|
||||
<AccountManagement
|
||||
t={t}
|
||||
userState={userState}
|
||||
status={status}
|
||||
systemToken={systemToken}
|
||||
setShowEmailBindModal={setShowEmailBindModal}
|
||||
setShowWeChatBindModal={setShowWeChatBindModal}
|
||||
generateAccessToken={generateAccessToken}
|
||||
handleSystemTokenClick={handleSystemTokenClick}
|
||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||
/>
|
||||
|
||||
{/* 右侧:其他设置 */}
|
||||
<NotificationSettings
|
||||
t={t}
|
||||
notificationSettings={notificationSettings}
|
||||
handleNotificationSettingChange={handleNotificationSettingChange}
|
||||
saveNotificationSettings={saveNotificationSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模态框组件 */}
|
||||
<EmailBindModal
|
||||
t={t}
|
||||
showEmailBindModal={showEmailBindModal}
|
||||
setShowEmailBindModal={setShowEmailBindModal}
|
||||
inputs={inputs}
|
||||
handleInputChange={handleInputChange}
|
||||
sendVerificationCode={sendVerificationCode}
|
||||
bindEmail={bindEmail}
|
||||
disableButton={disableButton}
|
||||
loading={loading}
|
||||
countdown={countdown}
|
||||
turnstileEnabled={turnstileEnabled}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
setTurnstileToken={setTurnstileToken}
|
||||
/>
|
||||
|
||||
<WeChatBindModal
|
||||
t={t}
|
||||
showWeChatBindModal={showWeChatBindModal}
|
||||
setShowWeChatBindModal={setShowWeChatBindModal}
|
||||
inputs={inputs}
|
||||
handleInputChange={handleInputChange}
|
||||
bindWeChat={bindWeChat}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<AccountDeleteModal
|
||||
t={t}
|
||||
showAccountDeleteModal={showAccountDeleteModal}
|
||||
setShowAccountDeleteModal={setShowAccountDeleteModal}
|
||||
inputs={inputs}
|
||||
handleInputChange={handleInputChange}
|
||||
deleteAccount={deleteAccount}
|
||||
userState={userState}
|
||||
turnstileEnabled={turnstileEnabled}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
setTurnstileToken={setTurnstileToken}
|
||||
/>
|
||||
|
||||
<ChangePasswordModal
|
||||
t={t}
|
||||
showChangePasswordModal={showChangePasswordModal}
|
||||
setShowChangePasswordModal={setShowChangePasswordModal}
|
||||
inputs={inputs}
|
||||
handleInputChange={handleInputChange}
|
||||
changePassword={changePassword}
|
||||
turnstileEnabled={turnstileEnabled}
|
||||
turnstileSiteKey={turnstileSiteKey}
|
||||
setTurnstileToken={setTurnstileToken}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonalSetting;
|
||||
@@ -1,9 +1,28 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin } from '@douyinfe/semi-ui';
|
||||
|
||||
import { API, showError, toBoolean } from '../../helpers/index.js';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit.js';
|
||||
import RequestRateLimit from '../../pages/Setting/RateLimit/SettingsRequestRateLimit';
|
||||
|
||||
const RateLimitSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -1,12 +1,31 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings.js';
|
||||
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings.js';
|
||||
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor.js';
|
||||
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor.js';
|
||||
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync.js';
|
||||
import GroupRatioSettings from '../../pages/Setting/Ratio/GroupRatioSettings';
|
||||
import ModelRatioSettings from '../../pages/Setting/Ratio/ModelRatioSettings';
|
||||
import ModelSettingsVisualEditor from '../../pages/Setting/Ratio/ModelSettingsVisualEditor';
|
||||
import ModelRatioNotSetEditor from '../../pages/Setting/Ratio/ModelRationNotSetEditor';
|
||||
import UpstreamRatioSync from '../../pages/Setting/Ratio/UpstreamRatioSync';
|
||||
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
|
||||
@@ -84,34 +103,19 @@ const RatioSetting = () => {
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs type='card'>
|
||||
<Tabs.TabPane tab={t('模型倍率设置')} itemKey='model'>
|
||||
<ModelRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<ModelRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('分组倍率设置')} itemKey='group'>
|
||||
<GroupRatioSettings
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<GroupRatioSettings options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('可视化倍率设置')} itemKey='visual'>
|
||||
<ModelSettingsVisualEditor
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<ModelSettingsVisualEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('未设置倍率模型')} itemKey='unset_models'>
|
||||
<ModelRatioNotSetEditor
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<ModelRatioNotSetEditor options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('上游倍率同步')} itemKey='upstream_sync'>
|
||||
<UpstreamRatioSync
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
/>
|
||||
<UpstreamRatioSync options={inputs} refresh={onRefresh} />
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
@@ -119,4 +123,4 @@ const RatioSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RatioSetting;
|
||||
export default RatioSetting;
|
||||
@@ -1,3 +1,22 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Button,
|
||||
@@ -66,6 +85,7 @@ const SystemSetting = () => {
|
||||
LinuxDOOAuthEnabled: '',
|
||||
LinuxDOClientId: '',
|
||||
LinuxDOClientSecret: '',
|
||||
LinuxDOMinimumTrustLevel: '',
|
||||
ServerAddress: '',
|
||||
});
|
||||
|
||||
@@ -453,6 +473,15 @@ const SystemSetting = () => {
|
||||
value: inputs.LinuxDOClientSecret,
|
||||
});
|
||||
}
|
||||
if (
|
||||
originInputs['LinuxDOMinimumTrustLevel'] !==
|
||||
inputs.LinuxDOMinimumTrustLevel
|
||||
) {
|
||||
options.push({
|
||||
key: 'LinuxDOMinimumTrustLevel',
|
||||
value: inputs.LinuxDOMinimumTrustLevel,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.length > 0) {
|
||||
await updateOptions(options);
|
||||
@@ -504,11 +533,15 @@ const SystemSetting = () => {
|
||||
field='ServerAddress'
|
||||
label={t('服务器地址')}
|
||||
placeholder='https://yourdomain.com'
|
||||
extraText={t('该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置')}
|
||||
extraText={t(
|
||||
'该服务器地址将影响支付回调地址以及默认首页展示的地址,请确保正确配置',
|
||||
)}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitServerAddress}>{t('更新服务器地址')}</Button>
|
||||
<Button onClick={submitServerAddress}>
|
||||
{t('更新服务器地址')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
@@ -729,7 +762,10 @@ const SystemSetting = () => {
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input field='SMTPServer' label={t('SMTP 服务器地址')} />
|
||||
<Form.Input
|
||||
field='SMTPServer'
|
||||
label={t('SMTP 服务器地址')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input field='SMTPPort' label={t('SMTP 端口')} />
|
||||
@@ -743,7 +779,10 @@ const SystemSetting = () => {
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input field='SMTPFrom' label={t('SMTP 发送者邮箱')} />
|
||||
<Form.Input
|
||||
field='SMTPFrom'
|
||||
label={t('SMTP 发送者邮箱')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
@@ -771,7 +810,9 @@ const SystemSetting = () => {
|
||||
<Card>
|
||||
<Form.Section text={t('配置 OIDC')}>
|
||||
<Text>
|
||||
{t('用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP')}
|
||||
{t(
|
||||
'用以支持通过 OIDC 登录,例如 Okta、Auth0 等兼容 OIDC 协议的 IdP',
|
||||
)}
|
||||
</Text>
|
||||
<Banner
|
||||
type='info'
|
||||
@@ -779,7 +820,9 @@ const SystemSetting = () => {
|
||||
style={{ marginBottom: 20, marginTop: 16 }}
|
||||
/>
|
||||
<Text>
|
||||
{t('若你的 OIDC Provider 支持 Discovery Endpoint,你可以仅填写 OIDC Well-Known URL,系统会自动获取 OIDC 配置')}
|
||||
{t(
|
||||
'若你的 OIDC Provider 支持 Discovery Endpoint,你可以仅填写 OIDC Well-Known URL,系统会自动获取 OIDC 配置',
|
||||
)}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
@@ -836,7 +879,9 @@ const SystemSetting = () => {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitOIDCSettings}>{t('保存 OIDC 设置')}</Button>
|
||||
<Button onClick={submitOIDCSettings}>
|
||||
{t('保存 OIDC 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
@@ -897,14 +942,14 @@ const SystemSetting = () => {
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Col xs={24} sm={24} md={10} lg={10} xl={10}>
|
||||
<Form.Input
|
||||
field='LinuxDOClientId'
|
||||
label={t('Linux DO Client ID')}
|
||||
placeholder={t('输入你注册的 LinuxDO OAuth APP 的 ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Col xs={24} sm={24} md={10} lg={10} xl={10}>
|
||||
<Form.Input
|
||||
field='LinuxDOClientSecret'
|
||||
label={t('Linux DO Client Secret')}
|
||||
@@ -912,6 +957,13 @@ const SystemSetting = () => {
|
||||
placeholder={t('敏感信息不会发送到前端显示')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={4} lg={4} xl={4}>
|
||||
<Form.Input
|
||||
field='LinuxDOMinimumTrustLevel'
|
||||
label='LinuxDO Minimum Trust Level'
|
||||
placeholder='允许注册的最低信任等级'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitLinuxDOOAuth}>
|
||||
{t('保存 Linux DO OAuth 设置')}
|
||||
@@ -1000,7 +1052,9 @@ const SystemSetting = () => {
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Button onClick={submitTurnstile}>{t('保存 Turnstile 设置')}</Button>
|
||||
<Button onClick={submitTurnstile}>
|
||||
{t('保存 Turnstile 设置')}
|
||||
</Button>
|
||||
</Form.Section>
|
||||
</Card>
|
||||
|
||||
@@ -1015,7 +1069,11 @@ const SystemSetting = () => {
|
||||
okText={t('确认')}
|
||||
cancelText={t('取消')}
|
||||
>
|
||||
<p>{t('您确定要取消密码登录功能吗?这可能会影响用户的登录方式。')}</p>
|
||||
<p>
|
||||
{t(
|
||||
'您确定要取消密码登录功能吗?这可能会影响用户的登录方式。',
|
||||
)}
|
||||
</p>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
488
web/src/components/settings/personal/cards/AccountManagement.jsx
Normal file
488
web/src/components/settings/personal/cards/AccountManagement.jsx
Normal file
@@ -0,0 +1,488 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Space,
|
||||
Typography,
|
||||
Avatar,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Popover,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconMail,
|
||||
IconShield,
|
||||
IconGithubLogo,
|
||||
IconKey,
|
||||
IconLock,
|
||||
IconDelete,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { SiTelegram, SiWechat, SiLinux } from 'react-icons/si';
|
||||
import { UserPlus, ShieldCheck } from 'lucide-react';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
import {
|
||||
onGitHubOAuthClicked,
|
||||
onOIDCClicked,
|
||||
onLinuxDOOAuthClicked,
|
||||
} from '../../../../helpers';
|
||||
import TwoFASetting from '../components/TwoFASetting';
|
||||
|
||||
const AccountManagement = ({
|
||||
t,
|
||||
userState,
|
||||
status,
|
||||
systemToken,
|
||||
setShowEmailBindModal,
|
||||
setShowWeChatBindModal,
|
||||
generateAccessToken,
|
||||
handleSystemTokenClick,
|
||||
setShowChangePasswordModal,
|
||||
setShowAccountDeleteModal,
|
||||
}) => {
|
||||
const renderAccountInfo = (accountId, label) => {
|
||||
if (!accountId || accountId === '') {
|
||||
return <span className='text-gray-500'>{t('未绑定')}</span>;
|
||||
}
|
||||
|
||||
const popContent = (
|
||||
<div className='text-xs p-2'>
|
||||
<Typography.Paragraph copyable={{ content: accountId }}>
|
||||
{accountId}
|
||||
</Typography.Paragraph>
|
||||
{label ? (
|
||||
<div className='mt-1 text-[11px] text-gray-500'>{label}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popContent} position='top' trigger='hover'>
|
||||
<span className='block max-w-full truncate text-gray-600 hover:text-blue-600 cursor-pointer'>
|
||||
{accountId}
|
||||
</span>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Card className='!rounded-2xl'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='teal' className='mr-3 shadow-md'>
|
||||
<UserPlus size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('账户管理')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('账户绑定、安全设置和身份验证')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs type='card' defaultActiveKey='binding'>
|
||||
{/* 账户绑定 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<UserPlus size={16} className='mr-2' />
|
||||
{t('账户绑定')}
|
||||
</div>
|
||||
}
|
||||
itemKey='binding'
|
||||
>
|
||||
<div className='py-4'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
|
||||
{/* 邮箱绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<IconMail
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
{t('邮箱')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{renderAccountInfo(
|
||||
userState.user?.email,
|
||||
t('邮箱地址'),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() => setShowEmailBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.email !== ''
|
||||
? t('修改绑定')
|
||||
: t('绑定')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 微信绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<SiWechat
|
||||
size={20}
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
{t('微信')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
? t('已绑定')
|
||||
: t('未绑定')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
disabled={!status.wechat_login}
|
||||
onClick={() => setShowWeChatBindModal(true)}
|
||||
>
|
||||
{userState.user && userState.user.wechat_id !== ''
|
||||
? t('修改绑定')
|
||||
: status.wechat_login
|
||||
? t('绑定')
|
||||
: t('未启用')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* GitHub绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<IconGithubLogo
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
{t('GitHub')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{renderAccountInfo(
|
||||
userState.user?.github_id,
|
||||
t('GitHub ID'),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
onGitHubOAuthClicked(status.github_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.github_id !== '') ||
|
||||
!status.github_oauth
|
||||
}
|
||||
>
|
||||
{status.github_oauth ? t('绑定') : t('未启用')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* OIDC绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<IconShield
|
||||
size='default'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
{t('OIDC')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{renderAccountInfo(
|
||||
userState.user?.oidc_id,
|
||||
t('OIDC ID'),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
onOIDCClicked(
|
||||
status.oidc_authorization_endpoint,
|
||||
status.oidc_client_id,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.oidc_id !== '') ||
|
||||
!status.oidc_enabled
|
||||
}
|
||||
>
|
||||
{status.oidc_enabled ? t('绑定') : t('未启用')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Telegram绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<SiTelegram
|
||||
size={20}
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
{t('Telegram')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{renderAccountInfo(
|
||||
userState.user?.telegram_id,
|
||||
t('Telegram ID'),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{status.telegram_oauth ? (
|
||||
userState.user.telegram_id !== '' ? (
|
||||
<Button disabled={true} size='small'>
|
||||
{t('已绑定')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className='scale-75'>
|
||||
<TelegramLoginButton
|
||||
dataAuthUrl='/api/oauth/telegram/bind'
|
||||
botName={status.telegram_bot_name}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Button disabled={true} size='small'>
|
||||
{t('未启用')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* LinuxDO绑定 */}
|
||||
<Card className='!rounded-xl'>
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div className='flex items-center flex-1 min-w-0'>
|
||||
<div className='w-10 h-10 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-3 flex-shrink-0'>
|
||||
<SiLinux
|
||||
size={20}
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1 min-w-0'>
|
||||
<div className='font-medium text-gray-900'>
|
||||
{t('LinuxDO')}
|
||||
</div>
|
||||
<div className='text-sm text-gray-500 truncate'>
|
||||
{renderAccountInfo(
|
||||
userState.user?.linux_do_id,
|
||||
t('LinuxDO ID'),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='small'
|
||||
onClick={() =>
|
||||
onLinuxDOOAuthClicked(status.linuxdo_client_id)
|
||||
}
|
||||
disabled={
|
||||
(userState.user && userState.user.linux_do_id !== '') ||
|
||||
!status.linuxdo_oauth
|
||||
}
|
||||
>
|
||||
{status.linuxdo_oauth ? t('绑定') : t('未启用')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
{/* 安全设置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<ShieldCheck size={16} className='mr-2' />
|
||||
{t('安全设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey='security'
|
||||
>
|
||||
<div className='py-4'>
|
||||
<div className='space-y-6'>
|
||||
<Space vertical className='w-full'>
|
||||
{/* 系统访问令牌 */}
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconKey size='large' className='text-slate-600' />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('系统访问令牌')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('用于API调用的身份验证令牌,请妥善保管')}
|
||||
</Typography.Text>
|
||||
{systemToken && (
|
||||
<div className='mt-3'>
|
||||
<Input
|
||||
readonly
|
||||
value={systemToken}
|
||||
onClick={handleSystemTokenClick}
|
||||
size='large'
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={generateAccessToken}
|
||||
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
||||
icon={<IconKey />}
|
||||
>
|
||||
{systemToken ? t('重新生成') : t('生成令牌')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 密码管理 */}
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconLock size='large' className='text-slate-600' />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title heading={6} className='mb-1'>
|
||||
{t('密码管理')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('定期更改密码可以提高账户安全性')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => setShowChangePasswordModal(true)}
|
||||
className='!bg-slate-600 hover:!bg-slate-700 w-full sm:w-auto'
|
||||
icon={<IconLock />}
|
||||
>
|
||||
{t('修改密码')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 两步验证设置 */}
|
||||
<TwoFASetting t={t} />
|
||||
|
||||
{/* 危险区域 */}
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-slate-100 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconDelete size='large' className='text-slate-600' />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title
|
||||
heading={6}
|
||||
className='mb-1 text-slate-700'
|
||||
>
|
||||
{t('删除账户')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t('此操作不可逆,所有数据将被永久删除')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='solid'
|
||||
onClick={() => setShowAccountDeleteModal(true)}
|
||||
className='w-full sm:w-auto !bg-slate-500 hover:!bg-slate-600'
|
||||
icon={<IconDelete />}
|
||||
>
|
||||
{t('删除账户')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountManagement;
|
||||
280
web/src/components/settings/personal/cards/ModelsList.jsx
Normal file
280
web/src/components/settings/personal/cards/ModelsList.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Empty,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Collapsible,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Typography,
|
||||
Avatar,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoContent,
|
||||
IllustrationNoContentDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { renderModelTag, getModelCategories } from '../../../../helpers';
|
||||
|
||||
const ModelsList = ({ t, models, modelsLoading, copyText }) => {
|
||||
const [isModelsExpanded, setIsModelsExpanded] = useState(() => {
|
||||
// Initialize from localStorage if available
|
||||
const savedState = localStorage.getItem('modelsExpanded');
|
||||
return savedState ? JSON.parse(savedState) : false;
|
||||
});
|
||||
const [activeModelCategory, setActiveModelCategory] = useState('all');
|
||||
const MODELS_DISPLAY_COUNT = 25; // 默认显示的模型数量
|
||||
|
||||
// Save models expanded state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('modelsExpanded', JSON.stringify(isModelsExpanded));
|
||||
}, [isModelsExpanded]);
|
||||
|
||||
return (
|
||||
<div className='py-4'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='green' className='mr-3 shadow-md'>
|
||||
<Settings size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('可用模型')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('查看当前可用的所有模型')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 可用模型部分 */}
|
||||
<div className='bg-gray-50 dark:bg-gray-800 rounded-xl'>
|
||||
{modelsLoading ? (
|
||||
// 骨架屏加载状态 - 模拟实际加载后的布局
|
||||
<div className='space-y-4'>
|
||||
{/* 模拟分类标签 */}
|
||||
<div
|
||||
className='mb-4'
|
||||
style={{ borderBottom: '1px solid var(--semi-color-border)' }}
|
||||
>
|
||||
<div className='flex overflow-x-auto py-2 gap-2'>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<Skeleton.Button
|
||||
key={`cat-${index}`}
|
||||
style={{
|
||||
width: index === 0 ? 130 : 100 + Math.random() * 50,
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模拟模型标签列表 */}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{Array.from({ length: 20 }).map((_, index) => (
|
||||
<Skeleton.Button
|
||||
key={`model-${index}`}
|
||||
style={{
|
||||
width: 100 + Math.random() * 100,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
margin: '4px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className='py-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoContent style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark
|
||||
style={{ width: 150, height: 150 }}
|
||||
/>
|
||||
}
|
||||
description={t('没有可用模型')}
|
||||
style={{ padding: '24px 0' }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 模型分类标签页 */}
|
||||
<div className='mb-4'>
|
||||
<Tabs
|
||||
type='card'
|
||||
activeKey={activeModelCategory}
|
||||
onChange={(key) => setActiveModelCategory(key)}
|
||||
className='mt-2'
|
||||
collapsible
|
||||
>
|
||||
{Object.entries(getModelCategories(t)).map(
|
||||
([key, category]) => {
|
||||
// 计算该分类下的模型数量
|
||||
const modelCount =
|
||||
key === 'all'
|
||||
? models.length
|
||||
: models.filter((model) =>
|
||||
category.filter({ model_name: model }),
|
||||
).length;
|
||||
|
||||
if (modelCount === 0 && key !== 'all') return null;
|
||||
|
||||
return (
|
||||
<TabPane
|
||||
tab={
|
||||
<span className='flex items-center gap-2'>
|
||||
{category.icon && (
|
||||
<span className='w-4 h-4'>{category.icon}</span>
|
||||
)}
|
||||
{category.label}
|
||||
<Tag
|
||||
color={
|
||||
activeModelCategory === key ? 'red' : 'grey'
|
||||
}
|
||||
size='small'
|
||||
shape='circle'
|
||||
>
|
||||
{modelCount}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
itemKey={key}
|
||||
key={key}
|
||||
/>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className='bg-white dark:bg-gray-700 rounded-lg p-3'>
|
||||
{(() => {
|
||||
// 根据当前选中的分类过滤模型
|
||||
const categories = getModelCategories(t);
|
||||
const filteredModels =
|
||||
activeModelCategory === 'all'
|
||||
? models
|
||||
: models.filter((model) =>
|
||||
categories[activeModelCategory].filter({
|
||||
model_name: model,
|
||||
}),
|
||||
);
|
||||
|
||||
// 如果过滤后没有模型,显示空状态
|
||||
if (filteredModels.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoContent
|
||||
style={{ width: 120, height: 120 }}
|
||||
/>
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoContentDark
|
||||
style={{ width: 120, height: 120 }}
|
||||
/>
|
||||
}
|
||||
description={t('该分类下没有可用模型')}
|
||||
style={{ padding: '16px 0' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredModels.length <= MODELS_DISPLAY_COUNT) {
|
||||
return (
|
||||
<Space wrap>
|
||||
{filteredModels.map((model) =>
|
||||
renderModelTag(model, {
|
||||
size: 'small',
|
||||
shape: 'circle',
|
||||
onClick: () => copyText(model),
|
||||
}),
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<Collapsible isOpen={isModelsExpanded}>
|
||||
<Space wrap>
|
||||
{filteredModels.map((model) =>
|
||||
renderModelTag(model, {
|
||||
size: 'small',
|
||||
shape: 'circle',
|
||||
onClick: () => copyText(model),
|
||||
}),
|
||||
)}
|
||||
<Tag
|
||||
color='grey'
|
||||
type='light'
|
||||
className='cursor-pointer !rounded-lg'
|
||||
onClick={() => setIsModelsExpanded(false)}
|
||||
icon={<IconChevronUp />}
|
||||
>
|
||||
{t('收起')}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Collapsible>
|
||||
{!isModelsExpanded && (
|
||||
<Space wrap>
|
||||
{filteredModels
|
||||
.slice(0, MODELS_DISPLAY_COUNT)
|
||||
.map((model) =>
|
||||
renderModelTag(model, {
|
||||
size: 'small',
|
||||
shape: 'circle',
|
||||
onClick: () => copyText(model),
|
||||
}),
|
||||
)}
|
||||
<Tag
|
||||
color='grey'
|
||||
type='light'
|
||||
className='cursor-pointer !rounded-lg'
|
||||
onClick={() => setIsModelsExpanded(true)}
|
||||
icon={<IconChevronDown />}
|
||||
>
|
||||
{t('更多')}{' '}
|
||||
{filteredModels.length - MODELS_DISPLAY_COUNT}{' '}
|
||||
{t('个模型')}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsList;
|
||||
@@ -0,0 +1,792 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect, useState, useContext } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Card,
|
||||
Avatar,
|
||||
Form,
|
||||
Radio,
|
||||
Toast,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Switch,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons';
|
||||
import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react';
|
||||
import {
|
||||
renderQuotaWithPrompt,
|
||||
API,
|
||||
showSuccess,
|
||||
showError,
|
||||
} from '../../../../helpers';
|
||||
import CodeViewer from '../../../playground/CodeViewer';
|
||||
import { StatusContext } from '../../../../context/Status';
|
||||
import { UserContext } from '../../../../context/User';
|
||||
import { useUserPermissions } from '../../../../hooks/common/useUserPermissions';
|
||||
|
||||
const NotificationSettings = ({
|
||||
t,
|
||||
notificationSettings,
|
||||
handleNotificationSettingChange,
|
||||
saveNotificationSettings,
|
||||
}) => {
|
||||
const formApiRef = useRef(null);
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const [userState] = useContext(UserContext);
|
||||
|
||||
// 左侧边栏设置相关状态
|
||||
const [sidebarLoading, setSidebarLoading] = useState(false);
|
||||
const [activeTabKey, setActiveTabKey] = useState('notification');
|
||||
const [sidebarModulesUser, setSidebarModulesUser] = useState({
|
||||
chat: {
|
||||
enabled: true,
|
||||
playground: true,
|
||||
chat: true,
|
||||
},
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: {
|
||||
enabled: true,
|
||||
topup: true,
|
||||
personal: true,
|
||||
},
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
},
|
||||
});
|
||||
const [adminConfig, setAdminConfig] = useState(null);
|
||||
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
const {
|
||||
permissions,
|
||||
loading: permissionsLoading,
|
||||
hasSidebarSettingsPermission,
|
||||
isSidebarSectionAllowed,
|
||||
isSidebarModuleAllowed,
|
||||
} = useUserPermissions();
|
||||
|
||||
// 左侧边栏设置处理函数
|
||||
const handleSectionChange = (sectionKey) => {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
enabled: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
};
|
||||
};
|
||||
|
||||
const handleModuleChange = (sectionKey, moduleKey) => {
|
||||
return (checked) => {
|
||||
const newModules = {
|
||||
...sidebarModulesUser,
|
||||
[sectionKey]: {
|
||||
...sidebarModulesUser[sectionKey],
|
||||
[moduleKey]: checked,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(newModules);
|
||||
};
|
||||
};
|
||||
|
||||
const saveSidebarSettings = async () => {
|
||||
setSidebarLoading(true);
|
||||
try {
|
||||
const res = await API.put('/api/user/self', {
|
||||
sidebar_modules: JSON.stringify(sidebarModulesUser),
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('侧边栏设置保存成功'));
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('保存失败'));
|
||||
}
|
||||
setSidebarLoading(false);
|
||||
};
|
||||
|
||||
const resetSidebarModules = () => {
|
||||
const defaultConfig = {
|
||||
chat: { enabled: true, playground: true, chat: true },
|
||||
console: {
|
||||
enabled: true,
|
||||
detail: true,
|
||||
token: true,
|
||||
log: true,
|
||||
midjourney: true,
|
||||
task: true,
|
||||
},
|
||||
personal: { enabled: true, topup: true, personal: true },
|
||||
admin: {
|
||||
enabled: true,
|
||||
channel: true,
|
||||
models: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
setSidebarModulesUser(defaultConfig);
|
||||
};
|
||||
|
||||
// 加载左侧边栏配置
|
||||
useEffect(() => {
|
||||
const loadSidebarConfigs = async () => {
|
||||
try {
|
||||
// 获取管理员全局配置
|
||||
if (statusState?.status?.SidebarModulesAdmin) {
|
||||
const adminConf = JSON.parse(statusState.status.SidebarModulesAdmin);
|
||||
setAdminConfig(adminConf);
|
||||
}
|
||||
|
||||
// 获取用户个人配置
|
||||
const userRes = await API.get('/api/user/self');
|
||||
if (userRes.data.success && userRes.data.data.sidebar_modules) {
|
||||
const userConf = JSON.parse(userRes.data.data.sidebar_modules);
|
||||
setSidebarModulesUser(userConf);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载边栏配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadSidebarConfigs();
|
||||
}, [statusState]);
|
||||
|
||||
// 初始化表单值
|
||||
useEffect(() => {
|
||||
if (formApiRef.current && notificationSettings) {
|
||||
formApiRef.current.setValues(notificationSettings);
|
||||
}
|
||||
}, [notificationSettings]);
|
||||
|
||||
// 处理表单字段变化
|
||||
const handleFormChange = (field, value) => {
|
||||
handleNotificationSettingChange(field, value);
|
||||
};
|
||||
|
||||
// 检查功能是否被管理员允许
|
||||
const isAllowedByAdmin = (sectionKey, moduleKey = null) => {
|
||||
if (!adminConfig) return true;
|
||||
|
||||
if (moduleKey) {
|
||||
return (
|
||||
adminConfig[sectionKey]?.enabled && adminConfig[sectionKey]?.[moduleKey]
|
||||
);
|
||||
} else {
|
||||
return adminConfig[sectionKey]?.enabled;
|
||||
}
|
||||
};
|
||||
|
||||
// 区域配置数据(根据权限过滤)
|
||||
const sectionConfigs = [
|
||||
{
|
||||
key: 'chat',
|
||||
title: t('聊天区域'),
|
||||
description: t('操练场和聊天功能'),
|
||||
modules: [
|
||||
{
|
||||
key: 'playground',
|
||||
title: t('操练场'),
|
||||
description: t('AI模型测试环境'),
|
||||
},
|
||||
{ key: 'chat', title: t('聊天'), description: t('聊天会话管理') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'console',
|
||||
title: t('控制台区域'),
|
||||
description: t('数据管理和日志查看'),
|
||||
modules: [
|
||||
{ key: 'detail', title: t('数据看板'), description: t('系统数据统计') },
|
||||
{ key: 'token', title: t('令牌管理'), description: t('API令牌管理') },
|
||||
{ key: 'log', title: t('使用日志'), description: t('API使用记录') },
|
||||
{
|
||||
key: 'midjourney',
|
||||
title: t('绘图日志'),
|
||||
description: t('绘图任务记录'),
|
||||
},
|
||||
{ key: 'task', title: t('任务日志'), description: t('系统任务记录') },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人中心区域'),
|
||||
description: t('用户个人功能'),
|
||||
modules: [
|
||||
{ key: 'topup', title: t('钱包管理'), description: t('余额充值管理') },
|
||||
{
|
||||
key: 'personal',
|
||||
title: t('个人设置'),
|
||||
description: t('个人信息设置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 管理员区域:根据后端权限控制显示
|
||||
{
|
||||
key: 'admin',
|
||||
title: t('管理员区域'),
|
||||
description: t('系统管理功能'),
|
||||
modules: [
|
||||
{ key: 'channel', title: t('渠道管理'), description: t('API渠道配置') },
|
||||
{ key: 'models', title: t('模型管理'), description: t('AI模型配置') },
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
description: t('兑换码生成管理'),
|
||||
},
|
||||
{ key: 'user', title: t('用户管理'), description: t('用户账户管理') },
|
||||
{
|
||||
key: 'setting',
|
||||
title: t('系统设置'),
|
||||
description: t('系统参数配置'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.filter((section) => {
|
||||
// 使用后端权限验证替代前端角色判断
|
||||
return isSidebarSectionAllowed(section.key);
|
||||
})
|
||||
.map((section) => ({
|
||||
...section,
|
||||
modules: section.modules.filter((module) =>
|
||||
isSidebarModuleAllowed(section.key, module.key),
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(section) =>
|
||||
// 过滤掉没有可用模块的区域
|
||||
section.modules.length > 0 && isAllowedByAdmin(section.key),
|
||||
);
|
||||
|
||||
// 表单提交
|
||||
const handleSubmit = () => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current
|
||||
.validate()
|
||||
.then(() => {
|
||||
saveNotificationSettings();
|
||||
})
|
||||
.catch((errors) => {
|
||||
console.log('表单验证失败:', errors);
|
||||
Toast.error(t('请检查表单填写是否正确'));
|
||||
});
|
||||
} else {
|
||||
saveNotificationSettings();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className='!rounded-2xl shadow-sm border-0'
|
||||
footer={
|
||||
<div className='flex justify-end gap-3'>
|
||||
{activeTabKey === 'sidebar' ? (
|
||||
// 边栏设置标签页的按钮
|
||||
<>
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={resetSidebarModules}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('重置为默认')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={saveSidebarSettings}
|
||||
loading={sidebarLoading}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('保存边栏设置')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// 其他标签页的通用保存按钮
|
||||
<Button type='primary' onClick={handleSubmit}>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center mb-4'>
|
||||
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
|
||||
<Bell size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Typography.Text className='text-lg font-medium'>
|
||||
{t('其他设置')}
|
||||
</Typography.Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('通知、价格和隐私相关设置')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
initValues={notificationSettings}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{() => (
|
||||
<Tabs
|
||||
type='card'
|
||||
defaultActiveKey='notification'
|
||||
onChange={(key) => setActiveTabKey(key)}
|
||||
>
|
||||
{/* 通知配置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<Bell size={16} className='mr-2' />
|
||||
{t('通知配置')}
|
||||
</div>
|
||||
}
|
||||
itemKey='notification'
|
||||
>
|
||||
<div className='py-4'>
|
||||
<Form.RadioGroup
|
||||
field='warningType'
|
||||
label={t('通知方式')}
|
||||
initValue={notificationSettings.warningType}
|
||||
onChange={(value) => handleFormChange('warningType', value)}
|
||||
rules={[{ required: true, message: t('请选择通知方式') }]}
|
||||
>
|
||||
<Radio value='email'>{t('邮件通知')}</Radio>
|
||||
<Radio value='webhook'>{t('Webhook通知')}</Radio>
|
||||
<Radio value='bark'>{t('Bark通知')}</Radio>
|
||||
</Form.RadioGroup>
|
||||
|
||||
<Form.AutoComplete
|
||||
field='warningThreshold'
|
||||
label={
|
||||
<span>
|
||||
{t('额度预警阈值')}{' '}
|
||||
{renderQuotaWithPrompt(
|
||||
notificationSettings.warningThreshold,
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
placeholder={t('请输入预警额度')}
|
||||
data={[
|
||||
{ value: 100000, label: '0.2$' },
|
||||
{ value: 500000, label: '1$' },
|
||||
{ value: 1000000, label: '5$' },
|
||||
{ value: 5000000, label: '10$' },
|
||||
]}
|
||||
onChange={(val) => handleFormChange('warningThreshold', val)}
|
||||
prefix={<IconBell />}
|
||||
extraText={t(
|
||||
'当剩余额度低于此数值时,系统将通过选择的方式发送通知',
|
||||
)}
|
||||
style={{ width: '100%', maxWidth: '300px' }}
|
||||
rules={[
|
||||
{ required: true, message: t('请输入预警阈值') },
|
||||
{
|
||||
validator: (rule, value) => {
|
||||
const numValue = Number(value);
|
||||
if (isNaN(numValue) || numValue <= 0) {
|
||||
return Promise.reject(t('预警阈值必须为正数'));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 邮件通知设置 */}
|
||||
{notificationSettings.warningType === 'email' && (
|
||||
<Form.Input
|
||||
field='notificationEmail'
|
||||
label={t('通知邮箱')}
|
||||
placeholder={t('留空则使用账号绑定的邮箱')}
|
||||
onChange={(val) =>
|
||||
handleFormChange('notificationEmail', val)
|
||||
}
|
||||
prefix={<IconMail />}
|
||||
extraText={t(
|
||||
'设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱',
|
||||
)}
|
||||
showClear
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Webhook通知设置 */}
|
||||
{notificationSettings.warningType === 'webhook' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='webhookUrl'
|
||||
label={t('Webhook地址')}
|
||||
placeholder={t(
|
||||
'请输入Webhook地址,例如: https://example.com/webhook',
|
||||
)}
|
||||
onChange={(val) => handleFormChange('webhookUrl', val)}
|
||||
prefix={<IconLink />}
|
||||
extraText={t(
|
||||
'只支持HTTPS,系统将以POST方式发送通知,请确保地址可以接收POST请求',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required:
|
||||
notificationSettings.warningType === 'webhook',
|
||||
message: t('请输入Webhook地址'),
|
||||
},
|
||||
{
|
||||
pattern: /^https:\/\/.+/,
|
||||
message: t('Webhook地址必须以https://开头'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Form.Input
|
||||
field='webhookSecret'
|
||||
label={t('接口凭证')}
|
||||
placeholder={t('请输入密钥')}
|
||||
onChange={(val) => handleFormChange('webhookSecret', val)}
|
||||
prefix={<IconKey />}
|
||||
extraText={t(
|
||||
'密钥将以Bearer方式添加到请求头中,用于验证webhook请求的合法性',
|
||||
)}
|
||||
showClear
|
||||
/>
|
||||
|
||||
<Form.Slot label={t('Webhook请求结构说明')}>
|
||||
<div>
|
||||
<div style={{ height: '200px', marginBottom: '12px' }}>
|
||||
<CodeViewer
|
||||
content={{
|
||||
type: 'quota_exceed',
|
||||
title: '额度预警通知',
|
||||
content:
|
||||
'您的额度即将用尽,当前剩余额度为 {{value}}',
|
||||
values: ['$0.99'],
|
||||
timestamp: 1739950503,
|
||||
}}
|
||||
title='webhook'
|
||||
language='json'
|
||||
/>
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 leading-relaxed'>
|
||||
<div>
|
||||
<strong>type:</strong>{' '}
|
||||
{t('通知类型 (quota_exceed: 额度预警)')}{' '}
|
||||
</div>
|
||||
<div>
|
||||
<strong>title:</strong> {t('通知标题')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>content:</strong>{' '}
|
||||
{t('通知内容,支持 {{value}} 变量占位符')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>values:</strong>{' '}
|
||||
{t('按顺序替换content中的变量占位符')}
|
||||
</div>
|
||||
<div>
|
||||
<strong>timestamp:</strong> {t('Unix时间戳')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Slot>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bark推送设置 */}
|
||||
{notificationSettings.warningType === 'bark' && (
|
||||
<>
|
||||
<Form.Input
|
||||
field='barkUrl'
|
||||
label={t('Bark推送URL')}
|
||||
placeholder={t(
|
||||
'请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}',
|
||||
)}
|
||||
onChange={(val) => handleFormChange('barkUrl', val)}
|
||||
prefix={<IconLink />}
|
||||
extraText={t(
|
||||
'支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)',
|
||||
)}
|
||||
showClear
|
||||
rules={[
|
||||
{
|
||||
required: notificationSettings.warningType === 'bark',
|
||||
message: t('请输入Bark推送URL'),
|
||||
},
|
||||
{
|
||||
pattern: /^https?:\/\/.+/,
|
||||
message: t('Bark推送URL必须以http://或https://开头'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
|
||||
<div className='text-sm text-gray-700 mb-3'>
|
||||
<strong>{t('模板示例')}</strong>
|
||||
</div>
|
||||
<div className='text-xs text-gray-600 font-mono bg-white p-3 rounded-lg shadow-sm mb-4'>
|
||||
https://api.day.app/yourkey/{'{{title}}'}/
|
||||
{'{{content}}'}?sound=alarm&group=quota
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 space-y-2'>
|
||||
<div>
|
||||
• <strong>{'title'}:</strong> {t('通知标题')}
|
||||
</div>
|
||||
<div>
|
||||
• <strong>{'content'}:</strong> {t('通知内容')}
|
||||
</div>
|
||||
<div className='mt-3 pt-3 border-t border-gray-200'>
|
||||
<span className='text-gray-400'>
|
||||
{t('更多参数请参考')}
|
||||
</span>{' '}
|
||||
<a
|
||||
href='https://github.com/Finb/Bark'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='text-blue-500 hover:text-blue-600 font-medium'
|
||||
>
|
||||
Bark 官方文档
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
{/* 价格设置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<DollarSign size={16} className='mr-2' />
|
||||
{t('价格设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey='pricing'
|
||||
>
|
||||
<div className='py-4'>
|
||||
<Form.Switch
|
||||
field='acceptUnsetModelRatioModel'
|
||||
label={t('接受未设置价格模型')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) =>
|
||||
handleFormChange('acceptUnsetModelRatioModel', value)
|
||||
}
|
||||
extraText={t(
|
||||
'当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
{/* 隐私设置 Tab */}
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<ShieldCheck size={16} className='mr-2' />
|
||||
{t('隐私设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey='privacy'
|
||||
>
|
||||
<div className='py-4'>
|
||||
<Form.Switch
|
||||
field='recordIpLog'
|
||||
label={t('记录请求与错误日志IP')}
|
||||
checkedText={t('开')}
|
||||
uncheckedText={t('关')}
|
||||
onChange={(value) => handleFormChange('recordIpLog', value)}
|
||||
extraText={t(
|
||||
'开启后,仅"消费"和"错误"日志将记录您的客户端IP地址',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
|
||||
{/* 左侧边栏设置 Tab - 根据后端权限控制显示 */}
|
||||
{hasSidebarSettingsPermission() && (
|
||||
<TabPane
|
||||
tab={
|
||||
<div className='flex items-center'>
|
||||
<Settings size={16} className='mr-2' />
|
||||
{t('边栏设置')}
|
||||
</div>
|
||||
}
|
||||
itemKey='sidebar'
|
||||
>
|
||||
<div className='py-4'>
|
||||
<div className='mb-4'>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
{t('您可以个性化设置侧边栏的要显示功能')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{/* 边栏设置功能区域容器 */}
|
||||
<div
|
||||
className='border rounded-xl p-4'
|
||||
style={{
|
||||
borderColor: 'var(--semi-color-border)',
|
||||
backgroundColor: 'var(--semi-color-bg-1)',
|
||||
}}
|
||||
>
|
||||
{sectionConfigs.map((section) => (
|
||||
<div key={section.key} className='mb-6'>
|
||||
{/* 区域标题和总开关 */}
|
||||
<div
|
||||
className='flex justify-between items-center mb-4 p-4 rounded-lg'
|
||||
style={{
|
||||
backgroundColor: 'var(--semi-color-fill-0)',
|
||||
border: '1px solid var(--semi-color-border-light)',
|
||||
borderColor: 'var(--semi-color-fill-1)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className='font-semibold text-base text-gray-900 mb-1'>
|
||||
{section.title}
|
||||
</div>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
}}
|
||||
>
|
||||
{section.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={sidebarModulesUser[section.key]?.enabled}
|
||||
onChange={handleSectionChange(section.key)}
|
||||
size='default'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 功能模块网格 */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{section.modules
|
||||
.filter((module) =>
|
||||
isAllowedByAdmin(section.key, module.key),
|
||||
)
|
||||
.map((module) => (
|
||||
<Col
|
||||
key={module.key}
|
||||
xs={24}
|
||||
sm={24}
|
||||
md={12}
|
||||
lg={8}
|
||||
xl={8}
|
||||
>
|
||||
<Card
|
||||
className={`!rounded-xl border border-gray-200 hover:border-blue-300 transition-all duration-200 ${
|
||||
sidebarModulesUser[section.key]?.enabled
|
||||
? ''
|
||||
: 'opacity-50'
|
||||
}`}
|
||||
bodyStyle={{ padding: '16px' }}
|
||||
hoverable
|
||||
>
|
||||
<div className='flex justify-between items-center h-full'>
|
||||
<div className='flex-1 text-left'>
|
||||
<div className='font-semibold text-sm text-gray-900 mb-1'>
|
||||
{module.title}
|
||||
</div>
|
||||
<Typography.Text
|
||||
type='secondary'
|
||||
size='small'
|
||||
className='block'
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--semi-color-text-2)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='ml-4'>
|
||||
<Switch
|
||||
checked={
|
||||
sidebarModulesUser[section.key]?.[
|
||||
module.key
|
||||
]
|
||||
}
|
||||
onChange={handleModuleChange(
|
||||
section.key,
|
||||
module.key,
|
||||
)}
|
||||
size='default'
|
||||
disabled={
|
||||
!sidebarModulesUser[section.key]
|
||||
?.enabled
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
</div>{' '}
|
||||
{/* 关闭边栏设置功能区域容器 */}
|
||||
</div>
|
||||
</TabPane>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationSettings;
|
||||
723
web/src/components/settings/personal/components/TwoFASetting.jsx
Normal file
723
web/src/components/settings/personal/components/TwoFASetting.jsx
Normal file
@@ -0,0 +1,723 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
import { API, showError, showSuccess, showWarning } from '../../../../helpers';
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Input,
|
||||
Modal,
|
||||
Tag,
|
||||
Typography,
|
||||
Steps,
|
||||
Space,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconShield,
|
||||
IconAlertTriangle,
|
||||
IconRefresh,
|
||||
IconCopy,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const TwoFASetting = ({ t }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState({
|
||||
enabled: false,
|
||||
locked: false,
|
||||
backup_codes_remaining: 0,
|
||||
});
|
||||
|
||||
// 模态框状态
|
||||
const [setupModalVisible, setSetupModalVisible] = useState(false);
|
||||
const [enableModalVisible, setEnableModalVisible] = useState(false);
|
||||
const [disableModalVisible, setDisableModalVisible] = useState(false);
|
||||
const [backupModalVisible, setBackupModalVisible] = useState(false);
|
||||
|
||||
// 表单数据
|
||||
const [setupData, setSetupData] = useState(null);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [backupCodes, setBackupCodes] = useState([]);
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
// 获取2FA状态
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/2fa/status');
|
||||
if (res.data.success) {
|
||||
setStatus(res.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('获取2FA状态失败'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
// 初始化2FA设置
|
||||
const handleSetup2FA = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/2fa/setup');
|
||||
if (res.data.success) {
|
||||
setSetupData(res.data.data);
|
||||
setSetupModalVisible(true);
|
||||
setCurrentStep(0);
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('设置2FA失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 启用2FA
|
||||
const handleEnable2FA = async () => {
|
||||
if (!verificationCode) {
|
||||
showWarning(t('请输入验证码'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/2fa/enable', {
|
||||
code: verificationCode,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('两步验证启用成功!'));
|
||||
setEnableModalVisible(false);
|
||||
setSetupModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setCurrentStep(0);
|
||||
fetchStatus();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('启用2FA失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 禁用2FA
|
||||
const handleDisable2FA = async () => {
|
||||
if (!verificationCode) {
|
||||
showWarning(t('请输入验证码或备用码'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirmDisable) {
|
||||
showWarning(t('请确认您已了解禁用两步验证的后果'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/2fa/disable', {
|
||||
code: verificationCode,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('两步验证已禁用'));
|
||||
setDisableModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setConfirmDisable(false);
|
||||
fetchStatus();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('禁用2FA失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重新生成备用码
|
||||
const handleRegenerateBackupCodes = async () => {
|
||||
if (!verificationCode) {
|
||||
showWarning(t('请输入验证码'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/user/2fa/backup_codes', {
|
||||
code: verificationCode,
|
||||
});
|
||||
if (res.data.success) {
|
||||
setBackupCodes(res.data.data.backup_codes);
|
||||
showSuccess(t('备用码重新生成成功'));
|
||||
setVerificationCode('');
|
||||
fetchStatus();
|
||||
} else {
|
||||
showError(res.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('重新生成备用码失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 通用复制函数
|
||||
const copyTextToClipboard = (text, successMessage = t('已复制到剪贴板')) => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
showSuccess(successMessage);
|
||||
})
|
||||
.catch(() => {
|
||||
showError(t('复制失败,请手动复制'));
|
||||
});
|
||||
};
|
||||
|
||||
const copyBackupCodes = () => {
|
||||
const codesText = backupCodes.join('\n');
|
||||
copyTextToClipboard(codesText, t('备用码已复制到剪贴板'));
|
||||
};
|
||||
|
||||
// 备用码展示组件
|
||||
const BackupCodesDisplay = ({ codes, title, onCopy }) => {
|
||||
return (
|
||||
<Card className='!rounded-xl' style={{ width: '100%' }}>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{title}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 gap-2'>
|
||||
{codes.map((code, index) => (
|
||||
<div key={index} className='rounded-lg p-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Text
|
||||
code
|
||||
className='text-sm font-mono text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{code}
|
||||
</Text>
|
||||
<Text type='quaternary' className='text-xs'>
|
||||
#{(index + 1).toString().padStart(2, '0')}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Divider margin={12} />
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
icon={<IconCopy />}
|
||||
onClick={onCopy}
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700 w-full'
|
||||
>
|
||||
{t('复制所有代码')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设置模态框footer
|
||||
const renderSetupModalFooter = () => {
|
||||
return (
|
||||
<>
|
||||
{currentStep > 0 && (
|
||||
<Button
|
||||
onClick={() => setCurrentStep(currentStep - 1)}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('上一步')}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < 2 ? (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => setCurrentStep(currentStep + 1)}
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
if (!verificationCode) {
|
||||
showWarning(t('请输入验证码'));
|
||||
return;
|
||||
}
|
||||
handleEnable2FA();
|
||||
}}
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('完成设置并启用两步验证')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染禁用模态框footer
|
||||
const renderDisableModalFooter = () => {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDisableModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setConfirmDisable(false);
|
||||
}}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
disabled={!confirmDisable || !verificationCode}
|
||||
onClick={handleDisable2FA}
|
||||
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
|
||||
>
|
||||
{t('确认禁用')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染重新生成模态框footer
|
||||
const renderRegenerateModalFooter = () => {
|
||||
if (backupCodes.length > 0) {
|
||||
return (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={() => {
|
||||
setBackupModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setBackupCodes([]);
|
||||
}}
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('完成')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setBackupModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setBackupCodes([]);
|
||||
}}
|
||||
className='!rounded-lg'
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
disabled={!verificationCode}
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
>
|
||||
{t('生成新的备用码')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className='!rounded-xl w-full'>
|
||||
<div className='flex flex-col sm:flex-row items-start sm:justify-between gap-4'>
|
||||
<div className='flex items-start w-full sm:w-auto'>
|
||||
<div className='w-12 h-12 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mr-4 flex-shrink-0'>
|
||||
<IconShield
|
||||
size='large'
|
||||
className='text-slate-600 dark:text-slate-300'
|
||||
/>
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<div className='flex items-center gap-2 mb-1'>
|
||||
<Typography.Title heading={6} className='mb-0'>
|
||||
{t('两步验证设置')}
|
||||
</Typography.Title>
|
||||
{status.enabled ? (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('已启用')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='red' shape='circle' size='small'>
|
||||
{t('未启用')}
|
||||
</Tag>
|
||||
)}
|
||||
{status.locked && (
|
||||
<Tag color='orange' shape='circle' size='small'>
|
||||
{t('账户已锁定')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Typography.Text type='tertiary' className='text-sm'>
|
||||
{t(
|
||||
'两步验证(2FA)为您的账户提供额外的安全保护。启用后,登录时需要输入密码和验证器应用生成的验证码。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
{status.enabled && (
|
||||
<div className='mt-2'>
|
||||
<Text size='small' type='secondary'>
|
||||
{t('剩余备用码:')}
|
||||
{status.backup_codes_remaining || 0}
|
||||
{t('个')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col space-y-2 w-full sm:w-auto'>
|
||||
{!status.enabled ? (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='default'
|
||||
onClick={handleSetup2FA}
|
||||
loading={loading}
|
||||
className='!rounded-lg !bg-slate-600 hover:!bg-slate-700'
|
||||
icon={<IconShield />}
|
||||
>
|
||||
{t('启用验证')}
|
||||
</Button>
|
||||
) : (
|
||||
<div className='flex flex-col space-y-2'>
|
||||
<Button
|
||||
type='danger'
|
||||
theme='solid'
|
||||
size='default'
|
||||
onClick={() => setDisableModalVisible(true)}
|
||||
className='!rounded-lg !bg-slate-500 hover:!bg-slate-600'
|
||||
icon={<IconAlertTriangle />}
|
||||
>
|
||||
{t('禁用两步验证')}
|
||||
</Button>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
size='default'
|
||||
onClick={() => setBackupModalVisible(true)}
|
||||
className='!rounded-lg'
|
||||
icon={<IconRefresh />}
|
||||
>
|
||||
{t('重新生成备用码')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 2FA设置模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconShield className='mr-2 text-slate-600' />
|
||||
{t('设置两步验证')}
|
||||
</div>
|
||||
}
|
||||
visible={setupModalVisible}
|
||||
onCancel={() => {
|
||||
setSetupModalVisible(false);
|
||||
setSetupData(null);
|
||||
setCurrentStep(0);
|
||||
setVerificationCode('');
|
||||
}}
|
||||
footer={renderSetupModalFooter()}
|
||||
width={650}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
{setupData && (
|
||||
<div className='space-y-6'>
|
||||
{/* 步骤进度 */}
|
||||
<Steps type='basic' size='small' current={currentStep}>
|
||||
<Steps.Step
|
||||
title={t('扫描二维码')}
|
||||
description={t('使用认证器应用扫描二维码')}
|
||||
/>
|
||||
<Steps.Step
|
||||
title={t('保存备用码')}
|
||||
description={t('保存备用码以备不时之需')}
|
||||
/>
|
||||
<Steps.Step
|
||||
title={t('验证设置')}
|
||||
description={t('输入验证码完成设置')}
|
||||
/>
|
||||
</Steps>
|
||||
|
||||
{/* 步骤内容 */}
|
||||
<div className='rounded-xl'>
|
||||
{currentStep === 0 && (
|
||||
<div>
|
||||
<Paragraph className='text-gray-600 dark:text-gray-300 mb-4'>
|
||||
{t(
|
||||
'使用认证器应用(如 Google Authenticator、Microsoft Authenticator)扫描下方二维码:',
|
||||
)}
|
||||
</Paragraph>
|
||||
<div className='flex justify-center mb-4'>
|
||||
<div className='bg-white p-4 rounded-lg shadow-sm'>
|
||||
<QRCodeSVG value={setupData.qr_code_data} size={180} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='bg-blue-50 dark:bg-blue-900 rounded-lg p-3'>
|
||||
<Text className='text-blue-800 dark:text-blue-200 text-sm'>
|
||||
{t('或手动输入密钥:')}
|
||||
<Text code copyable className='ml-2'>
|
||||
{setupData.secret}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 1 && (
|
||||
<div className='space-y-4'>
|
||||
{/* 备用码展示 */}
|
||||
<BackupCodesDisplay
|
||||
codes={setupData.backup_codes}
|
||||
title={t('备用恢复代码')}
|
||||
onCopy={() => {
|
||||
const codesText = setupData.backup_codes.join('\n');
|
||||
copyTextToClipboard(codesText, t('备用码已复制到剪贴板'));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<Input
|
||||
placeholder={t('输入认证器应用显示的6位数字验证码')}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
size='large'
|
||||
maxLength={6}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 禁用2FA模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconAlertTriangle className='mr-2 text-red-500' />
|
||||
{t('禁用两步验证')}
|
||||
</div>
|
||||
}
|
||||
visible={disableModalVisible}
|
||||
onCancel={() => {
|
||||
setDisableModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setConfirmDisable(false);
|
||||
}}
|
||||
footer={renderDisableModalFooter()}
|
||||
width={550}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{/* 警告提示 */}
|
||||
<div className='rounded-xl'>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'警告:禁用两步验证将永久删除您的验证设置和所有备用码,此操作不可撤销!',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
className='block mb-2 text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('禁用后的影响:')}
|
||||
</Text>
|
||||
<ul className='space-y-2 text-sm text-slate-600 dark:text-slate-300'>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='warning' />
|
||||
{t('降低您账户的安全性')}
|
||||
</li>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='warning' />
|
||||
{t('需要重新完整设置才能再次启用')}
|
||||
</li>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='danger' />
|
||||
{t('永久删除您的两步验证设置')}
|
||||
</li>
|
||||
<li className='flex items-start gap-2'>
|
||||
<Badge dot type='danger' />
|
||||
{t('永久删除所有备用码(包括未使用的)')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Divider margin={16} />
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
className='block mb-2 text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('验证身份')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t('请输入认证器验证码或备用码')}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Checkbox
|
||||
checked={confirmDisable}
|
||||
onChange={(e) => setConfirmDisable(e.target.checked)}
|
||||
className='text-sm'
|
||||
>
|
||||
{t(
|
||||
'我已了解禁用两步验证将永久删除所有相关设置和备用码,此操作不可撤销',
|
||||
)}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* 重新生成备用码模态框 */}
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconRefresh className='mr-2 text-slate-600' />
|
||||
{t('重新生成备用码')}
|
||||
</div>
|
||||
}
|
||||
visible={backupModalVisible}
|
||||
onCancel={() => {
|
||||
setBackupModalVisible(false);
|
||||
setVerificationCode('');
|
||||
setBackupCodes([]);
|
||||
}}
|
||||
footer={renderRegenerateModalFooter()}
|
||||
width={500}
|
||||
style={{ maxWidth: '90vw' }}
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
{backupCodes.length === 0 ? (
|
||||
<>
|
||||
{/* 警告提示 */}
|
||||
<div className='rounded-xl'>
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。',
|
||||
)}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 验证区域 */}
|
||||
<div className='space-y-4'>
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
className='block mb-2 text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('验证身份')}
|
||||
</Text>
|
||||
<Input
|
||||
placeholder={t('请输入认证器验证码')}
|
||||
value={verificationCode}
|
||||
onChange={setVerificationCode}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 成功提示 */}
|
||||
<Space vertical style={{ width: '100%' }}>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Badge dot type='success' />
|
||||
<Text
|
||||
strong
|
||||
className='text-lg text-slate-700 dark:text-slate-200'
|
||||
>
|
||||
{t('新的备用码已生成')}
|
||||
</Text>
|
||||
</div>
|
||||
<Text className='text-slate-500 dark:text-slate-400 text-sm'>
|
||||
{t('旧的备用码已失效,请保存新的备用码')}
|
||||
</Text>
|
||||
|
||||
{/* 备用码展示 */}
|
||||
<BackupCodesDisplay
|
||||
codes={backupCodes}
|
||||
title={t('新的备用恢复代码')}
|
||||
onCopy={copyBackupCodes}
|
||||
/>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TwoFASetting;
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
Tag,
|
||||
Divider,
|
||||
Typography,
|
||||
Badge,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
isRoot,
|
||||
isAdmin,
|
||||
renderQuota,
|
||||
stringToColor,
|
||||
} from '../../../../helpers';
|
||||
import { Coins, BarChart2, Users } from 'lucide-react';
|
||||
|
||||
const UserInfoHeader = ({ t, userState }) => {
|
||||
const getUsername = () => {
|
||||
if (userState.user) {
|
||||
return userState.user.username;
|
||||
} else {
|
||||
return 'null';
|
||||
}
|
||||
};
|
||||
|
||||
const getAvatarText = () => {
|
||||
const username = getUsername();
|
||||
if (username && username.length > 0) {
|
||||
return username.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return 'NA';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className='!rounded-2xl overflow-hidden'
|
||||
cover={
|
||||
<div
|
||||
className='relative h-32'
|
||||
style={{
|
||||
'--palette-primary-darkerChannel': '0 75 80',
|
||||
backgroundImage: `linear-gradient(0deg, rgba(var(--palette-primary-darkerChannel) / 80%), rgba(var(--palette-primary-darkerChannel) / 80%)), url('/cover-4.webp')`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
{/* 用户信息内容 */}
|
||||
<div className='relative z-10 h-full flex flex-col justify-end p-6'>
|
||||
<div className='flex items-center'>
|
||||
<div className='flex items-stretch gap-3 sm:gap-4 flex-1 min-w-0'>
|
||||
<Avatar size='large' color={stringToColor(getUsername())}>
|
||||
{getAvatarText()}
|
||||
</Avatar>
|
||||
<div className='flex-1 min-w-0 flex flex-col justify-between'>
|
||||
<div
|
||||
className='text-3xl font-bold truncate'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{getUsername()}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{isRoot() ? (
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{t('超级管理员')}
|
||||
</Tag>
|
||||
) : isAdmin() ? (
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{t('管理员')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
size='large'
|
||||
shape='circle'
|
||||
style={{ color: 'white' }}
|
||||
>
|
||||
{t('普通用户')}
|
||||
</Tag>
|
||||
)}
|
||||
<Tag size='large' shape='circle' style={{ color: 'white' }}>
|
||||
ID: {userState?.user?.id}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* 当前余额和桌面版统计信息 */}
|
||||
<div className='flex items-start justify-between gap-6'>
|
||||
{/* 当前余额显示 */}
|
||||
<Badge count={t('当前余额')} position='rightTop' type='danger'>
|
||||
<div className='text-2xl sm:text-3xl md:text-4xl font-bold tracking-wide'>
|
||||
{renderQuota(userState?.user?.quota)}
|
||||
</div>
|
||||
</Badge>
|
||||
|
||||
{/* 桌面版统计信息(Semi UI 卡片) */}
|
||||
<div className='hidden lg:block flex-shrink-0'>
|
||||
<Card
|
||||
size='small'
|
||||
className='!rounded-xl'
|
||||
bodyStyle={{ padding: '12px 16px' }}
|
||||
>
|
||||
<div className='flex items-center gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Coins size={16} />
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('历史消耗')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{renderQuota(userState?.user?.used_quota)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider layout='vertical' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<BarChart2 size={16} />
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('请求次数')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState.user?.request_count || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider layout='vertical' />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('用户分组')}
|
||||
</Typography.Text>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState?.user?.group || t('默认')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端和中等屏幕统计信息卡片 */}
|
||||
<div className='lg:hidden mt-2'>
|
||||
<Card
|
||||
size='small'
|
||||
className='!rounded-xl'
|
||||
bodyStyle={{ padding: '12px 16px' }}
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Coins size={16} />
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('历史消耗')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{renderQuota(userState?.user?.used_quota)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider margin='8px' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BarChart2 size={16} />
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('请求次数')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState.user?.request_count || 0}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Divider margin='8px' />
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Users size={16} />
|
||||
<Typography.Text size='small' type='tertiary'>
|
||||
{t('用户分组')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Typography.Text size='small' type='tertiary' strong>
|
||||
{userState?.user?.group || t('默认')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserInfoHeader;
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Banner, Input, Modal, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconDelete, IconUser } from '@douyinfe/semi-icons';
|
||||
import Turnstile from 'react-turnstile';
|
||||
|
||||
const AccountDeleteModal = ({
|
||||
t,
|
||||
showAccountDeleteModal,
|
||||
setShowAccountDeleteModal,
|
||||
inputs,
|
||||
handleInputChange,
|
||||
deleteAccount,
|
||||
userState,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
setTurnstileToken,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconDelete className='mr-2 text-red-500' />
|
||||
{t('删除账户确认')}
|
||||
</div>
|
||||
}
|
||||
visible={showAccountDeleteModal}
|
||||
onCancel={() => setShowAccountDeleteModal(false)}
|
||||
onOk={deleteAccount}
|
||||
size={'small'}
|
||||
centered={true}
|
||||
className='modern-modal'
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<Banner
|
||||
type='danger'
|
||||
description={t('您正在删除自己的帐户,将清空所有数据且不可恢复')}
|
||||
closeIcon={null}
|
||||
className='!rounded-lg'
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong className='block mb-2 text-red-600'>
|
||||
{t('请输入您的用户名以确认删除')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
placeholder={t('输入你的账户名{{username}}以确认删除', {
|
||||
username: ` ${userState?.user?.username} `,
|
||||
})}
|
||||
name='self_account_deletion_confirmation'
|
||||
value={inputs.self_account_deletion_confirmation}
|
||||
onChange={(value) =>
|
||||
handleInputChange('self_account_deletion_confirmation', value)
|
||||
}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconUser />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className='flex justify-center'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountDeleteModal;
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Input, Modal, Typography } from '@douyinfe/semi-ui';
|
||||
import { IconLock } from '@douyinfe/semi-icons';
|
||||
import Turnstile from 'react-turnstile';
|
||||
|
||||
const ChangePasswordModal = ({
|
||||
t,
|
||||
showChangePasswordModal,
|
||||
setShowChangePasswordModal,
|
||||
inputs,
|
||||
handleInputChange,
|
||||
changePassword,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
setTurnstileToken,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconLock className='mr-2 text-orange-500' />
|
||||
{t('修改密码')}
|
||||
</div>
|
||||
}
|
||||
visible={showChangePasswordModal}
|
||||
onCancel={() => setShowChangePasswordModal(false)}
|
||||
onOk={changePassword}
|
||||
size={'small'}
|
||||
centered={true}
|
||||
className='modern-modal'
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div>
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('原密码')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
name='original_password'
|
||||
placeholder={t('请输入原密码')}
|
||||
type='password'
|
||||
value={inputs.original_password}
|
||||
onChange={(value) => handleInputChange('original_password', value)}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('新密码')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
name='set_new_password'
|
||||
placeholder={t('请输入新密码')}
|
||||
type='password'
|
||||
value={inputs.set_new_password}
|
||||
onChange={(value) => handleInputChange('set_new_password', value)}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Typography.Text strong className='block mb-2'>
|
||||
{t('确认新密码')}
|
||||
</Typography.Text>
|
||||
<Input
|
||||
name='set_new_password_confirmation'
|
||||
placeholder={t('请再次输入新密码')}
|
||||
type='password'
|
||||
value={inputs.set_new_password_confirmation}
|
||||
onChange={(value) =>
|
||||
handleInputChange('set_new_password_confirmation', value)
|
||||
}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconLock />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className='flex justify-center'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePasswordModal;
|
||||
108
web/src/components/settings/personal/modals/EmailBindModal.jsx
Normal file
108
web/src/components/settings/personal/modals/EmailBindModal.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Input, Modal } from '@douyinfe/semi-ui';
|
||||
import { IconMail, IconKey } from '@douyinfe/semi-icons';
|
||||
import Turnstile from 'react-turnstile';
|
||||
|
||||
const EmailBindModal = ({
|
||||
t,
|
||||
showEmailBindModal,
|
||||
setShowEmailBindModal,
|
||||
inputs,
|
||||
handleInputChange,
|
||||
sendVerificationCode,
|
||||
bindEmail,
|
||||
disableButton,
|
||||
loading,
|
||||
countdown,
|
||||
turnstileEnabled,
|
||||
turnstileSiteKey,
|
||||
setTurnstileToken,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<IconMail className='mr-2 text-blue-500' />
|
||||
{t('绑定邮箱地址')}
|
||||
</div>
|
||||
}
|
||||
visible={showEmailBindModal}
|
||||
onCancel={() => setShowEmailBindModal(false)}
|
||||
onOk={bindEmail}
|
||||
size={'small'}
|
||||
centered={true}
|
||||
maskClosable={false}
|
||||
className='modern-modal'
|
||||
>
|
||||
<div className='space-y-4 py-4'>
|
||||
<div className='flex gap-3'>
|
||||
<Input
|
||||
placeholder={t('输入邮箱地址')}
|
||||
onChange={(value) => handleInputChange('email', value)}
|
||||
name='email'
|
||||
type='email'
|
||||
size='large'
|
||||
className='!rounded-lg flex-1'
|
||||
prefix={<IconMail />}
|
||||
/>
|
||||
<Button
|
||||
onClick={sendVerificationCode}
|
||||
disabled={disableButton || loading}
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
size='large'
|
||||
>
|
||||
{disableButton
|
||||
? `${t('重新发送')} (${countdown})`
|
||||
: t('获取验证码')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
placeholder={t('验证码')}
|
||||
name='email_verification_code'
|
||||
value={inputs.email_verification_code}
|
||||
onChange={(value) =>
|
||||
handleInputChange('email_verification_code', value)
|
||||
}
|
||||
size='large'
|
||||
className='!rounded-lg'
|
||||
prefix={<IconKey />}
|
||||
/>
|
||||
|
||||
{turnstileEnabled && (
|
||||
<div className='flex justify-center'>
|
||||
<Turnstile
|
||||
sitekey={turnstileSiteKey}
|
||||
onVerify={(token) => {
|
||||
setTurnstileToken(token);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailBindModal;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user