Initial commit
868
web/bun.lock
Normal file
@@ -0,0 +1,868 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "web",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "6",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"antd": "6",
|
||||
"artplayer": "^5.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.13.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^7.3.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ant-design/colors": ["@ant-design/colors@8.0.0", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-6YzkKCw30EI/E9kHOIXsQDHmMvTllT8STzjMb4K2qzit33RW2pqCJP0sk+hidBntXxE+Vz4n1+RvCTfBw6OErw=="],
|
||||
|
||||
"@ant-design/cssinjs": ["@ant-design/cssinjs@2.0.3", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-HAo8SZ3a6G8v6jT0suCz1270na6EA3obeJWM4uzRijBhdwdoMAXWK2f4WWkwB28yUufsfk3CAhN1coGPQq4kNQ=="],
|
||||
|
||||
"@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@2.0.2", "", { "dependencies": { "@ant-design/cssinjs": "^2.0.1", "@babel/runtime": "^7.23.2", "@rc-component/util": "^1.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Mq3Hm6fJuQeFNKSp3+yT4bjuhVbdrsyXE2RyfpJFL0xiYNZdaJ6oFaE3zFrzmHbmvTd2Wp3HCbRtkD4fU+v2ZA=="],
|
||||
|
||||
"@ant-design/fast-color": ["@ant-design/fast-color@3.0.1", "", {}, "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw=="],
|
||||
|
||||
"@ant-design/icons": ["@ant-design/icons@6.1.0", "", { "dependencies": { "@ant-design/colors": "^8.0.0", "@ant-design/icons-svg": "^4.4.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-KrWMu1fIg3w/1F2zfn+JlfNDU8dDqILfA5Tg85iqs1lf8ooyGlbkA+TkwfOKKgqpUmAiRY1PTFpuOU2DAIgSUg=="],
|
||||
|
||||
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="],
|
||||
|
||||
"@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
|
||||
|
||||
"@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="],
|
||||
|
||||
"@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
||||
|
||||
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||
|
||||
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="],
|
||||
|
||||
"@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="],
|
||||
|
||||
"@rc-component/async-validator": ["@rc-component/async-validator@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA=="],
|
||||
|
||||
"@rc-component/cascader": ["@rc-component/cascader@1.11.0", "", { "dependencies": { "@rc-component/select": "~1.5.0", "@rc-component/tree": "~1.1.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-VDiEsskThWi8l0/1Nquc9I4ytcMKQYAb9Jkm6wiX5O5fpcMRsm+b8OulBMbr/b4rFTl/2y2y4GdKqQ+2whD+XQ=="],
|
||||
|
||||
"@rc-component/checkbox": ["@rc-component/checkbox@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-08yTH8m+bSm8TOqbybbJ9KiAuIATti6bDs2mVeSfu4QfEnyeF6X0enHVvD1NEAyuBWEAo56QtLe++MYs2D9XiQ=="],
|
||||
|
||||
"@rc-component/collapse": ["@rc-component/collapse@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw=="],
|
||||
|
||||
"@rc-component/color-picker": ["@rc-component/color-picker@3.0.3", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-V7gFF9O7o5XwIWafdbOtqI4BUUkEUkgdBwp6favy3xajMX/2dDqytFaiXlcwrpq6aRyPLp5dKLAG5RFKLXMeGA=="],
|
||||
|
||||
"@rc-component/context": ["@rc-component/context@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw=="],
|
||||
|
||||
"@rc-component/dialog": ["@rc-component/dialog@1.8.2", "", { "dependencies": { "@rc-component/motion": "^1.1.3", "@rc-component/portal": "^2.1.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-CwDSjpjZ1FcgsdKFPuSoYfi9Vbt2bp+ak4Pzkwq4APQC8DopJKWetRu1V+HE9vI1CNAeqvT5WAvAxE6RiDhl7A=="],
|
||||
|
||||
"@rc-component/drawer": ["@rc-component/drawer@1.4.1", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.1.3", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-kNJQie/QjJO5wGeWrZQwSGeuo8staxXx1nYN+dpK2UY7i8teo5PQdZ6ukKSnnW9vmPXsLn3F5nKYRbf43e8+5g=="],
|
||||
|
||||
"@rc-component/dropdown": ["@rc-component/dropdown@1.0.2", "", { "dependencies": { "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg=="],
|
||||
|
||||
"@rc-component/form": ["@rc-component/form@1.6.2", "", { "dependencies": { "@rc-component/async-validator": "^5.1.0", "@rc-component/util": "^1.6.2", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OgIn2RAoaSBqaIgzJf/X6iflIa9LpTozci1lagLBdURDFhGA370v0+T0tXxOi8YShMjTha531sFhwtnrv+EJaQ=="],
|
||||
|
||||
"@rc-component/image": ["@rc-component/image@1.6.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-tSfn2ZE/oP082g4QIOxeehkmgnXB7R+5AFj/lIFr4k7pEuxHBdyGIq9axoCY9qea8NN0DY6p4IB/F07tLqaT5A=="],
|
||||
|
||||
"@rc-component/input": ["@rc-component/input@1.1.2", "", { "dependencies": { "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg=="],
|
||||
|
||||
"@rc-component/input-number": ["@rc-component/input-number@1.6.2", "", { "dependencies": { "@rc-component/mini-decimal": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w=="],
|
||||
|
||||
"@rc-component/mentions": ["@rc-component/mentions@1.6.0", "", { "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/menu": "~1.2.0", "@rc-component/textarea": "~1.1.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ=="],
|
||||
|
||||
"@rc-component/menu": ["@rc-component/menu@1.2.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg=="],
|
||||
|
||||
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ=="],
|
||||
|
||||
"@rc-component/motion": ["@rc-component/motion@1.1.6", "", { "dependencies": { "@rc-component/util": "^1.2.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aEQobs/YA0kqRvHIPjQvOytdtdRVyhf/uXAal4chBjxDu6odHckExJzjn2D+Ju1aKK6hx3pAs6BXdV9+86xkgQ=="],
|
||||
|
||||
"@rc-component/mutate-observer": ["@rc-component/mutate-observer@2.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w=="],
|
||||
|
||||
"@rc-component/notification": ["@rc-component/notification@1.2.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA=="],
|
||||
|
||||
"@rc-component/overflow": ["@rc-component/overflow@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw=="],
|
||||
|
||||
"@rc-component/pagination": ["@rc-component/pagination@1.2.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw=="],
|
||||
|
||||
"@rc-component/picker": ["@rc-component/picker@1.9.0", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/trigger": "^3.6.15", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-OLisdk8AWVCG9goBU1dWzuH5QlBQk8jktmQ6p0/IyBFwdKGwyIZOSjnBYo8hooHiTdl0lU+wGf/OfMtVBw02KQ=="],
|
||||
|
||||
"@rc-component/portal": ["@rc-component/portal@2.2.0", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ=="],
|
||||
|
||||
"@rc-component/progress": ["@rc-component/progress@1.0.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ=="],
|
||||
|
||||
"@rc-component/qrcode": ["@rc-component/qrcode@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.24.7" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA=="],
|
||||
|
||||
"@rc-component/rate": ["@rc-component/rate@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw=="],
|
||||
|
||||
"@rc-component/resize-observer": ["@rc-component/resize-observer@1.1.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-NfXXMmiR+SmUuKE1NwJESzEUYUFWIDUn2uXpxCTOLwiRUUakd62DRNFjRJArgzyFW8S5rsL4aX5XlyIXyC/vRA=="],
|
||||
|
||||
"@rc-component/segmented": ["@rc-component/segmented@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@rc-component/motion": "^1.1.4", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg=="],
|
||||
|
||||
"@rc-component/select": ["@rc-component/select@1.5.2", "", { "dependencies": { "@rc-component/overflow": "^1.0.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.3.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-7wqD5D4I2+fc5XoB4nzDDK656QPlDnFAUaxLljkU1wwSpi4+MZxndv9vgg7NQfveuuf0/ilUdOjuPg7NPl7Mmg=="],
|
||||
|
||||
"@rc-component/slider": ["@rc-component/slider@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g=="],
|
||||
|
||||
"@rc-component/steps": ["@rc-component/steps@1.2.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw=="],
|
||||
|
||||
"@rc-component/switch": ["@rc-component/switch@1.0.3", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw=="],
|
||||
|
||||
"@rc-component/table": ["@rc-component/table@1.9.1", "", { "dependencies": { "@rc-component/context": "^2.0.1", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.1.0", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg=="],
|
||||
|
||||
"@rc-component/tabs": ["@rc-component/tabs@1.7.0", "", { "dependencies": { "@rc-component/dropdown": "~1.0.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "^1.1.3", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w=="],
|
||||
|
||||
"@rc-component/textarea": ["@rc-component/textarea@1.1.2", "", { "dependencies": { "@rc-component/input": "~1.1.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A=="],
|
||||
|
||||
"@rc-component/tooltip": ["@rc-component/tooltip@1.4.0", "", { "dependencies": { "@rc-component/trigger": "^3.7.1", "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg=="],
|
||||
|
||||
"@rc-component/tour": ["@rc-component/tour@2.3.0", "", { "dependencies": { "@rc-component/portal": "^2.2.0", "@rc-component/trigger": "^3.0.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow=="],
|
||||
|
||||
"@rc-component/tree": ["@rc-component/tree@1.1.0", "", { "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/util": "^1.2.1", "@rc-component/virtual-list": "^1.0.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-HZs3aOlvFgQdgrmURRc/f4IujiNBf4DdEeXUlkS0lPoLlx9RoqsZcF0caXIAMVb+NaWqKtGQDnrH8hqLCN5zlA=="],
|
||||
|
||||
"@rc-component/tree-select": ["@rc-component/tree-select@1.6.0", "", { "dependencies": { "@rc-component/select": "~1.5.0", "@rc-component/tree": "~1.1.0", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UvEGmZT+gcVvRwImAZg3/sXw9nUdn4FmCs1rSIMWjEXEIAo0dTGmIyWuLCvs+1rGe9AZ7CHMPiQUEbdadwV0fw=="],
|
||||
|
||||
"@rc-component/trigger": ["@rc-component/trigger@3.9.0", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.2.0", "@rc-component/resize-observer": "^1.1.1", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg=="],
|
||||
|
||||
"@rc-component/upload": ["@rc-component/upload@1.1.0", "", { "dependencies": { "@rc-component/util": "^1.3.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw=="],
|
||||
|
||||
"@rc-component/util": ["@rc-component/util@1.6.2", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-RPQASdThk6oKCaV0wUA4s5nh8Scs+0nSywHASc+XsHrmBUZ284LyvVYpd6Dq+JZSN+IyhY0b/TSfgjPf+Qzicg=="],
|
||||
|
||||
"@rc-component/virtual-list": ["@rc-component/virtual-list@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.20.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.53", "", {}, "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.54.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/type-utils": "8.54.0", "@typescript-eslint/utils": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.54.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.54.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.54.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.54.0", "@typescript-eslint/types": "^8.54.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0" } }, "sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.54.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.54.0", "", {}, "sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.54.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.54.0", "@typescript-eslint/tsconfig-utils": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/visitor-keys": "8.54.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.54.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.54.0", "", { "dependencies": { "@typescript-eslint/types": "8.54.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"antd": ["antd@6.2.2", "", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/cssinjs": "^2.0.3", "@ant-design/cssinjs-utils": "^2.0.2", "@ant-design/fast-color": "^3.0.1", "@ant-design/icons": "^6.1.0", "@ant-design/react-slick": "~2.0.0", "@babel/runtime": "^7.28.4", "@rc-component/cascader": "~1.11.0", "@rc-component/checkbox": "~1.0.1", "@rc-component/collapse": "~1.2.0", "@rc-component/color-picker": "~3.0.3", "@rc-component/dialog": "~1.8.0", "@rc-component/drawer": "~1.4.0", "@rc-component/dropdown": "~1.0.2", "@rc-component/form": "~1.6.2", "@rc-component/image": "~1.6.0", "@rc-component/input": "~1.1.2", "@rc-component/input-number": "~1.6.2", "@rc-component/mentions": "~1.6.0", "@rc-component/menu": "~1.2.0", "@rc-component/motion": "~1.1.6", "@rc-component/mutate-observer": "^2.0.1", "@rc-component/notification": "~1.2.0", "@rc-component/pagination": "~1.2.0", "@rc-component/picker": "~1.9.0", "@rc-component/progress": "~1.0.2", "@rc-component/qrcode": "~1.1.1", "@rc-component/rate": "~1.0.1", "@rc-component/resize-observer": "^1.1.1", "@rc-component/segmented": "~1.3.0", "@rc-component/select": "~1.5.1", "@rc-component/slider": "~1.0.1", "@rc-component/steps": "~1.2.2", "@rc-component/switch": "~1.0.3", "@rc-component/table": "~1.9.1", "@rc-component/tabs": "~1.7.0", "@rc-component/textarea": "~1.1.2", "@rc-component/tooltip": "~1.4.0", "@rc-component/tour": "~2.3.0", "@rc-component/tree": "~1.1.0", "@rc-component/tree-select": "~1.6.0", "@rc-component/trigger": "^3.9.0", "@rc-component/upload": "~1.1.0", "@rc-component/util": "^1.7.0", "clsx": "^2.1.1", "dayjs": "^1.11.11", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-f5RvWnhjt2gZTpBMW3msHwA3IeaCJBHDwVyEsskYGp0EXcRhhklWrltkybDki0ysBNywkjLPp3wuuWhIKfplcQ=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"artplayer": ["artplayer@5.3.0", "", { "dependencies": { "option-validator": "^2.0.6" } }, "sha512-yExO39MpEg4P+bxgChxx1eJfiUPE4q1QQRLCmqGhlsj+ANuaoEkR8hF93LdI5ZyrAcIbJkuEndxEiUoKobifDw=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="],
|
||||
|
||||
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"dompurify": ["dompurify@3.2.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.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-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="],
|
||||
|
||||
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
|
||||
|
||||
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
|
||||
|
||||
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
|
||||
|
||||
"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-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||
|
||||
"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@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||
|
||||
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
|
||||
|
||||
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||
|
||||
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
|
||||
|
||||
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||
|
||||
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"monaco-editor": ["monaco-editor@0.55.1", "", { "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" } }, "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"option-validator": ["option-validator@2.0.6", "", { "dependencies": { "kind-of": "^6.0.3" } }, "sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
|
||||
|
||||
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
|
||||
|
||||
"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-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.54.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.54.0", "@typescript-eslint/parser": "8.54.0", "@typescript-eslint/typescript-estree": "8.54.0", "@typescript-eslint/utils": "8.54.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
|
||||
|
||||
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@ant-design/colors/@ant-design/fast-color": ["@ant-design/fast-color@3.0.0", "", {}, "sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA=="],
|
||||
|
||||
"@ant-design/cssinjs/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@ant-design/cssinjs-utils/@ant-design/cssinjs": ["@ant-design/cssinjs@2.0.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-Lw1Z4cUQxdMmTNir67gU0HCpTl5TtkKCJPZ6UBvCqzcOTl/QmMFB6qAEoj8qFl0CuZDX9qQYa3m9+rEKfaBSbA=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
|
||||
|
||||
"@rc-component/cascader/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/collapse/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/color-picker/@ant-design/fast-color": ["@ant-design/fast-color@3.0.0", "", {}, "sha512-eqvpP7xEDm2S7dUzl5srEQCBTXZMmY3ekf97zI+M2DHOYyKdJGH0qua0JACHTqbkRnD/KHFQP9J1uMJ/XWVzzA=="],
|
||||
|
||||
"@rc-component/dialog/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/drawer/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/dropdown/@rc-component/trigger": ["@rc-component/trigger@3.7.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ=="],
|
||||
|
||||
"@rc-component/form/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/image/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/mentions/@rc-component/trigger": ["@rc-component/trigger@3.7.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ=="],
|
||||
|
||||
"@rc-component/menu/@rc-component/trigger": ["@rc-component/trigger@3.7.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ=="],
|
||||
|
||||
"@rc-component/overflow/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@rc-component/picker/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@rc-component/picker/@rc-component/trigger": ["@rc-component/trigger@3.7.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ=="],
|
||||
|
||||
"@rc-component/portal/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/resize-observer/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/select/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/table/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/tabs/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@rc-component/textarea/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@rc-component/tooltip/@rc-component/trigger": ["@rc-component/trigger@3.7.2", "", { "dependencies": { "@rc-component/motion": "^1.1.4", "@rc-component/portal": "^2.0.0", "@rc-component/resize-observer": "^1.0.0", "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-25x+D2k9SAkaK/MNMNmv2Nlv8FH1D9RtmjoMoLEw1Cid+sMV4pAAT5k49ku59UeXaOA1qwLUVrBUMq4A6gUSsQ=="],
|
||||
|
||||
"@rc-component/tour/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/tree-select/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/trigger/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"@rc-component/virtual-list/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||
|
||||
"antd/@ant-design/colors": ["@ant-design/colors@8.0.1", "", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
|
||||
|
||||
"antd/@rc-component/util": ["@rc-component/util@1.8.1", "", { "dependencies": { "is-mobile": "^5.0.0", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-Ku6BzF0Ov5L9U3ewFJZDQ//iWCR2nIkLBBiYSrhxIVl3PqeUqiYP2W8gNI8qoKAFQKZdYcS0+B6+SQTDtv/erw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"@rc-component/dropdown/@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-6CQHPuZzKeYxs8GzbisbvchLPlhRC4wH1+P9PeiylLwC9WUt6CPmVh4SR9Fs+avpaGuNTU/7a8maC9csjc2lSw=="],
|
||||
|
||||
"@rc-component/dropdown/@rc-component/trigger/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@rc-component/mentions/@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-6CQHPuZzKeYxs8GzbisbvchLPlhRC4wH1+P9PeiylLwC9WUt6CPmVh4SR9Fs+avpaGuNTU/7a8maC9csjc2lSw=="],
|
||||
|
||||
"@rc-component/mentions/@rc-component/trigger/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@rc-component/menu/@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-6CQHPuZzKeYxs8GzbisbvchLPlhRC4wH1+P9PeiylLwC9WUt6CPmVh4SR9Fs+avpaGuNTU/7a8maC9csjc2lSw=="],
|
||||
|
||||
"@rc-component/menu/@rc-component/trigger/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@rc-component/picker/@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-6CQHPuZzKeYxs8GzbisbvchLPlhRC4wH1+P9PeiylLwC9WUt6CPmVh4SR9Fs+avpaGuNTU/7a8maC9csjc2lSw=="],
|
||||
|
||||
"@rc-component/tooltip/@rc-component/trigger/@rc-component/portal": ["@rc-component/portal@2.1.2", "", { "dependencies": { "@rc-component/util": "^1.2.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-6CQHPuZzKeYxs8GzbisbvchLPlhRC4wH1+P9PeiylLwC9WUt6CPmVh4SR9Fs+avpaGuNTU/7a8maC9csjc2lSw=="],
|
||||
|
||||
"@rc-component/tooltip/@rc-component/trigger/@rc-component/resize-observer": ["@rc-component/resize-observer@1.0.1", "", { "dependencies": { "@rc-component/util": "^1.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-r+w+Mz1EiueGk1IgjB3ptNXLYSLZ5vnEfKHH+gfgj7JMupftyzvUUl3fRcMZe5uMM04x0n8+G2o/c6nlO2+Wag=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
}
|
||||
}
|
||||
37
web/eslint.config.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-refresh/only-export-components': [
|
||||
'error',
|
||||
{
|
||||
allowConstantExport: true,
|
||||
allowExportNames: ['routes', 'useAuth', 'useTheme', 'useAppWindows', 'useI18n'],
|
||||
},
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
22
web/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foxel</title>
|
||||
<link rel='stylesheet'
|
||||
href='https://foxel.cc/fonts/result.css' />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<style>
|
||||
* {
|
||||
font-family: 'Maple Mono Normal NL NF CN';
|
||||
}
|
||||
</style>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
36
web/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "6",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"antd": "6",
|
||||
"artplayer": "^5.3.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
28
web/plugin-frame.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foxel Plugin Frame</title>
|
||||
<link rel='stylesheet' href='https://foxel.cc/fonts/result.css' />
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Maple Mono Normal NL NF CN';
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/plugin-frame.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
1
web/public/icon/anthropic.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>
|
||||
|
After Width: | Height: | Size: 368 B |
1
web/public/icon/azure-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
1
web/public/icon/claude-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
web/public/icon/deepseek-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z" fill="#4D6BFE"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
1
web/public/icon/gemini-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
1
web/public/icon/ollama.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Ollama</title><path d="M7.905 1.09c.216.085.411.225.588.41.295.306.544.744.734 1.263.191.522.315 1.1.362 1.68a5.054 5.054 0 012.049-.636l.051-.004c.87-.07 1.73.087 2.48.474.101.053.2.11.297.17.05-.569.172-1.134.36-1.644.19-.52.439-.957.733-1.264a1.67 1.67 0 01.589-.41c.257-.1.53-.118.796-.042.401.114.745.368 1.016.737.248.337.434.769.561 1.287.23.934.27 2.163.115 3.645l.053.04.026.019c.757.576 1.284 1.397 1.563 2.35.435 1.487.216 3.155-.534 4.088l-.018.021.002.003c.417.762.67 1.567.724 2.4l.002.03c.064 1.065-.2 2.137-.814 3.19l-.007.01.01.024c.472 1.157.62 2.322.438 3.486l-.006.039a.651.651 0 01-.747.536.648.648 0 01-.54-.742c.167-1.033.01-2.069-.48-3.123a.643.643 0 01.04-.617l.004-.006c.604-.924.854-1.83.8-2.72-.046-.779-.325-1.544-.8-2.273a.644.644 0 01.18-.886l.009-.006c.243-.159.467-.565.58-1.12a4.229 4.229 0 00-.095-1.974c-.205-.7-.58-1.284-1.105-1.683-.595-.454-1.383-.673-2.38-.61a.653.653 0 01-.632-.371c-.314-.665-.772-1.141-1.343-1.436a3.288 3.288 0 00-1.772-.332c-1.245.099-2.343.801-2.67 1.686a.652.652 0 01-.61.425c-1.067.002-1.893.252-2.497.703-.522.39-.878.935-1.066 1.588a4.07 4.07 0 00-.068 1.886c.112.558.331 1.02.582 1.269l.008.007c.212.207.257.53.109.785-.36.622-.629 1.549-.673 2.44-.05 1.018.186 1.902.719 2.536l.016.019a.643.643 0 01.095.69c-.576 1.236-.753 2.252-.562 3.052a.652.652 0 01-1.269.298c-.243-1.018-.078-2.184.473-3.498l.014-.035-.008-.012a4.339 4.339 0 01-.598-1.309l-.005-.019a5.764 5.764 0 01-.177-1.785c.044-.91.278-1.842.622-2.59l.012-.026-.002-.002c-.293-.418-.51-.953-.63-1.545l-.005-.024a5.352 5.352 0 01.093-2.49c.262-.915.777-1.701 1.536-2.269.06-.045.123-.09.186-.132-.159-1.493-.119-2.73.112-3.67.127-.518.314-.95.562-1.287.27-.368.614-.622 1.015-.737.266-.076.54-.059.797.042zm4.116 9.09c.936 0 1.8.313 2.446.855.63.527 1.005 1.235 1.005 1.94 0 .888-.406 1.58-1.133 2.022-.62.375-1.451.557-2.403.557-1.009 0-1.871-.259-2.493-.734-.617-.47-.963-1.13-.963-1.845 0-.707.398-1.417 1.056-1.946.668-.537 1.55-.849 2.485-.849zm0 .896a3.07 3.07 0 00-1.916.65c-.461.37-.722.835-.722 1.25 0 .428.21.829.61 1.134.455.347 1.124.548 1.943.548.799 0 1.473-.147 1.932-.426.463-.28.7-.686.7-1.257 0-.423-.246-.89-.683-1.256-.484-.405-1.14-.643-1.864-.643zm.662 1.21l.004.004c.12.151.095.37-.056.49l-.292.23v.446a.375.375 0 01-.376.373.375.375 0 01-.376-.373v-.46l-.271-.218a.347.347 0 01-.052-.49.353.353 0 01.494-.051l.215.172.22-.174a.353.353 0 01.49.051zm-5.04-1.919c.478 0 .867.39.867.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zm8.706 0c.48 0 .868.39.868.871a.87.87 0 01-.868.871.87.87 0 01-.867-.87.87.87 0 01.867-.872zM7.44 2.3l-.003.002a.659.659 0 00-.285.238l-.005.006c-.138.189-.258.467-.348.832-.17.692-.216 1.631-.124 2.782.43-.128.899-.208 1.404-.237l.01-.001.019-.034c.046-.082.095-.161.148-.239.123-.771.022-1.692-.253-2.444-.134-.364-.297-.65-.453-.813a.628.628 0 00-.107-.09L7.44 2.3zm9.174.04l-.002.001a.628.628 0 00-.107.09c-.156.163-.32.45-.453.814-.29.794-.387 1.776-.23 2.572l.058.097.008.014h.03a5.184 5.184 0 011.466.212c.086-1.124.038-2.043-.128-2.722-.09-.365-.21-.643-.349-.832l-.004-.006a.659.659 0 00-.285-.239h-.004z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
1
web/public/icon/openai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
web/public/icon/siliconcloud-color.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>SiliconCloud</title><path clip-rule="evenodd" d="M22.956 6.521H12.522c-.577 0-1.044.468-1.044 1.044v3.13c0 .577-.466 1.044-1.043 1.044H1.044c-.577 0-1.044.467-1.044 1.044v4.174C0 17.533.467 18 1.044 18h10.434c.577 0 1.044-.467 1.044-1.043v-3.13c0-.578.466-1.044 1.043-1.044h9.391c.577 0 1.044-.467 1.044-1.044V7.565c0-.576-.467-1.044-1.044-1.044z" fill="#6E29F6" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 520 B |
1
web/public/icon/zai.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Z.ai</title><path d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"></path></svg>
|
||||
|
After Width: | Height: | Size: 319 B |
3
web/public/logo.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="745" height="745" viewBox="-102 0 745 745" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#000" fill-rule="evenodd" d="m232.6.1c1.6.9 1.6.9 2.6 2.9-1.4 30.7-.5 63.9-7 94 6.2.9 6.2.9 11.8-1.3 1.6-1.9 2.9-3.9 4.3-6 1-1.5 2-3 3-4.4.7-1.1.7-1.1 1.5-2.2 3.1-4.4 6.8-8.1 10.4-12 1.1-1.3 2.2-2.5 3.3-3.8 1.2-1.4 2.5-2.8 3.7-4.2 1.3-1.5 1.3-1.5 2.6-3 7.1-8 14.7-14.7 23.4-21 1.5-1.1 1.5-1.1 3-2.2 8-5.7 8-5.7 11-6.8 1.7.3 3.3.6 5 1 .2 7.5-.2 14.8-1.2 22.2-.1 1-.3 2.1-.4 3.1-2.5 18.4-6.5 35.5-13.4 52.7-.3 1-.7 2-1 3 1.5 2.1 3.1 4 4.8 5.9 1 1.2 2.1 2.4 3.1 3.5.5.6 1 1.2 1.6 1.8 14.2 16.2 25.9 33.7 36.2 52.5 16.7 30.4 38.1 47.8 71.3 58.2 4.7 1.3 9.4 2.4 14.2 3.4 4.5.9 8.1 1.8 11.8 4.6 1.5 3 1.4 4.6 1 8-4.2 9.4-11.4 17.9-21 22-25.6 6-52.9-.9-78.3-5.2-8.1-1.4-16.1-2.7-24.2-3.8-4.2-.6-8.4-1.3-12.7-2.1-3.6-.7-7.2-1.3-10.8-1.9-.8-.1-1.6-.3-2.5-.4-13.6-2.3-27.1-2.9-40.9-2.9-1.2 0-1.2 0-2.4 0-42.9-.1-82 14.3-112.8 44.8-.5.5-1 1-1.5 1.5-.5.5-1 1-1.4 1.5-13 13.2-22.8 28.3-31.6 44.5-.4.7-.7 1.3-1.1 2-10.5 19.7-17.6 42.5-20.6 64.6-.3 2.2-.6 4.4-1 6.5-2.3 15.8-1.6 32-1.3 47.8 0 1.4 0 1.4 0 2.7.1 9.7 1.6 19.1 3.2 28.6.2 1.2.4 2.3.6 3.5 3.1 17.6 8 34.5 13.9 51.3.6 1.8 1.2 3.6 1.9 5.4 8.4 24.1 17 48.6 28.4 71.5 1.8-6.1 2.2-11.1 1.8-17.4-.1-.9-.1-1.8-.2-2.7-.7-11.5-2.1-23-3.4-34.5-.3-2.4-.5-4.7-.8-7.1-1.3-11.3-2.6-22.6-4-33.8-.2-1.9-.5-3.9-.7-5.8-.4-3.6-.9-7.2-1.3-10.7-6-49.3 4.1-107.3 34.9-147.6 3.1-3.9 6.3-7.6 9.7-11.2.9-1.1 1.9-2.1 2.8-3.2 17.2-19.4 37.6-34.2 61.4-44.4 1.2-.5 1.2-.5 2.4-1.1 3.9-1.6 7.1-2.9 11.4-2.4.6 1.3.6 1.3 1.2 2.7 12.2 26.9 28.6 50.3 49.4 71.2 1.3 1.3 1.3 1.3 2.6 2.6 5.2 5.2 10.6 9.9 16.3 14.5 2.8 2.2 5.3 4.5 7.9 7 3.6 3.3 7.2 6.4 10.9 9.4 4.5 3.7 8.8 7.5 13.1 11.5 3.2 2.9 6.4 5.7 9.8 8.3 10.1 8.1 19.4 17.7 27.7 27.8.7.8 1.4 1.6 2.1 2.5 31.8 37.9 46 81.3 47.5 130.3 0 1.4.1 2.8.1 4.2.1 3.3.2 6.7.3 10 4.6-10.5 8.6-21 11.5-32.1.5-1.7 1-3.5 1.5-5.2 10.6-40.2-5.8-87-25.6-121.5-3.3-5.5-7.2-10.4-11.4-15.2-.8-.9-1.5-1.8-2.3-2.7-6.3-7.2-13-14.6-20.7-20.3 4.3-13.6 23.8-23.2 35.6-29.5 35.2-17.8 73.2-18.1 110.5-6.1 5.7 2 10.9 4.3 15.9 7.6-.6 3.3-1.6 4.9-3.9 7.3-8.1 8.9-14.9 20.4-19.1 31.8-.4 1.1-.8 2.3-1.3 3.4-7.8 23.2-7.7 49.7-9.8 73.9-3.9 44.5-8.9 93.5-35.9 130.7-.4.6-.8 1.2-1.3 1.8-7.1 9.8-14.7 19.2-22.7 28.2-.5.5-.9 1.1-1.4 1.6-15.2 17.2-32.7 30.6-52.5 42.1-1.9 1.1-3.8 2.3-5.7 3.4-40.6 24.5-85.9 33.3-132.9 33.2-.9 0-1.8 0-2.8 0-13.5 0-26.9-.7-40.4-2.2-1.2-.1-1.2-.1-2.3-.3-8-.9-15.5-2-23-4.9 1.4-3.9 3.3-7.3 5.5-10.8.7-1.1 1.4-2.3 2.1-3.4.4-.6.7-1.2 1.1-1.8 17.7-28.5 32.4-56.7 33.3-91 0-1 .1-1.9.1-2.9.5-22.1-1.9-42.6-7.1-64.1-2.9 14.3-5.6 28.6-8.2 42.9-.5 2.7-1 5.4-1.5 8.1-.2 1.4-.5 2.7-.7 4.1-7.2 40.4-20.7 77.2-49.6 106.9-.9 1-.9 1-1.8 2-10.7 12-27.5 16.9-43.1 17.9-1.6 0-1.6 0-3.1.1-.7 0-1.4 0-2.1.1-9.1.2-18.2.1-27.3-.9-.7-.1-1.4-.2-2.1-.2-3.8-.5-7-1.3-10.5-2.9.5-6 1.8-10.6 4.4-15.9.3-.7.7-1.5 1.1-2.2 6.8-13.6 15.7-26.7 25.6-38.3 4.3-5 7.5-10.1 10.4-15.9.5-1 .5-1 1-2.1 2.7-5.1 2.7-5.1 3.5-10.6-1.9-2.2-3.7-3.9-5.9-5.8-1.2-1.1-2.4-2.1-3.6-3.2-.9-.8-.9-.8-1.8-1.6-7-6.1-12.9-13.1-18.6-20.4-1.1-1.3-1.1-1.3-2.2-2.6-36.3-45.6-75.1-110-80.8-169.4-.3-2.5-.5-4.9-.8-7.4-.8-7.9-1.5-15.7-2.2-23.6-.1-.8-.1-1.7-.2-2.6-.4-4.8-.6-9.6-.8-14.4 0-1.6 0-1.6-.1-3.1-1.3-47.3 7.4-94 25.1-137.9.4-1 .8-2.1 1.3-3.2 8.7-21.4 18.7-41.7 31.7-60.8.4-.6.9-1.3 1.3-1.9 11.4-17 23.8-33.1 37.7-48.1.7-.7 1.4-1.5 2.1-2.2 4.3-4.5 8.9-8.5 13.8-12.3.6-.5 1.2-1 1.8-1.4 2.7-2.1 5.3-4 8.3-5.8 9.3-5.6 12.5-14.2 16.9-23.8 4.5-9.6 10.3-18.3 16.6-26.9 2.7-3.7 5.2-7.5 7.7-11.3 9.9-15 22.2-27.8 34.8-40.4 1.6-1.6 3.2-3.2 4.8-4.8 21.2-21.3 21.2-21.3 28.6-20.9z m62.2 174.1c8.4 4.3 11.4 9.9 14.4 18.6.7 2.4 1.4 4.7 2 7.1-3.8 3.8-14.9 2.3-19.8 2.3-1 0-2 0-3.1.1-8 .1-16.2-.9-22.4-6.4-2.1-2.3-2.7-3.5-3.1-6.6.6-5.5 3.2-9.2 7-13 6.9-5.3 17-4.9 25-2.1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
69
web/src/App.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { AppRouter } from './router/index.tsx';
|
||||
import { AuthProvider } from './contexts/AuthContext.tsx';
|
||||
import { status as getStatus } from './api/config.ts';
|
||||
import type { SystemStatus } from './api/config.ts';
|
||||
import { SystemContext } from './contexts/SystemContext.tsx';
|
||||
import { ThemeProvider } from './contexts/ThemeContext.tsx';
|
||||
import { Spin } from 'antd';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import SetupPage from './pages/SetupPage.tsx';
|
||||
import { I18nProvider } from './i18n';
|
||||
|
||||
function AppInner() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
useEffect(() => {
|
||||
async function checkInitialization() {
|
||||
try {
|
||||
const status = await getStatus();
|
||||
setStatus(status);
|
||||
document.title = status.title;
|
||||
let favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement | null;
|
||||
if (!favicon) {
|
||||
favicon = document.createElement('link');
|
||||
favicon.rel = 'icon';
|
||||
document.head.appendChild(favicon);
|
||||
}
|
||||
if (favicon) {
|
||||
favicon.href = status.favicon || status.logo;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check initialization status:", error);
|
||||
}
|
||||
}
|
||||
checkInitialization();
|
||||
}, []);
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SystemContext.Provider value={status}>
|
||||
<AuthProvider>
|
||||
<ThemeProvider>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</ThemeProvider>
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AppInner />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
33
web/src/api/adapters.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import request from './client';
|
||||
|
||||
export interface AdapterItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
config: any;
|
||||
enabled: boolean;
|
||||
path?: string | null;
|
||||
sub_path?: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterTypeField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'password' | 'number' | 'boolean';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
default?: any;
|
||||
}
|
||||
|
||||
export interface AdapterTypeMeta {
|
||||
type: string;
|
||||
config_schema: AdapterTypeField[];
|
||||
}
|
||||
|
||||
export const adaptersApi = {
|
||||
list: () => request<AdapterItem[]>('/adapters'),
|
||||
create: (payload: Omit<AdapterItem, 'id'>) => request<AdapterItem>('/adapters', { method: 'POST', json: payload }),
|
||||
update: (id: number, payload: Omit<AdapterItem, 'id'>) => request<AdapterItem>(`/adapters/${id}`, { method: 'PUT', json: payload }),
|
||||
remove: (id: number) => request<void>(`/adapters/${id}`, { method: 'DELETE' }),
|
||||
available: () => request<AdapterTypeMeta[]>('/adapters/available'),
|
||||
};
|
||||
121
web/src/api/agent.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import request, { API_BASE_URL } from './client';
|
||||
|
||||
export type AgentChatMessage = Record<string, any>;
|
||||
|
||||
export interface AgentChatContext {
|
||||
current_path?: string | null;
|
||||
}
|
||||
|
||||
export interface AgentChatRequest {
|
||||
messages: AgentChatMessage[];
|
||||
auto_execute?: boolean;
|
||||
approved_tool_call_ids?: string[];
|
||||
rejected_tool_call_ids?: string[];
|
||||
context?: AgentChatContext;
|
||||
}
|
||||
|
||||
export interface PendingToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
requires_confirmation: boolean;
|
||||
}
|
||||
|
||||
export interface AgentChatResponse {
|
||||
messages: AgentChatMessage[];
|
||||
pending_tool_calls?: PendingToolCall[];
|
||||
}
|
||||
|
||||
export type AgentSseEvent =
|
||||
| { event: 'assistant_start'; data: { id: string } }
|
||||
| { event: 'assistant_delta'; data: { id: string; delta: string } }
|
||||
| { event: 'assistant_end'; data: { id: string; message: AgentChatMessage } }
|
||||
| { event: 'tool_start'; data: { tool_call_id: string; name: string } }
|
||||
| { event: 'tool_end'; data: { tool_call_id: string; name: string; message: AgentChatMessage } }
|
||||
| { event: 'pending'; data: { pending_tool_calls: PendingToolCall[] } }
|
||||
| { event: 'done'; data: AgentChatResponse };
|
||||
|
||||
export const agentApi = {
|
||||
chat: (payload: AgentChatRequest) =>
|
||||
request<AgentChatResponse>('/agent/chat', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
}),
|
||||
chatStream: async (
|
||||
payload: AgentChatRequest,
|
||||
onEvent: (evt: AgentSseEvent) => void,
|
||||
options?: { signal?: AbortSignal }
|
||||
) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
};
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const resp = await fetch(`${API_BASE_URL}/agent/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
let errMsg = resp.statusText;
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (Array.isArray((data as any)?.detail)) {
|
||||
errMsg = (data as any).detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ');
|
||||
} else {
|
||||
errMsg = (typeof (data as any)?.detail === 'string') ? (data as any).detail : JSON.stringify(data);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
errMsg = await resp.text();
|
||||
} catch { void 0; }
|
||||
}
|
||||
throw new Error(errMsg || `Request failed: ${resp.status}`);
|
||||
}
|
||||
|
||||
const reader = resp.body?.getReader();
|
||||
if (!reader) throw new Error('Stream not supported');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
const flush = (raw: string) => {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let eventName = 'message';
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
eventName = line.slice(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
dataLines.push(line.slice(5).trimStart());
|
||||
}
|
||||
}
|
||||
const dataStr = dataLines.join('\n').trim();
|
||||
if (!eventName || !dataStr) return;
|
||||
try {
|
||||
const data = JSON.parse(dataStr);
|
||||
onEvent({ event: eventName as any, data } as any);
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
while (true) {
|
||||
const idx = buffer.indexOf('\n\n');
|
||||
if (idx === -1) break;
|
||||
const chunk = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
if (chunk.trim()) flush(chunk);
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) flush(buffer);
|
||||
},
|
||||
};
|
||||
90
web/src/api/aiProviders.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import request from './client';
|
||||
|
||||
export type AIAbility = 'chat' | 'vision' | 'embedding' | 'rerank' | 'voice' | 'tools';
|
||||
|
||||
export interface AIProviderPayload {
|
||||
name: string;
|
||||
identifier: string;
|
||||
provider_type?: string | null;
|
||||
api_format: 'openai' | 'gemini' | 'anthropic' | 'ollama';
|
||||
base_url?: string | null;
|
||||
api_key?: string | null;
|
||||
logo_url?: string | null;
|
||||
extra_config?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AIProvider extends Omit<AIProviderPayload, 'extra_config' | 'api_key'> {
|
||||
id: number;
|
||||
has_api_key: boolean;
|
||||
extra_config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
models?: AIModel[];
|
||||
}
|
||||
|
||||
export interface AIModelPayload {
|
||||
name: string;
|
||||
display_name?: string | null;
|
||||
description?: string | null;
|
||||
capabilities?: AIAbility[];
|
||||
context_window?: number | null;
|
||||
embedding_dimensions?: number | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface AIModel extends Omit<AIModelPayload, 'metadata'> {
|
||||
id: number;
|
||||
provider_id: number;
|
||||
metadata: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
provider?: AIProvider;
|
||||
}
|
||||
|
||||
export type AIDefaultAssignments = Partial<Record<AIAbility, number | null>>;
|
||||
export type AIDefaultModels = Partial<Record<AIAbility, AIModel | null>>;
|
||||
|
||||
export async function fetchProviders() {
|
||||
const data = await request<{ providers: AIProvider[] }>('/ai/providers');
|
||||
return data.providers;
|
||||
}
|
||||
|
||||
export async function createProvider(payload: AIProviderPayload) {
|
||||
return request<AIProvider>('/ai/providers', { method: 'POST', json: payload });
|
||||
}
|
||||
|
||||
export async function updateProvider(id: number, payload: Partial<AIProviderPayload>) {
|
||||
return request<AIProvider>(`/ai/providers/${id}`, { method: 'PUT', json: payload });
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: number) {
|
||||
await request(`/ai/providers/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function syncProviderModels(id: number) {
|
||||
return request<{ created: number; updated: number }>(`/ai/providers/${id}/sync-models`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchRemoteModels(providerId: number) {
|
||||
return request<{ models: AIModelPayload[] }>(`/ai/providers/${providerId}/remote-models`);
|
||||
}
|
||||
|
||||
export async function createModel(providerId: number, payload: AIModelPayload) {
|
||||
return request<AIModel>(`/ai/providers/${providerId}/models`, { method: 'POST', json: payload });
|
||||
}
|
||||
|
||||
export async function updateModel(modelId: number, payload: Partial<AIModelPayload>) {
|
||||
return request<AIModel>(`/ai/models/${modelId}`, { method: 'PUT', json: payload });
|
||||
}
|
||||
|
||||
export async function deleteModel(modelId: number) {
|
||||
await request(`/ai/models/${modelId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function fetchDefaults() {
|
||||
return request<AIDefaultModels>('/ai/defaults');
|
||||
}
|
||||
|
||||
export async function updateDefaults(payload: AIDefaultAssignments) {
|
||||
return request<AIDefaultModels>('/ai/defaults', { method: 'PUT', json: payload });
|
||||
}
|
||||
68
web/src/api/audit.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import request from './client';
|
||||
|
||||
export interface AuditLogItem {
|
||||
id: number;
|
||||
created_at: string;
|
||||
action: string;
|
||||
description?: string | null;
|
||||
user_id?: number | null;
|
||||
username?: string | null;
|
||||
client_ip?: string | null;
|
||||
method: string;
|
||||
path: string;
|
||||
status_code: number;
|
||||
duration_ms?: number | null;
|
||||
success: boolean;
|
||||
request_params?: Record<string, any> | null;
|
||||
request_body?: Record<string, any> | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedAuditLogs {
|
||||
items: AuditLogItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface GetAuditLogsParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
action?: string;
|
||||
success?: boolean;
|
||||
username?: string;
|
||||
path?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export interface ClearAuditLogsParams {
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export const auditApi = {
|
||||
list: (params: GetAuditLogsParams = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.page) query.append('page', params.page.toString());
|
||||
if (params.page_size) query.append('page_size', params.page_size.toString());
|
||||
if (params.action) query.append('action', params.action);
|
||||
if (params.success !== undefined && params.success !== null) query.append('success', String(params.success));
|
||||
if (params.username) query.append('username', params.username);
|
||||
if (params.path) query.append('path', params.path);
|
||||
if (params.start_time) query.append('start_time', params.start_time);
|
||||
if (params.end_time) query.append('end_time', params.end_time);
|
||||
const qs = query.toString();
|
||||
return request<PaginatedAuditLogs>(`/audit/logs${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
clear: (params: ClearAuditLogsParams = {}) => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.start_time) query.append('start_time', params.start_time);
|
||||
if (params.end_time) query.append('end_time', params.end_time);
|
||||
const qs = query.toString();
|
||||
return request<{ deleted_count: number }>(`/audit/logs${qs ? `?${qs}` : ''}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
96
web/src/api/auth.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import request from './client';
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
gravatar_url: string;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateMePayload {
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
old_password?: string;
|
||||
new_password?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestPayload {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmPayload {
|
||||
token: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: async (username: string, password: string, email: string, full_name?: string): Promise<any> => {
|
||||
return request('/auth/register', {
|
||||
method: 'POST',
|
||||
json: { username, password, email, full_name },
|
||||
});
|
||||
},
|
||||
login: async (payload: LoginPayload) => {
|
||||
const form = new URLSearchParams();
|
||||
form.append('username', payload.username);
|
||||
form.append('password', payload.password);
|
||||
try {
|
||||
return await request<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[authApi.login] error:', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
logout: () => {
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
me: async () => {
|
||||
return await request<MeResponse>('/auth/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
},
|
||||
updateMe: async (payload: UpdateMePayload) => {
|
||||
return await request<MeResponse>('/auth/me', {
|
||||
method: 'PUT',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
requestPasswordReset: async (payload: PasswordResetRequestPayload) => {
|
||||
return await request('/auth/password-reset/request', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
verifyPasswordResetToken: async (token: string) => {
|
||||
return await request<{ username: string; email: string }>('/auth/password-reset/verify?token=' + encodeURIComponent(token));
|
||||
},
|
||||
confirmPasswordReset: async (payload: PasswordResetConfirmPayload) => {
|
||||
return await request('/auth/password-reset/confirm', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
42
web/src/api/backup.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import request from './client';
|
||||
|
||||
export const backupApi = {
|
||||
export: async (sections?: string[]) => {
|
||||
const params = new URLSearchParams();
|
||||
(sections || []).forEach((section) => params.append('sections', section));
|
||||
const query = params.toString();
|
||||
const response = await request(`/backup/export${query ? `?${query}` : ''}`, {
|
||||
method: 'GET',
|
||||
rawResponse: true,
|
||||
}) as Response;
|
||||
|
||||
const contentDisposition = response.headers.get('content-disposition');
|
||||
let filename = 'backup.json';
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
|
||||
if (filenameMatch && filenameMatch.length > 1) {
|
||||
filename = filenameMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
import: async (file: File, mode: 'replace' | 'merge' = 'replace') => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('mode', mode);
|
||||
return request('/backup/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
};
|
||||
77
web/src/api/client.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export interface RequestOptions extends RequestInit {
|
||||
json?: any;
|
||||
formData?: FormData;
|
||||
text?: string;
|
||||
rawResponse?: boolean;
|
||||
}
|
||||
const BASE_URL = import.meta.env.PROD ? '/api' : 'http://127.0.0.1:8000/api';
|
||||
export const API_BASE_URL = BASE_URL;
|
||||
|
||||
async function request<T = any>(url: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { json, formData, text, rawResponse, headers, ...rest } = options;
|
||||
const finalHeaders: Record<string, string> = {
|
||||
...(headers as Record<string, string> || {})
|
||||
};
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
finalHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let body: BodyInit | undefined;
|
||||
if (json !== undefined) {
|
||||
body = JSON.stringify(json);
|
||||
finalHeaders['Content-Type'] = 'application/json';
|
||||
} else if (formData) {
|
||||
body = formData;
|
||||
} else if (text !== undefined) {
|
||||
body = text;
|
||||
finalHeaders['Content-Type'] = 'text/plain;charset=utf-8';
|
||||
} else if ((rest as any).body !== undefined) {
|
||||
body = (rest as any).body;
|
||||
delete (rest as any).body;
|
||||
}
|
||||
|
||||
const resp = await fetch(BASE_URL + url, {
|
||||
...rest,
|
||||
headers: finalHeaders,
|
||||
body,
|
||||
});
|
||||
|
||||
if (rawResponse) return resp as any;
|
||||
|
||||
if (!resp.ok) {
|
||||
let errMsg = resp.statusText;
|
||||
try {
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data?.detail)) {
|
||||
errMsg = data.detail.map((e: any) => e.msg || JSON.stringify(e)).join('; ');
|
||||
} else {
|
||||
errMsg = (typeof data?.detail === 'string') ? data.detail : (data.detail ? JSON.stringify(data.detail) : JSON.stringify(data));
|
||||
}
|
||||
} catch { void 0; }
|
||||
throw new Error(errMsg || `Request failed: ${resp.status}`);
|
||||
}
|
||||
|
||||
const contentType = resp.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const json = await resp.json();
|
||||
if (json && typeof json === 'object' && 'code' in json && ('msg' in json || 'message' in json)) {
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.msg || json.message || 'Error');
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
if (contentType.startsWith('text/')) {
|
||||
return await resp.text() as any;
|
||||
}
|
||||
return await resp.arrayBuffer() as any;
|
||||
}
|
||||
|
||||
export { vfsApi, type VfsEntry, type DirListing } from './vfs';
|
||||
export { adaptersApi, type AdapterItem, type AdapterTypeField, type AdapterTypeMeta } from './adapters';
|
||||
export { shareApi, type ShareInfo, type ShareInfoWithPassword } from './share';
|
||||
export { offlineDownloadsApi, type OfflineDownloadTask, type OfflineDownloadCreate, type TaskProgress } from './offlineDownloads';
|
||||
export default request;
|
||||
38
web/src/api/config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import request from './client';
|
||||
|
||||
export async function getConfig(key: string) {
|
||||
return request<{ key: string; value: string }>('/config/?key=' + encodeURIComponent(key));
|
||||
}
|
||||
|
||||
export async function setConfig(key: string, value?: string | null) {
|
||||
const form = new FormData();
|
||||
form.append('key', key);
|
||||
form.append('value', value ?? '');
|
||||
return request('/config/', { method: 'POST', formData: form });
|
||||
}
|
||||
|
||||
export async function getAllConfig() {
|
||||
return request<Record<string, string>>('/config/all');
|
||||
}
|
||||
|
||||
export async function getPublicConfig() {
|
||||
return request<Record<string, string>>('/config/public');
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
version: string;
|
||||
title: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
is_initialized: boolean;
|
||||
app_domain?: string;
|
||||
file_domain?: string;
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return request<SystemStatus>('/config/status');
|
||||
}
|
||||
|
||||
export async function getLatestVersion() {
|
||||
return request<{ latest_version: string | null, body: string | null }>('/config/latest-version');
|
||||
}
|
||||
41
web/src/api/email.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import request from './client';
|
||||
|
||||
export interface EmailTestPayload {
|
||||
to: string;
|
||||
subject: string;
|
||||
template?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function sendTestEmail(payload: EmailTestPayload) {
|
||||
return request<{ task_id: string }>('/email/test', {
|
||||
method: 'POST',
|
||||
json: {
|
||||
template: 'test',
|
||||
context: {},
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listEmailTemplates() {
|
||||
return request<{ templates: string[] }>('/email/templates');
|
||||
}
|
||||
|
||||
export async function getEmailTemplate(name: string) {
|
||||
return request<{ name: string; content: string }>(`/email/templates/${encodeURIComponent(name)}`);
|
||||
}
|
||||
|
||||
export async function updateEmailTemplate(name: string, content: string) {
|
||||
return request(`/email/templates/${encodeURIComponent(name)}`, {
|
||||
method: 'POST',
|
||||
json: { content },
|
||||
});
|
||||
}
|
||||
|
||||
export async function previewEmailTemplate(name: string, context: Record<string, unknown>) {
|
||||
return request<{ html: string }>(`/email/templates/${encodeURIComponent(name)}/preview`, {
|
||||
method: 'POST',
|
||||
json: { context },
|
||||
});
|
||||
}
|
||||
55
web/src/api/notices.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface NoticeItem {
|
||||
id: number;
|
||||
title: string;
|
||||
contentMd: string;
|
||||
isPopup: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface GetNoticesResponse {
|
||||
items: NoticeItem[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GetNoticesParams {
|
||||
version: string;
|
||||
page?: number;
|
||||
}
|
||||
|
||||
const FOXEL_CORE_BASE = 'https://foxel.cc';
|
||||
|
||||
function normalizeVersion(version: string) {
|
||||
return (version || '').trim().replace(/^v/i, '');
|
||||
}
|
||||
|
||||
function extractErrorMessage(data: any) {
|
||||
if (!data) return '';
|
||||
if (typeof data === 'string') return data;
|
||||
if (typeof data.detail === 'string') return data.detail;
|
||||
if (typeof data.code === 'string') return data.code;
|
||||
if (typeof data.message === 'string') return data.message;
|
||||
if (typeof data.msg === 'string') return data.msg;
|
||||
return '';
|
||||
}
|
||||
|
||||
export const noticesApi = {
|
||||
list: async (params: GetNoticesParams): Promise<GetNoticesResponse> => {
|
||||
const url = new URL('/api/notices', FOXEL_CORE_BASE);
|
||||
url.searchParams.set('version', normalizeVersion(params.version));
|
||||
url.searchParams.set('page', String(params.page ?? 1));
|
||||
|
||||
const resp = await fetch(url.href);
|
||||
if (!resp.ok) {
|
||||
let msg = resp.statusText || `Request failed: ${resp.status}`;
|
||||
try {
|
||||
const data = await resp.json();
|
||||
msg = extractErrorMessage(data) || msg;
|
||||
} catch { void 0; }
|
||||
throw new Error(msg);
|
||||
}
|
||||
return await resp.json();
|
||||
},
|
||||
};
|
||||
|
||||
35
web/src/api/offlineDownloads.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import request from './client';
|
||||
|
||||
export interface TaskProgress {
|
||||
stage?: string | null;
|
||||
percent?: number | null;
|
||||
bytes_total?: number | null;
|
||||
bytes_done?: number | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface OfflineDownloadTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed';
|
||||
result?: any;
|
||||
error?: string | null;
|
||||
task_info: Record<string, any>;
|
||||
progress?: TaskProgress | null;
|
||||
meta?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface OfflineDownloadCreate {
|
||||
url: string;
|
||||
dest_dir: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export const offlineDownloadsApi = {
|
||||
create: (payload: OfflineDownloadCreate) => request<{ task_id: string }>('/offline-downloads/', {
|
||||
method: 'POST',
|
||||
json: payload,
|
||||
}),
|
||||
list: () => request<OfflineDownloadTask[]>('/offline-downloads/'),
|
||||
detail: (taskId: string) => request<OfflineDownloadTask>(`/offline-downloads/${taskId}`),
|
||||
};
|
||||
26
web/src/api/permissions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import request from './client';
|
||||
import type { PathRuleInfo } from './roles';
|
||||
|
||||
export interface PermissionInfo {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface UserPermissions {
|
||||
user_id: number;
|
||||
is_admin: boolean;
|
||||
permissions: string[];
|
||||
path_rules: PathRuleInfo[];
|
||||
}
|
||||
|
||||
export const permissionsApi = {
|
||||
listAll: async (): Promise<PermissionInfo[]> => {
|
||||
return await request<PermissionInfo[]>('/permissions');
|
||||
},
|
||||
|
||||
getMine: async (): Promise<UserPermissions> => {
|
||||
return await request<UserPermissions>('/me/permissions');
|
||||
},
|
||||
};
|
||||
155
web/src/api/pluginCenter.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
export interface RepoItem {
|
||||
key: string;
|
||||
name: string;
|
||||
version: string;
|
||||
author?: string;
|
||||
description?: string;
|
||||
website?: string;
|
||||
github?: string;
|
||||
icon?: string;
|
||||
supportedExts?: string[];
|
||||
createdAt?: number;
|
||||
downloads?: number;
|
||||
directUrl: string;
|
||||
}
|
||||
|
||||
export interface RepoListResponse {
|
||||
items: RepoItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface RepoQueryParams {
|
||||
query?: string;
|
||||
author?: string;
|
||||
sort?: 'downloads' | 'createdAt';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
// foxel-core 应用中心的数据结构
|
||||
export interface FoxelCoreApp {
|
||||
key: string;
|
||||
version: string;
|
||||
name: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
description: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
author: string;
|
||||
website: string;
|
||||
tags: {
|
||||
zh: string[];
|
||||
en: string[];
|
||||
};
|
||||
approvedAt: number;
|
||||
detailUrl: string;
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppsResponse {
|
||||
apps: FoxelCoreApp[];
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppVersion {
|
||||
version: string;
|
||||
name: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
description: {
|
||||
zh: string;
|
||||
en: string;
|
||||
};
|
||||
author: string;
|
||||
website: string;
|
||||
tags: {
|
||||
zh: string[];
|
||||
en: string[];
|
||||
};
|
||||
approvedAt: number;
|
||||
releaseNotesMd: string | null;
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppDetail {
|
||||
key: string;
|
||||
latest: FoxelCoreAppVersion & {
|
||||
downloadUrl: string;
|
||||
};
|
||||
versions: FoxelCoreAppVersion[];
|
||||
}
|
||||
|
||||
export interface FoxelCoreAppDetailResponse {
|
||||
app: FoxelCoreAppDetail;
|
||||
}
|
||||
|
||||
const CENTER_BASE = 'https://center.foxel.cc';
|
||||
const FOXEL_CORE_BASE = 'https://foxel.cc';
|
||||
|
||||
export function buildCenterUrl(path: string) {
|
||||
return new URL(path, CENTER_BASE).href;
|
||||
}
|
||||
|
||||
export async function fetchRepoList(params: RepoQueryParams = {}): Promise<RepoListResponse> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.query) query.set('query', params.query);
|
||||
if (params.author) query.set('author', params.author);
|
||||
if (params.sort) query.set('sort', params.sort);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? 12));
|
||||
|
||||
const url = `${CENTER_BASE}/api/repo?${query.toString()}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Repo fetch failed: ${resp.status}`);
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 foxel-core 应用中心获取应用列表
|
||||
*/
|
||||
export async function fetchFoxelCoreApps(query?: string): Promise<FoxelCoreApp[]> {
|
||||
const url = new URL('/api/apps', FOXEL_CORE_BASE);
|
||||
const q = query?.trim();
|
||||
if (q) {
|
||||
url.searchParams.set('q', q);
|
||||
}
|
||||
const resp = await fetch(url.href);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch apps: ${resp.status}`);
|
||||
}
|
||||
const data: FoxelCoreAppsResponse = await resp.json();
|
||||
return data.apps;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 foxel-core 应用中心获取应用详情(含历史版本)
|
||||
*/
|
||||
export async function fetchFoxelCoreAppDetail(appKey: string): Promise<FoxelCoreAppDetail> {
|
||||
const url = `${FOXEL_CORE_BASE}/api/apps/${encodeURIComponent(appKey)}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch app detail: ${resp.status}`);
|
||||
}
|
||||
const data: FoxelCoreAppDetailResponse = await resp.json();
|
||||
return data.app;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 foxel-core 下载应用包文件
|
||||
*/
|
||||
export async function downloadFoxelCoreApp(app: Pick<FoxelCoreApp, 'key' | 'version' | 'downloadUrl'>): Promise<File> {
|
||||
const url = `${FOXEL_CORE_BASE}${app.downloadUrl}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to download app: ${resp.status}`);
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = `${app.key}-${app.version}.foxpkg`;
|
||||
return new File([blob], filename, { type: 'application/octet-stream' });
|
||||
}
|
||||
68
web/src/api/plugins.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import request from './client';
|
||||
|
||||
export interface PluginItem {
|
||||
id: number;
|
||||
key: string;
|
||||
open_app?: boolean | null;
|
||||
name?: string | null;
|
||||
version?: string | null;
|
||||
supported_exts?: string[] | null;
|
||||
default_bounds?: Record<string, number> | null;
|
||||
default_maximized?: boolean | null;
|
||||
icon?: string | null;
|
||||
description?: string | null;
|
||||
author?: string | null;
|
||||
website?: string | null;
|
||||
github?: string | null;
|
||||
license?: string | null;
|
||||
manifest?: Record<string, unknown> | null;
|
||||
loaded_routes?: string[] | null;
|
||||
loaded_processors?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PluginInstallResult {
|
||||
success: boolean;
|
||||
plugin?: PluginItem;
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export const pluginsApi = {
|
||||
/**
|
||||
* 获取已安装插件列表
|
||||
*/
|
||||
list: () => request<PluginItem[]>(`/plugins`),
|
||||
|
||||
/**
|
||||
* 获取单个插件详情
|
||||
*/
|
||||
get: (key: string) => request<PluginItem>(`/plugins/${key}`),
|
||||
|
||||
/**
|
||||
* 安装插件(上传 .foxpkg)
|
||||
*/
|
||||
install: async (file: File): Promise<PluginInstallResult> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return request<PluginInstallResult>(`/plugins/install`, {
|
||||
method: 'POST',
|
||||
formData,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除/卸载插件
|
||||
*/
|
||||
remove: (key: string) => request(`/plugins/${key}`, { method: 'DELETE' }),
|
||||
|
||||
/**
|
||||
* 获取插件 bundle URL
|
||||
*/
|
||||
getBundleUrl: (key: string) => `/api/plugins/${key}/bundle.js`,
|
||||
|
||||
/**
|
||||
* 获取插件资源 URL
|
||||
*/
|
||||
getAssetUrl: (key: string, assetPath: string) =>
|
||||
`/api/plugins/${key}/assets/${assetPath}`,
|
||||
};
|
||||
63
web/src/api/processors.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import request from './client';
|
||||
|
||||
export interface ProcessorTypeField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'password' | 'number' | 'select';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
default?: any;
|
||||
options?: { label: string; value: string | number }[];
|
||||
}
|
||||
|
||||
export interface ProcessorTypeMeta {
|
||||
type: string;
|
||||
name: string;
|
||||
supported_exts: string[];
|
||||
config_schema: ProcessorTypeField[];
|
||||
produces_file: boolean;
|
||||
supports_directory?: boolean;
|
||||
module_path?: string | null;
|
||||
}
|
||||
|
||||
export const processorsApi = {
|
||||
list: () => request<ProcessorTypeMeta[]>('/processors', {
|
||||
method: 'GET'
|
||||
}),
|
||||
process: (params: {
|
||||
path: string;
|
||||
processor_type: string;
|
||||
config: any;
|
||||
save_to?: string;
|
||||
overwrite?: boolean;
|
||||
}) =>
|
||||
request<{ task_id: string }>('/processors/process', {
|
||||
method: 'POST',
|
||||
json: params,
|
||||
}),
|
||||
processDirectory: (params: {
|
||||
path: string;
|
||||
processor_type: string;
|
||||
config: any;
|
||||
overwrite: boolean;
|
||||
max_depth?: number | null;
|
||||
suffix?: string | null;
|
||||
}) =>
|
||||
request<{ task_id: string }>('/processors/process-directory', {
|
||||
method: 'POST',
|
||||
json: params,
|
||||
}),
|
||||
getSource: (type: string) =>
|
||||
request<{ source: string; module_path: string }>('/processors/source/' + encodeURIComponent(type), {
|
||||
method: 'GET',
|
||||
}),
|
||||
updateSource: (type: string, source: string) =>
|
||||
request<boolean>('/processors/source/' + encodeURIComponent(type), {
|
||||
method: 'PUT',
|
||||
json: { source },
|
||||
}),
|
||||
reload: () =>
|
||||
request<boolean>('/processors/reload', {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
109
web/src/api/roles.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import request from './client';
|
||||
import type { UserInfo } from './users';
|
||||
|
||||
export interface RoleInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_system: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RoleDetail extends RoleInfo {
|
||||
permissions: string[];
|
||||
path_rules_count: number;
|
||||
}
|
||||
|
||||
export interface RoleCreate {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface RoleUpdate {
|
||||
name?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface PathRuleInfo {
|
||||
id: number;
|
||||
role_id: number;
|
||||
path_pattern: string;
|
||||
is_regex: boolean;
|
||||
can_read: boolean;
|
||||
can_write: boolean;
|
||||
can_delete: boolean;
|
||||
can_share: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PathRuleCreate {
|
||||
path_pattern: string;
|
||||
is_regex?: boolean;
|
||||
can_read?: boolean;
|
||||
can_write?: boolean;
|
||||
can_delete?: boolean;
|
||||
can_share?: boolean;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export const rolesApi = {
|
||||
list: async (): Promise<RoleInfo[]> => {
|
||||
return await request<RoleInfo[]>('/roles');
|
||||
},
|
||||
|
||||
get: async (roleId: number): Promise<RoleDetail> => {
|
||||
return await request<RoleDetail>(`/roles/${roleId}`);
|
||||
},
|
||||
|
||||
getUsers: async (roleId: number): Promise<UserInfo[]> => {
|
||||
return await request<UserInfo[]>(`/roles/${roleId}/users`);
|
||||
},
|
||||
|
||||
create: async (data: RoleCreate): Promise<RoleInfo> => {
|
||||
return await request<RoleInfo>('/roles', {
|
||||
method: 'POST',
|
||||
json: data,
|
||||
});
|
||||
},
|
||||
|
||||
update: async (roleId: number, data: RoleUpdate): Promise<RoleInfo> => {
|
||||
return await request<RoleInfo>(`/roles/${roleId}`, {
|
||||
method: 'PUT',
|
||||
json: data,
|
||||
});
|
||||
},
|
||||
|
||||
remove: async (roleId: number): Promise<void> => {
|
||||
await request(`/roles/${roleId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
setPermissions: async (roleId: number, permissionCodes: string[]): Promise<string[]> => {
|
||||
return await request<string[]>(`/roles/${roleId}/permissions`, {
|
||||
method: 'POST',
|
||||
json: { permission_codes: permissionCodes },
|
||||
});
|
||||
},
|
||||
|
||||
getPathRules: async (roleId: number): Promise<PathRuleInfo[]> => {
|
||||
return await request<PathRuleInfo[]>(`/roles/${roleId}/path-rules`);
|
||||
},
|
||||
|
||||
addPathRule: async (roleId: number, data: PathRuleCreate): Promise<PathRuleInfo> => {
|
||||
return await request<PathRuleInfo>(`/roles/${roleId}/path-rules`, {
|
||||
method: 'POST',
|
||||
json: data,
|
||||
});
|
||||
},
|
||||
|
||||
updatePathRule: async (ruleId: number, data: PathRuleCreate): Promise<PathRuleInfo> => {
|
||||
return await request<PathRuleInfo>(`/path-rules/${ruleId}`, {
|
||||
method: 'PUT',
|
||||
json: data,
|
||||
});
|
||||
},
|
||||
|
||||
deletePathRule: async (ruleId: number): Promise<void> => {
|
||||
await request(`/path-rules/${ruleId}`, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
48
web/src/api/share.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import request, { API_BASE_URL } from './client';
|
||||
import type { DirListing } from './vfs';
|
||||
|
||||
export interface ShareInfo {
|
||||
id: number;
|
||||
token: string;
|
||||
name: string;
|
||||
paths: string[];
|
||||
created_at: string;
|
||||
expires_at?: string;
|
||||
access_type: 'public' | 'password';
|
||||
}
|
||||
|
||||
export interface ShareInfoWithPassword extends ShareInfo {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ShareCreatePayload {
|
||||
name: string;
|
||||
paths: string[];
|
||||
expires_in_days?: number;
|
||||
access_type: 'public' | 'password';
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ClearExpiredResult {
|
||||
deleted_count: number;
|
||||
}
|
||||
|
||||
export const shareApi = {
|
||||
create: (payload: ShareCreatePayload) => request<ShareInfoWithPassword>('/shares', { method: 'POST', json: payload }),
|
||||
list: () => request<ShareInfo[]>('/shares'),
|
||||
remove: (shareId: number) => request<void>(`/shares/${shareId}`, { method: 'DELETE' }),
|
||||
clearExpired: () => request<ClearExpiredResult>(`/shares/expired`, { method: 'DELETE' }),
|
||||
get: (token: string) => request<ShareInfo>(`/s/${token}`),
|
||||
verifyPassword: (token: string, password: string) => request<void>(`/s/${token}/verify`, { method: 'POST', json: { password } }),
|
||||
listDir: (token: string, path: string = '/', password?: string) => {
|
||||
const params: Record<string, string> = { path };
|
||||
if (password) {
|
||||
params.password = password;
|
||||
}
|
||||
return request<DirListing>(`/s/${token}/ls?${new URLSearchParams(params)}`);
|
||||
},
|
||||
downloadUrl: (token: string, path: string, password?: string) => {
|
||||
const url = `${API_BASE_URL}/s/${token}/download?path=${encodeURIComponent(path)}`;
|
||||
return password ? `${url}&password=${encodeURIComponent(password)}` : url;
|
||||
},
|
||||
};
|
||||
45
web/src/api/tasks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import request from './client';
|
||||
import type { TaskProgress } from './offlineDownloads';
|
||||
|
||||
export interface AutomationTask {
|
||||
id: number;
|
||||
name: string;
|
||||
event: string;
|
||||
trigger_config?: Record<string, any>;
|
||||
processor_type: string;
|
||||
processor_config: Record<string, any>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type AutomationTaskCreate = Omit<AutomationTask, 'id'>;
|
||||
export type AutomationTaskUpdate = Partial<AutomationTaskCreate>;
|
||||
|
||||
export interface QueuedTask {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'pending' | 'running' | 'success' | 'failed';
|
||||
result?: any;
|
||||
error?: string;
|
||||
task_info: Record<string, any>;
|
||||
progress?: TaskProgress | null;
|
||||
meta?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface TaskQueueSettings {
|
||||
concurrency: number;
|
||||
active_workers: number;
|
||||
}
|
||||
|
||||
export interface TaskQueueSettingsUpdate {
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
export const tasksApi = {
|
||||
list: () => request<AutomationTask[]>('/tasks/'),
|
||||
create: (payload: AutomationTaskCreate) => request<AutomationTask>('/tasks/', { method: 'POST', json: payload }),
|
||||
update: (id: number, payload: AutomationTaskUpdate) => request<AutomationTask>(`/tasks/${id}`, { method: 'PUT', json: payload }),
|
||||
remove: (id: number) => request<void>(`/tasks/${id}`, { method: 'DELETE' }),
|
||||
getQueue: () => request<QueuedTask[]>('/tasks/queue'),
|
||||
getQueueSettings: () => request<TaskQueueSettings>('/tasks/queue/settings'),
|
||||
updateQueueSettings: (payload: TaskQueueSettingsUpdate) => request<TaskQueueSettings>('/tasks/queue/settings', { method: 'POST', json: payload }),
|
||||
};
|
||||
77
web/src/api/users.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import request from './client';
|
||||
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string | null;
|
||||
full_name: string | null;
|
||||
disabled: boolean;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
last_login: string | null;
|
||||
}
|
||||
|
||||
export interface UserDetail extends UserInfo {
|
||||
roles: string[];
|
||||
created_by_username: string | null;
|
||||
}
|
||||
|
||||
export interface UserCreate {
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
is_admin?: boolean;
|
||||
disabled?: boolean;
|
||||
role_ids?: number[];
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
email?: string | null;
|
||||
full_name?: string | null;
|
||||
password?: string | null;
|
||||
is_admin?: boolean | null;
|
||||
disabled?: boolean | null;
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
list: async (): Promise<UserInfo[]> => {
|
||||
return await request<UserInfo[]>('/users');
|
||||
},
|
||||
|
||||
get: async (userId: number): Promise<UserDetail> => {
|
||||
return await request<UserDetail>(`/users/${userId}`);
|
||||
},
|
||||
|
||||
create: async (data: UserCreate): Promise<UserDetail> => {
|
||||
return await request<UserDetail>('/users', {
|
||||
method: 'POST',
|
||||
json: data,
|
||||
});
|
||||
},
|
||||
|
||||
update: async (userId: number, data: UserUpdate): Promise<UserDetail> => {
|
||||
return await request<UserDetail>(`/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
json: data,
|
||||
});
|
||||
},
|
||||
|
||||
remove: async (userId: number): Promise<void> => {
|
||||
await request(`/users/${userId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
setRoles: async (userId: number, roleIds: number[]): Promise<string[]> => {
|
||||
return await request<string[]>(`/users/${userId}/roles`, {
|
||||
method: 'POST',
|
||||
json: { role_ids: roleIds },
|
||||
});
|
||||
},
|
||||
|
||||
removeRole: async (userId: number, roleId: number): Promise<string[]> => {
|
||||
return await request<string[]>(`/users/${userId}/roles/${roleId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
65
web/src/api/vectorDB.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import client from './client';
|
||||
|
||||
export interface VectorDBIndexInfo {
|
||||
index_name: string;
|
||||
index_type?: string;
|
||||
metric_type?: string;
|
||||
indexed_rows: number;
|
||||
pending_index_rows: number;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
export interface VectorDBCollectionStats {
|
||||
name: string;
|
||||
row_count: number;
|
||||
dimension: number | null;
|
||||
estimated_memory_bytes: number;
|
||||
is_vector_collection: boolean;
|
||||
indexes: VectorDBIndexInfo[];
|
||||
}
|
||||
|
||||
export interface VectorDBStats {
|
||||
collections: VectorDBCollectionStats[];
|
||||
collection_count: number;
|
||||
total_vectors: number;
|
||||
estimated_total_memory_bytes: number;
|
||||
db_file_size_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface VectorDBProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'text' | 'password';
|
||||
required?: boolean;
|
||||
default?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface VectorDBProviderMeta {
|
||||
type: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
config_schema: VectorDBProviderField[];
|
||||
}
|
||||
|
||||
export interface VectorDBCurrentConfig {
|
||||
type: string;
|
||||
config: Record<string, string>;
|
||||
label?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateVectorDBConfigResponse {
|
||||
config: VectorDBCurrentConfig;
|
||||
stats: VectorDBStats;
|
||||
}
|
||||
|
||||
export const vectorDBApi = {
|
||||
getProviders: () => client<VectorDBProviderMeta[]>('/vector-db/providers', { method: 'GET' }),
|
||||
getConfig: () => client<VectorDBCurrentConfig>('/vector-db/config', { method: 'GET' }),
|
||||
getStats: () => client<VectorDBStats>('/vector-db/stats', { method: 'GET' }),
|
||||
updateConfig: (payload: { type: string; config: Record<string, string> }) =>
|
||||
client<UpdateVectorDBConfigResponse>('/vector-db/config', { method: 'POST', json: payload }),
|
||||
clearAll: () => client('/vector-db/clear-all', { method: 'POST' }),
|
||||
};
|
||||
144
web/src/api/vfs.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import request, { API_BASE_URL } from './client';
|
||||
|
||||
export interface VfsEntry {
|
||||
name: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
mtime: number;
|
||||
type?: string;
|
||||
has_thumbnail?: boolean;
|
||||
}
|
||||
|
||||
export interface DirListing {
|
||||
path: string;
|
||||
entries: VfsEntry[];
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
id: string;
|
||||
path: string;
|
||||
score: number;
|
||||
chunk_id?: string;
|
||||
snippet?: string;
|
||||
mime?: string;
|
||||
source_type?: string;
|
||||
start_offset?: number;
|
||||
end_offset?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface SearchPagination {
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
items: SearchResultItem[];
|
||||
query: string;
|
||||
mode?: string;
|
||||
pagination?: SearchPagination;
|
||||
}
|
||||
|
||||
export const vfsApi = {
|
||||
list: (path: string, page: number = 1, pageSize: number = 50, sortBy: string = 'name', sortOrder: string = 'asc') => {
|
||||
const cleaned = path.replace(/\\/g, '/');
|
||||
const trimmed = cleaned === '/' ? '' : cleaned.replace(/^\/+/, '');
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString(),
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder
|
||||
});
|
||||
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
|
||||
},
|
||||
readFile: async (path: string) => {
|
||||
const enc = encodeURI(path.replace(/^\/+/, ''));
|
||||
const resp = await request(`/fs/file/${enc}`, { rawResponse: true });
|
||||
return await (resp as Response).arrayBuffer();
|
||||
},
|
||||
uploadFile: (fullPath: string, file: File | Blob) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return request(`/fs/file/${encodeURI(fullPath.replace(/^\/+/, ''))}`, { method: 'POST', formData: fd });
|
||||
},
|
||||
mkdir: (path: string) => request('/fs/mkdir', { method: 'POST', json: { path } }),
|
||||
deletePath: (path: string) => request(`/fs/${encodeURI(path.replace(/^\/+/, ''))}`, { method: 'DELETE' }),
|
||||
move: (src: string, dst: string, options?: { overwrite?: boolean }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
|
||||
const query = params.toString();
|
||||
return request(`/fs/move${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
|
||||
},
|
||||
copy: (src: string, dst: string, options?: { overwrite?: boolean }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.overwrite !== undefined) params.set('overwrite', String(options.overwrite));
|
||||
const query = params.toString();
|
||||
return request(`/fs/copy${query ? `?${query}` : ''}`, { method: 'POST', json: { src, dst } });
|
||||
},
|
||||
rename: (src: string, dst: string) => request('/fs/rename', { method: 'POST', json: { src, dst } }),
|
||||
thumb: (path: string, w=256, h=256, fit='cover') =>
|
||||
request<ArrayBuffer>(`/fs/thumb/${encodeURI(path.replace(/^\/+/, ''))}?w=${w}&h=${h}&fit=${fit}`),
|
||||
streamUrl: (path: string) => `${API_BASE_URL}/fs/stream/${encodeURI(path.replace(/^\/+/, ''))}`,
|
||||
stat: (path: string) => request(`/fs/stat/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||
getTempLinkToken: (path: string, expiresIn: number = 3600) =>
|
||||
request<{token: string, path: string, url: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}?expires_in=${expiresIn}`),
|
||||
getTempPublicUrl: (token: string) => `${API_BASE_URL}/fs/public/${token}`,
|
||||
uploadStream: (fullPath: string, file: File, overwrite: boolean = true, onProgress?: (loaded: number, total: number) => void) => {
|
||||
const enc = encodeURI(fullPath.replace(/^\/+/, ''));
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE_URL}/fs/upload/${enc}?overwrite=${overwrite}`);
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (ev.lengthComputable && onProgress) onProgress(ev.loaded, ev.total);
|
||||
};
|
||||
xhr.onreadystatechange = () => {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
if (json.code === 0) return resolve(json.data);
|
||||
return reject(new Error(json.msg || json.message || 'Upload failed'));
|
||||
} catch {
|
||||
return reject(new Error('Invalid response'));
|
||||
}
|
||||
} else {
|
||||
let err = 'Upload failed';
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
err = json.detail || json.msg || json.message || err;
|
||||
} catch { void 0; }
|
||||
reject(new Error(err));
|
||||
}
|
||||
}
|
||||
};
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
xhr.send(fd);
|
||||
});
|
||||
},
|
||||
searchFiles: (
|
||||
q: string,
|
||||
top_k: number = 10,
|
||||
mode: 'vector' | 'filename' = 'vector',
|
||||
page?: number,
|
||||
page_size?: number,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
q,
|
||||
top_k: String(top_k),
|
||||
mode,
|
||||
});
|
||||
if (page !== undefined) params.set('page', String(page));
|
||||
if (page_size !== undefined) params.set('page_size', String(page_size));
|
||||
return request<SearchResponse>(`/fs/search?${params.toString()}`);
|
||||
},
|
||||
};
|
||||
386
web/src/apps/AppWindowsLayer.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { Space, Button } from 'antd';
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import type { AppDescriptor, AppComponentProps, AppOpenComponentProps } from './types';
|
||||
import type { VfsEntry } from '../api/client';
|
||||
|
||||
export interface AppWindowItem {
|
||||
id: string;
|
||||
app: AppDescriptor;
|
||||
kind: 'file' | 'app';
|
||||
entry?: VfsEntry;
|
||||
filePath?: string;
|
||||
maximized: boolean;
|
||||
minimized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type AppWindowPatch = Partial<Pick<AppWindowItem, 'maximized' | 'minimized' | 'x' | 'y' | 'width' | 'height'>>;
|
||||
|
||||
interface AppWindowsLayerProps {
|
||||
windows: AppWindowItem[];
|
||||
onClose: (id: string) => void;
|
||||
onToggleMax: (id: string) => void;
|
||||
onBringToFront: (id: string) => void;
|
||||
onUpdateWindow: (id: string, patch: AppWindowPatch) => void;
|
||||
}
|
||||
|
||||
export const AppWindowsLayer: React.FC<AppWindowsLayerProps> = ({ windows, onClose, onToggleMax, onBringToFront, onUpdateWindow }) => {
|
||||
const dragRef = useRef<{
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
newX: number;
|
||||
newY: number;
|
||||
} | null>(null);
|
||||
const resizeRef = useRef<{
|
||||
id: string;
|
||||
dir: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
origin: { x: number; y: number; w: number; h: number };
|
||||
newX: number;
|
||||
newY: number;
|
||||
newW: number;
|
||||
newH: number;
|
||||
} | null>(null);
|
||||
|
||||
const windowEls = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const onMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (dragRef.current) {
|
||||
const { id, startX, startY, originX, originY } = dragRef.current;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
const newX = Math.max(0, originX + dx);
|
||||
const newY = Math.max(0, originY + dy);
|
||||
dragRef.current.newX = newX;
|
||||
dragRef.current.newY = newY;
|
||||
const el = windowEls.current[id];
|
||||
if (el) {
|
||||
el.style.left = newX + 'px';
|
||||
el.style.top = newY + 'px';
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (resizeRef.current) {
|
||||
const { id, dir, startX, startY, origin } = resizeRef.current;
|
||||
let { x, y, w, h } = { x: origin.x, y: origin.y, w: origin.w, h: origin.h };
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
const minW = 360;
|
||||
const minH = 240;
|
||||
if (dir.includes('e')) w = Math.max(minW, origin.w + dx);
|
||||
if (dir.includes('s')) h = Math.max(minH, origin.h + dy);
|
||||
if (dir.includes('w')) { w = Math.max(minW, origin.w - dx); x = origin.x + (origin.w - w); }
|
||||
if (dir.includes('n')) { h = Math.max(minH, origin.h - dy); y = origin.y + (origin.h - h); }
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
x = Math.min(Math.max(0, x), vw - 100);
|
||||
y = Math.min(Math.max(0, y), vh - 60);
|
||||
resizeRef.current.newX = x;
|
||||
resizeRef.current.newY = y;
|
||||
resizeRef.current.newW = w;
|
||||
resizeRef.current.newH = h;
|
||||
const el = windowEls.current[id];
|
||||
if (el) {
|
||||
el.style.left = x + 'px';
|
||||
el.style.top = y + 'px';
|
||||
el.style.width = w + 'px';
|
||||
el.style.height = h + 'px';
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (dragRef.current) {
|
||||
const { id, newX, newY, originX, originY } = dragRef.current;
|
||||
if (newX !== undefined && newY !== undefined && (newX !== originX || newY !== originY)) {
|
||||
onUpdateWindow(id, { x: newX, y: newY });
|
||||
}
|
||||
dragRef.current = null;
|
||||
}
|
||||
if (resizeRef.current) {
|
||||
const { id, newX, newY, newW, newH } = resizeRef.current;
|
||||
if (newW && newH) {
|
||||
onUpdateWindow(id, { x: newX, y: newY, width: newW, height: newH });
|
||||
}
|
||||
resizeRef.current = null;
|
||||
}
|
||||
}, [onUpdateWindow]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, [onMouseMove, onMouseUp]);
|
||||
|
||||
const startDrag = (e: React.MouseEvent, w: AppWindowItem) => {
|
||||
if (e.detail === 2) return;
|
||||
if (w.maximized) return;
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
onBringToFront(w.id);
|
||||
dragRef.current = {
|
||||
id: w.id,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
originX: w.x,
|
||||
originY: w.y,
|
||||
newX: w.x,
|
||||
newY: w.y
|
||||
};
|
||||
};
|
||||
|
||||
const startResize = (e: React.MouseEvent, w: AppWindowItem, dir: string) => {
|
||||
e.stopPropagation();
|
||||
if (w.maximized) return;
|
||||
onBringToFront(w.id);
|
||||
resizeRef.current = {
|
||||
id: w.id,
|
||||
dir,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
origin: { x: w.x, y: w.y, w: w.width, h: w.height },
|
||||
newX: w.x,
|
||||
newY: w.y,
|
||||
newW: w.width,
|
||||
newH: w.height
|
||||
};
|
||||
};
|
||||
|
||||
const isInteracting = (id: string) =>
|
||||
dragRef.current?.id === id || resizeRef.current?.id === id;
|
||||
|
||||
const resizeHandles = (w: AppWindowItem) => {
|
||||
const dirs = ['n','s','e','w','ne','nw','se','sw'];
|
||||
const cursorMap: Record<string,string> = {
|
||||
n:'ns-resize', s:'ns-resize', e:'ew-resize', w:'ew-resize',
|
||||
ne:'nesw-resize', sw:'nesw-resize', nw:'nwse-resize', se:'nwse-resize'
|
||||
};
|
||||
const posStyle: Record<string, React.CSSProperties> = {
|
||||
n:{ top:0, left:'50%', transform:'translate(-50%, -50%)', width:'calc(100% - 28px)', height:10 },
|
||||
s:{ bottom:0, left:'50%', transform:'translate(-50%, 50%)', width:'calc(100% - 28px)', height:10 },
|
||||
e:{ right:0, top:'50%', transform:'translate(50%,-50%)', width:10, height:'calc(100% - 28px)' },
|
||||
w:{ left:0, top:'50%', transform:'translate(-50%, -50%)', width:10, height:'calc(100% - 28px)' },
|
||||
ne:{ top:0, right:0, transform:'translate(50%,-50%)', width:14, height:14 },
|
||||
nw:{ top:0, left:0, transform:'translate(-50%,-50%)', width:14, height:14 },
|
||||
se:{ bottom:0, right:0, transform:'translate(50%,50%)', width:14, height:14 },
|
||||
sw:{ bottom:0, left:0, transform:'translate(-50%,50%)', width:14, height:14 }
|
||||
};
|
||||
return dirs.map(d => (
|
||||
<div
|
||||
key={d}
|
||||
onMouseDown={e => startResize(e, w, d)}
|
||||
style={{
|
||||
position:'absolute',
|
||||
...posStyle[d],
|
||||
cursor: cursorMap[d],
|
||||
zIndex: 10,
|
||||
borderRadius: 4,
|
||||
background: 'transparent'
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
const visibleWindows = windows.filter(w => !w.minimized);
|
||||
|
||||
return (
|
||||
<>
|
||||
{visibleWindows.map((w, idx) => {
|
||||
const isFileWindow = w.kind !== 'app';
|
||||
const FileComp = w.app.component as React.FC<AppComponentProps>;
|
||||
const OpenComp = w.app.openAppComponent as React.FC<AppOpenComponentProps> | undefined;
|
||||
const ContentComp = (isFileWindow ? FileComp : OpenComp) as React.FC<any> | undefined;
|
||||
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
|
||||
const titleText = isFileWindow ? `${w.app.name} - ${w.entry?.name || ''}` : w.app.name;
|
||||
|
||||
if (!ContentComp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!useSystemWindow) {
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
ref={el => { windowEls.current[w.id] = el; }}
|
||||
onMouseDown={() => onBringToFront(w.id)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: w.maximized ? 0 : w.y,
|
||||
left: w.maximized ? 0 : w.x,
|
||||
width: w.maximized ? '100vw' : w.width,
|
||||
height: w.maximized ? '100vh' : w.height,
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
boxShadow: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
zIndex: 3000 + idx
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'transparent'
|
||||
}}
|
||||
>
|
||||
{isFileWindow ? (
|
||||
<ContentComp
|
||||
filePath={w.filePath || ''}
|
||||
entry={w.entry as VfsEntry}
|
||||
onRequestClose={() => onClose(w.id)}
|
||||
/>
|
||||
) : (
|
||||
<ContentComp onRequestClose={() => onClose(w.id)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 否则继续使用系统窗口渲染(不改动原有逻辑)
|
||||
const interacting = isInteracting(w.id);
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
ref={el => { windowEls.current[w.id] = el; }}
|
||||
onMouseDown={() => onBringToFront(w.id)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: w.maximized ? 0 : w.y,
|
||||
left: w.maximized ? 0 : w.x,
|
||||
width: w.maximized ? '100vw' : w.width,
|
||||
height: w.maximized ? '100vh' : w.height,
|
||||
background: 'var(--ant-color-bg-elevated, var(--ant-color-bg-container))',
|
||||
border: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.18))',
|
||||
borderRadius: w.maximized ? 0 : 12,
|
||||
boxShadow: w.maximized
|
||||
? 'none'
|
||||
: interacting
|
||||
? '0 20px 50px -12px rgba(0,0,0,0.35)'
|
||||
: '0 12px 32px -8px rgba(0,0,0,0.25)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
backdropFilter: 'blur(12px) saturate(150%)',
|
||||
zIndex: 3000 + idx,
|
||||
willChange: 'left,top,width,height',
|
||||
transition: interacting ? 'none' : 'top .15s,left .15s,width .15s,height .15s,box-shadow .25s'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onMouseDown={(e) => startDrag(e, w)}
|
||||
onDoubleClick={() => onToggleMax(w.id)}
|
||||
style={{
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
background: 'var(--ant-color-fill-secondary, rgba(0,0,0,0.25))',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, rgba(255,255,255,0.1))',
|
||||
color: 'var(--ant-color-text, #333)',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
letterSpacing: .2,
|
||||
userSelect: 'none',
|
||||
cursor: w.maximized ? 'default' : 'grab'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
paddingRight: 8,
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
{titleText}
|
||||
</span>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label="最小化"
|
||||
icon={<MinusOutlined />}
|
||||
onClick={() => onUpdateWindow(w.id, { minimized: true })}
|
||||
style={{
|
||||
color: 'var(--ant-color-text-secondary, #555)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={w.maximized ? '还原' : '最大化'}
|
||||
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={() => onToggleMax(w.id)}
|
||||
style={{
|
||||
color: 'var(--ant-color-text-secondary, #555)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
aria-label="关闭"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => onClose(w.id)}
|
||||
style={{
|
||||
color: 'var(--ant-color-error, #ff4d4f)',
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
background: 'transparent', // Let the app's own background show through
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{!w.maximized && resizeHandles(w)}
|
||||
{isFileWindow ? (
|
||||
<ContentComp
|
||||
filePath={w.filePath || ''}
|
||||
entry={w.entry as VfsEntry}
|
||||
onRequestClose={() => onClose(w.id)}
|
||||
/>
|
||||
) : (
|
||||
<ContentComp onRequestClose={() => onClose(w.id)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
127
web/src/apps/PluginHost/index.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import type { AppComponentProps, AppOpenComponentProps } from '../types';
|
||||
import type { PluginItem } from '../../api/plugins';
|
||||
|
||||
export interface PluginAppHostProps extends AppComponentProps {
|
||||
plugin: PluginItem;
|
||||
}
|
||||
|
||||
function buildPluginFrameUrl(params: Record<string, string>): string {
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (typeof v !== 'string') return;
|
||||
const value = v.trim();
|
||||
if (!value) return;
|
||||
qs.set(k, value);
|
||||
});
|
||||
return `/plugin-frame.html?${qs.toString()}`;
|
||||
}
|
||||
|
||||
function getPluginStylePaths(plugin: PluginItem): string[] {
|
||||
const styles = (plugin.manifest as any)?.frontend?.styles as unknown;
|
||||
if (!Array.isArray(styles)) return [];
|
||||
return styles.filter((s) => typeof s === 'string' && s.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件宿主组件 - 文件打开模式
|
||||
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
|
||||
*/
|
||||
export const PluginAppHost: React.FC<PluginAppHostProps> = ({
|
||||
plugin,
|
||||
filePath,
|
||||
entry,
|
||||
onRequestClose,
|
||||
}) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
|
||||
const src = useMemo(
|
||||
() =>
|
||||
buildPluginFrameUrl({
|
||||
pluginKey: plugin.key,
|
||||
pluginVersion: plugin.version || '',
|
||||
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
|
||||
mode: 'file',
|
||||
filePath,
|
||||
entry: JSON.stringify(entry),
|
||||
}),
|
||||
[plugin, filePath, entry]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
if (ev.source !== iframeRef.current?.contentWindow) return;
|
||||
const data = ev.data as any;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.type === 'foxel-plugin:close' && data.pluginKey === plugin.key) {
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [plugin.key]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title={`plugin:${plugin.key}`}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export interface PluginAppOpenHostProps extends AppOpenComponentProps {
|
||||
plugin: PluginItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件宿主组件 - 独立应用模式
|
||||
* 使用 iframe 隔离渲染与样式,避免插件污染宿主 DOM/CSS。
|
||||
* 注意:同源且不加 sandbox 时,不是安全沙箱(插件仍可通过 window.parent 访问宿主)。
|
||||
*/
|
||||
export const PluginAppOpenHost: React.FC<PluginAppOpenHostProps> = ({ plugin, onRequestClose }) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const onCloseRef = useRef(onRequestClose);
|
||||
onCloseRef.current = onRequestClose;
|
||||
|
||||
const src = useMemo(
|
||||
() =>
|
||||
buildPluginFrameUrl({
|
||||
pluginKey: plugin.key,
|
||||
pluginVersion: plugin.version || '',
|
||||
pluginStyles: JSON.stringify(getPluginStylePaths(plugin)),
|
||||
mode: 'app',
|
||||
}),
|
||||
[plugin]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
if (ev.source !== iframeRef.current?.contentWindow) return;
|
||||
const data = ev.data as any;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
if (data.type === 'foxel-plugin:close' && data.pluginKey === plugin.key) {
|
||||
onCloseRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [plugin.key]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
title={`plugin:${plugin.key}:app`}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
156
web/src/apps/registry.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { VfsEntry } from '../api/client';
|
||||
import type { AppDescriptor } from './types';
|
||||
import React from 'react';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { PluginAppHost, PluginAppOpenHost } from './PluginHost';
|
||||
import { getPluginAssetUrl } from '../plugins/runtime';
|
||||
|
||||
const apps: AppDescriptor[] = [];
|
||||
|
||||
/**
|
||||
* 获取插件的唯一 key
|
||||
*/
|
||||
function getPluginAppKey(p: PluginItem): string {
|
||||
return `plugin:${p.key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析插件图标 URL
|
||||
* 支持绝对路径、相对路径(插件资源)、外部 URL
|
||||
*/
|
||||
function resolvePluginIcon(p: PluginItem): string | undefined {
|
||||
if (!p.icon) return undefined;
|
||||
|
||||
// 外部 URL
|
||||
if (p.icon.startsWith('http://') || p.icon.startsWith('https://')) {
|
||||
return p.icon;
|
||||
}
|
||||
|
||||
// 绝对路径
|
||||
if (p.icon.startsWith('/')) {
|
||||
return p.icon;
|
||||
}
|
||||
|
||||
// 插件资源路径
|
||||
return getPluginAssetUrl(p.key, p.icon);
|
||||
}
|
||||
|
||||
function resolvePluginUseSystemWindow(p: PluginItem): boolean | undefined {
|
||||
const frontend = (p.manifest as any)?.frontend as any;
|
||||
const value = frontend?.use_system_window ?? frontend?.useSystemWindow;
|
||||
return typeof value === 'boolean' ? value : undefined;
|
||||
}
|
||||
|
||||
function registerPluginAsApp(p: PluginItem) {
|
||||
const key = getPluginAppKey(p);
|
||||
if (apps.find((a) => a.key === key)) return;
|
||||
|
||||
const supported = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (!p.supported_exts || p.supported_exts.length === 0) return true;
|
||||
return p.supported_exts.includes(ext);
|
||||
};
|
||||
|
||||
apps.push({
|
||||
key,
|
||||
name: p.name || `插件 ${p.key}`,
|
||||
supported,
|
||||
component: (props: any) => React.createElement(PluginAppHost, { plugin: p, ...props }),
|
||||
openAppComponent: p.open_app
|
||||
? (props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })
|
||||
: undefined,
|
||||
iconUrl: resolvePluginIcon(p),
|
||||
default: false,
|
||||
defaultBounds: p.default_bounds || undefined,
|
||||
defaultMaximized: p.default_maximized || undefined,
|
||||
useSystemWindow: resolvePluginUseSystemWindow(p),
|
||||
description: p.description || undefined,
|
||||
author: p.author || undefined,
|
||||
supportedExts: p.supported_exts || undefined,
|
||||
website: p.website || undefined,
|
||||
github: p.github || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
items.forEach((p) => registerPluginAsApp(p));
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
|
||||
const appsLoadedPromise = loadApps();
|
||||
|
||||
export async function ensureAppsLoaded() {
|
||||
await appsLoadedPromise;
|
||||
}
|
||||
|
||||
export function listPluginApps(): AppDescriptor[] {
|
||||
return apps;
|
||||
}
|
||||
|
||||
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
|
||||
return apps.filter((a) => a.supported(entry));
|
||||
}
|
||||
|
||||
export function getAppByKey(key: string): AppDescriptor | undefined {
|
||||
return apps.find((a) => a.key === key);
|
||||
}
|
||||
|
||||
export function getDefaultAppForEntry(entry: VfsEntry): AppDescriptor | undefined {
|
||||
if (entry.is_dir) return;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (!ext) return apps.find((a) => a.supported(entry) && a.default);
|
||||
const saved = localStorage.getItem(`app.default.${ext}`);
|
||||
if (saved) {
|
||||
return apps.find((a) => a.key === saved && a.supported(entry)) || undefined;
|
||||
}
|
||||
return apps.find((a) => a.supported(entry) && a.default);
|
||||
}
|
||||
|
||||
export type { AppDescriptor };
|
||||
export type { AppComponentProps } from './types';
|
||||
|
||||
export async function reloadPluginApps() {
|
||||
try {
|
||||
const items = await pluginsApi.list();
|
||||
|
||||
// 生成要保留的 key 集合
|
||||
const keepKeys = new Set(items.map((p) => getPluginAppKey(p)));
|
||||
|
||||
// 移除已卸载的插件应用
|
||||
for (let i = apps.length - 1; i >= 0; i--) {
|
||||
const a = apps[i];
|
||||
if (!keepKeys.has(a.key)) {
|
||||
apps.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新或添加插件应用
|
||||
items.forEach((p) => {
|
||||
const key = getPluginAppKey(p);
|
||||
const existing = apps.find((a) => a.key === key);
|
||||
if (!existing) {
|
||||
registerPluginAsApp(p);
|
||||
} else {
|
||||
// 更新现有应用信息
|
||||
existing.name = p.name || `插件 ${p.key}`;
|
||||
existing.defaultBounds = p.default_bounds || undefined;
|
||||
existing.defaultMaximized = p.default_maximized || undefined;
|
||||
existing.useSystemWindow = resolvePluginUseSystemWindow(p);
|
||||
existing.iconUrl = resolvePluginIcon(p);
|
||||
existing.description = p.description || undefined;
|
||||
existing.author = p.author || undefined;
|
||||
existing.supportedExts = p.supported_exts || undefined;
|
||||
existing.openAppComponent = p.open_app
|
||||
? (props: any) => React.createElement(PluginAppOpenHost, { plugin: p, ...props })
|
||||
: undefined;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
void 0;
|
||||
}
|
||||
}
|
||||
44
web/src/apps/types.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { VfsEntry } from '../api/client';
|
||||
|
||||
export interface AppComponentProps {
|
||||
filePath: string;
|
||||
entry: VfsEntry;
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
export interface AppOpenComponentProps {
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
export interface AppDescriptor {
|
||||
key: string;
|
||||
name: string;
|
||||
supported: (entry: VfsEntry) => boolean;
|
||||
component: React.ComponentType<AppComponentProps>;
|
||||
/**
|
||||
* 独立打开应用(不依赖文件)
|
||||
* 缺省表示该应用仅支持“通过文件打开”。
|
||||
*/
|
||||
openAppComponent?: React.ComponentType<AppOpenComponentProps>;
|
||||
iconUrl?: string;
|
||||
default?: boolean;
|
||||
defaultMaximized?: boolean;
|
||||
description?: string;
|
||||
author?: string;
|
||||
supportedExts?: string[];
|
||||
website?: string;
|
||||
github?: string;
|
||||
/**
|
||||
* 应用窗口的默认位置与尺寸(非最大化时生效)
|
||||
* 任意字段缺省则按系统默认/级联偏移。
|
||||
*/
|
||||
defaultBounds?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
// 新增:是否使用系统窗口(带标题栏、拖拽与缩放)
|
||||
// 默认为 true;若设为 false 则渲染为无壳的嵌入式视图。
|
||||
useSystemWindow?: boolean;
|
||||
}
|
||||
891
web/src/components/AiAgentWidget.tsx
Normal file
@@ -0,0 +1,891 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Avatar, Button, Divider, Flex, Input, List, Modal, Space, Switch, Tag, Typography, message, theme } from 'antd';
|
||||
import { RobotOutlined, SendOutlined, DeleteOutlined, ToolOutlined, DownOutlined, UpOutlined, CodeOutlined, CopyOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { agentApi, type AgentChatMessage, type PendingToolCall } from '../api/agent';
|
||||
import { useI18n } from '../i18n';
|
||||
import '../styles/ai-agent.css';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
function normalizePath(p?: string | null): string | null {
|
||||
if (!p) return null;
|
||||
const s = ('/' + p).replace(/\/+/, '/').replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
return s;
|
||||
}
|
||||
|
||||
function extractTextContent(content: any): string {
|
||||
if (content == null) return '';
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (typeof item === 'string') {
|
||||
if (item.trim()) parts.push(item);
|
||||
continue;
|
||||
}
|
||||
const text = typeof item?.text === 'string' ? item.text : '';
|
||||
if (text.trim()) parts.push(text);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(content, null, 2);
|
||||
} catch {
|
||||
return String(content);
|
||||
}
|
||||
}
|
||||
|
||||
function tryParseJson<T = any>(raw: string): T | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
try {
|
||||
return JSON.parse(s) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function shortId(id: string, keep: number = 6): string {
|
||||
const s = String(id || '');
|
||||
if (s.length <= keep * 2 + 3) return s;
|
||||
return `${s.slice(0, keep)}…${s.slice(-keep)}`;
|
||||
}
|
||||
|
||||
function clampText(value: string, maxLen: number): string {
|
||||
if (value.length <= maxLen) return value;
|
||||
return `${value.slice(0, maxLen)}…`;
|
||||
}
|
||||
|
||||
function formatDisplayValue(value: any, maxLen: number = 120): string {
|
||||
if (value == null) return '';
|
||||
if (typeof value === 'string') return clampText(value, maxLen);
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return clampText(JSON.stringify(value), maxLen);
|
||||
} catch {
|
||||
return clampText(String(value), maxLen);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: any): value is Record<string, any> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
type ToolPayload = {
|
||||
ok?: boolean;
|
||||
summary?: string;
|
||||
view?: {
|
||||
type?: string;
|
||||
title?: string;
|
||||
meta?: Record<string, any>;
|
||||
items?: any[];
|
||||
text?: string;
|
||||
message?: string;
|
||||
};
|
||||
data?: any;
|
||||
error?: any;
|
||||
};
|
||||
|
||||
function parseToolPayload(raw: string): ToolPayload | null {
|
||||
const parsed = tryParseJson<ToolPayload>(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
interface AiAgentWidgetProps {
|
||||
currentPath?: string | null;
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
}
|
||||
|
||||
const AiAgentWidget = memo(function AiAgentWidget({ currentPath, open, onOpenChange }: AiAgentWidgetProps) {
|
||||
const { t } = useI18n();
|
||||
const { token } = theme.useToken();
|
||||
const [autoExecute, setAutoExecute] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [messages, setMessages] = useState<AgentChatMessage[]>([]);
|
||||
const [pending, setPending] = useState<PendingToolCall[]>([]);
|
||||
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||
const [expandedRaw, setExpandedRaw] = useState<Record<string, boolean>>({});
|
||||
const [runningTools, setRunningTools] = useState<Record<string, string>>({});
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<TextAreaRef | null>(null);
|
||||
const streamControllerRef = useRef<AbortController | null>(null);
|
||||
const streamSeqRef = useRef(0);
|
||||
const baseMessagesRef = useRef<AgentChatMessage[]>([]);
|
||||
const assistantIndexRef = useRef<Record<string, number>>({});
|
||||
const toolNameByIdRef = useRef<Record<string, string>>({});
|
||||
|
||||
const effectivePath = useMemo(() => normalizePath(currentPath), [currentPath]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const t = window.setTimeout(scrollToBottom, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [messages, open, pending, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || loading || pending.length > 0) return;
|
||||
const t = window.setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [open, loading, messages.length, pending.length]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toolCallsById = useMemo(() => {
|
||||
const map = new Map<string, { name: string; args: Record<string, any> }>();
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== 'object') continue;
|
||||
if (msg.role !== 'assistant') continue;
|
||||
const toolCalls = (msg as any).tool_calls;
|
||||
if (!Array.isArray(toolCalls)) continue;
|
||||
for (const call of toolCalls) {
|
||||
const id = typeof call?.id === 'string' ? call.id : '';
|
||||
const fn = call?.function;
|
||||
const name = typeof fn?.name === 'string' ? fn.name : '';
|
||||
const rawArgs = typeof fn?.arguments === 'string' ? fn.arguments : '';
|
||||
if (!id || !name) continue;
|
||||
const parsedArgs = tryParseJson<Record<string, any>>(rawArgs) || {};
|
||||
map.set(id, { name, args: parsedArgs });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [messages]);
|
||||
|
||||
const runStream = useCallback(async (payload: Partial<Parameters<typeof agentApi.chat>[0]> & { messages: AgentChatMessage[] }) => {
|
||||
streamControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
streamControllerRef.current = controller;
|
||||
streamSeqRef.current += 1;
|
||||
const seq = streamSeqRef.current;
|
||||
|
||||
baseMessagesRef.current = payload.messages;
|
||||
assistantIndexRef.current = {};
|
||||
|
||||
setLoading(true);
|
||||
const approvedIds = payload.approved_tool_call_ids || [];
|
||||
if (Array.isArray(approvedIds) && approvedIds.length > 0) {
|
||||
const preRunning: Record<string, string> = {};
|
||||
approvedIds.forEach((id) => {
|
||||
if (typeof id === 'string' && id.trim()) preRunning[id] = '';
|
||||
});
|
||||
setRunningTools(preRunning);
|
||||
} else {
|
||||
setRunningTools({});
|
||||
}
|
||||
|
||||
try {
|
||||
await agentApi.chatStream(
|
||||
{
|
||||
messages: payload.messages,
|
||||
auto_execute: autoExecute,
|
||||
context: effectivePath ? { current_path: effectivePath } : undefined,
|
||||
approved_tool_call_ids: payload.approved_tool_call_ids,
|
||||
rejected_tool_call_ids: payload.rejected_tool_call_ids,
|
||||
},
|
||||
(evt) => {
|
||||
if (seq !== streamSeqRef.current) return;
|
||||
switch (evt.event) {
|
||||
case 'assistant_start': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
if (!id) return;
|
||||
setMessages((prev) => {
|
||||
const idx = prev.length;
|
||||
assistantIndexRef.current[id] = idx;
|
||||
return [...prev, { role: 'assistant', content: '' }];
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'assistant_delta': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
const delta = String((evt.data as any)?.delta || '');
|
||||
if (!id || !delta) return;
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current[id];
|
||||
if (idx === undefined || idx < 0 || idx >= prev.length) return prev;
|
||||
const cur = prev[idx] as any;
|
||||
const curContent = typeof cur?.content === 'string' ? cur.content : extractTextContent(cur?.content);
|
||||
const next = prev.slice();
|
||||
next[idx] = { ...cur, content: (curContent || '') + delta };
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'assistant_end': {
|
||||
const id = String((evt.data as any)?.id || '');
|
||||
const msg = (evt.data as any)?.message;
|
||||
if (!id || !msg || typeof msg !== 'object') return;
|
||||
setMessages((prev) => {
|
||||
const idx = assistantIndexRef.current[id];
|
||||
if (idx === undefined || idx < 0 || idx >= prev.length) return prev;
|
||||
const next = prev.slice();
|
||||
next[idx] = msg;
|
||||
return next;
|
||||
});
|
||||
delete assistantIndexRef.current[id];
|
||||
return;
|
||||
}
|
||||
case 'tool_start': {
|
||||
const toolCallId = String((evt.data as any)?.tool_call_id || '');
|
||||
const name = String((evt.data as any)?.name || '');
|
||||
if (!toolCallId) return;
|
||||
if (name) toolNameByIdRef.current[toolCallId] = name;
|
||||
setRunningTools((prev) => ({ ...prev, [toolCallId]: name || prev[toolCallId] || '' }));
|
||||
return;
|
||||
}
|
||||
case 'tool_end': {
|
||||
const toolCallId = String((evt.data as any)?.tool_call_id || '');
|
||||
const name = String((evt.data as any)?.name || '');
|
||||
const msg = (evt.data as any)?.message;
|
||||
if (toolCallId && name) toolNameByIdRef.current[toolCallId] = name;
|
||||
if (toolCallId) {
|
||||
setRunningTools((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[toolCallId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (msg && typeof msg === 'object') {
|
||||
setMessages((prev) => [...prev, msg]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'pending': {
|
||||
const items = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
|
||||
setPending(items);
|
||||
return;
|
||||
}
|
||||
case 'done': {
|
||||
const base = baseMessagesRef.current || [];
|
||||
const newMessages = Array.isArray((evt.data as any)?.messages) ? (evt.data as any).messages : [];
|
||||
const nextPending = Array.isArray((evt.data as any)?.pending_tool_calls) ? (evt.data as any).pending_tool_calls : [];
|
||||
setMessages([...base, ...newMessages]);
|
||||
setPending(nextPending);
|
||||
setRunningTools({});
|
||||
assistantIndexRef.current = {};
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (controller.signal.aborted) return;
|
||||
message.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
if (seq === streamSeqRef.current) {
|
||||
setLoading(false);
|
||||
if (controller.signal.aborted) {
|
||||
setRunningTools({});
|
||||
assistantIndexRef.current = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [autoExecute, effectivePath, t]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const text = input.trim();
|
||||
if (!text) return;
|
||||
if (pending.length > 0) {
|
||||
message.warning(t('Please confirm pending actions first'));
|
||||
return;
|
||||
}
|
||||
const nextUserMsg: AgentChatMessage = { role: 'user', content: text };
|
||||
setInput('');
|
||||
const base = [...messages, nextUserMsg];
|
||||
setMessages(base);
|
||||
setPending([]);
|
||||
await runStream({ messages: base });
|
||||
}, [input, messages, pending.length, runStream, t]);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
streamControllerRef.current?.abort();
|
||||
setMessages([]);
|
||||
setPending([]);
|
||||
setExpandedTools({});
|
||||
setExpandedRaw({});
|
||||
setRunningTools({});
|
||||
}, []);
|
||||
|
||||
const approveOne = useCallback(async (id: string) => {
|
||||
await runStream({ messages, approved_tool_call_ids: [id] });
|
||||
}, [messages, runStream]);
|
||||
|
||||
const rejectOne = useCallback(async (id: string) => {
|
||||
await runStream({ messages, rejected_tool_call_ids: [id] });
|
||||
}, [messages, runStream]);
|
||||
|
||||
const approveAll = useCallback(async () => {
|
||||
const ids = pending.map((p) => p.id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
await runStream({ messages, approved_tool_call_ids: ids });
|
||||
}, [messages, pending, runStream]);
|
||||
|
||||
const rejectAll = useCallback(async () => {
|
||||
const ids = pending.map((p) => p.id).filter(Boolean);
|
||||
if (ids.length === 0) return;
|
||||
await runStream({ messages, rejected_tool_call_ids: ids });
|
||||
}, [messages, pending, runStream]);
|
||||
|
||||
const messageItems = useMemo(() => {
|
||||
return messages.filter((m) => {
|
||||
if (!m || typeof m !== 'object') return false;
|
||||
const role = typeof (m as any).role === 'string' ? String((m as any).role) : '';
|
||||
if (!role || role === 'system') return false;
|
||||
if (role === 'assistant') {
|
||||
const text = extractTextContent((m as any).content);
|
||||
return !!text.trim();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const runningToolEntries = useMemo(() => Object.entries(runningTools).filter(([id]) => !!id), [runningTools]);
|
||||
const runningToolCount = runningToolEntries.length;
|
||||
|
||||
const copyToClipboard = useCallback(async (raw: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
message.success(t('Copied'));
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Operation failed'));
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const renderToolResultSummary = useCallback((rawContent: string) => {
|
||||
const payload = parseToolPayload(rawContent);
|
||||
if (!payload) return '';
|
||||
const summary = typeof payload.summary === 'string' ? payload.summary.trim() : '';
|
||||
if (summary) return summary;
|
||||
|
||||
if (payload.ok === false) {
|
||||
const message = typeof payload.error?.message === 'string' ? payload.error.message : '';
|
||||
return message ? `${t('Error')}: ${message}` : t('Error');
|
||||
}
|
||||
|
||||
const view = payload.view || {};
|
||||
const viewType = typeof view.type === 'string' ? view.type : '';
|
||||
if (viewType === 'text') {
|
||||
const text = typeof view.text === 'string' ? view.text : '';
|
||||
return text ? `${text.length} ${t('chars')}` : '';
|
||||
}
|
||||
if (viewType === 'list') {
|
||||
const items = Array.isArray(view.items) ? view.items : [];
|
||||
return `${items.length} ${t('items')}`;
|
||||
}
|
||||
if (viewType === 'kv') {
|
||||
const items = Array.isArray(view.items) ? view.items : [];
|
||||
return `${items.length} ${t('items')}`;
|
||||
}
|
||||
return '';
|
||||
}, [t]);
|
||||
|
||||
const renderToolDetails = useCallback((toolKey: string, rawContent: string) => {
|
||||
const payload = parseToolPayload(rawContent);
|
||||
const view = payload?.view;
|
||||
const showRaw = !!expandedRaw[toolKey];
|
||||
const toggleRaw = () => setExpandedRaw((prev) => ({ ...prev, [toolKey]: !prev[toolKey] }));
|
||||
|
||||
const rawJson = (() => {
|
||||
if (!rawContent?.trim()) return '';
|
||||
const parsed = tryParseJson<any>(rawContent);
|
||||
if (!parsed) return rawContent;
|
||||
try {
|
||||
return JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
return rawContent;
|
||||
}
|
||||
})();
|
||||
|
||||
const header = (
|
||||
<Space size={10} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CodeOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); toggleRaw(); }}
|
||||
>
|
||||
{t('Raw JSON')}
|
||||
</Button>
|
||||
{showRaw && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={(e) => { e.stopPropagation(); void copyToClipboard(rawJson); }}
|
||||
>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const viewType = typeof view?.type === 'string' ? view.type : '';
|
||||
const title = typeof view?.title === 'string' ? view.title : '';
|
||||
const metaEntries = isPlainObject(view?.meta) ? Object.entries(view!.meta) : [];
|
||||
|
||||
const renderMeta = () => {
|
||||
if (metaEntries.length === 0 && !title) return null;
|
||||
return (
|
||||
<>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{title ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{title}</Text>
|
||||
) : null}
|
||||
{metaEntries.slice(0, 6).map(([key, value]) => (
|
||||
<Text key={key} type="secondary" style={{ fontSize: 12 }}>
|
||||
{key}: {formatDisplayValue(value, 180) || '-'}
|
||||
</Text>
|
||||
))}
|
||||
</Space>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (viewType === 'error') {
|
||||
const message = typeof view?.message === 'string'
|
||||
? view.message
|
||||
: (typeof payload?.error?.message === 'string' ? payload.error.message : t('Error'));
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{message || t('Error')}
|
||||
</Paragraph>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'text') {
|
||||
const text = typeof view?.text === 'string' ? view.text : '';
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<pre className="fx-agent-pre" style={{ marginTop: metaEntries.length || title ? 0 : 10 }}>{text || ''}</pre>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'kv') {
|
||||
const items = Array.isArray(view?.items) ? view!.items : [];
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any, idx) => {
|
||||
const key = typeof item?.key === 'string' ? item.key : (typeof item?.label === 'string' ? item.label : String(idx));
|
||||
const value = typeof item?.value === 'string' ? item.value : formatDisplayValue(item?.value, 200);
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap>
|
||||
<Text code style={{ fontVariantNumeric: 'tabular-nums' }}>{key || '-'}</Text>
|
||||
<Text>{value || '-'}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewType === 'list') {
|
||||
const items = Array.isArray(view?.items) ? view!.items : [];
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{renderMeta()}
|
||||
<List
|
||||
size="small"
|
||||
dataSource={items}
|
||||
locale={{ emptyText: t('No results') }}
|
||||
renderItem={(item: any) => {
|
||||
if (isPlainObject(item)) {
|
||||
const entries = Object.entries(item);
|
||||
const shown = entries.slice(0, 4);
|
||||
const extra = entries.length - shown.length;
|
||||
return (
|
||||
<List.Item>
|
||||
<Space size={10} wrap style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size={10} wrap>
|
||||
{shown.map(([key, value]) => (
|
||||
<Text key={key}>
|
||||
<Text type="secondary">{key}</Text>: {formatDisplayValue(value, 160) || '-'}
|
||||
</Text>
|
||||
))}
|
||||
{extra > 0 ? <Text type="secondary">+{extra}</Text> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<List.Item>
|
||||
<Text>{formatDisplayValue(item, 200) || '-'}</Text>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
{showRaw && (
|
||||
<>
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fx-agent-tool-details">
|
||||
{header}
|
||||
<Divider style={{ margin: '10px 0' }} />
|
||||
{showRaw ? (
|
||||
<pre className="fx-agent-pre">{rawJson}</pre>
|
||||
) : (
|
||||
<Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{extractTextContent(payload ?? rawContent) || <Text type="secondary">{t('No content')}</Text>}
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, [copyToClipboard, expandedRaw, t]);
|
||||
|
||||
const renderToolArgsSummary = useCallback((args?: Record<string, any> | null) => {
|
||||
const entries = Object.entries(args || {})
|
||||
.filter(([, value]) => value != null && String(value).trim() !== '');
|
||||
if (entries.length === 0) return '';
|
||||
return entries.slice(0, 2)
|
||||
.map(([key, value]) => `${key}: ${formatDisplayValue(value, 60)}`)
|
||||
.join(' · ');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={(
|
||||
<Flex align="center" justify="space-between" gap={12} wrap>
|
||||
<Text strong>{t('AI Agent')}</Text>
|
||||
<Space align="center">
|
||||
<Text type="secondary">{t('Auto execute')}</Text>
|
||||
<Switch size="small" checked={autoExecute} onChange={setAutoExecute} />
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={clearChat}
|
||||
disabled={loading || messageItems.length === 0}
|
||||
>
|
||||
{t('Clear')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
)}
|
||||
open={open}
|
||||
onCancel={() => { streamControllerRef.current?.abort(); onOpenChange(false); }}
|
||||
width={720}
|
||||
centered
|
||||
closable={false}
|
||||
destroyOnHidden
|
||||
footer={null}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 8,
|
||||
background: token.colorBgContainer,
|
||||
height: '70vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical gap={0} style={{ flex: 1, minHeight: 0 }} className="fx-agent-container">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="fx-agent-chat-scroll"
|
||||
>
|
||||
{messageItems.length === 0 ? (
|
||||
<div className="fx-agent-empty">
|
||||
<Avatar size={36} icon={<RobotOutlined />} style={{ background: token.colorPrimary }} />
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary">{t('Start a conversation')}</Text>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="fx-agent-messages">
|
||||
{messageItems.map((m, idx) => {
|
||||
const role = String((m as any).role);
|
||||
const isUser = role === 'user';
|
||||
const isTool = role === 'tool';
|
||||
const toolCallId = typeof (m as any).tool_call_id === 'string' ? String((m as any).tool_call_id) : '';
|
||||
const toolInfo = toolCallId ? toolCallsById.get(toolCallId) : null;
|
||||
const toolName = toolInfo?.name || (toolCallId ? toolNameByIdRef.current[toolCallId] : '') || '';
|
||||
const msgKey = toolCallId ? `tool:${toolCallId}` : `${role}:${idx}`;
|
||||
|
||||
if (isTool) {
|
||||
const rawContent = extractTextContent((m as any).content);
|
||||
const expanded = !!expandedTools[msgKey];
|
||||
const summary = rawContent ? renderToolResultSummary(rawContent) : '';
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-tool">
|
||||
<div className="fx-agent-tool-block">
|
||||
<div className="fx-agent-tool-bar">
|
||||
<Space size={6} wrap className="fx-agent-tool-pills">
|
||||
<Tag className="fx-agent-pill" bordered={false} icon={<ToolOutlined />}>
|
||||
{t('MCP Tool')}
|
||||
</Tag>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-strong" bordered={false} icon={<CodeOutlined />}>
|
||||
{toolName || t('Tool')}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={() => setExpandedTools((prev) => ({ ...prev, [msgKey]: !prev[msgKey] }))}
|
||||
>
|
||||
{expanded ? t('Collapse') : t('Expand')}
|
||||
</Button>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="fx-agent-tool-summary-line">
|
||||
<Text type="secondary">{summary}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
{expanded && (
|
||||
<div className="fx-agent-tool-expanded">
|
||||
{toolInfo?.args && Object.keys(toolInfo.args).length > 0 && (
|
||||
<div style={{ marginBottom: 10 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Arguments')}</Text>
|
||||
<pre className="fx-agent-pre fx-agent-pre-compact">
|
||||
{JSON.stringify(toolInfo.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{renderToolDetails(msgKey, rawContent)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const text = extractTextContent((m as any).content);
|
||||
if (isUser) {
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-user">
|
||||
<div className="fx-agent-user-block fx-agent-content">
|
||||
{text.trim() ? <div className="fx-agent-text">{text}</div> : <Text type="secondary">{t('No content')}</Text>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msgKey} className="fx-agent-msg fx-agent-msg-assistant">
|
||||
<div className="fx-agent-assistant-block fx-agent-content">
|
||||
{text.trim() ? (
|
||||
<div className="fx-agent-md">
|
||||
<ReactMarkdown>{text}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Text type="secondary">{t('No content')}</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{runningToolCount > 0 && (
|
||||
<div className="fx-agent-running">
|
||||
<LoadingOutlined spin />
|
||||
<Text type="secondary">{t('Calling tools')}</Text>
|
||||
<Space size={6} wrap>
|
||||
{runningToolEntries.slice(0, 2).map(([id, name]) => (
|
||||
<Tag key={id} bordered={false} color="blue">
|
||||
{(name || t('Tool'))} #{shortId(id, 4)}
|
||||
</Tag>
|
||||
))}
|
||||
{runningToolCount > 2 && (
|
||||
<Text type="secondary">+{runningToolCount - 2}</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<div className="fx-agent-pending-group">
|
||||
<div className="fx-agent-pending-head">
|
||||
<Space size={8} wrap>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-warn" bordered={false}>
|
||||
{t('Pending actions')}
|
||||
</Tag>
|
||||
<Text type="secondary">{pending.length}</Text>
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Button size="small" type="primary" onClick={approveAll} loading={loading}>
|
||||
{t('Execute all')}
|
||||
</Button>
|
||||
<Button size="small" onClick={rejectAll} disabled={loading}>
|
||||
{t('Cancel all')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div className="fx-agent-pending-list">
|
||||
{pending.map((p) => {
|
||||
const args = p.arguments || {};
|
||||
const key = `pending:${p.id}`;
|
||||
const expanded = !!expandedTools[key];
|
||||
const running = Object.prototype.hasOwnProperty.call(runningTools, p.id);
|
||||
const summary = renderToolArgsSummary(args);
|
||||
return (
|
||||
<div key={p.id} className="fx-agent-tool-block fx-agent-pending-item">
|
||||
<div className="fx-agent-tool-bar">
|
||||
<Space size={6} wrap className="fx-agent-tool-pills">
|
||||
<Tag className="fx-agent-pill" bordered={false} icon={<ToolOutlined />}>
|
||||
{t('MCP Tool')}
|
||||
</Tag>
|
||||
<Tag className="fx-agent-pill fx-agent-pill-strong" bordered={false} icon={<CodeOutlined />}>
|
||||
{p.name}
|
||||
</Tag>
|
||||
{running ? <LoadingOutlined spin style={{ color: token.colorPrimary }} /> : null}
|
||||
</Space>
|
||||
<Space size={6}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => void approveOne(p.id)}
|
||||
loading={loading && running}
|
||||
disabled={loading && !running}
|
||||
>
|
||||
{t('Execute')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => void rejectOne(p.id)}
|
||||
disabled={loading && !running}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={expanded ? <UpOutlined /> : <DownOutlined />}
|
||||
onClick={() => setExpandedTools((prev) => ({ ...prev, [key]: !prev[key] }))}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
{summary ? (
|
||||
<div className="fx-agent-tool-summary-line">
|
||||
<Text type="secondary">{summary}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
{expanded && (
|
||||
<div className="fx-agent-tool-expanded">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{t('Arguments')}</Text>
|
||||
<pre className="fx-agent-pre">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fx-agent-composer">
|
||||
<Flex vertical gap={8}>
|
||||
<Space wrap>
|
||||
{effectivePath && (
|
||||
<Tag bordered={false} color="blue">{t('Current')}: {effectivePath}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Input.TextArea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('Type a message')}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
autoFocus
|
||||
disabled={loading || pending.length > 0}
|
||||
variant="borderless"
|
||||
onPressEnter={(e) => {
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
void handleSend();
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSend}
|
||||
loading={loading}
|
||||
disabled={loading || pending.length > 0 || !input.trim()}
|
||||
>
|
||||
{t('Send')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AiAgentWidget;
|
||||
20
web/src/components/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Dropdown, Button } from 'antd';
|
||||
import { GlobalOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { memo } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const LanguageSwitcher = memo(function LanguageSwitcher() {
|
||||
const { lang, setLang, t } = useI18n();
|
||||
const items = [
|
||||
{ key: 'zh', label: t('Chinese'), icon: lang === 'zh' ? <CheckOutlined /> : undefined, onClick: () => setLang('zh') },
|
||||
{ key: 'en', label: t('English'), icon: lang === 'en' ? <CheckOutlined /> : undefined, onClick: () => setLang('en') },
|
||||
];
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']}>
|
||||
<Button icon={<GlobalOutlined />}>{t('Language')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
|
||||
export default LanguageSwitcher;
|
||||
|
||||
184
web/src/components/NoticesModal.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, List, Typography, theme, Flex, Button, Empty, message, Divider, Spin } from 'antd';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { format } from 'date-fns';
|
||||
import { noticesApi, type NoticeItem } from '../api/notices';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface NoticesModalProps {
|
||||
open: boolean;
|
||||
version: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const NoticesModal = memo(function NoticesModal({ open, version, onClose }: NoticesModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<NoticeItem[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
|
||||
const selected = useMemo(() => items.find(i => i.id === selectedId) ?? null, [items, selectedId]);
|
||||
const hasMore = items.length < total;
|
||||
|
||||
const loadPage = async (targetPage: number, mode: 'replace' | 'append') => {
|
||||
if (mode === 'replace') setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
try {
|
||||
const resp = await noticesApi.list({ version, page: targetPage });
|
||||
setPage(resp.page ?? targetPage);
|
||||
setTotal(resp.total ?? 0);
|
||||
setItems(prev => mode === 'replace' ? resp.items : [...prev, ...resp.items]);
|
||||
if (mode === 'replace') {
|
||||
setSelectedId(resp.items[0]?.id ?? null);
|
||||
} else {
|
||||
setSelectedId(prev => prev ?? resp.items[0]?.id ?? null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message || t('Error'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setItems([]);
|
||||
setPage(1);
|
||||
setTotal(0);
|
||||
setSelectedId(null);
|
||||
loadPage(1, 'replace');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, version]);
|
||||
|
||||
const formatTime = (ts: number) => {
|
||||
try {
|
||||
return format(new Date(ts), 'yyyy-MM-dd HH:mm');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Notices')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={980}
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
height: '70vh',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex style={{ height: '70vh', minHeight: 0 }}>
|
||||
<div style={{
|
||||
width: 320,
|
||||
minWidth: 280,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
}}>
|
||||
<Typography.Text type="secondary">{t('Total')}: {total}</Typography.Text>
|
||||
<Typography.Text type="secondary">{items.length}/{total}</Typography.Text>
|
||||
</div>
|
||||
<List
|
||||
size="small"
|
||||
loading={loading && items.length === 0}
|
||||
dataSource={items}
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = item.id === selectedId;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isSelected ? 'rgba(22,119,255,0.08)' : undefined,
|
||||
borderInlineStart: isSelected ? `3px solid ${token.colorPrimary}` : '3px solid transparent',
|
||||
paddingInlineStart: 10,
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={<Typography.Text strong={isSelected}>{item.title}</Typography.Text>}
|
||||
description={<Typography.Text type="secondary">{formatTime(item.createdAt)}</Typography.Text>}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div style={{
|
||||
padding: 12,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}>
|
||||
<Button
|
||||
block
|
||||
loading={loadingMore}
|
||||
disabled={!hasMore}
|
||||
onClick={() => loadPage(page + 1, 'append')}
|
||||
>
|
||||
{t('Load more')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0, padding: 16, overflow: 'auto' }}>
|
||||
{selected ? (
|
||||
<>
|
||||
<Typography.Title level={4} style={{ marginTop: 0, marginBottom: 6 }}>
|
||||
{selected.title}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">{formatTime(selected.createdAt)}</Typography.Text>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
{selected.contentMd?.trim() ? (
|
||||
<div style={{ color: token.colorText, lineHeight: 1.7 }}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||
ul: ({ ...props }) => <ul style={{ paddingLeft: 20, marginBottom: 12 }} {...props} />,
|
||||
li: ({ ...props }) => <li style={{ marginBottom: 6 }} {...props} />,
|
||||
p: ({ ...props }) => <p style={{ marginBottom: 12 }} {...props} />,
|
||||
}}
|
||||
>
|
||||
{selected.contentMd}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('No content')} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', paddingTop: 80 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('No notices')} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default NoticesModal;
|
||||
|
||||
8
web/src/components/PageCard.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Card, type CardProps } from 'antd';
|
||||
import { memo } from 'react';
|
||||
|
||||
const PageCard = memo((props: CardProps) => {
|
||||
return <Card styles={{ body: { overflowY: 'auto', height: 'calc(100vh - 145px)' } }} {...props} />;
|
||||
});
|
||||
|
||||
export default PageCard;
|
||||
143
web/src/components/PathSelectorModal.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { memo, useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Button, List, Typography, Space, Input, message } from 'antd';
|
||||
import { FolderOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../api/client';
|
||||
import { getFileIcon } from '../pages/FileExplorerPage/components/FileIcons';
|
||||
|
||||
export type PathSelectorMode = 'directory' | 'file' | 'any';
|
||||
|
||||
interface PathSelectorModalProps {
|
||||
open: boolean;
|
||||
mode?: PathSelectorMode;
|
||||
initialPath?: string;
|
||||
onOk: (path: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return '/';
|
||||
const s = ('/' + p).replace(/\/+/, '/');
|
||||
return s.replace(/\\/g, '/').replace(/\/+$/, '') || '/';
|
||||
}
|
||||
|
||||
function joinPath(dir: string, name: string): string {
|
||||
const base = normalizePath(dir);
|
||||
if (base === '/') return `/${name}`;
|
||||
return `${base}/${name}`.replace(/\/+/, '/');
|
||||
}
|
||||
|
||||
const PathSelectorModal = memo(function PathSelectorModal({ open, mode = 'directory', initialPath = '/', onOk, onCancel }: PathSelectorModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [path, setPath] = useState<string>(normalizePath(initialPath));
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selected, setSelected] = useState<string | null>(null); // selected file name within current folder
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (mode === 'file') return t('Select File');
|
||||
if (mode === 'any') return t('Select Path');
|
||||
return t('Select Folder');
|
||||
}, [mode, t]);
|
||||
|
||||
const load = async (p: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const listing = await vfsApi.list(p, 1, 500, 'name', 'asc');
|
||||
setEntries(listing.entries);
|
||||
setPath(listing.path || p);
|
||||
setSelected(null);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load(normalizePath(initialPath));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialPath]);
|
||||
|
||||
const canOk = useMemo(() => {
|
||||
if (mode === 'file') return !!selected;
|
||||
return true;
|
||||
}, [mode, selected]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (mode === 'directory') {
|
||||
onOk(normalizePath(path));
|
||||
return;
|
||||
}
|
||||
if (mode === 'file') {
|
||||
if (!selected) {
|
||||
message.warning(t('Please select a file'));
|
||||
return;
|
||||
}
|
||||
onOk(joinPath(path, selected));
|
||||
return;
|
||||
}
|
||||
// any
|
||||
if (selected) onOk(joinPath(path, selected));
|
||||
else onOk(normalizePath(path));
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
const cur = normalizePath(path);
|
||||
if (cur === '/') return;
|
||||
const parent = cur.replace(/\/+$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
load(parent);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: !canOk }}
|
||||
width={720}
|
||||
>
|
||||
<Space style={{ width: '100%', marginBottom: 12 }} align="center">
|
||||
<Typography.Text type="secondary">{t('Current')}</Typography.Text>
|
||||
<Input value={path} readOnly />
|
||||
<Button onClick={goUp} icon={<ArrowUpOutlined />} disabled={path === '/'}>{t('Up')}</Button>
|
||||
{mode !== 'file' && (
|
||||
<Button type="primary" onClick={() => onOk(normalizePath(path))}>{t('Select Current Folder')}</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<List
|
||||
bordered
|
||||
loading={loading}
|
||||
dataSource={entries}
|
||||
style={{ maxHeight: 420, overflow: 'auto' }}
|
||||
renderItem={(item) => {
|
||||
const isSelected = selected === item.name && !item.is_dir;
|
||||
return (
|
||||
<List.Item
|
||||
onClick={() => {
|
||||
if (item.is_dir) {
|
||||
load(joinPath(path, item.name));
|
||||
} else {
|
||||
setSelected((prev) => (prev === item.name ? null : item.name));
|
||||
}
|
||||
}}
|
||||
style={{ cursor: 'pointer', background: isSelected ? 'rgba(22,119,255,0.08)' : undefined }}
|
||||
>
|
||||
<Space>
|
||||
{item.is_dir ? <FolderOutlined /> : getFileIcon(item.name)}
|
||||
<Typography.Text strong={item.is_dir}>{item.name}</Typography.Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default PathSelectorModal;
|
||||
|
||||
63
web/src/components/ProcessorConfigForm.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import type { ProcessorTypeMeta } from '../api/processors';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
interface ProcessorConfigFormProps {
|
||||
processorMeta: ProcessorTypeMeta | undefined;
|
||||
form: any;
|
||||
configPath: string[];
|
||||
}
|
||||
|
||||
export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ processorMeta, configPath }) => {
|
||||
const { t } = useI18n();
|
||||
if (!processorMeta) {
|
||||
return <Typography.Text type="secondary">{t('Please select a processor')}</Typography.Text>;
|
||||
}
|
||||
if (!processorMeta.config_schema?.length) {
|
||||
return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{processorMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
|
||||
let inputNode: React.ReactNode;
|
||||
|
||||
switch (field.type) {
|
||||
case 'password':
|
||||
inputNode = <Input.Password placeholder={field.placeholder} />;
|
||||
break;
|
||||
case 'number':
|
||||
inputNode = <Input type="number" placeholder={field.placeholder} />;
|
||||
break;
|
||||
case 'select':
|
||||
inputNode = (
|
||||
<Select placeholder={field.placeholder || t('Please select')}>
|
||||
{field.options?.map((opt: any) => (
|
||||
<Select.Option key={String(opt.value)} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
inputNode = <Input placeholder={field.placeholder} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={[...configPath, field.key]}
|
||||
label={t(field.label)}
|
||||
rules={rules}
|
||||
initialValue={field.default}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
122
web/src/components/ProfileModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { Modal, Form, Input, message, Collapse } from 'antd';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface ProfileModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ProfileModal = memo(function ProfileModal({ open, onClose }: ProfileModalProps) {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && user) {
|
||||
form.setFieldsValue({
|
||||
username: user.username || '',
|
||||
full_name: user.full_name || '',
|
||||
email: user.email || '',
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
});
|
||||
} else if (!open) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, user, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload: any = {};
|
||||
if (values.full_name !== (user?.full_name || '')) payload.full_name = values.full_name || null;
|
||||
if (values.email !== (user?.email || '')) payload.email = values.email || null;
|
||||
if (values.old_password || values.new_password) {
|
||||
payload.old_password = values.old_password || '';
|
||||
payload.new_password = values.new_password || '';
|
||||
}
|
||||
setLoading(true);
|
||||
await authApi.updateMe(payload);
|
||||
await refreshUser();
|
||||
message.success(t('Saved successfully'));
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
message.error(e.message || t('Save failed'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Profile')}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
confirmLoading={loading}
|
||||
okText={t('Save')}
|
||||
cancelText={t('Cancel')}
|
||||
forceRender
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="username" label={t('Username')}>
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="full_name" label={t('Full Name')}>
|
||||
<Input placeholder={t('Full Name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label={t('Email')}>
|
||||
<Input placeholder={t('Email')} type="email" />
|
||||
</Form.Item>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={[{
|
||||
key: 'pwd',
|
||||
label: t('Change Password'),
|
||||
children: (
|
||||
<>
|
||||
<Form.Item
|
||||
name="old_password"
|
||||
label={t('Old Password')}
|
||||
dependencies={["new_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const newPwd = form.getFieldValue('new_password');
|
||||
if ((value && !newPwd) || (!value && newPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('Old Password')} autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="new_password"
|
||||
label={t('New Password')}
|
||||
dependencies={["old_password"]}
|
||||
rules={[{
|
||||
validator: async (_, value) => {
|
||||
const oldPwd = form.getFieldValue('old_password');
|
||||
if ((value && !oldPwd) || (!value && oldPwd)) {
|
||||
throw new Error(t('Please fill both old and new password'));
|
||||
}
|
||||
}
|
||||
}]}
|
||||
>
|
||||
<Input.Password placeholder={t('New Password')} autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
}]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileModal;
|
||||
26
web/src/components/WeChatModal.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Modal, theme } from 'antd';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface WeChatModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function WeChatModal({ open, onClose }: WeChatModalProps) {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Modal open={open} onCancel={onClose} title={t('Join Community')} footer={null} width={320}>
|
||||
<div style={{ textAlign: 'center', padding: '12px 0' }}>
|
||||
<img src="https://foxel.cc/image/wechat.png" width={200} alt="wechat" />
|
||||
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>
|
||||
{t('Scan to join WeChat group')}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: token.colorTextTertiary }}>
|
||||
{t('If QR expires, add drizzle2001 to join')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
215
web/src/contexts/AppWindowsContext.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { Modal, Checkbox } from 'antd';
|
||||
import type { VfsEntry } from '../api/client';
|
||||
import type { AppDescriptor } from '../apps/registry';
|
||||
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../apps/registry';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
type WindowBase = {
|
||||
id: string;
|
||||
app: AppDescriptor;
|
||||
maximized: boolean;
|
||||
minimized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type AppWindowItem =
|
||||
| (WindowBase & {
|
||||
kind: 'file';
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
})
|
||||
| (WindowBase & {
|
||||
kind: 'app';
|
||||
});
|
||||
|
||||
type AppWindowPatch = Partial<Pick<AppWindowItem, 'maximized' | 'minimized' | 'x' | 'y' | 'width' | 'height'>>;
|
||||
|
||||
interface AppWindowsContextValue {
|
||||
windows: AppWindowItem[];
|
||||
openWithApp: (entry: VfsEntry, app: AppDescriptor, currentPath: string) => void;
|
||||
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
|
||||
confirmOpenWithApp: (entry: VfsEntry, appKey: string, currentPath: string) => void;
|
||||
openApp: (app: AppDescriptor) => void;
|
||||
closeWindow: (id: string) => void;
|
||||
toggleMax: (id: string) => void;
|
||||
bringToFront: (id: string) => void;
|
||||
updateWindow: (id: string, patch: AppWindowPatch) => void;
|
||||
minimizeWindow: (id: string) => void;
|
||||
restoreWindow: (id: string) => void;
|
||||
toggleMinimize: (id: string) => void;
|
||||
}
|
||||
|
||||
const AppWindowsContext = createContext<AppWindowsContextValue | null>(null);
|
||||
|
||||
export const AppWindowsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [windows, setWindows] = useState<AppWindowItem[]>([]);
|
||||
|
||||
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor, currentPath: string) => {
|
||||
const fullPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||
setWindows(ws => {
|
||||
const idx = ws.length;
|
||||
const bounds = app.defaultBounds || {};
|
||||
const baseX = bounds.x ?? (160 + idx * 32);
|
||||
const baseY = bounds.y ?? (100 + idx * 28);
|
||||
const baseW = bounds.width ?? 640;
|
||||
const baseH = bounds.height ?? 480;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const finalW = Math.min(baseW, vw - 40);
|
||||
const finalH = Math.min(baseH, vh - 60);
|
||||
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
|
||||
const finalY = Math.min(Math.max(0, baseY), vh - finalH - 8);
|
||||
return [
|
||||
...ws,
|
||||
{
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
|
||||
kind: 'file',
|
||||
app,
|
||||
entry,
|
||||
filePath: fullPath,
|
||||
maximized: !!app.defaultMaximized,
|
||||
minimized: false,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: finalW,
|
||||
height: finalH,
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openApp = useCallback((app: AppDescriptor) => {
|
||||
if (!app.openAppComponent) {
|
||||
return;
|
||||
}
|
||||
setWindows(ws => {
|
||||
const idx = ws.length;
|
||||
const bounds = app.defaultBounds || {};
|
||||
const baseX = bounds.x ?? (160 + idx * 32);
|
||||
const baseY = bounds.y ?? (100 + idx * 28);
|
||||
const baseW = bounds.width ?? 640;
|
||||
const baseH = bounds.height ?? 480;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const finalW = Math.min(baseW, vw - 40);
|
||||
const finalH = Math.min(baseH, vh - 60);
|
||||
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
|
||||
const finalY = Math.min(Math.max(0, baseY), vh - finalH - 8);
|
||||
return [
|
||||
...ws,
|
||||
{
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
|
||||
kind: 'app',
|
||||
app,
|
||||
maximized: !!app.defaultMaximized,
|
||||
minimized: false,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: finalW,
|
||||
height: finalH,
|
||||
},
|
||||
];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openFileWithDefaultApp = useCallback((entry: VfsEntry, currentPath: string) => {
|
||||
const apps = getAppsForEntry(entry);
|
||||
if (!apps.length) {
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
const extQuery = ext ? `.${ext}` : entry.name;
|
||||
Modal.confirm({
|
||||
title: t('Cannot open file'),
|
||||
content: t('No app available for this file. Go to App Store to search {ext}?', { ext: extQuery }),
|
||||
okText: t('Go to App Store'),
|
||||
cancelText: t('Cancel'),
|
||||
onOk: () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tab', 'discover');
|
||||
if (extQuery) {
|
||||
params.set('query', extQuery);
|
||||
}
|
||||
navigate(`/plugins?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
|
||||
openWithApp(entry, defaultApp, currentPath);
|
||||
}, [navigate, openWithApp, t]);
|
||||
|
||||
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string, currentPath: string) => {
|
||||
const app = getAppByKey(appKey);
|
||||
if (!app) {
|
||||
Modal.error({ title: t('Error'), content: t('App "{key}" not found.', { key: appKey }) });
|
||||
return;
|
||||
}
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
let setDefault = false;
|
||||
Modal.confirm({
|
||||
title: t('Open with {app}', { app: app.name }),
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>{t('File')}: {entry.name}</div>
|
||||
<Checkbox onChange={e => (setDefault = e.target.checked)}>
|
||||
{t('Set as default for .{ext}', { ext })}
|
||||
</Checkbox>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
if (setDefault && ext) {
|
||||
localStorage.setItem(`app.default.${ext}`, app.key);
|
||||
}
|
||||
openWithApp(entry, app, currentPath);
|
||||
},
|
||||
});
|
||||
}, [openWithApp, t]);
|
||||
|
||||
const closeWindow = (id: string) => setWindows(ws => ws.filter(w => w.id !== id));
|
||||
const toggleMax = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, maximized: !w.maximized } : w)));
|
||||
const bringToFront = (id: string) => setWindows(ws => {
|
||||
const target = ws.find(w => w.id === id);
|
||||
if (!target) return ws;
|
||||
return [...ws.filter(w => w.id !== id), target];
|
||||
});
|
||||
const updateWindow = (id: string, patch: AppWindowPatch) =>
|
||||
setWindows(ws => ws.map(w => (w.id === id ? { ...w, ...patch } : w)));
|
||||
|
||||
const minimizeWindow = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: true } : w)));
|
||||
const restoreWindow = (id: string) => setWindows(ws => {
|
||||
const target = ws.find(w => w.id === id);
|
||||
if (!target) return ws;
|
||||
const restored = { ...target, minimized: false };
|
||||
return [...ws.filter(w => w.id !== id), restored];
|
||||
});
|
||||
const toggleMinimize = (id: string) => setWindows(ws => ws.map(w => (w.id === id ? { ...w, minimized: !w.minimized } : w)));
|
||||
|
||||
const value = useMemo<AppWindowsContextValue>(() => ({
|
||||
windows,
|
||||
openWithApp,
|
||||
openFileWithDefaultApp,
|
||||
confirmOpenWithApp,
|
||||
openApp,
|
||||
closeWindow,
|
||||
toggleMax,
|
||||
bringToFront,
|
||||
updateWindow,
|
||||
minimizeWindow,
|
||||
restoreWindow,
|
||||
toggleMinimize,
|
||||
}), [windows, openWithApp, openFileWithDefaultApp, confirmOpenWithApp, openApp]);
|
||||
|
||||
return <AppWindowsContext.Provider value={value}>{children}</AppWindowsContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAppWindows() {
|
||||
const ctx = useContext(AppWindowsContext);
|
||||
if (!ctx) throw new Error('useAppWindows must be used within AppWindowsProvider');
|
||||
return ctx;
|
||||
}
|
||||
66
web/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { authApi, type MeResponse } from '../api/auth';
|
||||
|
||||
interface AuthContextType {
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
register: (username: string, password: string, email: string, full_name?: string) => Promise<void>;
|
||||
user: MeResponse | null;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({} as any);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
|
||||
const [user, setUser] = useState<MeResponse | null>(null);
|
||||
const isAuthenticated = !!token;
|
||||
|
||||
useEffect(() => {
|
||||
if (token) localStorage.setItem('token', token);
|
||||
else localStorage.removeItem('token');
|
||||
}, [token]);
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const res = await authApi.login({ username, password });
|
||||
if (res) {
|
||||
setToken(res.access_token);
|
||||
try { await refreshUser(); } catch { void 0; }
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const register = async (username: string, password: string, email: string, full_name?: string) => {
|
||||
await authApi.register(username, password, email, full_name);
|
||||
};
|
||||
|
||||
const refreshUser = async () => {
|
||||
if (!localStorage.getItem('token')) { setUser(null); return; }
|
||||
const me = await authApi.me();
|
||||
setUser(me);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
refreshUser().catch(() => setUser(null));
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register, user, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
12
web/src/contexts/SystemContext.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { SystemStatus } from '../api/config';
|
||||
|
||||
export const SystemContext = createContext<SystemStatus | null>(null);
|
||||
|
||||
export const useSystemStatus = () => {
|
||||
const context = useContext(SystemContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSystemStatus must be used within a SystemProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
202
web/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ConfigProvider, theme as antdTheme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import type { ThemeConfig } from 'antd/es/config-provider/context';
|
||||
import { getPublicConfig } from '../api/config';
|
||||
import { useAuth } from './AuthContext';
|
||||
import baseTheme from '../theme';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeState {
|
||||
mode: ThemeMode;
|
||||
primaryColor?: string | null;
|
||||
borderRadius?: number | null;
|
||||
customTokens?: Record<string, any> | null;
|
||||
customCSS?: string | null;
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
refreshTheme: () => Promise<void>;
|
||||
previewTheme: (patch: Partial<ThemeState>) => void;
|
||||
mode: ThemeMode;
|
||||
resolvedMode: ThemeMode;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ThemeContextType>({} as any);
|
||||
|
||||
const CONFIG_KEYS = {
|
||||
MODE: 'THEME_MODE',
|
||||
PRIMARY: 'THEME_PRIMARY_COLOR',
|
||||
RADIUS: 'THEME_BORDER_RADIUS',
|
||||
TOKENS: 'THEME_CUSTOM_TOKENS',
|
||||
CSS: 'THEME_CUSTOM_CSS',
|
||||
};
|
||||
|
||||
function parseJSON<T = any>(text: string | null | undefined): T | null {
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function useSystemDarkPreferred() {
|
||||
const [isDark, setIsDark] = useState<boolean>(
|
||||
typeof window !== 'undefined' && window.matchMedia
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
: false
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!window.matchMedia) return;
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => setIsDark(e.matches);
|
||||
mql.addEventListener?.('change', handler);
|
||||
return () => mql.removeEventListener?.('change', handler);
|
||||
}, []);
|
||||
return isDark;
|
||||
}
|
||||
|
||||
function buildThemeConfig(state: ThemeState, systemDark: boolean): ThemeConfig {
|
||||
const resolvedMode: ThemeMode = state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode;
|
||||
const algorithm = resolvedMode === 'dark'
|
||||
? [antdTheme.darkAlgorithm, antdTheme.compactAlgorithm]
|
||||
: [antdTheme.defaultAlgorithm, antdTheme.compactAlgorithm];
|
||||
|
||||
const safeBaseTokens: Record<string, any> = resolvedMode === 'dark'
|
||||
? {
|
||||
borderRadius: baseTheme.token?.borderRadius,
|
||||
fontSize: baseTheme.token?.fontSize,
|
||||
controlHeight: baseTheme.token?.controlHeight,
|
||||
boxShadow: baseTheme.token?.boxShadow,
|
||||
}
|
||||
: { ...(baseTheme.token as any) };
|
||||
|
||||
const token = {
|
||||
...safeBaseTokens,
|
||||
...(state.primaryColor ? { colorPrimary: state.primaryColor } : {}),
|
||||
...(state.borderRadius != null ? { borderRadius: state.borderRadius } : {}),
|
||||
...(state.customTokens || {}),
|
||||
} as any;
|
||||
|
||||
const baseComponents = { ...(baseTheme.components as any) };
|
||||
if (resolvedMode === 'dark' && baseComponents) {
|
||||
if (baseComponents.Menu) {
|
||||
const rest = { ...baseComponents.Menu };
|
||||
delete rest.itemHoverColor;
|
||||
delete rest.itemHoverBg;
|
||||
delete rest.itemSelectedBg;
|
||||
delete rest.itemSelectedColor;
|
||||
baseComponents.Menu = rest;
|
||||
}
|
||||
if (baseComponents.Dropdown) {
|
||||
const rest = { ...baseComponents.Dropdown };
|
||||
delete rest.controlItemBgHover;
|
||||
baseComponents.Dropdown = rest;
|
||||
}
|
||||
if (baseComponents.Table) {
|
||||
const rest = { ...baseComponents.Table };
|
||||
delete rest.headerBg;
|
||||
delete rest.rowHoverBg;
|
||||
baseComponents.Table = rest;
|
||||
}
|
||||
}
|
||||
|
||||
return { algorithm, token, components: baseComponents } satisfies ThemeConfig;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { lang } = useI18n();
|
||||
const systemDark = useSystemDarkPreferred();
|
||||
const [state, setState] = useState<ThemeState>({ mode: 'light' });
|
||||
const stateRef = useRef(state);
|
||||
const styleTagRef = useRef<HTMLStyleElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const ensureStyleTag = useCallback(() => {
|
||||
if (styleTagRef.current) return styleTagRef.current;
|
||||
let styleEl = document.getElementById('foxel-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'foxel-custom-css';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleTagRef.current = styleEl;
|
||||
return styleEl;
|
||||
}, []);
|
||||
|
||||
const applyCustomCSS = useCallback((cssText: string | null | undefined) => {
|
||||
const el = ensureStyleTag();
|
||||
el.textContent = cssText || '';
|
||||
}, [ensureStyleTag]);
|
||||
|
||||
const applyHtmlDataTheme = useCallback((mode: ThemeMode) => {
|
||||
const finalMode = mode === 'system' ? (systemDark ? 'dark' : 'light') : mode;
|
||||
document.documentElement.setAttribute('data-theme', finalMode);
|
||||
}, [systemDark]);
|
||||
|
||||
const refreshTheme = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
applyHtmlDataTheme(stateRef.current.mode || 'light');
|
||||
applyCustomCSS(stateRef.current.customCSS || '');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = await getPublicConfig();
|
||||
const mode = (cfg[CONFIG_KEYS.MODE] as ThemeMode) || 'light';
|
||||
const primary = (cfg[CONFIG_KEYS.PRIMARY] as string) || null;
|
||||
const radiusStr = cfg[CONFIG_KEYS.RADIUS];
|
||||
const radius = radiusStr != null ? Number(radiusStr) : null;
|
||||
const customTokens = parseJSON<Record<string, any>>(cfg[CONFIG_KEYS.TOKENS]);
|
||||
const customCSS = (cfg[CONFIG_KEYS.CSS] as string) || '';
|
||||
setState({ mode, primaryColor: primary, borderRadius: radius, customTokens, customCSS });
|
||||
applyHtmlDataTheme(mode);
|
||||
applyCustomCSS(customCSS);
|
||||
} catch {
|
||||
applyHtmlDataTheme('light');
|
||||
applyCustomCSS('');
|
||||
}
|
||||
}, [applyCustomCSS, applyHtmlDataTheme, isAuthenticated]);
|
||||
|
||||
const previewTheme = useCallback((patch: Partial<ThemeState>) => {
|
||||
const next: ThemeState = { ...stateRef.current, ...patch };
|
||||
stateRef.current = next;
|
||||
setState(next);
|
||||
applyHtmlDataTheme(next.mode || 'light');
|
||||
applyCustomCSS(next.customCSS || '');
|
||||
}, [applyCustomCSS, applyHtmlDataTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTheme();
|
||||
}, [refreshTheme]);
|
||||
|
||||
const themeConfig = useMemo(() => buildThemeConfig(state, systemDark), [state, systemDark]);
|
||||
const resolvedMode: ThemeMode = useMemo(() => (state.mode === 'system' ? (systemDark ? 'dark' : 'light') : state.mode), [state.mode, systemDark]);
|
||||
const locale = useMemo(() => (lang === 'zh' ? zhCN : enUS), [lang]);
|
||||
|
||||
const ctxValue = useMemo<ThemeContextType>(() => ({
|
||||
refreshTheme,
|
||||
previewTheme,
|
||||
mode: state.mode,
|
||||
resolvedMode,
|
||||
}), [previewTheme, refreshTheme, resolvedMode, state.mode]);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={ctxValue}>
|
||||
<ConfigProvider theme={{ ...themeConfig, cssVar: {} }} locale={locale}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
285
web/src/global.css
Normal file
@@ -0,0 +1,285 @@
|
||||
html,body,#root { height: 100%; }
|
||||
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background: var(--ant-color-bg-layout, #f9f9f9); }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--ant-color-fill-tertiary, #d9d9d9); border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--ant-color-fill-secondary, #bfbfbf); }
|
||||
|
||||
.fx-surface { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:12px; }
|
||||
.fx-card { background: var(--ant-color-bg-container, #fff); border:1px solid var(--ant-color-border, #eaeaea); border-radius:14px; box-shadow:0 1px 2px rgba(0,0,0,.04),0 4px 10px -2px rgba(0,0,0,.03); }
|
||||
.fx-fade-text { color: var(--ant-color-text-secondary, #555); }
|
||||
|
||||
.fx-center-card {
|
||||
background: var(--ant-color-bg-container, #fff);
|
||||
border: 1px solid var(--ant-color-border, #eaeaea);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04), 0 10px 24px -18px rgba(0,0,0,.22);
|
||||
}
|
||||
.fx-center-card-hero {
|
||||
position: relative;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--ant-color-fill-secondary, #f3f3f3), var(--ant-color-fill-tertiary, #f5f5f5));
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-hero {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
}
|
||||
.fx-center-card-iconbox {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(255,255,255,0.65);
|
||||
background: rgba(255,255,255,0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.08);
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-iconbox {
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
background: rgba(0,0,0,0.28);
|
||||
}
|
||||
.fx-center-card-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.fx-center-card-pills {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.fx-center-pill {
|
||||
max-width: 120px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
border: 1px solid rgba(255,255,255,0.55);
|
||||
background: rgba(255,255,255,0.72);
|
||||
color: rgba(0,0,0,0.72);
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
[data-theme="dark"] .fx-center-pill {
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
background: rgba(0,0,0,0.28);
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
.fx-center-pill-success {
|
||||
border-color: rgba(82,196,26,0.35);
|
||||
background: rgba(82,196,26,0.14);
|
||||
color: var(--ant-color-success, #52c41a);
|
||||
}
|
||||
.fx-center-card-body {
|
||||
padding: 14px 14px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.fx-center-card-titleRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.fx-center-card-title {
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
color: var(--ant-color-text, #111);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fx-center-card-version {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary, #666);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fx-center-card-metaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary, #666);
|
||||
}
|
||||
.fx-center-card-metaLeft,
|
||||
.fx-center-card-metaRight {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.fx-center-card-desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--ant-color-text-secondary, #555);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 42px;
|
||||
}
|
||||
.fx-center-card-footer {
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
||||
background: var(--ant-color-fill-tertiary, #fafafa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-footer {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
.fx-center-card-footerLeft,
|
||||
.fx-center-card-footerRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.fx-center-card-actions,
|
||||
.fx-center-card-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.fx-center-card-iconLink {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
color: var(--ant-color-text-tertiary, #666);
|
||||
transition: background .18s ease, color .18s ease;
|
||||
}
|
||||
.fx-center-card-iconLink:hover {
|
||||
background: var(--ant-color-fill-secondary, #f2f2f2);
|
||||
color: var(--ant-color-text, #111);
|
||||
}
|
||||
[data-theme="dark"] .fx-center-card-iconLink:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
|
||||
.fx-quiet-btn.ant-btn-text:not(:hover) { color: var(--ant-color-text-tertiary, #666); }
|
||||
|
||||
/* 使用 antd 默认布局背景 */
|
||||
.ant-layout { background: transparent; }
|
||||
|
||||
/* Menu compact spacing adjustments */
|
||||
.ant-menu-inline .ant-menu-item { margin-block:2px; }
|
||||
|
||||
/* Sidebar high-contrast selection */
|
||||
.sider-menu .ant-menu-item-selected {
|
||||
background: var(--ant-color-primary, #111) !important;
|
||||
color:#fff !important;
|
||||
}
|
||||
.sider-menu .ant-menu-item-selected .ant-menu-item-icon,
|
||||
.sider-menu .ant-menu-item-selected .anticon { color:#fff !important; }
|
||||
.sider-menu .ant-menu-item:not(.ant-menu-item-selected):hover { background: var(--ant-color-fill-tertiary, #f2f2f2); }
|
||||
|
||||
.row-selected td { background: rgba(24,144,255,0.12) !important; }
|
||||
.row-selected:hover td { background: rgba(24,144,255,0.2) !important; }
|
||||
|
||||
.fx-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:20px; align-content:start; }
|
||||
.fx-grid-item { cursor:pointer; border-radius:14px; padding:12px 12px 10px; background: var(--ant-color-fill-tertiary, #f5f5f5); position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
|
||||
.fx-grid-item.dir { background: var(--ant-color-fill-secondary, #f3f3f3); }
|
||||
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background: var(--ant-color-primary-bg, #e6f4ff); }
|
||||
.fx-grid-item:hover { background: var(--ant-color-fill, #ededed); box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.fx-grid-item .thumb { height:120px; border-radius:10px; background: var(--ant-color-bg-container, #fff); display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px var(--ant-color-border-secondary, #eee); }
|
||||
.fx-grid-item .thumb img { width:100%; height:100%; object-fit:cover; }
|
||||
.fx-grid-item .thumb .badge { position:absolute; top:6px; left:6px; background: var(--ant-color-primary, #111); color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
||||
.fx-grid-item .thumb .score-badge {
|
||||
left:auto;
|
||||
right:8px;
|
||||
top:8px;
|
||||
background: rgba(0,0,0,0.45);
|
||||
border: 1px solid rgba(255,255,255,0.25);
|
||||
color: rgba(255,255,255,0.92);
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0;
|
||||
transform: translateY(-2px);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.08);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.fx-grid-item:hover .thumb .score-badge,
|
||||
.fx-grid-item.selected .thumb .score-badge {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
||||
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||
|
||||
.processors-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 5px;
|
||||
}
|
||||
.processors-tabs .ant-tabs-content-holder,
|
||||
.processors-tabs .ant-tabs-content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.processors-tabs .ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.processors-tabs .ant-tabs-tabpane-active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.plugins-tabs {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.plugins-tabs .ant-tabs-content-holder,
|
||||
.plugins-tabs .ant-tabs-content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.plugins-tabs .ant-tabs-tabpane {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
}
|
||||
.plugins-tabs .ant-tabs-tabpane-active {
|
||||
display: flex;
|
||||
}
|
||||
34
web/src/hooks/useAsyncSafeEffect.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export interface AsyncEffectCtx {
|
||||
isDisposed: () => boolean;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export function useAsyncSafeEffect(
|
||||
effect: (ctx: AsyncEffectCtx) => void | Promise<void>,
|
||||
deps: React.DependencyList,
|
||||
cleanup?: (ctx: AsyncEffectCtx) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
const ac = new AbortController();
|
||||
const ctx: AsyncEffectCtx = {
|
||||
isDisposed: () => disposed,
|
||||
signal: ac.signal,
|
||||
};
|
||||
|
||||
Promise.resolve(effect(ctx)).catch(() => {
|
||||
// 故意忽略 effect 内部抛出的异常,交由调用方处理
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try {
|
||||
cleanup?.(ctx);
|
||||
} finally {
|
||||
ac.abort();
|
||||
}
|
||||
};
|
||||
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}
|
||||
59
web/src/i18n/index.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import en from './locales/en.json';
|
||||
import zhOverrides from './locales/zh.json';
|
||||
|
||||
type Lang = 'zh' | 'en';
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
const dicts: Record<Lang, Dict> = {
|
||||
en,
|
||||
zh: { ...en, ...zhOverrides },
|
||||
};
|
||||
|
||||
export interface I18nContextValue {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue | null>(null);
|
||||
|
||||
function interpolate(template: string, params?: Record<string, string | number>): string {
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
|
||||
}
|
||||
|
||||
export function I18nProvider({ children }: PropsWithChildren) {
|
||||
const [lang, setLangState] = useState<Lang>(() => (localStorage.getItem('lang') as Lang) || 'zh');
|
||||
|
||||
const setLang = useCallback((l: Lang) => {
|
||||
setLangState(l);
|
||||
localStorage.setItem('lang', l);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = lang;
|
||||
window.dispatchEvent(new CustomEvent('foxel:langchange', { detail: { lang } }));
|
||||
}, [lang]);
|
||||
|
||||
const t = useCallback((key: string, params?: Record<string, string | number>) => {
|
||||
const dict = dicts[lang] || {};
|
||||
const raw = dict[key] ?? key; // fallback to key (English)
|
||||
return interpolate(raw, params);
|
||||
}, [lang]);
|
||||
|
||||
const value = useMemo<I18nContextValue>(() => ({ lang, setLang, t }), [lang, setLang, t]);
|
||||
|
||||
return (
|
||||
<I18nContext.Provider value={value}>
|
||||
{children}
|
||||
</I18nContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const ctx = useContext(I18nContext);
|
||||
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
|
||||
return ctx;
|
||||
}
|
||||
796
web/src/i18n/locales/en.json
Normal file
@@ -0,0 +1,796 @@
|
||||
{
|
||||
"All Files": "All Files",
|
||||
"Manage": "Manage",
|
||||
"Follow System": "System",
|
||||
"Automation": "Automation",
|
||||
"My Shares": "My Shares",
|
||||
"Offline Downloads": "Offline Downloads",
|
||||
"Adapters": "Adapters",
|
||||
"Plugins": "App Center",
|
||||
"System Settings": "System Settings",
|
||||
"Registration Settings": "Registration Settings",
|
||||
"Backup & Restore": "Backup & Restore",
|
||||
"System Logs": "System Logs",
|
||||
"Audit Logs": "Audit Logs",
|
||||
"Audit Log Details": "Audit Log Details",
|
||||
"Search files / tags / types": "Search files / tags / types",
|
||||
"Log Out": "Log Out",
|
||||
"Admin": "Admin",
|
||||
"Enable Registration": "Enable Registration",
|
||||
"Default Role for New Registrations": "Default Role for New Registrations",
|
||||
"Please select default role": "Please select default role",
|
||||
"Enabling registration allows new users to sign up and assigns them the default role": "Enabling registration allows new users to sign up and assigns them the default role",
|
||||
"Create Account": "Create Account",
|
||||
"Sign Up": "Sign Up",
|
||||
"Sign up to your Foxel account": "Sign up to your Foxel account",
|
||||
"Already have an account?": "Already have an account?",
|
||||
"Register failed": "Register failed",
|
||||
"Please input email!": "Please input email!",
|
||||
"Profile": "Profile",
|
||||
"Account Settings": "Account Settings",
|
||||
"Language": "Language",
|
||||
"Chinese": "中文",
|
||||
"English": "English",
|
||||
"Full Name": "Full Name",
|
||||
"Email": "Email",
|
||||
"Change Password": "Change Password",
|
||||
"Old Password": "Old Password",
|
||||
"New Password": "New Password",
|
||||
"Please fill both old and new password": "Please fill both old and new password",
|
||||
"Welcome Back": "Welcome Back",
|
||||
"Sign in to your Foxel account": "Sign in to your Foxel account",
|
||||
"Username / Email": "Username / Email",
|
||||
"Password": "Password",
|
||||
"Sign In": "Sign In",
|
||||
"Please enter username and password": "Please enter username and password",
|
||||
"Login failed": "Login failed",
|
||||
"Your next-generation file manager": "Your next-generation file manager",
|
||||
"Cross-platform sync, access anywhere": "Cross-platform sync, access anywhere",
|
||||
"AI-powered search for quick find": "AI-powered search for quick find",
|
||||
"Flexible sharing and collaboration": "Flexible sharing and collaboration",
|
||||
"Powerful automation to simplify tasks": "Powerful automation to simplify tasks",
|
||||
"Join our community:": "Join our community:",
|
||||
"Refresh": "Refresh",
|
||||
"Copy": "Copy",
|
||||
"Copied link": "Link copied",
|
||||
"Share canceled": "Share canceled",
|
||||
"Cancel failed": "Cancel failed",
|
||||
"Load failed": "Load failed",
|
||||
"Are you sure to cancel share?": "Are you sure to cancel share?",
|
||||
"Clear expired shares": "Clear expired shares",
|
||||
"Confirm clear expired shares?": "Confirm clear expired shares?",
|
||||
"Cleared {count} expired shares": "Cleared {count} expired shares",
|
||||
"Please select time range": "Please select time range",
|
||||
"Share Name": "Share Name",
|
||||
"Share Content": "Share Content",
|
||||
"Created At": "Created At",
|
||||
"Expires At": "Expires At",
|
||||
"Forever": "Forever",
|
||||
"Access": "Access",
|
||||
"Public": "Public",
|
||||
"By Password": "By Password",
|
||||
"Password Required": "Password Required",
|
||||
"Please enter password": "Please enter password",
|
||||
"Confirm": "Confirm",
|
||||
"Unable to load share info": "Unable to load share info",
|
||||
"Share load failed": "Failed to load share",
|
||||
"Wrong password": "Wrong password",
|
||||
"Root": "All Files",
|
||||
"Created on {date}": "Created on {date}",
|
||||
"Expires on {date}": "Expires on {date}",
|
||||
"Download File": "Download File",
|
||||
"Preview not supported for this file type": "Preview not supported for this file type",
|
||||
"Back": "Back",
|
||||
"Download": "Download",
|
||||
"No offline download tasks": "No offline download tasks",
|
||||
"Create Offline Download": "Create Offline Download",
|
||||
"Offline Download Tasks": "Offline Download Tasks",
|
||||
"URL": "URL",
|
||||
"Please input URL": "Please input URL",
|
||||
"Destination Folder": "Destination Folder",
|
||||
"Select destination": "Select destination",
|
||||
"Filename": "Filename",
|
||||
"Please input filename": "Please input filename",
|
||||
"Start Download": "Start Download",
|
||||
"Stage": "Stage",
|
||||
"Progress": "Progress",
|
||||
"Bytes": "Bytes",
|
||||
"Save Path": "Save Path",
|
||||
"Queued": "Queued",
|
||||
"Downloading": "Downloading",
|
||||
"Transferring": "Transferring",
|
||||
"Completed": "Completed",
|
||||
"Pending": "Pending",
|
||||
"Running": "Running",
|
||||
"Success": "Success",
|
||||
"Failed": "Failed",
|
||||
"Failure": "Failure",
|
||||
"Home": "Home",
|
||||
"File Manager": "File Manager",
|
||||
"New Folder": "New Folder",
|
||||
"New File": "New File",
|
||||
"Upload": "Upload",
|
||||
"Name": "Name",
|
||||
"Size": "Size",
|
||||
"Modified Time": "Modified Time",
|
||||
"Grid": "Grid",
|
||||
"List": "List",
|
||||
"Mount Point": "Mount Point",
|
||||
"Move": "Move",
|
||||
"Move to": "Move to",
|
||||
"Copy to": "Copy to",
|
||||
"Destination path": "Destination path",
|
||||
"Move task queued": "Move task queued",
|
||||
"Move completed": "Move completed",
|
||||
"Copy task queued": "Copy task queued",
|
||||
"Copy completed": "Copy completed",
|
||||
"Please input destination path": "Please input destination path",
|
||||
"Upload File": "Upload File",
|
||||
"Upload Files": "Upload Files",
|
||||
"Upload Folder": "Upload Folder",
|
||||
"Open": "Open",
|
||||
"Open With": "Open With",
|
||||
"Default": "Default",
|
||||
"Processor": "Processor",
|
||||
"Share": "Share",
|
||||
"Rename": "Rename",
|
||||
"Delete": "Delete",
|
||||
"Details": "Details",
|
||||
"Get Direct Link": "Get Direct Link",
|
||||
"User": "User",
|
||||
"Status Code": "Status Code",
|
||||
"Duration (ms)": "Duration (ms)",
|
||||
"Client IP": "Client IP",
|
||||
"Result": "Result",
|
||||
"Request Params": "Request Params",
|
||||
"Request Body": "Request Body",
|
||||
"Total progress": "Total progress",
|
||||
"Upload bytes summary": "{uploaded} / {total}",
|
||||
"Upload task summary": "Tasks: {completed} / {total} completed, {pending} pending, {failures} failed",
|
||||
"Overwrite confirmation required": "Overwrite confirmation required",
|
||||
"Target already exists: {path}": "Target already exists: {path}",
|
||||
"Overwrite": "Overwrite",
|
||||
"Skip": "Skip",
|
||||
"Overwrite All": "Overwrite All",
|
||||
"Skip All": "Skip All",
|
||||
"Directory": "Directory",
|
||||
"Creating directory...": "Creating directory...",
|
||||
"Directory ready": "Directory ready",
|
||||
"Create directory failed": "Create directory failed",
|
||||
"Waiting to create": "Waiting to create",
|
||||
"Waiting for overwrite decision": "Waiting for overwrite decision",
|
||||
"Waiting to upload": "Waiting to upload",
|
||||
"Skipped": "Skipped",
|
||||
"Upload succeeded": "Upload succeeded",
|
||||
"Upload failed": "Upload failed",
|
||||
"No items selected for upload": "No items selected for upload",
|
||||
"No uploadable files or directories found": "No uploadable files or directories found",
|
||||
"Missing file content": "Missing file content",
|
||||
"Directory conflicts with existing file": "A file with the same name already exists at the target location",
|
||||
"Join Community": "Join Community",
|
||||
"Scan to join WeChat group": "Scan to join WeChat group",
|
||||
"If QR expires, add drizzle2001 to join": "If QR expires, add drizzle2001 to join",
|
||||
"Version Info": "Version Info",
|
||||
"Current Version": "Current Version",
|
||||
"Latest Version": "Latest Version",
|
||||
"New version found: {version}": "New version found: {version}",
|
||||
"Please update to the latest for features and fixes": "Please update to the latest for features and fixes",
|
||||
"Open Releases": "Open Releases",
|
||||
"Changelog": "Changelog",
|
||||
"Fetching latest version...": "Fetching latest version...",
|
||||
"Update available": "Update available",
|
||||
"You are on the latest: {version}": "You are on the latest: {version}",
|
||||
"Up to date": "Up to date",
|
||||
"Share {count} items": "Share {count} items",
|
||||
"Share link created": "Share link created",
|
||||
"Create failed": "Create failed",
|
||||
"Copied to clipboard": "Copied to clipboard",
|
||||
"Expiration (days)": "Expiration (days)",
|
||||
"Set 0 or negative for forever": "Set 0 or negative for forever",
|
||||
"Share link created successfully!": "Share link created successfully!",
|
||||
"Share Link": "Share Link",
|
||||
"Share created": "Share created",
|
||||
"Create Share": "Create Share",
|
||||
"Done": "Done",
|
||||
"Create": "Create",
|
||||
"Failed to generate link": "Failed to generate link",
|
||||
"Markdown copied to clipboard": "Markdown copied to clipboard",
|
||||
"Generate a direct link for {name}": "Generate a direct link for {name}",
|
||||
"1 hour": "1 hour",
|
||||
"1 day": "1 day",
|
||||
"7 days": "7 days",
|
||||
"Generating link...": "Generating link...",
|
||||
"Link will appear here": "Link will appear here",
|
||||
"Copy Markdown": "Copy Markdown",
|
||||
"Close": "Close",
|
||||
"Task Queue": "Task Queue",
|
||||
"Last updated at {time}": "Last updated at {time}",
|
||||
"Total Tasks": "Total Tasks",
|
||||
"Running Tasks": "Running Tasks",
|
||||
"Waiting Tasks": "Waiting Tasks",
|
||||
"Failed Tasks": "Failed Tasks",
|
||||
"Active Workers": "Active Workers",
|
||||
"Task Type": "Task Type",
|
||||
"Search by name or ID": "Search by name or ID",
|
||||
"Filter by status": "Filter by status",
|
||||
"Queue Concurrency": "Queue Concurrency",
|
||||
"Settings saved": "Settings saved",
|
||||
"Expand": "Expand",
|
||||
"Adjust worker concurrency immediately": "Adjust worker concurrency immediately",
|
||||
"Auto": "Auto",
|
||||
"Manual": "Manual",
|
||||
"Camera Make": "Camera Make",
|
||||
"Camera Model": "Camera Model",
|
||||
"Capture Time": "Capture Time",
|
||||
"X Resolution": "X Resolution",
|
||||
"Y Resolution": "Y Resolution",
|
||||
"Exposure Time": "Exposure Time",
|
||||
"Aperture": "Aperture",
|
||||
"Focal Length": "Focal Length",
|
||||
"Width": "Width",
|
||||
"Height": "Height",
|
||||
"No common EXIF info": "No common EXIF info",
|
||||
"File Properties": "File Properties",
|
||||
"Loading file info...": "Loading file info...",
|
||||
"Basic Info": "Basic Info",
|
||||
"Type": "Type",
|
||||
"Folder": "Folder",
|
||||
"File": "File",
|
||||
"Path": "Path",
|
||||
"Path copied to clipboard": "Path copied to clipboard",
|
||||
"Copy failed": "Copy failed",
|
||||
"Permissions": "Permissions",
|
||||
"EXIF Info": "EXIF Info",
|
||||
"Index Info": "Index Info",
|
||||
"Indexed Items": "Indexed Items",
|
||||
"Indexed Types": "Indexed Types",
|
||||
"No index data": "No index data",
|
||||
"Indexed Chunks": "Indexed Chunks",
|
||||
"More Indexed Chunks": "More Indexed Chunks",
|
||||
"Chunk ID": "Chunk ID",
|
||||
"Offset Range": "Offset Range",
|
||||
"Vector ID": "Vector ID",
|
||||
"Preview": "Preview",
|
||||
"Showing first {count} entries": "Showing first {count} entries",
|
||||
"Smart Search": "Smart Search",
|
||||
"Name Search": "Name Search",
|
||||
"Search Results": "Search Results",
|
||||
"No files found": "No files found",
|
||||
"Relevance": "Relevance",
|
||||
"Saved successfully": "Saved successfully",
|
||||
"Save failed": "Save failed",
|
||||
"Loading...": "Loading...",
|
||||
"Appearance Settings": "Appearance Settings",
|
||||
"Theme": "Theme",
|
||||
"Theme Mode": "Theme Mode",
|
||||
"Light": "Light",
|
||||
"Dark": "Dark",
|
||||
"Primary Color": "Primary Color",
|
||||
"Border Radius": "Border Radius",
|
||||
"Advanced": "Advanced",
|
||||
"Override AntD Tokens (JSON)": "Override AntD Tokens (JSON)",
|
||||
"e.g. {\"colorText\": \"#222\"}": "e.g. {\"colorText\": \"#222\"}",
|
||||
"Custom CSS": "Custom CSS",
|
||||
"Save": "Save",
|
||||
"App Settings": "App Settings",
|
||||
"Email Settings": "Email Settings",
|
||||
"AI Settings": "AI Settings",
|
||||
"Protocol Mappings": "Protocol Mappings",
|
||||
"S3 Mapping": "S3 Mapping",
|
||||
"S3 Endpoint": "S3 Endpoint",
|
||||
"Bucket Name": "Bucket Name",
|
||||
"Bucket API Path": "Bucket API Path",
|
||||
"Region": "Region",
|
||||
"Base Path": "Base Path",
|
||||
"Access Key": "Access Key",
|
||||
"Secret Key": "Secret Key",
|
||||
"Vision Model": "Vision Model",
|
||||
"Embedding Model": "Embedding Model",
|
||||
"Embedding Dimension": "Embedding Dimension",
|
||||
"Vector Database": "Vector Database",
|
||||
"Vector Database Settings": "Vector Database Settings",
|
||||
"Current Statistics": "Current Statistics",
|
||||
"Collections": "Collections",
|
||||
"Vectors": "Vectors",
|
||||
"Database Size": "Database Size",
|
||||
"Estimated Memory": "Estimated Memory",
|
||||
"No collections": "No collections",
|
||||
"Dimension": "Dimension",
|
||||
"Non-vector collection": "Non-vector collection",
|
||||
"Estimated memory": "Estimated memory",
|
||||
"Indexes": "Indexes",
|
||||
"Unnamed index": "Unnamed index",
|
||||
"Indexed rows": "Indexed rows",
|
||||
"Pending rows": "Pending rows",
|
||||
"Estimated memory is calculated as vectors x dimension x 4 bytes (float32).": "Estimated memory is calculated as vectors x dimension x 4 bytes (float32).",
|
||||
"Database Provider": "Database Provider",
|
||||
"Please select a provider": "Please select a provider",
|
||||
"Coming soon": "Coming soon",
|
||||
"This provider is not available yet": "This provider is not available yet",
|
||||
"Database file path": "Database file path",
|
||||
"Server URI": "Server URI",
|
||||
"Token": "Token",
|
||||
"Server URL": "Server URL",
|
||||
"API Key": "API Key",
|
||||
"Embedded Milvus Lite (local file storage).": "Embedded Milvus Lite (local file storage).",
|
||||
"Remote Milvus instance accessed via URI.": "Remote Milvus instance accessed via URI.",
|
||||
"Qdrant vector database (HTTP API).": "Qdrant vector database (HTTP API).",
|
||||
"Database Type": "Database Type",
|
||||
"Confirm embedding dimension change": "Confirm embedding dimension change",
|
||||
"Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?": "Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?",
|
||||
"Confirm clear vector database?": "Confirm clear vector database?",
|
||||
"This will delete all collections irreversibly.": "This will delete all collections irreversibly.",
|
||||
"Confirm Clear": "Confirm Clear",
|
||||
"Vector database cleared": "Vector database cleared",
|
||||
"Clear failed": "Clear failed",
|
||||
"Clear Vector DB": "Clear Vector DB",
|
||||
"App Name": "App Name",
|
||||
"Logo URL": "Logo URL",
|
||||
"Favicon URL": "Favicon URL",
|
||||
"App Domain": "App Domain",
|
||||
"File Domain": "File Domain",
|
||||
"Configure Access Key and Secret to enable S3 mapping.": "Configure Access Key and Secret to enable S3 mapping.",
|
||||
"Mount point inside the virtual file system (e.g. / or /workspace).": "Mount point inside the virtual file system (e.g. / or /workspace).",
|
||||
"Leave blank to accept any region.": "Leave blank to accept any region.",
|
||||
"Please input bucket name": "Please input bucket name",
|
||||
"Please input region": "Please input region",
|
||||
"Please input access key": "Please input access key",
|
||||
"Please input secret key": "Please input secret key",
|
||||
"Save S3 Settings": "Save S3 Settings",
|
||||
"Example CLI command": "Example CLI command",
|
||||
"WebDAV Mapping": "WebDAV Mapping",
|
||||
"WebDAV Endpoint": "WebDAV Endpoint",
|
||||
"Basic (system account password)": "Basic (system account password)",
|
||||
"Root Path": "Root Path",
|
||||
"Client Compatibility": "Client Compatibility",
|
||||
"Supports Finder, Windows network drive, rclone, and other WebDAV clients.": "Supports Finder, Windows network drive, rclone, and other WebDAV clients.",
|
||||
"Toggle the switch to expose the virtual file system via WebDAV.": "Toggle the switch to expose the virtual file system via WebDAV.",
|
||||
"SMTP Settings": "SMTP Settings",
|
||||
"SMTP Host": "SMTP Host",
|
||||
"Please input SMTP host": "Please input SMTP host",
|
||||
"SMTP Port": "SMTP Port",
|
||||
"Please input SMTP port": "Please input SMTP port",
|
||||
"Security": "Security",
|
||||
"None": "None",
|
||||
"SSL": "SSL",
|
||||
"STARTTLS": "STARTTLS",
|
||||
"Timeout (seconds)": "Timeout (seconds)",
|
||||
"Sender": "Sender",
|
||||
"Sender Name": "Sender Name",
|
||||
"Sender Email": "Sender Email",
|
||||
"Please input sender email": "Please input sender email",
|
||||
"Authentication": "Authentication",
|
||||
"SMTP Username": "SMTP Username",
|
||||
"SMTP Password": "SMTP Password",
|
||||
"Test Email": "Test Email",
|
||||
"Current Configuration": "Current Configuration",
|
||||
"Available variables": "Available variables",
|
||||
"Not set": "Not set",
|
||||
"Password Reset Template": "Password Reset Template",
|
||||
"Live Preview": "Live Preview",
|
||||
"Foxel Mail Test": "Foxel Mail Test",
|
||||
"Recipient Address": "Recipient Address",
|
||||
"Please input recipient email": "Please input recipient email",
|
||||
"Test Subject": "Test Subject",
|
||||
"Test User Name": "Test User Name",
|
||||
"Optional": "Optional",
|
||||
"Send Test Email": "Send Test Email",
|
||||
"Please complete all required fields": "Please complete all required fields",
|
||||
"SMTP port must be a positive number": "SMTP port must be a positive number",
|
||||
"Test email queued (task {{taskId}})": "Test email queued (task {{taskId}})",
|
||||
"Test email failed": "Test email failed",
|
||||
"Forgot Password?": "Forgot password?",
|
||||
"Reset Your Password": "Reset Your Password",
|
||||
"Enter the email linked to your account and we will send a reset link.": "Enter the email linked to your account and we will send a reset link.",
|
||||
"If the email exists, a reset link has been sent.": "If the email exists, a reset link has been sent.",
|
||||
"Send Reset Link": "Send Reset Link",
|
||||
"Resend Link": "Resend Link",
|
||||
"Back to login": "Back to login",
|
||||
"Request failed": "Request failed",
|
||||
"Reset link is invalid": "Reset link is invalid",
|
||||
"Reset link is invalid or expired": "Reset link is invalid or expired",
|
||||
"Reset failed": "Reset failed",
|
||||
"Try again": "Try again",
|
||||
"Set a new password": "Set a new password",
|
||||
"Please enter new password": "Please enter new password",
|
||||
"Confirm Password": "Confirm Password",
|
||||
"Please confirm new password": "Please confirm new password",
|
||||
"Update Password": "Update Password",
|
||||
"Passwords do not match": "Passwords do not match",
|
||||
"Password updated, please login again.": "Password updated, please login again.",
|
||||
"Failed to reset password": "Failed to reset password",
|
||||
"Vision API URL": "Vision API URL",
|
||||
"Vision API Key": "Vision API Key",
|
||||
"Embedding API URL": "Embedding API URL",
|
||||
"Embedding API Key": "Embedding API Key",
|
||||
"AI Providers & Models": "AI Providers & Models",
|
||||
"Manage AI providers, synchronize compatible models, and configure default capabilities across the system.": "Manage AI providers, synchronize compatible models, and configure default capabilities across the system.",
|
||||
"Add Provider": "Add Provider",
|
||||
"Edit Provider": "Edit Provider",
|
||||
"Pull Models": "Pull Models",
|
||||
"Manual Add": "Manual Add",
|
||||
"Clear Remote List": "Clear Remote List",
|
||||
"Select models from the list to add them automatically": "Select models from the list to add them automatically",
|
||||
"No remote models": "No remote models",
|
||||
"No remote models found": "No remote models found",
|
||||
"No remote models match search": "No remote models match search",
|
||||
"Search fetched models": "Search fetched models",
|
||||
"Already Added": "Already Added",
|
||||
"Add Selected Models": "Add Selected Models",
|
||||
"Fetch failed": "Fetch failed",
|
||||
"Select models to add": "Select models to add",
|
||||
"Added {count} models": "Added {count} models",
|
||||
"Choose Template": "Choose Template",
|
||||
"Configure Provider": "Configure Provider",
|
||||
"Back to Templates": "Back to Templates",
|
||||
"View Docs": "View Docs",
|
||||
"Custom Provider": "Custom Provider",
|
||||
"Custom Provider Description": "Bring your own endpoint compatible with OpenAI or Gemini formats.",
|
||||
"OpenAI Provider": "OpenAI",
|
||||
"OpenAI Provider Description": "Access GPT-4o, GPT-4.1, GPT-5 and more models from OpenAI.",
|
||||
"Azure OpenAI Provider": "Azure OpenAI",
|
||||
"Azure OpenAI Provider Description": "Use OpenAI models deployed on Microsoft Azure.",
|
||||
"Google AI Provider": "Google AI",
|
||||
"Google AI Provider Description": "Gemini series models served via the Google AI platform.",
|
||||
"SiliconFlow Provider": "SiliconFlow",
|
||||
"SiliconFlow Provider Description": "High-performance inference platform with OpenAI-compatible APIs.",
|
||||
"OpenRouter Provider": "OpenRouter",
|
||||
"OpenRouter Provider Description": "Connect to multiple AI providers through a single OpenAI-style endpoint.",
|
||||
"Anthropic Provider": "Anthropic",
|
||||
"Anthropic Provider Description": "Claude 4 family models exposed through the Claude API.",
|
||||
"Z.ai Provider": "Z.ai",
|
||||
"Z.ai Provider Description": "Z.ai models served via BigModel Open Platform (OpenAI-style).",
|
||||
"DeepSeek Provider": "DeepSeek",
|
||||
"DeepSeek Provider Description": "DeepSeek language models via OpenAI-compatible API.",
|
||||
"Grok Provider": "Grok (xAI)",
|
||||
"Grok Provider Description": "Grok models powered by xAI with OpenAI-style routes.",
|
||||
"Ollama Provider": "Ollama",
|
||||
"Ollama Provider Description": "Self-host and run models locally with Ollama's native HTTP API.",
|
||||
"Voyage Provider": "Voyage AI",
|
||||
"Voyage Provider Description": "High-quality embeddings and rerankers from Voyage AI.",
|
||||
"Delete provider?": "Delete provider?",
|
||||
"Deleting this provider will also remove all associated models. Continue?": "Deleting this provider will also remove all associated models. Continue?",
|
||||
"Deleted successfully": "Deleted successfully",
|
||||
"Sync Models": "Sync Models",
|
||||
"Sync completed: {created} created, {updated} updated": "Sync completed: {created} created, {updated} updated",
|
||||
"Sync failed": "Sync failed",
|
||||
"Add Model": "Add Model",
|
||||
"Edit Model": "Edit Model",
|
||||
"Delete model?": "Delete model?",
|
||||
"This operation cannot be undone. Continue?": "This operation cannot be undone. Continue?",
|
||||
"No models yet": "No models yet",
|
||||
"Add your first AI provider to get started": "Add your first AI provider to get started",
|
||||
"Default Models Configuration": "Default Models Configuration",
|
||||
"Main Chat Model": "Main Chat Model",
|
||||
"Primary assistant for conversations, reasoning, and tool calls.": "Primary assistant for conversations, reasoning, and tool calls.",
|
||||
"Handles multimodal perception such as image understanding.": "Handles multimodal perception such as image understanding.",
|
||||
"Transforms content into dense vectors for search and retrieval.": "Transforms content into dense vectors for search and retrieval.",
|
||||
"Optimises ranking quality for search candidates.": "Optimises ranking quality for search candidates.",
|
||||
"Covers text-to-speech and speech understanding scenarios.": "Covers text-to-speech and speech understanding scenarios.",
|
||||
"Supports function calling, orchestration, and automation.": "Supports function calling, orchestration, and automation.",
|
||||
"Select a model": "Select a model",
|
||||
"Template": "Template",
|
||||
"Select a template": "Select a template",
|
||||
"Display Name": "Display Name",
|
||||
"Enter name": "Enter name",
|
||||
"Identifier": "Identifier",
|
||||
"Enter identifier": "Enter identifier",
|
||||
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "Only lowercase letters, numbers, dash, dot and underscore are allowed",
|
||||
"API Format": "API Format",
|
||||
"Base URL": "Base URL",
|
||||
"Enter base url": "Enter base URL",
|
||||
"Optional, can also be provided per request": "Optional, can also be provided per request",
|
||||
"Model Identifier": "Model Identifier",
|
||||
"Enter model identifier": "Enter model identifier",
|
||||
"Description": "Description",
|
||||
"Capabilities": "Capabilities",
|
||||
"Context Window": "Context Window",
|
||||
"Embedding Dimensions": "Embedding Dimensions",
|
||||
"Price /1K input tokens": "Price /1K input tokens",
|
||||
"Price /1K output tokens": "Price /1K output tokens",
|
||||
"Missing required config:": "Missing required config:",
|
||||
"Updated successfully": "Updated successfully",
|
||||
"Created successfully": "Created successfully",
|
||||
"Operation failed": "Operation failed",
|
||||
"Deleted": "Deleted",
|
||||
"Delete failed": "Delete failed",
|
||||
"Status updated": "Status updated",
|
||||
"Update failed": "Update failed",
|
||||
"Mount Path": "Mount Path",
|
||||
"Sub Path": "Sub Path",
|
||||
"Sub Path (optional)": "Sub Path (optional)",
|
||||
"Sub directory inside adapter": "Sub directory inside adapter",
|
||||
"Enabled": "Enabled",
|
||||
"Actions": "Actions",
|
||||
"Edit": "Edit",
|
||||
"Confirm delete?": "Confirm delete?",
|
||||
"No config fields": "No config fields",
|
||||
"Please input {label}": "Please input {label}",
|
||||
"Storage Adapters": "Storage Adapters",
|
||||
"Create Adapter": "Create Adapter",
|
||||
"Unique name": "Unique name",
|
||||
"Select adapter type": "Select adapter type",
|
||||
"/ or /drive": "/ or /drive",
|
||||
"Adapter Config": "Adapter Config",
|
||||
"adapter.type.local": "Local Filesystem",
|
||||
"adapter.type.foxel": "Foxel Node",
|
||||
"adapter.type.dropbox": "Dropbox",
|
||||
"adapter.type.webdav": "WebDAV",
|
||||
"adapter.type.googledrive": "Google Drive",
|
||||
"adapter.type.onedrive": "OneDrive",
|
||||
"adapter.type.s3": "Amazon S3",
|
||||
"adapter.type.ftp": "FTP",
|
||||
"adapter.type.sftp": "SFTP",
|
||||
"adapter.type.telegram": "Telegram",
|
||||
"adapter.type.quark": "Quark Drive",
|
||||
"adapter.type.alist": "AList",
|
||||
"adapter.type.openlist": "OpenList",
|
||||
"Automation Tasks": "Automation Tasks",
|
||||
"Create Task": "Create Task",
|
||||
"Edit Task": "Edit Task",
|
||||
"Create Automation Task": "Create Automation Task",
|
||||
"Task Name": "Task Name",
|
||||
"Trigger Event": "Trigger Event",
|
||||
"File Written": "File Written",
|
||||
"File Deleted": "File Deleted",
|
||||
"Scheduled": "Scheduled",
|
||||
"Matching Rules": "Matching Rules",
|
||||
"Path Prefix (optional)": "Path Prefix (optional)",
|
||||
"Filename Regex (optional)": "Filename Regex (optional)",
|
||||
"Schedule": "Schedule",
|
||||
"Cron Expression": "Cron Expression",
|
||||
"Action": "Action",
|
||||
"Current Task Queue": "Current Task Queue",
|
||||
"Params": "Params",
|
||||
"Status": "Status",
|
||||
"Confirm clear logs?": "Confirm clear logs?",
|
||||
"This will delete logs in selected range irreversibly.": "This will delete logs in selected range irreversibly.",
|
||||
"This will delete all logs irreversibly.": "This will delete all logs irreversibly.",
|
||||
"Cleared {count} logs": "Cleared {count} logs",
|
||||
"Time": "Time",
|
||||
"Weekday": "Weekday",
|
||||
"Level": "Level",
|
||||
"Source": "Source",
|
||||
"Message": "Message",
|
||||
"User ID": "User ID",
|
||||
"Search source": "Search source",
|
||||
"Clear": "Clear",
|
||||
"Log Details": "Log Details",
|
||||
"Raw Log": "Raw Log",
|
||||
"Export started, check your downloads.": "Export started, check your downloads.",
|
||||
"Export failed": "Export failed",
|
||||
"Confirm import backup?": "Confirm import backup?",
|
||||
"Are you sure to import from this file?": "Are you sure to import from this file?",
|
||||
"Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!": "Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!",
|
||||
"Confirm Import": "Confirm Import",
|
||||
"Import succeeded! The page will refresh.": "Import succeeded! The page will refresh.",
|
||||
"Import failed": "Import failed",
|
||||
"Export": "Export",
|
||||
"Import": "Import",
|
||||
"Export all data (adapters, users, tasks, shares) into a JSON file.": "Export all data (adapters, users, tasks, shares) into a JSON file.",
|
||||
"Export selected data into a JSON file.": "Export selected data into a JSON file.",
|
||||
"Keep your backup file safe.": "Keep your backup file safe.",
|
||||
"Select backup sections": "Select backup sections",
|
||||
"User Accounts": "User Accounts",
|
||||
"Share Links": "Share Links",
|
||||
"Configurations": "Configurations",
|
||||
"AI Providers": "AI Providers",
|
||||
"AI Models": "AI Models",
|
||||
"AI Default Models": "AI Default Models",
|
||||
"Plugin Data": "Plugins",
|
||||
"Export Backup": "Export Backup",
|
||||
"Restore data from a previously exported JSON file.": "Restore data from a previously exported JSON file.",
|
||||
"Warning: This will clear and overwrite existing data.": "Warning: This will clear and overwrite existing data.",
|
||||
"Import mode": "Import mode",
|
||||
"Merge (upsert by ID)": "Merge (upsert by ID)",
|
||||
"Replace (clear before import)": "Replace (clear before import)",
|
||||
"Warning: This will clear data in the backup sections before importing.": "Warning: This will clear data in the backup sections before importing.",
|
||||
"Warning: This will merge data in the backup sections and overwrite existing records with the same ID.": "Warning: This will merge data in the backup sections and overwrite existing records with the same ID.",
|
||||
"Choose File and Restore": "Choose File and Restore",
|
||||
"No files yet here": "No files yet here",
|
||||
"This folder is empty": "This folder is empty",
|
||||
"Start uploading files or create folders to organize your content": "Start uploading files or create folders to organize your content",
|
||||
"You can create folders or upload files here": "You can create folders or upload files here",
|
||||
"Please input name": "Please input name",
|
||||
"Confirm delete {name}?": "Confirm delete {name}?",
|
||||
"items": "items",
|
||||
"Downloading folders is not supported": "Downloading folders is not supported",
|
||||
"Download failed": "Download failed",
|
||||
"Please select files or folders to share": "Please select files or folders to share",
|
||||
"Direct links for folders are not supported": "Direct links for folders are not supported",
|
||||
"Processing finished": "Processing finished",
|
||||
"Processing failed": "Processing failed",
|
||||
"Processors": "Processors",
|
||||
"Processor List": "Processor List",
|
||||
"Reload": "Reload",
|
||||
"Run Processor": "Run Processor",
|
||||
"Target Path": "Target Path",
|
||||
"Please select a path": "Please select a path",
|
||||
"Select Directory": "Select Directory",
|
||||
"Overwrite original": "Overwrite original",
|
||||
"Save To": "Save To",
|
||||
"Optional output path": "Optional output path",
|
||||
"Run": "Run",
|
||||
"Select a processor": "Select a processor",
|
||||
"No module path": "No module path",
|
||||
"Source saved": "Source saved",
|
||||
"Processors reloaded": "Processors reloaded",
|
||||
"Unsaved changes": "Unsaved changes",
|
||||
"Switching processor will discard unsaved changes. Continue?": "Switching processor will discard unsaved changes. Continue?",
|
||||
"Task submitted": "Task submitted",
|
||||
"Supported Extensions": "Supported Extensions",
|
||||
"All": "All",
|
||||
"Produces File": "Produces File",
|
||||
"Yes": "Yes",
|
||||
"No": "No",
|
||||
"Please select a processor": "Please select a processor",
|
||||
"Select a path": "Select a path",
|
||||
"Source Editor": "Source Editor",
|
||||
"Module Path": "Module Path",
|
||||
"Directory processing always overwrites original files": "Directory processing always overwrites original files",
|
||||
"Directory execution will enqueue one task per file": "Directory execution will enqueue a scan task, then one task per file",
|
||||
"No data": "No data",
|
||||
"Select File": "Select File",
|
||||
"Select Path": "Select Path",
|
||||
"Select Folder": "Select Folder",
|
||||
"Select": "Select",
|
||||
"Current": "Current",
|
||||
"Up": "Up",
|
||||
"Select Current Folder": "Select Current Folder",
|
||||
"Please select a file": "Please select a file",
|
||||
"Please select a .foxpkg file": "Please select a .foxpkg file",
|
||||
"Invalid file": "Invalid file",
|
||||
"Installed successfully": "Installed successfully",
|
||||
"Installation failed": "Installation failed",
|
||||
"Plugin": "Plugin",
|
||||
"No description": "No description",
|
||||
"Any": "Any",
|
||||
"Open Link": "Open Link",
|
||||
"Link copied": "Link copied",
|
||||
"Copy Link": "Copy Link",
|
||||
"Open App": "Open App",
|
||||
"Update App": "Update App",
|
||||
"Confirm delete this plugin?": "Confirm delete this plugin?",
|
||||
"Uninstall": "Uninstall",
|
||||
"Author": "Author",
|
||||
"Website": "Website",
|
||||
"Install Plugin": "Install Plugin",
|
||||
"Confirm Install": "Confirm Install",
|
||||
"Selected {count} files": "Selected {count} files",
|
||||
"Installation will stop on first failure": "Installation will stop on first failure",
|
||||
"Installing": "Installing",
|
||||
"Remove": "Remove",
|
||||
"Cancel": "Cancel",
|
||||
"Install App": "Install App",
|
||||
"Search name/author/extension": "Search name/author/extension",
|
||||
"Search name/author/url/extension": "Search name/author/url/extension",
|
||||
"No plugins": "No plugins",
|
||||
"Install": "Install",
|
||||
"App URL": "App URL",
|
||||
"Please input a valid URL": "Please input a valid URL",
|
||||
"Installed": "Installed",
|
||||
"Discover": "Discover",
|
||||
"Search apps": "Search apps",
|
||||
"Sort by": "Sort by",
|
||||
"Downloads": "Downloads",
|
||||
"Created (newest)": "Created (newest)",
|
||||
"Installed already": "Installed",
|
||||
"No results": "No results",
|
||||
"Download and Install": "Download & Install",
|
||||
"Loading apps": "Loading apps",
|
||||
"Failed to load apps": "Failed to load apps",
|
||||
"Version": "Version",
|
||||
"Tags": "Tags",
|
||||
"Approved": "Approved",
|
||||
"Coming soon v2": "Coming soon v2",
|
||||
"Initialization succeeded! Logging you in...": "Initialization succeeded! Logging you in...",
|
||||
"Initialization failed, please try later": "Initialization failed, please try later",
|
||||
"Vector DB setup failed, you can configure it later in System Settings": "Vector DB setup failed, you can configure it later in System Settings",
|
||||
"Database Setup": "Database Setup",
|
||||
"Choose database driver": "Choose database driver",
|
||||
"Select database and vector database for system data": "Select database and vector database for system data",
|
||||
"Database Driver": "Database Driver",
|
||||
"Vector DB Driver": "Vector DB Driver",
|
||||
"Initialize Mount": "Initialize Mount",
|
||||
"Configure initial storage": "Configure initial storage",
|
||||
"Create the first storage mount for your files": "Create the first storage mount for your files",
|
||||
"Mount Name": "Mount Name",
|
||||
"Local Storage": "Local Storage",
|
||||
"Please input mount name!": "Please input mount name!",
|
||||
"Storage Type": "Storage Type",
|
||||
"Please input mount path!": "Please input mount path!",
|
||||
"Root Directory": "Root Directory",
|
||||
"Please input root directory!": "Please input root directory!",
|
||||
"e.g., data/ or /var/foxel/data": "e.g., data/ or /var/foxel/data",
|
||||
"Optional, used for external links. Leave empty to use the current site.": "Optional, used for external links. Leave empty to use the current site.",
|
||||
"Create Admin": "Create Admin",
|
||||
"Create admin account": "Create admin account",
|
||||
"This is the first account with full permissions": "This is the first account with full permissions",
|
||||
"Username": "Username",
|
||||
"Please input a valid email!": "Please input a valid email!",
|
||||
"Please confirm your password!": "Please confirm your password!",
|
||||
"Passwords do not match!": "Passwords do not match!",
|
||||
"System Initialization": "System Initialization",
|
||||
"Previous": "Previous",
|
||||
"Next": "Next",
|
||||
"Finish Initialization": "Finish Initialization",
|
||||
"Plugin run failed": "Plugin run failed",
|
||||
"Plugin Error": "Plugin Error",
|
||||
"Cannot open file": "Cannot open file",
|
||||
"Cannot open file: no available app": "Cannot open file: no available app",
|
||||
"No app available for this file. Go to App Store to search {ext}?": "No app available for this file. Go to App Store to search {ext}?",
|
||||
"Go to App Store": "Go to App Store",
|
||||
"Error": "Error",
|
||||
"App \"{key}\" not found.": "App \"{key}\" not found.",
|
||||
"Open with {app}": "Open with {app}",
|
||||
"Set as default for .{ext}": "Set as default for .{ext}",
|
||||
"AI Agent": "AI Agent",
|
||||
"Notices": "Notices",
|
||||
"No notices": "No notices",
|
||||
"Load more": "Load more",
|
||||
"Auto execute": "Auto execute",
|
||||
"Start a conversation": "Start a conversation",
|
||||
"No content": "No content",
|
||||
"Pending actions": "Pending actions",
|
||||
"Execute": "Execute",
|
||||
"Execute all": "Execute all",
|
||||
"Cancel all": "Cancel all",
|
||||
"Type a message": "Type a message",
|
||||
"Send": "Send",
|
||||
"Please confirm pending actions first": "Please confirm pending actions first",
|
||||
"You": "You",
|
||||
"Tool": "Tool",
|
||||
"MCP Tool": "MCP Tool",
|
||||
"Arguments": "Arguments",
|
||||
"Raw JSON": "Raw JSON",
|
||||
"Collapse": "Collapse",
|
||||
"Copied": "Copied",
|
||||
"Canceled": "Canceled",
|
||||
"Tasks submitted": "Tasks submitted",
|
||||
"Calling tools": "Calling tools",
|
||||
"Advanced tokens must be valid JSON": "Advanced tokens must be valid JSON",
|
||||
"Search": "Search",
|
||||
"Total": "Total",
|
||||
"Mode": "Mode",
|
||||
"Has more": "Has more",
|
||||
"Page": "Page",
|
||||
"results": "results",
|
||||
"chars": "chars",
|
||||
"Truncated": "Truncated",
|
||||
"Write": "Write",
|
||||
"Read": "Read",
|
||||
"Created": "Created",
|
||||
"Moved": "Moved",
|
||||
"Renamed": "Renamed",
|
||||
"Info": "Info",
|
||||
"User Management": "User Management",
|
||||
"Role Management": "Role Management",
|
||||
"Users": "Users",
|
||||
"Create User": "Create User",
|
||||
"Create Role": "Create Role",
|
||||
"Super Admin": "Super Admin",
|
||||
"Disabled": "Disabled",
|
||||
"Active": "Active",
|
||||
"Search users or roles": "Search users or roles",
|
||||
"Last Login": "Last Login",
|
||||
"Roles": "Roles",
|
||||
"Quick Create Role": "Quick Create Role",
|
||||
"Select roles": "Select roles",
|
||||
"Created by": "Created by",
|
||||
"New Password (leave empty to keep current)": "New Password (leave empty to keep current)",
|
||||
"Role Name": "Role Name",
|
||||
"Path Rules": "Path Rules",
|
||||
"Add Path Rule": "Add Path Rule",
|
||||
"Edit Path Rule": "Edit Path Rule",
|
||||
"Path Pattern": "Path Pattern",
|
||||
"Is Regex": "Is Regex",
|
||||
"Priority": "Priority",
|
||||
"Higher value = higher priority": "Higher value = higher priority",
|
||||
"System Permissions": "System Permissions",
|
||||
"Download and preview files": "Download and preview files",
|
||||
"Upload and modify files": "Upload and modify files",
|
||||
"Delete files and folders": "Delete files and folders",
|
||||
"Create share links": "Create share links",
|
||||
"permission.category.system": "System",
|
||||
"permission.category.adapter": "Adapter"
|
||||
}
|
||||
802
web/src/i18n/locales/zh.json
Normal file
@@ -0,0 +1,802 @@
|
||||
{
|
||||
"All Files": "全部文件",
|
||||
"Manage": "管理",
|
||||
"System": "系统",
|
||||
"Automation": "自动任务",
|
||||
"My Shares": "我的分享",
|
||||
"Offline Downloads": "离线下载",
|
||||
"No offline download tasks": "暂无离线下载任务",
|
||||
"Create Offline Download": "创建离线下载任务",
|
||||
"Offline Download Tasks": "离线下载任务列表",
|
||||
"URL": "下载地址",
|
||||
"Please input URL": "请输入下载地址",
|
||||
"Destination Folder": "保存目录",
|
||||
"Select destination": "请选择保存目录",
|
||||
"Filename": "文件名",
|
||||
"Please input filename": "请输入文件名",
|
||||
"Start Download": "开始下载",
|
||||
"Stage": "阶段",
|
||||
"Progress": "进度",
|
||||
"Bytes": "已传输",
|
||||
"Save Path": "保存路径",
|
||||
"Queued": "排队中",
|
||||
"Downloading": "下载中",
|
||||
"Transferring": "转存中",
|
||||
"Completed": "已完成",
|
||||
"Pending": "等待",
|
||||
"Running": "进行中",
|
||||
"Success": "成功",
|
||||
"Failed": "失败",
|
||||
"Failure": "失败",
|
||||
"Adapters": "存储挂载",
|
||||
"Plugins": "应用中心",
|
||||
"System Settings": "系统设置",
|
||||
"Registration Settings": "注册设置",
|
||||
"Backup & Restore": "备份恢复",
|
||||
"System Logs": "系统日志",
|
||||
"Audit Logs": "审计日志",
|
||||
"Audit Log Details": "审计日志详情",
|
||||
"Search files / tags / types": "搜索文件 / 标签 / 类型",
|
||||
"Log Out": "退出登录",
|
||||
"Admin": "管理员",
|
||||
"Enable Registration": "开启注册",
|
||||
"Default Role for New Registrations": "默认注册角色",
|
||||
"Please select default role": "请选择默认注册角色",
|
||||
"Enabling registration allows new users to sign up and assigns them the default role": "开启后,用户可自行注册,并自动分配为默认角色",
|
||||
"Create Account": "创建账号",
|
||||
"Sign Up": "注册",
|
||||
"Sign up to your Foxel account": "注册 Foxel 账号",
|
||||
"Already have an account?": "已有账号?",
|
||||
"Register failed": "注册失败",
|
||||
"Please input email!": "请输入邮箱!",
|
||||
"Profile": "个人资料",
|
||||
"Account Settings": "账户设置",
|
||||
"Language": "语言",
|
||||
"Full Name": "昵称",
|
||||
"Email": "邮箱",
|
||||
"Change Password": "修改密码",
|
||||
"Old Password": "原密码",
|
||||
"New Password": "新密码",
|
||||
"Please fill both old and new password": "请同时填写原密码和新密码",
|
||||
"Welcome Back": "欢迎回来",
|
||||
"Sign in to your Foxel account": "登录到您的 Foxel 账户",
|
||||
"Username / Email": "用户名/邮箱",
|
||||
"Password": "密码",
|
||||
"Sign In": "登录",
|
||||
"Please enter username and password": "请输入用户名与密码",
|
||||
"Login failed": "登录失败",
|
||||
"Forgot Password?": "忘记密码?",
|
||||
"Your next-generation file manager": "您的下一代文件管理系统",
|
||||
"Cross-platform sync, access anywhere": "跨平台同步,随时随地访问",
|
||||
"AI-powered search for quick find": "AI 驱动的智能搜索,快速定位文件",
|
||||
"Flexible sharing and collaboration": "灵活的分享与协作,提升团队效率",
|
||||
"Powerful automation to simplify tasks": "强大的自动化工作流,简化繁琐任务",
|
||||
"Join our community:": "加入我们的社区:",
|
||||
"Reset Your Password": "重置你的密码",
|
||||
"Enter the email linked to your account and we will send a reset link.": "请输入你账户绑定的邮箱,我们会发送重置链接。",
|
||||
"If the email exists, a reset link has been sent.": "如果邮箱存在,我们已发送重置链接。",
|
||||
"Send Reset Link": "发送重置链接",
|
||||
"Resend Link": "重新发送链接",
|
||||
"Back to login": "返回登录",
|
||||
"Request failed": "请求失败",
|
||||
"Reset link is invalid": "重置链接无效",
|
||||
"Reset link is invalid or expired": "重置链接无效或已过期",
|
||||
"Reset failed": "重置失败",
|
||||
"Try again": "重试",
|
||||
"Set a new password": "设置新密码",
|
||||
"Please enter new password": "请输入新密码",
|
||||
"Confirm Password": "确认新密码",
|
||||
"Please confirm new password": "请确认新密码",
|
||||
"Update Password": "更新密码",
|
||||
"Passwords do not match": "两次输入的密码不一致",
|
||||
"Password updated, please login again.": "密码已更新,请重新登录。",
|
||||
"Failed to reset password": "密码重置失败",
|
||||
"Refresh": "刷新",
|
||||
"Copy": "复制",
|
||||
"Cancel": "取消",
|
||||
"Copied link": "链接已复制",
|
||||
"Share canceled": "分享已取消",
|
||||
"Cancel failed": "取消失败",
|
||||
"Load failed": "加载失败",
|
||||
"Are you sure to cancel share?": "确认取消分享?",
|
||||
"Clear expired shares": "清空过期分享",
|
||||
"Confirm clear expired shares?": "确认清空过期分享?",
|
||||
"Cleared {count} expired shares": "已清理 {count} 个过期分享",
|
||||
"Please select time range": "请选择时间范围",
|
||||
"Share Name": "分享名称",
|
||||
"Share Content": "分享内容",
|
||||
"Created At": "创建时间",
|
||||
"Expires At": "过期时间",
|
||||
"Forever": "永久有效",
|
||||
"Access": "访问",
|
||||
"Public": "公开",
|
||||
"By Password": "密码",
|
||||
"Password Required": "需要密码",
|
||||
"Please enter password": "请输入密码",
|
||||
"Confirm": "确认",
|
||||
"Unable to load share info": "无法加载分享信息",
|
||||
"Share load failed": "加载分享失败",
|
||||
"Wrong password": "密码错误",
|
||||
"Root": "全部文件",
|
||||
"Created on {date}": "创建于 {date}",
|
||||
"Expires on {date}": "将于 {date} 过期",
|
||||
"Download File": "下载文件",
|
||||
"Preview not supported for this file type": "暂不支持在线预览此类型文件",
|
||||
"Back": "返回",
|
||||
"Download": "下载",
|
||||
"Home": "主页",
|
||||
"File Manager": "文件管理",
|
||||
"New Folder": "新建目录",
|
||||
"New File": "新建文件",
|
||||
"Upload": "上传",
|
||||
"Name": "名称",
|
||||
"Size": "大小",
|
||||
"Modified Time": "修改时间",
|
||||
"Grid": "网格",
|
||||
"List": "列表",
|
||||
"Mount Point": "挂载点",
|
||||
"Move": "移动",
|
||||
"Move to": "移动到",
|
||||
"Copy to": "复制到",
|
||||
"Destination path": "目标路径",
|
||||
"Move task queued": "移动任务已排队",
|
||||
"Move completed": "移动完成",
|
||||
"Copy task queued": "复制任务已排队",
|
||||
"Copy completed": "复制完成",
|
||||
"Please input destination path": "请输入目标路径",
|
||||
"Upload File": "上传文件",
|
||||
"Upload Files": "上传文件",
|
||||
"Upload Folder": "上传文件夹",
|
||||
"Open": "打开",
|
||||
"Open With": "打开方式",
|
||||
"Default": "默认",
|
||||
"Processor": "处理器",
|
||||
"Share": "分享",
|
||||
"Rename": "重命名",
|
||||
"Delete": "删除",
|
||||
"Details": "详情",
|
||||
"Get Direct Link": "获取直链",
|
||||
"User": "用户",
|
||||
"Status Code": "状态码",
|
||||
"Duration (ms)": "耗时 (ms)",
|
||||
"Client IP": "客户端 IP",
|
||||
"Result": "结果",
|
||||
"Request Params": "请求参数",
|
||||
"Request Body": "请求体",
|
||||
"Total progress": "总体进度",
|
||||
"Upload task summary": "任务:已完成 {completed} / {total},待处理 {pending},失败 {failures}",
|
||||
"Overwrite confirmation required": "需要确认是否覆盖",
|
||||
"Target already exists: {path}": "目标已存在:{path}",
|
||||
"Overwrite": "覆盖",
|
||||
"Skip": "跳过",
|
||||
"Overwrite All": "全部覆盖",
|
||||
"Skip All": "全部跳过",
|
||||
"Directory": "目录",
|
||||
"Creating directory...": "正在创建目录...",
|
||||
"Directory ready": "目录已就绪",
|
||||
"Create directory failed": "创建目录失败",
|
||||
"Waiting to create": "等待创建",
|
||||
"Waiting for overwrite decision": "等待覆盖处理",
|
||||
"Waiting to upload": "等待上传",
|
||||
"Skipped": "已跳过",
|
||||
"Upload succeeded": "上传成功",
|
||||
"Upload failed": "上传失败",
|
||||
"No items selected for upload": "未选择任何可上传项",
|
||||
"No uploadable files or directories found": "未找到可上传的文件或目录",
|
||||
"Missing file content": "缺少文件内容",
|
||||
"Directory conflicts with existing file": "目标存在同名文件,无法创建目录",
|
||||
"Join Community": "加入社区",
|
||||
"Scan to join WeChat group": "微信扫码加入交流群",
|
||||
"If QR expires, add drizzle2001 to join": "如二维码失效,请添加 drizzle2001 拉群",
|
||||
"Version Info": "版本信息",
|
||||
"Current Version": "当前版本",
|
||||
"Latest Version": "最新版本",
|
||||
"New version found: {version}": "发现新版本: {version}",
|
||||
"Please update to the latest for features and fixes": "建议尽快更新到最新版本,以获得新功能和安全修复。",
|
||||
"Open Releases": "前往发布页面",
|
||||
"Changelog": "更新日志",
|
||||
"Fetching latest version...": "正在获取最新版本信息...",
|
||||
"Update available": "有更新",
|
||||
"You are on the latest: {version}": "当前为最新版: {version}",
|
||||
"Up to date": "已是最新版",
|
||||
"Share {count} items": "分享 {count} 个项目",
|
||||
"Share link created": "分享链接已创建",
|
||||
"Create failed": "创建失败",
|
||||
"Copied to clipboard": "已复制到剪贴板",
|
||||
"Expiration (days)": "有效期 (天)",
|
||||
"Set 0 or negative for forever": "设置为 0 或负数表示永久有效",
|
||||
"Share link created successfully!": "分享链接已成功创建!",
|
||||
"Share Link": "分享链接",
|
||||
"Share created": "分享创建成功",
|
||||
"Create Share": "创建分享",
|
||||
"Done": "完成",
|
||||
"Create": "创建",
|
||||
"Failed to generate link": "生成链接失败",
|
||||
"Markdown copied to clipboard": "Markdown 格式已复制到剪贴板",
|
||||
"Generate a direct link for {name}": "为 {name} 生成一个直接访问链接。",
|
||||
"1 hour": "1 小时",
|
||||
"1 day": "1 天",
|
||||
"7 days": "7 天",
|
||||
"Generating link...": "正在生成链接...",
|
||||
"Link will appear here": "链接将显示在这里",
|
||||
"Copy Markdown": "复制 Markdown",
|
||||
"Close": "关闭",
|
||||
"Task Queue": "任务队列",
|
||||
"Last updated at {time}": "上次刷新时间 {time}",
|
||||
"Total Tasks": "任务总数",
|
||||
"Waiting Tasks": "等待中的任务",
|
||||
"Failed Tasks": "失败的任务",
|
||||
"Active Workers": "活跃 Worker 数",
|
||||
"Task Type": "任务类型",
|
||||
"Search by name or ID": "按名称或 ID 搜索",
|
||||
"Filter by status": "按状态筛选",
|
||||
"Queue Concurrency": "队列并发数",
|
||||
"Settings saved": "设置已保存",
|
||||
"Expand": "展开",
|
||||
"Adjust worker concurrency immediately": "立即调整任务并发数",
|
||||
"Auto": "自动",
|
||||
"Manual": "手动",
|
||||
"Camera Make": "设备品牌",
|
||||
"Camera Model": "设备型号",
|
||||
"Capture Time": "拍摄时间",
|
||||
"X Resolution": "水平分辨率",
|
||||
"Y Resolution": "垂直分辨率",
|
||||
"Exposure Time": "曝光时间",
|
||||
"Aperture": "光圈值",
|
||||
"Focal Length": "焦距",
|
||||
"Width": "宽度",
|
||||
"Height": "高度",
|
||||
"No common EXIF info": "无常见EXIF信息",
|
||||
"File Properties": "文件属性",
|
||||
"Loading file info...": "加载文件信息...",
|
||||
"Basic Info": "基本信息",
|
||||
"Type": "类型",
|
||||
"Folder": "文件夹",
|
||||
"File": "文件",
|
||||
"Path": "路径",
|
||||
"Path copied to clipboard": "路径已复制到剪贴板",
|
||||
"Copy failed": "复制失败",
|
||||
"Permissions": "权限",
|
||||
"EXIF Info": "EXIF信息",
|
||||
"Index Info": "索引信息",
|
||||
"Indexed Items": "索引条目数",
|
||||
"Indexed Types": "索引类型统计",
|
||||
"No index data": "暂无索引数据",
|
||||
"Indexed Chunks": "索引条目",
|
||||
"More Indexed Chunks": "更多索引条目",
|
||||
"Chunk ID": "分片ID",
|
||||
"Offset Range": "偏移范围",
|
||||
"Vector ID": "向量ID",
|
||||
"Preview": "内容预览",
|
||||
"Showing first {count} entries": "仅展示前 {count} 条",
|
||||
"Smart Search": "智能搜索",
|
||||
"Name Search": "名称搜索",
|
||||
"Search Results": "搜索结果",
|
||||
"No files found": "未找到相关文件",
|
||||
"Relevance": "相关度",
|
||||
"Saved successfully": "保存成功",
|
||||
"Save failed": "保存失败",
|
||||
"Loading...": "加载中...",
|
||||
"Appearance Settings": "外观设置",
|
||||
"Theme": "主题",
|
||||
"Theme Mode": "主题模式",
|
||||
"Light": "亮色",
|
||||
"Dark": "暗色",
|
||||
"Follow System": "跟随系统",
|
||||
"Primary Color": "主色",
|
||||
"Border Radius": "圆角",
|
||||
"Advanced": "高级",
|
||||
"Override AntD Tokens (JSON)": "覆盖 AntD Token(JSON)",
|
||||
"e.g. {\"colorText\": \"#222\"}": "例如:{\"colorText\": \"#222\"}",
|
||||
"Custom CSS": "自定义 CSS",
|
||||
"Save": "保存",
|
||||
"App Settings": "应用设置",
|
||||
"Email Settings": "邮箱设置",
|
||||
"AI Settings": "AI设置",
|
||||
"Protocol Mappings": "映射协议",
|
||||
"S3 Mapping": "S3 映射",
|
||||
"S3 Endpoint": "S3 访问地址",
|
||||
"Bucket Name": "Bucket 名称",
|
||||
"Bucket API Path": "Bucket API 路径",
|
||||
"Region": "区域",
|
||||
"Base Path": "基础路径",
|
||||
"Choose Template": "选择模板",
|
||||
"Configure Provider": "配置提供商",
|
||||
"Back to Templates": "返回选择",
|
||||
"View Docs": "查看文档",
|
||||
"Vision Model": "视觉模型",
|
||||
"Embedding Model": "嵌入模型",
|
||||
"Embedding Dimension": "向量维度",
|
||||
"Vector Database": "向量数据库",
|
||||
"Vector Database Settings": "向量数据库设置",
|
||||
"Current Statistics": "当前统计",
|
||||
"Collections": "集合",
|
||||
"Vectors": "向量",
|
||||
"Database Size": "数据库大小",
|
||||
"Estimated Memory": "估算内存",
|
||||
"No collections": "暂无集合",
|
||||
"Dimension": "维度",
|
||||
"Non-vector collection": "非向量集合",
|
||||
"Estimated memory": "估算内存",
|
||||
"Indexes": "索引",
|
||||
"Unnamed index": "未命名索引",
|
||||
"Indexed rows": "已索引行数",
|
||||
"Pending rows": "待索引行数",
|
||||
"Estimated memory is calculated as vectors x dimension x 4 bytes (float32).": "估算内存 = 向量数量 x 维度 x 4 字节(float32)。",
|
||||
"Database Provider": "数据库提供者",
|
||||
"Please select a provider": "请选择提供者",
|
||||
"Coming soon": "敬请期待",
|
||||
"This provider is not available yet": "该提供者暂不可用",
|
||||
"Database file path": "数据库文件路径",
|
||||
"Server URI": "服务器 URI",
|
||||
"Token": "令牌",
|
||||
"Server URL": "服务器地址",
|
||||
"Embedded Milvus Lite (local file storage).": "嵌入式 Milvus Lite,本地文件存储。",
|
||||
"Remote Milvus instance accessed via URI.": "通过 URI 访问的远程 Milvus 实例。",
|
||||
"Qdrant vector database (HTTP API).": "Qdrant 向量数据库(HTTP API)。",
|
||||
"Database Type": "数据库类型",
|
||||
"Confirm embedding dimension change": "确认修改向量维度",
|
||||
"Changing the embedding dimension will clear the vector database automatically. You will need to rebuild indexes afterwards. Continue?": "修改向量维度会自动清空向量数据库,之后需要重建索引,是否继续?",
|
||||
"Confirm clear vector database?": "确认清空向量数据库?",
|
||||
"This will delete all collections irreversibly.": "此操作将删除所有集合中的所有数据,且不可逆。",
|
||||
"Confirm Clear": "确认清空",
|
||||
"Vector database cleared": "向量数据库已清空",
|
||||
"Clear failed": "清空失败",
|
||||
"Clear Vector DB": "清空向量库",
|
||||
"App Name": "应用名称",
|
||||
"Logo URL": "LOGO地址",
|
||||
"Favicon URL": "Favicon 地址",
|
||||
"App Domain": "应用域名",
|
||||
"File Domain": "文件域名",
|
||||
"Configure Access Key and Secret to enable S3 mapping.": "配置 Access Key 与 Secret 后才能启用 S3 映射。",
|
||||
"Mount point inside the virtual file system (e.g. / or /workspace).": "虚拟文件系统中的挂载路径,例如 / 或 /workspace。",
|
||||
"Leave blank to accept any region.": "留空表示接受任意 Region。",
|
||||
"Please input bucket name": "请输入 Bucket 名",
|
||||
"Please input region": "请输入 Region",
|
||||
"Please input access key": "请输入 Access Key",
|
||||
"Please input secret key": "请输入 Secret Key",
|
||||
"Save S3 Settings": "保存 S3 配置",
|
||||
"Example CLI command": "示例 CLI 命令",
|
||||
"WebDAV Mapping": "WebDAV 映射",
|
||||
"WebDAV Endpoint": "WebDAV 访问地址",
|
||||
"Basic (system account password)": "Basic(系统账号密码)",
|
||||
"Root Path": "根路径",
|
||||
"Client Compatibility": "客户端兼容性",
|
||||
"Supports Finder, Windows network drive, rclone, and other WebDAV clients.": "兼容 Finder、Windows 网络驱动器、rclone 等 WebDAV 客户端。",
|
||||
"Toggle the switch to expose the virtual file system via WebDAV.": "通过开关控制是否对外暴露虚拟文件系统的 WebDAV 协议。",
|
||||
"SMTP Settings": "SMTP 配置",
|
||||
"SMTP Host": "SMTP 服务器",
|
||||
"Please input SMTP host": "请输入 SMTP 服务器",
|
||||
"SMTP Port": "SMTP 端口",
|
||||
"Please input SMTP port": "请输入 SMTP 端口",
|
||||
"Security": "安全协议",
|
||||
"None": "无",
|
||||
"Timeout (seconds)": "超时时间(秒)",
|
||||
"Sender": "发件人",
|
||||
"Sender Name": "发件人名称",
|
||||
"Sender Email": "发件人邮箱",
|
||||
"Please input sender email": "请输入发件人邮箱",
|
||||
"Authentication": "身份认证",
|
||||
"SMTP Username": "SMTP 用户名",
|
||||
"SMTP Password": "SMTP 密码",
|
||||
"Test Email": "测试发信",
|
||||
"Current Configuration": "当前配置摘要",
|
||||
"Available variables": "可用变量",
|
||||
"Not set": "未设置",
|
||||
"Password Reset Template": "密码重置模板",
|
||||
"Live Preview": "实时预览",
|
||||
"Template saved": "模板已保存",
|
||||
"Failed to save template": "模板保存失败",
|
||||
"Failed to load template": "模板加载失败",
|
||||
"Preview failed": "预览失败",
|
||||
"Foxel Mail Test": "Foxel 邮件测试",
|
||||
"Recipient Address": "收件人地址",
|
||||
"Please input recipient email": "请输入收件人邮箱",
|
||||
"Test Subject": "测试邮件标题",
|
||||
"Test User Name": "测试用户名",
|
||||
"Optional": "可选",
|
||||
"Send Test Email": "发送测试邮件",
|
||||
"Please complete all required fields": "请填写所有必填项",
|
||||
"SMTP port must be a positive number": "SMTP 端口必须为正数",
|
||||
"Test email queued (task {{taskId}})": "测试邮件已入队(任务 {{taskId}})",
|
||||
"Test email failed": "测试邮件发送失败",
|
||||
"Vision API URL": "视觉模型 API 地址",
|
||||
"Vision API Key": "视觉模型 API Key",
|
||||
"Embedding API URL": "嵌入模型 API 地址",
|
||||
"Embedding API Key": "嵌入模型 API Key",
|
||||
"AI Providers & Models": "AI 提供商与模型",
|
||||
"Manage AI providers, synchronize compatible models, and configure default capabilities across the system.": "管理所有 AI 提供商,批量同步兼容模型,并配置系统默认能力。",
|
||||
"Add Provider": "添加提供商",
|
||||
"Edit Provider": "编辑提供商",
|
||||
"Pull Models": "拉取模型",
|
||||
"Manual Add": "手动添加",
|
||||
"Clear Remote List": "清空列表",
|
||||
"Select models from the list to add them automatically": "选择模型后可一键添加到系统",
|
||||
"No remote models": "暂无远程模型",
|
||||
"No remote models found": "未获取到远程模型",
|
||||
"No remote models match search": "没有匹配的远程模型",
|
||||
"Search fetched models": "搜索已拉取模型",
|
||||
"Already Added": "已添加",
|
||||
"Add Selected Models": "添加所选模型",
|
||||
"Fetch failed": "拉取失败",
|
||||
"Select models to add": "请选择要添加的模型",
|
||||
"Added {count} models": "已添加 {count} 个模型",
|
||||
"Custom Provider": "自定义提供商",
|
||||
"Custom Provider Description": "自定义兼容 OpenAI 或 Gemini 标准的 API 端点。",
|
||||
"OpenAI Provider": "OpenAI",
|
||||
"OpenAI Provider Description": "访问 OpenAI 的 GPT-4o、GPT-4.1、GPT-5 等模型。",
|
||||
"Azure OpenAI Provider": "Azure OpenAI",
|
||||
"Azure OpenAI Provider Description": "使用托管在微软 Azure 上的 OpenAI 模型。",
|
||||
"Google AI Provider": "Google AI",
|
||||
"Google AI Provider Description": "Google AI 平台提供的 Gemini 系列模型。",
|
||||
"SiliconFlow Provider": "硅基流动",
|
||||
"SiliconFlow Provider Description": "硅基流动高性能推理平台,兼容 OpenAI 接口。",
|
||||
"OpenRouter Provider Description": "通过一个 OpenAI 风格入口接入多家 AI 提供商。",
|
||||
"Anthropic Provider": "Anthropic",
|
||||
"Anthropic Provider Description": "通过 Claude API 使用 Claude 4 系列模型。",
|
||||
"Z.ai Provider": "Z.ai",
|
||||
"Z.ai Provider Description": "通过智谱开放平台接入(OpenAI 风格接口)。",
|
||||
"DeepSeek Provider": "DeepSeek",
|
||||
"DeepSeek Provider Description": "DeepSeek 语言模型,支持 OpenAI 兼容接口。",
|
||||
"Grok Provider Description": "xAI 的 Grok 模型,提供 OpenAI 风格接口。",
|
||||
"Ollama Provider": "Ollama",
|
||||
"Ollama Provider Description": "使用 Ollama 在本地运行并管理大模型。",
|
||||
"Voyage Provider Description": "Voyage AI 提供的高质量嵌入与重排序模型。",
|
||||
"Delete provider?": "确认删除该提供商?",
|
||||
"Deleting this provider will also remove all associated models. Continue?": "删除后将同时移除该提供商下的全部模型,是否继续?",
|
||||
"Deleted successfully": "删除成功",
|
||||
"Sync Models": "同步模型",
|
||||
"Sync completed: {created} created, {updated} updated": "同步完成:新增 {created} 个,更新 {updated} 个",
|
||||
"Sync failed": "同步失败",
|
||||
"Add Model": "添加模型",
|
||||
"Edit Model": "编辑模型",
|
||||
"Delete model?": "确认删除该模型?",
|
||||
"This operation cannot be undone. Continue?": "此操作不可撤销,是否继续?",
|
||||
"No models yet": "暂无模型",
|
||||
"Add your first AI provider to get started": "添加第一个 AI 提供商开始配置",
|
||||
"Default Models Configuration": "默认模型配置",
|
||||
"Main Chat Model": "主对话模型",
|
||||
"Rerank Model": "重排序模型",
|
||||
"Voice Model": "语音模型",
|
||||
"Tools Model": "工具模型",
|
||||
"Primary assistant for conversations, reasoning, and tool calls.": "用于对话、推理与工具调用的核心模型。",
|
||||
"Handles multimodal perception such as image understanding.": "负责多模态感知与图像理解。",
|
||||
"Transforms content into dense vectors for search and retrieval.": "将内容向量化以驱动搜索与检索。",
|
||||
"Optimises ranking quality for search candidates.": "重新排序候选结果,提升检索相关性。",
|
||||
"Covers text-to-speech and speech understanding scenarios.": "覆盖文本转语音与语音理解场景。",
|
||||
"Supports function calling, orchestration, and automation.": "支持函数调用、编排与自动化。",
|
||||
"Select a model": "选择模型",
|
||||
"Template": "模板",
|
||||
"Select a template": "选择模板",
|
||||
"Display Name": "显示名称",
|
||||
"Enter name": "请输入名称",
|
||||
"Identifier": "标识符",
|
||||
"Enter identifier": "请输入标识符",
|
||||
"Only lowercase letters, numbers, dash, dot and underscore are allowed": "仅允许小写字母、数字、连字符、点和下划线",
|
||||
"API Format": "API 格式",
|
||||
"Base URL": "基础 URL",
|
||||
"Enter base url": "请输入基础 URL",
|
||||
"Optional, can also be provided per request": "可选,也可在请求时提供",
|
||||
"Model Identifier": "模型标识",
|
||||
"Enter model identifier": "请输入模型标识",
|
||||
"Description": "描述",
|
||||
"Capabilities": "能力标签",
|
||||
"Context Window": "上下文窗口",
|
||||
"Embedding Dimensions": "向量维度",
|
||||
"Price /1K input tokens": "价格 /1K 输入 token",
|
||||
"Price /1K output tokens": "价格 /1K 输出 token",
|
||||
"Missing required config:": "缺少必填配置:",
|
||||
"Updated successfully": "更新成功",
|
||||
"Created successfully": "创建成功",
|
||||
"Operation failed": "操作失败",
|
||||
"Deleted": "已删除",
|
||||
"Delete failed": "删除失败",
|
||||
"Status updated": "状态已更新",
|
||||
"Update failed": "更新失败",
|
||||
"Mount Path": "挂载路径",
|
||||
"Sub Path": "子路径",
|
||||
"Sub Path (optional)": "子路径(可选)",
|
||||
"Sub directory inside adapter": "适配器内部子目录",
|
||||
"Enabled": "启用",
|
||||
"Actions": "操作",
|
||||
"Edit": "编辑",
|
||||
"Confirm delete?": "确认删除?",
|
||||
"No config fields": "无配置项",
|
||||
"Please input {label}": "请输入{label}",
|
||||
"Storage Adapters": "存储适配器",
|
||||
"Create Adapter": "新建适配器",
|
||||
"Unique name": "唯一名称",
|
||||
"Select adapter type": "选择适配器类型",
|
||||
"/ or /drive": "/或/drive",
|
||||
"Adapter Config": "适配器配置",
|
||||
"adapter.type.local": "本地文件系统",
|
||||
"adapter.type.foxel": "Foxel 节点",
|
||||
"adapter.type.dropbox": "Dropbox",
|
||||
"adapter.type.quark": "夸克网盘",
|
||||
"adapter.type.alist": "AList",
|
||||
"adapter.type.openlist": "OpenList",
|
||||
"Automation Tasks": "自动化任务",
|
||||
"Running Tasks": "运行中的任务",
|
||||
"Create Task": "新建任务",
|
||||
"Edit Task": "编辑任务",
|
||||
"Create Automation Task": "新建自动化任务",
|
||||
"Task Name": "任务名称",
|
||||
"Trigger Event": "触发事件",
|
||||
"File Written": "文件写入",
|
||||
"File Deleted": "文件删除",
|
||||
"Scheduled": "定时任务",
|
||||
"Matching Rules": "匹配规则",
|
||||
"Path Prefix (optional)": "路径前缀 (可选)",
|
||||
"Filename Regex (optional)": "文件名正则 (可选)",
|
||||
"Schedule": "定时设置",
|
||||
"Cron Expression": "Cron 表达式",
|
||||
"Action": "执行动作",
|
||||
"Current Task Queue": "当前任务队列",
|
||||
"Params": "参数",
|
||||
"Status": "状态",
|
||||
"Confirm clear logs?": "确认清理日志?",
|
||||
"This will delete logs in selected range irreversibly.": "该操作将删除选定时间范围内的所有日志,且不可恢复。",
|
||||
"This will delete all logs irreversibly.": "将删除全部日志且不可恢复",
|
||||
"Cleared {count} logs": "成功清理 {count} 条日志",
|
||||
"Time": "时间",
|
||||
"Weekday": "星期",
|
||||
"Level": "级别",
|
||||
"Source": "来源",
|
||||
"Message": "消息",
|
||||
"User ID": "用户 ID",
|
||||
"Search source": "搜索来源",
|
||||
"Clear": "清理",
|
||||
"Log Details": "日志详情",
|
||||
"Raw Log": "原始日志",
|
||||
"Export started, check your downloads.": "导出已开始,请检查您的下载。",
|
||||
"Export failed": "导出失败",
|
||||
"Confirm import backup?": "确认导入备份?",
|
||||
"Are you sure to import from this file?": "您确定要从此文件导入数据吗?",
|
||||
"Warning: This will overwrite all data including users (with passwords), settings, storages and tasks. Irreversible!": "警告:此操作将覆盖当前数据库中的所有现有数据,包括用户(含密码)、设置、存储和任务。此操作不可逆!",
|
||||
"Confirm Import": "确认导入",
|
||||
"Import succeeded! The page will refresh.": "导入成功!页面将刷新。",
|
||||
"Import failed": "导入失败",
|
||||
"Export": "导出",
|
||||
"Import": "恢复",
|
||||
"Export all data (adapters, users, tasks, shares) into a JSON file.": "点击按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。",
|
||||
"Export selected data into a JSON file.": "导出选中的数据为一个 JSON 文件。",
|
||||
"Keep your backup file safe.": "请妥善保管您的备份文件。",
|
||||
"Select backup sections": "选择备份内容",
|
||||
"User Accounts": "账号",
|
||||
"Share Links": "分享列表",
|
||||
"Configurations": "配置",
|
||||
"AI Providers": "AI 服务商",
|
||||
"AI Models": "AI 模型",
|
||||
"AI Default Models": "AI 默认模型",
|
||||
"Plugin Data": "插件",
|
||||
"Export Backup": "导出备份",
|
||||
"Restore data from a previously exported JSON file.": "从之前导出的JSON文件恢复数据。",
|
||||
"Warning: This will clear and overwrite existing data.": "警告:此操作将清除并覆盖现有数据。",
|
||||
"Import mode": "导入方式",
|
||||
"Merge (upsert by ID)": "增量+覆盖(按 ID)",
|
||||
"Replace (clear before import)": "清空后导入",
|
||||
"Warning: This will clear data in the backup sections before importing.": "警告:此操作会先清空备份中包含的分区数据,再导入。",
|
||||
"Warning: This will merge data in the backup sections and overwrite existing records with the same ID.": "警告:此操作会合并备份中包含的分区数据,并按 ID 覆盖已存在记录。",
|
||||
"Choose File and Restore": "选择文件并恢复",
|
||||
"No files yet here": "这里还没有任何文件",
|
||||
"This folder is empty": "此目录为空",
|
||||
"Start uploading files or create folders to organize your content": "开始上传文件或创建新目录来组织您的内容",
|
||||
"You can create folders or upload files here": "您可以在此目录中创建新的文件夹或上传文件",
|
||||
"Please input name": "请输入名称",
|
||||
"Confirm delete {name}?": "确认删除 {name} ?",
|
||||
"items": "项",
|
||||
"Downloading folders is not supported": "暂不支持下载目录",
|
||||
"Download failed": "下载失败",
|
||||
"Please select files or folders to share": "请选择要分享的文件或目录",
|
||||
"Direct links for folders are not supported": "不支持获取目录的直链",
|
||||
"Processing finished": "处理完成",
|
||||
"Processing failed": "处理失败",
|
||||
"Processors": "处理器",
|
||||
"Processor List": "处理器列表",
|
||||
"Reload": "重载",
|
||||
"Run Processor": "运行处理器",
|
||||
"Target Path": "目标路径",
|
||||
"Please select a path": "请选择路径",
|
||||
"Select Directory": "选择目录",
|
||||
"Overwrite original": "覆盖原文件",
|
||||
"Save To": "保存到",
|
||||
"Optional output path": "可选输出路径",
|
||||
"Run": "运行",
|
||||
"Select a processor": "选择处理器",
|
||||
"No module path": "未检测到模块路径",
|
||||
"Source saved": "源码已保存",
|
||||
"Processors reloaded": "处理器已重载",
|
||||
"Unsaved changes": "存在未保存的修改",
|
||||
"Switching processor will discard unsaved changes. Continue?": "切换处理器会丢失未保存的修改,确认继续?",
|
||||
"Task submitted": "任务已提交",
|
||||
"Supported Extensions": "支持的扩展名",
|
||||
"All": "全部",
|
||||
"Produces File": "生成文件",
|
||||
"Yes": "是",
|
||||
"No": "否",
|
||||
"Please select a processor": "请选择处理器",
|
||||
"Select a path": "请选择路径",
|
||||
"Source Editor": "源码编辑",
|
||||
"Module Path": "模块路径",
|
||||
"Directory processing always overwrites original files": "选择目录时会强制覆盖原文件",
|
||||
"Directory execution will enqueue one task per file": "目录模式会先创建扫描任务,后台再为每个文件创建任务",
|
||||
"Directory scope": "目录范围",
|
||||
"Current level only": "仅当前层级",
|
||||
"Include subdirectories": "包含子目录",
|
||||
"Max depth": "最大层级",
|
||||
"Leave empty to traverse all subdirectories": "留空表示遍历所有子目录",
|
||||
"Depth must be greater or equal to 0": "层级必须大于或等于 0",
|
||||
"Output suffix": "输出后缀",
|
||||
"Suffix will be inserted before the file extension, e.g. demo_processed.mp4": "后缀会插入到文件扩展名前,例如 demo_processed.mp4",
|
||||
"Suffix such as _processed": "例如 _processed 的后缀",
|
||||
"Suffix cannot be empty": "后缀不能为空",
|
||||
"No data": "暂无数据",
|
||||
"Select File": "选择文件",
|
||||
"Select Path": "选择路径",
|
||||
"Select Folder": "选择目录",
|
||||
"Select": "选择",
|
||||
"Current": "当前",
|
||||
"Up": "上一级",
|
||||
"Select Current Folder": "选择当前目录",
|
||||
"Please select a file": "请选择一个文件",
|
||||
"Please select a .foxpkg file": "请选择一个 .foxpkg 文件",
|
||||
"Invalid file": "无效文件",
|
||||
"Installed successfully": "安装成功",
|
||||
"Installation failed": "安装失败",
|
||||
"Plugin": "插件",
|
||||
"No description": "暂无描述",
|
||||
"Any": "任意",
|
||||
"Open Link": "打开链接",
|
||||
"Link copied": "已复制链接",
|
||||
"Copy Link": "复制链接",
|
||||
"Open App": "打开应用",
|
||||
"Update App": "更新应用",
|
||||
"Confirm delete this plugin?": "确认删除该插件?",
|
||||
"Uninstall": "卸载",
|
||||
"Author": "作者",
|
||||
"Website": "官网",
|
||||
"Install Plugin": "安装应用",
|
||||
"Confirm Install": "确认安装",
|
||||
"Selected {count} files": "已选择 {count} 个文件",
|
||||
"Installation will stop on first failure": "遇到失败将停止后续安装",
|
||||
"Installing": "安装中",
|
||||
"Remove": "移除",
|
||||
"Install App": "安装应用",
|
||||
"Search name/author/extension": "搜索 名称/作者/扩展名",
|
||||
"Search name/author/url/extension": "搜索 名称/作者/链接/扩展名",
|
||||
"No plugins": "暂无插件",
|
||||
"Install": "安装",
|
||||
"App URL": "应用链接",
|
||||
"Please input a valid URL": "请输入合法的 URL",
|
||||
"Installed": "已安装",
|
||||
"Discover": "发现",
|
||||
"Search apps": "搜索应用",
|
||||
"Sort by": "排序",
|
||||
"Downloads": "下载量",
|
||||
"Created (newest)": "创建时间(最新)",
|
||||
"Installed already": "已安装",
|
||||
"No results": "暂无结果",
|
||||
"Download and Install": "下载并安装",
|
||||
"Loading apps": "加载应用中",
|
||||
"Failed to load apps": "加载应用失败",
|
||||
"Version": "版本",
|
||||
"Tags": "标签",
|
||||
"Approved": "已审核",
|
||||
"Coming soon v2": "敬请期待 v2",
|
||||
"Initialization succeeded! Logging you in...": "初始化成功!正在为您登录,请不要刷新。",
|
||||
"Initialization failed, please try later": "初始化失败,请稍后重试",
|
||||
"Vector DB setup failed, you can configure it later in System Settings": "向量数据库配置失败,可稍后在系统设置中配置",
|
||||
"Database Setup": "数据库设置",
|
||||
"Choose database driver": "选择数据库驱动",
|
||||
"Select database and vector database for system data": "选择用于存储系统数据的数据库和向量数据库。",
|
||||
"Database Driver": "数据库驱动",
|
||||
"Vector DB Driver": "向量数据库驱动",
|
||||
"Initialize Mount": "初始化挂载",
|
||||
"Configure initial storage": "配置初始存储",
|
||||
"Create the first storage mount for your files": "为您的文件创建第一个存储挂载点。",
|
||||
"Mount Name": "挂载名称",
|
||||
"Local Storage": "本地存储",
|
||||
"Please input mount name!": "请输入挂载名称!",
|
||||
"Storage Type": "存储类型",
|
||||
"Please input mount path!": "请输入挂载路径!",
|
||||
"Root Directory": "根目录",
|
||||
"Please input root directory!": "请输入根目录!",
|
||||
"e.g., data/ or /var/foxel/data": "例如: data/ 或 /var/foxel/data",
|
||||
"Optional, used for external links. Leave empty to use the current site.": "可选,用于生成外部链接;留空则使用当前站点。",
|
||||
"Create Admin": "创建管理员",
|
||||
"Create admin account": "创建管理员账户",
|
||||
"This is the first account with full permissions": "这是系统的第一个账户,将拥有最高权限。",
|
||||
"Username": "用户名",
|
||||
"Please input a valid email!": "请输入有效的邮箱地址!",
|
||||
"Please confirm your password!": "请确认您的密码!",
|
||||
"Passwords do not match!": "两次输入的密码不一致!",
|
||||
"System Initialization": "系统初始化",
|
||||
"Previous": "上一步",
|
||||
"Next": "下一步",
|
||||
"Finish Initialization": "完成初始化",
|
||||
"Plugin run failed": "插件运行失败",
|
||||
"Plugin Error": "插件错误",
|
||||
"Cannot open file": "无法打开该文件",
|
||||
"Cannot open file: no available app": "无法打开该文件:没有可用的应用",
|
||||
"No app available for this file. Go to App Store to search {ext}?": "没有可用的应用。是否前往应用商店搜索 {ext} 可安装的应用?",
|
||||
"Go to App Store": "去应用商店",
|
||||
"Error": "错误",
|
||||
"App \"{key}\" not found.": "应用 \"{key}\" 不存在。",
|
||||
"Open with {app}": "使用 {app} 打开",
|
||||
"Set as default for .{ext}": "设为该类型(.{ext})默认应用",
|
||||
"AI Agent": "AI 助手",
|
||||
"Notices": "公告",
|
||||
"No notices": "暂无公告",
|
||||
"Load more": "加载更多",
|
||||
"Auto execute": "自动执行",
|
||||
"Start a conversation": "开始对话",
|
||||
"No content": "无内容",
|
||||
"Pending actions": "待确认操作",
|
||||
"Execute": "执行",
|
||||
"Execute all": "全部执行",
|
||||
"Cancel all": "全部取消",
|
||||
"Type a message": "输入消息",
|
||||
"Send": "发送",
|
||||
"Please confirm pending actions first": "请先确认待执行操作",
|
||||
"You": "你",
|
||||
"Tool": "工具",
|
||||
"MCP Tool": "MCP 工具",
|
||||
"Arguments": "参数",
|
||||
"Raw JSON": "原始 JSON",
|
||||
"Collapse": "收起",
|
||||
"Copied": "已复制",
|
||||
"Canceled": "已取消",
|
||||
"Tasks submitted": "已提交任务",
|
||||
"Calling tools": "正在调用工具",
|
||||
"Advanced tokens must be valid JSON": "高级 Token 需为合法 JSON",
|
||||
"Search": "搜索",
|
||||
"Total": "总计",
|
||||
"Mode": "模式",
|
||||
"Has more": "更多",
|
||||
"Page": "页",
|
||||
"results": "条结果",
|
||||
"chars": "字符",
|
||||
"Truncated": "已截断",
|
||||
"Write": "写入",
|
||||
"Read": "读取",
|
||||
"Created": "已创建",
|
||||
"Moved": "已移动",
|
||||
"Renamed": "已重命名",
|
||||
"Info": "信息",
|
||||
"User Management": "用户管理",
|
||||
"Role Management": "角色管理",
|
||||
"Users": "用户",
|
||||
"Create User": "创建用户",
|
||||
"Create Role": "创建角色",
|
||||
"Edit": "编辑",
|
||||
"Submit": "提交",
|
||||
"Super Admin": "超级管理员",
|
||||
"Disabled": "已禁用",
|
||||
"Active": "已启用",
|
||||
"Search users or roles": "搜索用户或角色",
|
||||
"Last Login": "上次登录",
|
||||
"Roles": "角色",
|
||||
"Quick Create Role": "快速创建角色",
|
||||
"Select roles": "选择角色",
|
||||
"Created by": "创建者",
|
||||
"New Password (leave empty to keep current)": "新密码(留空则不修改)",
|
||||
"Role Name": "角色名称",
|
||||
"Path Rules": "路径规则",
|
||||
"Add Path Rule": "添加路径规则",
|
||||
"Edit Path Rule": "编辑路径规则",
|
||||
"Path Pattern": "路径模式",
|
||||
"Regex": "正则",
|
||||
"Is Regex": "正则表达式",
|
||||
"Priority": "优先级",
|
||||
"Higher value = higher priority": "数值越大优先级越高",
|
||||
"Permissions": "权限",
|
||||
"System Permissions": "系统权限",
|
||||
"Download and preview files": "下载和预览文件",
|
||||
"Upload and modify files": "上传和修改文件",
|
||||
"Delete files and folders": "删除文件和目录",
|
||||
"Create share links": "创建分享链接",
|
||||
"Share": "分享",
|
||||
"Delete": "删除",
|
||||
"permission.category.system": "系统",
|
||||
"permission.category.adapter": "存储适配器"
|
||||
}
|
||||
125
web/src/layout/SearchDialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Modal, Input, Flex, Segmented } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { InputRef } from 'antd/es/input/Input';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SearchMode = 'vector' | 'filename';
|
||||
|
||||
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('vector');
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isOnFiles = location.pathname.startsWith('/files');
|
||||
const inputRef = useRef<InputRef | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!isOnFiles) {
|
||||
setSearch('');
|
||||
setSearchMode('vector');
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(location.search);
|
||||
setSearch(params.get('q') || '');
|
||||
setSearchMode(params.get('mode') === 'filename' ? 'filename' : 'vector');
|
||||
}, [open, isOnFiles, location.search]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSearch('');
|
||||
setSearchMode('vector');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
afterOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) return;
|
||||
window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
footer={null}
|
||||
width={720}
|
||||
centered
|
||||
title={null}
|
||||
closable={false}
|
||||
destroyOnHidden
|
||||
styles={{
|
||||
body: {
|
||||
padding: '12px 16px 16px',
|
||||
maxHeight: '70vh',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical style={{ gap: 12, flex: 1, minHeight: 0 }}>
|
||||
<Flex align="center" style={{ width: '100%', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: t('Smart Search'), value: 'vector' },
|
||||
{ label: t('Name Search'), value: 'filename' },
|
||||
]}
|
||||
value={searchMode}
|
||||
onChange={(value) => setSearchMode(value as SearchMode)}
|
||||
style={{
|
||||
minWidth: 160,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
size="large"
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('Search files / tags / types')}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
size="large"
|
||||
style={{ flex: 1, minWidth: 240 }}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 20,
|
||||
},
|
||||
}}
|
||||
ref={inputRef}
|
||||
onPressEnter={() => {
|
||||
const trimmed = search.trim();
|
||||
if (!trimmed) {
|
||||
if (isOnFiles) {
|
||||
navigate(location.pathname);
|
||||
}
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('q', trimmed);
|
||||
params.set('mode', searchMode);
|
||||
if (searchMode === 'filename') {
|
||||
params.set('page', '1');
|
||||
}
|
||||
const targetPath = isOnFiles ? location.pathname : '/files';
|
||||
navigate(`${targetPath}?${params.toString()}`);
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDialog;
|
||||
360
web/src/layout/SideNav.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { Layout, Menu, theme, Button, Modal, Tag, Tooltip, Descriptions, Alert, Divider, Spin } from 'antd';
|
||||
import { navGroups } from './nav.ts';
|
||||
import type { NavItem, NavGroup } from './nav.ts';
|
||||
import { memo, useEffect, useState, useMemo } from 'react';
|
||||
import { useSystemStatus } from '../contexts/SystemContext.tsx';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
FileTextOutlined,
|
||||
GithubOutlined,
|
||||
MenuFoldOutlined,
|
||||
SendOutlined,
|
||||
WechatOutlined,
|
||||
WarningOutlined
|
||||
} from '@ant-design/icons';
|
||||
import '../styles/sider-menu.css';
|
||||
import { getLatestVersion } from '../api/config.ts';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||
import WeChatModal from '../components/WeChatModal';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
const { Sider } = Layout;
|
||||
|
||||
export interface SideNavProps {
|
||||
collapsed: boolean;
|
||||
onToggle(): void;
|
||||
activeKey: string;
|
||||
onChange(key: string): void;
|
||||
}
|
||||
|
||||
const SideNav = memo(function SideNav({ collapsed, activeKey, onChange, onToggle }: SideNavProps) {
|
||||
const status = useSystemStatus();
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
|
||||
const [latestVersion, setLatestVersion] = useState<{
|
||||
version: string;
|
||||
body: string;
|
||||
} | null>(null);
|
||||
|
||||
// 根据用户权限过滤导航项
|
||||
const filteredNavGroups = useMemo(() => {
|
||||
const isAdmin = user?.is_admin ?? false;
|
||||
return navGroups
|
||||
.map(group => ({
|
||||
...group,
|
||||
children: group.children.filter(item => !item.adminOnly || isAdmin)
|
||||
}))
|
||||
.filter(group => group.children.length > 0);
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
getLatestVersion().then(resp => {
|
||||
if (resp.latest_version && resp.body) {
|
||||
setLatestVersion({
|
||||
version: resp.latest_version,
|
||||
body: resp.body
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const showVersionModal = () => {
|
||||
setIsVersionModalOpen(true);
|
||||
};
|
||||
|
||||
const hasUpdate = latestVersion && latestVersion.version !== status?.version;
|
||||
const { windows, restoreWindow } = useAppWindows();
|
||||
const minimized = windows.filter(w => w.minimized);
|
||||
const DEFAULT_APP_ICON =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<rect x="3" y="3" width="18" height="18" rx="4" ry="4" fill="currentColor" />
|
||||
<rect x="7" y="7" width="10" height="10" rx="2" ry="2" fill="#fff"/>
|
||||
</svg>`
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Sider
|
||||
collapsedWidth={60}
|
||||
collapsible
|
||||
trigger={null}
|
||||
collapsed={collapsed}
|
||||
width={208}
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'space-between',
|
||||
padding: '0 14px',
|
||||
fontWeight: 600,
|
||||
fontSize: 18,
|
||||
letterSpacing: .5,
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img
|
||||
src={status?.logo}
|
||||
alt="Foxel"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
objectFit: 'contain',
|
||||
marginRight: collapsed ? 0 : 8,
|
||||
...(resolvedMode === 'dark'
|
||||
? { filter: 'brightness(0) invert(1)' }
|
||||
: (status?.logo?.endsWith('.svg') ? { filter: 'brightness(0) saturate(100%)' } : {}))
|
||||
}}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span style={{ fontWeight: 700, color: resolvedMode === 'dark' ? '#fff' : token.colorText }}>
|
||||
{status?.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 展开时显示收缩按钮 */}
|
||||
{!collapsed && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuFoldOutlined />}
|
||||
onClick={onToggle}
|
||||
style={{ fontSize: 18 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 分组渲染 */}
|
||||
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '4px 4px 8px' }}>
|
||||
{filteredNavGroups.map((group: NavGroup) => (
|
||||
<div key={group.key} style={{ marginBottom: 12 }}>
|
||||
{group.title && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
letterSpacing: .5,
|
||||
padding: '6px 10px 4px',
|
||||
color: token.colorTextTertiary,
|
||||
textTransform: 'uppercase'
|
||||
}}
|
||||
>{t(group.title)}</div>
|
||||
)}
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectable
|
||||
inlineIndent={12}
|
||||
selectedKeys={[activeKey]}
|
||||
onClick={(e) => onChange(e.key)}
|
||||
items={group.children.map((i: NavItem) => ({ key: i.key, icon: i.icon, label: t(i.label) }))}
|
||||
style={{ borderInline: 'none', background: 'transparent' }}
|
||||
className="sider-menu-group foxel-sider-menu"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
bottom: '10px',
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
padding: '12px 8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexShrink: 0,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`
|
||||
}}
|
||||
>
|
||||
{/* 最小化应用 Dock */}
|
||||
{!collapsed && minimized.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: collapsed ? 'column' : 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
flexWrap: collapsed ? 'nowrap' : 'wrap',
|
||||
maxHeight: collapsed ? 160 : undefined,
|
||||
overflowY: collapsed ? 'auto' : 'visible',
|
||||
}}
|
||||
>
|
||||
{minimized.map(w => {
|
||||
const src = w.app.iconUrl || DEFAULT_APP_ICON;
|
||||
const title = w.kind === 'file' ? `${w.app.name} - ${w.entry.name}` : w.app.name;
|
||||
return (
|
||||
<Tooltip key={w.id} title={title} placement={collapsed ? 'right' : 'top'}>
|
||||
<Button
|
||||
shape="circle"
|
||||
onClick={() => restoreWindow(w.id)}
|
||||
icon={<img src={src} alt={w.app.name} style={{ width: 16, height: 16 }} />}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
fontSize: 12,
|
||||
color: token.colorTextSecondary,
|
||||
textAlign: 'center',
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer'
|
||||
}} onClick={showVersionModal}>
|
||||
{hasUpdate ? (
|
||||
<Tooltip title={t('New version found: {version}', { version: latestVersion?.version || '' })} placement={collapsed ? 'right' : 'top'}>
|
||||
<a rel="noopener noreferrer"
|
||||
style={{ textDecoration: 'none' }}>
|
||||
{collapsed ? (
|
||||
<Tag icon={<WarningOutlined />} color="warning" style={{ marginInlineEnd: 0 }} />
|
||||
) : (
|
||||
<Tag icon={<WarningOutlined />} color="warning">
|
||||
{t('Update available')} [{latestVersion?.version}]
|
||||
</Tag>
|
||||
)}
|
||||
</a>
|
||||
</Tooltip>
|
||||
) : (
|
||||
latestVersion ? (
|
||||
<Tooltip title={t('You are on the latest: {version}', { version: status?.version || '' })} placement={collapsed ? 'right' : 'top'}>
|
||||
{collapsed ? (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success" style={{ marginInlineEnd: 0 }} />
|
||||
) : (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||
{status?.version}
|
||||
</Tag>
|
||||
)}
|
||||
</Tooltip>
|
||||
) : (
|
||||
collapsed ? null : <Tag>{status?.version}</Tag>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div style={{ display: 'flex', flexDirection: 'row', gap: 8 }}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<GithubOutlined />}
|
||||
href="https://github.com/DrizzleTime/Foxel"
|
||||
target="_blank"
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<WechatOutlined />}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SendOutlined />}
|
||||
href="https://t.me/+thDsBfyqJxZkNTU1"
|
||||
target="_blank"
|
||||
/>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<FileTextOutlined />}
|
||||
href="https://foxel.cc"
|
||||
target="_blank"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</Sider>
|
||||
<WeChatModal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
|
||||
<Modal
|
||||
open={isVersionModalOpen}
|
||||
onCancel={() => setIsVersionModalOpen(false)}
|
||||
title={t('Version Info')}
|
||||
footer={null}
|
||||
width={600}
|
||||
>
|
||||
<div style={{ paddingTop: 12 }}>
|
||||
{latestVersion ? (
|
||||
<>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label={t('Current Version')}>
|
||||
<Tag>{status?.version}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Latest Version')}>
|
||||
<Tag color={hasUpdate ? 'orange' : 'green'}>{latestVersion.version}</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{hasUpdate && (
|
||||
<Alert
|
||||
message={<span style={{ color: token.colorText }}>{t('New version found: {version}', { version: latestVersion.version })}</span>}
|
||||
description={<span style={{ color: token.colorTextSecondary }}>{t('Please update to the latest for features and fixes')}</span>}
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 24, marginBottom: 24, background: token.colorInfoBg, borderColor: token.colorInfoBorder }}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
href="https://github.com/DrizzleTime/Foxel/releases"
|
||||
target="_blank"
|
||||
icon={<GithubOutlined />}
|
||||
>
|
||||
{t('Open Releases')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider titlePlacement="left" plain>{t('Changelog')}</Divider>
|
||||
<div style={{
|
||||
maxHeight: '40vh',
|
||||
overflowY: 'auto',
|
||||
padding: '8px 16px',
|
||||
background: token.colorFillAlter,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
border: `1px solid ${token.colorBorderSecondary}`
|
||||
}}>
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h3: ({ ...props }) => <h3 style={{
|
||||
fontSize: 16,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
paddingBottom: 8,
|
||||
marginTop: 24,
|
||||
marginBottom: 16,
|
||||
color: token.colorTextHeading
|
||||
}} {...props} />,
|
||||
ul: ({ ...props }) => <ul style={{ paddingLeft: 20 }} {...props} />,
|
||||
li: ({ ...props }) => <li style={{ marginBottom: 8 }} {...props} />,
|
||||
p: ({ ...props }) => <p style={{ marginBottom: 8 }} {...props} />,
|
||||
a: ({ ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />
|
||||
}}
|
||||
>{latestVersion.body}</ReactMarkdown>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', color: token.colorTextSecondary }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16 }}>{t('Fetching latest version...')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default SideNav;
|
||||
103
web/src/layout/TopHeader.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Layout, Button, Dropdown, theme, Flex, Avatar, Typography, Tooltip } from 'antd';
|
||||
import { SearchOutlined, MenuUnfoldOutlined, LogoutOutlined, UserOutlined, RobotOutlined, BellOutlined } from '@ant-design/icons';
|
||||
import { memo, useState } from 'react';
|
||||
import SearchDialog from './SearchDialog.tsx';
|
||||
import { authApi } from '../api/auth.ts';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ProfileModal from '../components/ProfileModal';
|
||||
import NoticesModal from '../components/NoticesModal';
|
||||
import { useSystemStatus } from '../contexts/SystemContext';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
export interface TopHeaderProps {
|
||||
collapsed: boolean;
|
||||
onToggle(): void;
|
||||
onOpenAiAgent(): void;
|
||||
}
|
||||
|
||||
const TopHeader = memo(function TopHeader({ collapsed, onToggle, onOpenAiAgent }: TopHeaderProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const { user } = useAuth();
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [noticesOpen, setNoticesOpen] = useState(false);
|
||||
const status = useSystemStatus();
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const openProfile = () => setProfileOpen(true);
|
||||
|
||||
return (
|
||||
<Header style={{ background: token.colorBgContainer, borderBottom: `1px solid ${token.colorBorderSecondary}`, display: 'flex', alignItems: 'center', gap: 16, backdropFilter: 'saturate(180%) blur(8px)' }}>
|
||||
{collapsed && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuUnfoldOutlined />}
|
||||
onClick={onToggle}
|
||||
style={{ fontSize: 18, marginRight: 8 }}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon={<SearchOutlined />}
|
||||
style={{ maxWidth: 420 }}
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
{t('Search files / tags / types')}
|
||||
</Button>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
|
||||
<Tooltip title={t('Notices')}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BellOutlined />}
|
||||
aria-label={t('Notices')}
|
||||
onClick={() => setNoticesOpen(true)}
|
||||
style={{ paddingInline: 8, height: 40 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('AI Agent')}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
aria-label={t('AI Agent')}
|
||||
onClick={onOpenAiAgent}
|
||||
style={{ paddingInline: 8, height: 40 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<LanguageSwitcher />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'profile', label: t('Profile'), icon: <UserOutlined />, onClick: openProfile },
|
||||
{ key: 'logout', label: t('Log Out'), icon: <LogoutOutlined />, onClick: handleLogout }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Button type="text" style={{ paddingInline: 8, height: 40 }}>
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatar size={28} src={user?.gravatar_url}>
|
||||
{(user?.full_name || user?.username || 'A').charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Typography.Text style={{ maxWidth: 160 }} ellipsis>
|
||||
{user?.full_name || user?.username || t('Admin')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<ProfileModal open={profileOpen} onClose={() => setProfileOpen(false)} />
|
||||
<NoticesModal open={noticesOpen} onClose={() => setNoticesOpen(false)} version={status?.version || ''} />
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
export default TopHeader;
|
||||
54
web/src/layout/nav.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FolderOpenOutlined,
|
||||
ApiOutlined,
|
||||
ShareAltOutlined,
|
||||
CloudDownloadOutlined,
|
||||
SettingOutlined,
|
||||
RobotOutlined,
|
||||
BugOutlined,
|
||||
DatabaseOutlined,
|
||||
AppstoreOutlined,
|
||||
CodeOutlined,
|
||||
ClockCircleOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface NavItem { key: string; icon: ReactNode; label: string; adminOnly?: boolean; }
|
||||
export interface NavGroup { key: string; title?: string; children: NavItem[]; }
|
||||
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
key: 'library',
|
||||
title: '',
|
||||
children: [
|
||||
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: 'All Files' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'manage',
|
||||
title: 'Manage',
|
||||
children: [
|
||||
{ key: 'processors', icon: React.createElement(CodeOutlined), label: 'Processors' },
|
||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: 'Automation' },
|
||||
{ key: 'task-queue', icon: React.createElement(ClockCircleOutlined), label: 'Task Queue' },
|
||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: 'My Shares' },
|
||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: 'Offline Downloads' },
|
||||
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: 'Adapters' },
|
||||
{ key: 'plugins', icon: React.createElement(AppstoreOutlined), label: 'Plugins' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
title: 'System',
|
||||
children: [
|
||||
{ key: 'users', icon: React.createElement(UserOutlined), label: 'User Management', adminOnly: true },
|
||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: 'System Settings', adminOnly: true },
|
||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: 'Backup & Restore' },
|
||||
{ key: 'audit', icon: React.createElement(BugOutlined), label: 'Audit Logs' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const primaryNav: NavItem[] = navGroups.flatMap(g => g.children);
|
||||
15
web/src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import 'antd/dist/reset.css';
|
||||
import './global.css';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
// 初始化插件依赖注入
|
||||
import { initExternals } from './plugins/externals';
|
||||
initExternals();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
);
|
||||
271
web/src/pages/AdaptersPage.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
|
||||
import PageCard from '../components/PageCard';
|
||||
import { adaptersApi, type AdapterItem, type AdapterTypeMeta } from '../api/client';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const AdaptersPage = memo(function AdaptersPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<AdapterItem[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AdapterItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [availableTypes, setAvailableTypes] = useState<AdapterTypeMeta[]>([]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [list, types] = await Promise.all([
|
||||
adaptersApi.list(),
|
||||
adaptersApi.available()
|
||||
]);
|
||||
setData(list);
|
||||
setAvailableTypes(types);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const defaultType = availableTypes[0]?.type || 'local';
|
||||
const typeMeta = availableTypes.find(t => t.type === defaultType);
|
||||
const cfgDefaults: Record<string, any> = {};
|
||||
typeMeta?.config_schema.forEach(f => {
|
||||
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
|
||||
});
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
type: defaultType,
|
||||
path: '/',
|
||||
sub_path: '',
|
||||
enabled: true,
|
||||
config: cfgDefaults
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (rec: AdapterItem) => {
|
||||
setEditing(rec);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
name: rec.name,
|
||||
type: rec.type,
|
||||
path: rec.path || '/',
|
||||
sub_path: rec.sub_path || '',
|
||||
enabled: rec.enabled,
|
||||
config: rec.config || {}
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const cfg = values.config || {};
|
||||
const typeMeta = availableTypes.find(t => t.type === values.type);
|
||||
const miss: string[] = [];
|
||||
typeMeta?.config_schema.forEach(f => {
|
||||
if (f.required && (cfg[f.key] === undefined || cfg[f.key] === null || cfg[f.key] === '')) {
|
||||
miss.push(f.label || f.key);
|
||||
}
|
||||
});
|
||||
if (miss.length) {
|
||||
message.error(t('Missing required config:') + ' ' + miss.join(', '));
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
name: values.name.trim(),
|
||||
type: values.type,
|
||||
path: values.path || '/',
|
||||
sub_path: values.sub_path?.trim() || null,
|
||||
enabled: values.enabled,
|
||||
config: cfg
|
||||
};
|
||||
setLoading(true);
|
||||
if (editing) {
|
||||
await adaptersApi.update(editing.id, body as any);
|
||||
message.success(t('Updated successfully'));
|
||||
} else {
|
||||
await adaptersApi.create(body as any);
|
||||
message.success(t('Created successfully'));
|
||||
}
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return; // 表单校验
|
||||
message.error(e.message || t('Operation failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async (rec: AdapterItem) => {
|
||||
try {
|
||||
await adaptersApi.remove(rec.id);
|
||||
message.success(t('Deleted'));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Delete failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (rec: AdapterItem, checked: boolean) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await adaptersApi.update(rec.id, { ...rec, enabled: checked });
|
||||
message.success(t('Status updated'));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Update failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderTypeLabel = useCallback((type?: string) => {
|
||||
if (!type) return '-';
|
||||
const key = `adapter.type.${type}`;
|
||||
const label = t(key);
|
||||
return label === key ? type : label;
|
||||
}, [t]);
|
||||
|
||||
const columns = [
|
||||
{ title: t('Name'), dataIndex: 'name' },
|
||||
{ title: t('Type'), dataIndex: 'type', width: 140, render: (value: string) => renderTypeLabel(value) },
|
||||
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: t('Enabled'),
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (v: boolean, rec: AdapterItem) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
size="small"
|
||||
loading={loading}
|
||||
onChange={checked => handleToggleEnabled(rec, checked)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 160,
|
||||
render: (_: any, rec: AdapterItem) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
|
||||
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>{t('Delete')}</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const selectedType = Form.useWatch('type', form);
|
||||
const currentTypeMeta = availableTypes.find(t => t.type === selectedType);
|
||||
|
||||
function renderConfigFields() {
|
||||
if (!currentTypeMeta) return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
|
||||
return currentTypeMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
|
||||
let inputNode: any = <Input placeholder={field.placeholder} />;
|
||||
let valuePropName: string | undefined;
|
||||
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
|
||||
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
|
||||
if (field.type === 'boolean') {
|
||||
inputNode = <Switch />;
|
||||
valuePropName = 'checked';
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={['config', field.key]}
|
||||
label={t(field.label)}
|
||||
rules={rules}
|
||||
valuePropName={valuePropName}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title={t('Storage Adapters')}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
columns={columns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Drawer
|
||||
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Adapter')}
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={() => { setOpen(false); setEditing(null); }}
|
||||
destroyOnHidden
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ enabled: true }}
|
||||
>
|
||||
<Form.Item name="name" label={t('Name')} rules={[{ required: true, message: t('Please input {label}', { label: t('Name') }) }]}>
|
||||
<Input placeholder={t('Unique name')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder={t('Select adapter type')}
|
||||
options={availableTypes.map(t => ({ value: t.type, label: renderTypeLabel(t.type) }))}
|
||||
onChange={(value) => {
|
||||
const t = availableTypes.find(v => v.type === value);
|
||||
const cfgDefaults: Record<string, any> = {};
|
||||
t?.config_schema.forEach(f => {
|
||||
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
|
||||
});
|
||||
form.setFieldsValue({ config: cfgDefaults });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="path" label={t('Mount Path')} rules={[{ required: true, message: t('Please input {label}', { label: t('Mount Path') }) }]}>
|
||||
<Input placeholder={t('/ or /drive')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sub_path" label={t('Sub Path (optional)')}>
|
||||
<Input placeholder={t('Sub directory inside adapter')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Adapter Config')}</Typography.Title>
|
||||
{renderConfigFields()}
|
||||
</Form>
|
||||
</Drawer>
|
||||
</PageCard>
|
||||
);
|
||||
});
|
||||
|
||||
export default AdaptersPage;
|
||||
329
web/src/pages/AuditLogsPage.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker, Descriptions, Divider, Typography } from 'antd';
|
||||
import PageCard from '../components/PageCard';
|
||||
import { auditApi, type AuditLogItem, type PaginatedAuditLogs } from '../api/audit';
|
||||
import { useI18n } from '../i18n';
|
||||
import { format, formatISO } from 'date-fns';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const ACTION_OPTIONS = [
|
||||
'login',
|
||||
'logout',
|
||||
'register',
|
||||
'read',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
'reset_password',
|
||||
'share',
|
||||
'download',
|
||||
'upload',
|
||||
'other'
|
||||
];
|
||||
|
||||
const HTTP_METHOD_COLOR_MAP: Record<string, string> = {
|
||||
GET: 'green',
|
||||
POST: 'blue',
|
||||
PUT: 'orange',
|
||||
PATCH: 'gold',
|
||||
DELETE: 'red',
|
||||
HEAD: 'cyan',
|
||||
OPTIONS: 'purple',
|
||||
};
|
||||
|
||||
const renderHttpMethodTag = (method: string) => {
|
||||
const upper = method.toUpperCase();
|
||||
const color = HTTP_METHOD_COLOR_MAP[upper] || 'default';
|
||||
return (
|
||||
<Tag
|
||||
bordered={false}
|
||||
color={color}
|
||||
style={{ margin: 0, paddingInline: 8, minWidth: 56, textAlign: 'center', fontWeight: 500 }}
|
||||
>
|
||||
{upper}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const AuditLogsPage = memo(function AuditLogsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<PaginatedAuditLogs | null>(null);
|
||||
const [filters, setFilters] = useState<{
|
||||
page: number;
|
||||
page_size: number;
|
||||
action: string;
|
||||
success: '' | boolean;
|
||||
username: string;
|
||||
path: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
}>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
action: '',
|
||||
success: '',
|
||||
username: '',
|
||||
path: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLogItem | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { ...filters };
|
||||
if (!params.action) delete params.action;
|
||||
if (params.success === '') delete params.success;
|
||||
if (!params.username) delete params.username;
|
||||
if (!params.path) delete params.path;
|
||||
if (!params.start_time) delete params.start_time;
|
||||
if (!params.end_time) delete params.end_time;
|
||||
const res = await auditApi.list(params);
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const handleClearLogs = () => {
|
||||
const hasRange = !!(filters.start_time || filters.end_time);
|
||||
Modal.confirm({
|
||||
title: t('Confirm clear logs?'),
|
||||
content: hasRange
|
||||
? t('This will delete logs in selected range irreversibly.')
|
||||
: t('This will delete all logs irreversibly.'),
|
||||
onOk: async () => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (filters.start_time) params.start_time = filters.start_time;
|
||||
if (filters.end_time) params.end_time = filters.end_time;
|
||||
const res = await auditApi.clear(params);
|
||||
message.success(t('Cleared {count} logs', { count: String(res.deleted_count) }));
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Clear failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('Time'),
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (ts: string) => format(new Date(ts), 'yyyy-MM-dd HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: t('Action'),
|
||||
dataIndex: 'action',
|
||||
width: 100,
|
||||
render: (action: string) => <Tag color="blue">{action}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('User'),
|
||||
dataIndex: 'username',
|
||||
width: 100,
|
||||
render: (_: any, rec: AuditLogItem) => rec.username || rec.user_id || '-',
|
||||
},
|
||||
{
|
||||
title: t('Path'),
|
||||
dataIndex: 'path',
|
||||
width: 350,
|
||||
ellipsis: true,
|
||||
render: (path: string, rec: AuditLogItem) => (
|
||||
<Space size={4}>
|
||||
{renderHttpMethodTag(rec.method)}
|
||||
<span style={{ maxWidth: 280, display: 'inline-block' }}>{path}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Status Code'),
|
||||
dataIndex: 'status_code',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: t('Duration (ms)'),
|
||||
dataIndex: 'duration_ms',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (ms?: number | null) => (ms !== null && ms !== undefined ? ms : '-'),
|
||||
},
|
||||
{
|
||||
title: t('Client IP'),
|
||||
dataIndex: 'client_ip',
|
||||
width: 140,
|
||||
render: (ip?: string | null) => ip || '-',
|
||||
},
|
||||
{
|
||||
title: t('Result'),
|
||||
dataIndex: 'success',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (success: boolean) => (
|
||||
<Tag color={success ? 'green' : 'red'}>
|
||||
{success ? t('Success') : t('Failure')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Actions'),
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: any, rec: AuditLogItem) => (
|
||||
<Button size="small" onClick={() => setSelectedLog(rec)}>{t('Details')}</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageCard
|
||||
title={t('Audit Logs')}
|
||||
extra={
|
||||
<Space align="center" wrap size={[8, 8]}>
|
||||
<RangePicker
|
||||
showTime
|
||||
size="small"
|
||||
onChange={dates => {
|
||||
setFilters(f => ({
|
||||
...f,
|
||||
start_time: dates?.[0] ? formatISO(dates[0].toDate()) : '',
|
||||
end_time: dates?.[1] ? formatISO(dates[1].toDate()) : '',
|
||||
page: 1,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
placeholder={t('Action')}
|
||||
allowClear
|
||||
size="small"
|
||||
value={filters.action || undefined}
|
||||
onChange={action => setFilters(f => ({ ...f, action: action || '', page: 1 }))}
|
||||
options={ACTION_OPTIONS.map(a => ({ value: a, label: a }))}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
placeholder={t('Result')}
|
||||
allowClear
|
||||
size="small"
|
||||
value={filters.success === '' ? undefined : filters.success}
|
||||
onChange={value => setFilters(f => ({ ...f, success: (value as boolean) ?? '', page: 1 }))}
|
||||
options={[
|
||||
{ value: true, label: t('Success') },
|
||||
{ value: false, label: t('Failure') },
|
||||
]}
|
||||
/>
|
||||
<Input.Search
|
||||
style={{ width: 120 }}
|
||||
placeholder={t('Username')}
|
||||
size="small"
|
||||
allowClear
|
||||
value={filters.username}
|
||||
onChange={e => setFilters(f => ({ ...f, username: e.target.value }))}
|
||||
onSearch={username => setFilters(f => ({ ...f, username, page: 1 }))}
|
||||
/>
|
||||
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button danger onClick={handleClearLogs}>{t('Clear')}</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={data?.items}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.page_size,
|
||||
total: data?.total,
|
||||
showSizeChanger: true,
|
||||
onChange: (page, pageSize) => setFilters(f => ({ ...f, page, page_size: pageSize })),
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
<Modal
|
||||
title={t('Audit Log Details')}
|
||||
open={!!selectedLog}
|
||||
onCancel={() => setSelectedLog(null)}
|
||||
footer={null}
|
||||
width={900}
|
||||
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
||||
>
|
||||
{selectedLog && (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions
|
||||
column={2}
|
||||
bordered
|
||||
size="small"
|
||||
labelStyle={{ minWidth: 120, whiteSpace: 'nowrap', fontWeight: 500 }}
|
||||
contentStyle={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
||||
>
|
||||
<Descriptions.Item label={t('Time')}>
|
||||
{format(new Date(selectedLog.created_at), 'yyyy-MM-dd HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Action')}>
|
||||
<Tag color="blue">{selectedLog.action}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('User')}>
|
||||
{selectedLog.username || selectedLog.user_id || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Client IP')}>
|
||||
{selectedLog.client_ip || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Path')} span={2}>
|
||||
<Space size={6} wrap style={{ wordBreak: 'break-all' }}>
|
||||
{renderHttpMethodTag(selectedLog.method)}
|
||||
<Typography.Text copyable style={{ wordBreak: 'break-all' }}>{selectedLog.path}</Typography.Text>
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Status Code')}>
|
||||
{selectedLog.status_code}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Duration (ms)')}>
|
||||
{selectedLog.duration_ms ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Result')}>
|
||||
<Tag color={selectedLog.success ? 'green' : 'red'}>
|
||||
{selectedLog.success ? t('Success') : t('Failure')}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Description')} span={2}>
|
||||
{selectedLog.description || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('Error')} span={2}>
|
||||
{selectedLog.error || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Divider style={{ margin: '12px 0 0' }} />
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('Request Params')}
|
||||
</Typography.Title>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
|
||||
{selectedLog.request_params ? JSON.stringify(selectedLog.request_params, null, 2) : '-'}
|
||||
</pre>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('Request Body')}
|
||||
</Typography.Title>
|
||||
<pre style={{ maxHeight: 200, overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
|
||||
{selectedLog.request_body ? JSON.stringify(selectedLog.request_body, null, 2) : '-'}
|
||||
</pre>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</PageCard>
|
||||
);
|
||||
});
|
||||
|
||||
export default AuditLogsPage;
|
||||
453
web/src/pages/FileExplorerPage/FileExplorerPage.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { theme, Pagination } from 'antd';
|
||||
import { useFileExplorer } from './hooks/useFileExplorer';
|
||||
import { useFileSelection } from './hooks/useFileSelection';
|
||||
import { useFileActions } from './hooks/useFileActions.tsx';
|
||||
import { useAppWindows } from '../../contexts/AppWindowsContext';
|
||||
import { useContextMenu } from './hooks/useContextMenu';
|
||||
import { useProcessor } from './hooks/useProcessor';
|
||||
import { useFileSearch } from './hooks/useFileSearch';
|
||||
import { useThumbnails } from './hooks/useThumbnails';
|
||||
import { useUploader } from './hooks/useUploader';
|
||||
import { Header } from './components/Header';
|
||||
import { GridView } from './components/GridView';
|
||||
import { FileListView } from './components/FileListView';
|
||||
import { EmptyState } from './components/EmptyState';
|
||||
import { ContextMenu } from './components/ContextMenu';
|
||||
import { DropzoneOverlay } from './components/DropzoneOverlay';
|
||||
import { CreateDirModal } from './components/Modals/CreateDirModal';
|
||||
import { CreateFileModal } from './components/Modals/CreateFileModal';
|
||||
import { RenameModal } from './components/Modals/RenameModal';
|
||||
import { ProcessorModal } from './components/Modals/ProcessorModal';
|
||||
import UploadModal from './components/Modals/UploadModal';
|
||||
import { ShareModal } from './components/Modals/ShareModal';
|
||||
import { DirectLinkModal } from './components/Modals/DirectLinkModal';
|
||||
import { FileDetailModal } from './components/FileDetailModal';
|
||||
import { MoveCopyModal } from './components/Modals/MoveCopyModal';
|
||||
import { SearchResultsView } from './components/SearchResultsView';
|
||||
import type { ViewMode } from './types';
|
||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||
import { LoadingSkeleton } from './components/LoadingSkeleton';
|
||||
|
||||
const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { navKey = 'files', '*': restPath = '' } = useParams();
|
||||
const { token } = theme.useToken();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [showSkeleton, setShowSkeleton] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const skeletonTimerRef = useRef<number | null>(null);
|
||||
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, sortBy, sortOrder, load, navigateTo, goUp, handlePaginationChange, refresh, handleSortChange } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection, setSelectedEntries } = useFileSelection();
|
||||
const { openFileWithDefaultApp, confirmOpenWithApp } = useAppWindows();
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const uploader = useUploader(path, refresh);
|
||||
const { handleFileDrop, openFilePicker, openDirectoryPicker, handleFileInputChange, handleDirectoryInputChange } = uploader;
|
||||
const { thumbs } = useThumbnails(entries, path);
|
||||
|
||||
// --- State for Modals ---
|
||||
const [creatingDir, setCreatingDir] = useState(false);
|
||||
const [creatingFile, setCreatingFile] = useState(false);
|
||||
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
|
||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||
const [directLinkEntry, setDirectLinkEntry] = useState<VfsEntry | null>(null);
|
||||
const [detailData, setDetailData] = useState<Record<string, unknown> | { error: string } | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [movingEntries, setMovingEntries] = useState<VfsEntry[]>([]);
|
||||
const [copyingEntries, setCopyingEntries] = useState<VfsEntry[]>([]);
|
||||
|
||||
// --- Search ---
|
||||
const fileSearch = useFileSearch({
|
||||
currentPath: path,
|
||||
navigateTo,
|
||||
openFileWithDefaultApp,
|
||||
openContextMenu,
|
||||
closeContextMenus,
|
||||
activeEntry: ctxMenu?.entry ?? null,
|
||||
});
|
||||
|
||||
const {
|
||||
isSearching,
|
||||
actionPath,
|
||||
loading: searchLoading,
|
||||
mode: searchMode,
|
||||
query: searchQuery,
|
||||
page: searchPage,
|
||||
pageSize: searchPageSize,
|
||||
displayItems: searchItems,
|
||||
selectedPaths: searchSelectedPaths,
|
||||
selectedNames: searchSelectedNames,
|
||||
contextEntries: searchContextEntries,
|
||||
entrySnapshot: searchEntrySnapshot,
|
||||
showPagination: showSearchPagination,
|
||||
totalItems: searchTotalItems,
|
||||
clearSearchParams,
|
||||
updateSearchPage,
|
||||
refreshSearch,
|
||||
openResult: openSearchResult,
|
||||
selectResult: selectSearchResult,
|
||||
openResultContextMenu: openSearchContextMenu,
|
||||
clearSelection: clearSearchSelection,
|
||||
} = fileSearch;
|
||||
|
||||
const entryBasePath = isSearching ? actionPath : path;
|
||||
|
||||
// --- Effects ---
|
||||
const routePath = '/' + (restPath || '').replace(/^\/+/, '');
|
||||
|
||||
useEffect(() => {
|
||||
load(routePath, 1, pagination.pageSize, sortBy, sortOrder);
|
||||
}, [routePath, navKey, load, pagination.pageSize, sortBy, sortOrder]);
|
||||
|
||||
const effectiveRefresh = useCallback(() => {
|
||||
if (isSearching) {
|
||||
refreshSearch();
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
}, [isSearching, refresh, refreshSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skeletonTimerRef.current !== null) {
|
||||
clearTimeout(skeletonTimerRef.current);
|
||||
skeletonTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
skeletonTimerRef.current = window.setTimeout(() => {
|
||||
setShowSkeleton(true);
|
||||
skeletonTimerRef.current = null;
|
||||
}, 200);
|
||||
} else {
|
||||
setShowSkeleton(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (skeletonTimerRef.current !== null) {
|
||||
clearTimeout(skeletonTimerRef.current);
|
||||
skeletonTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [loading]);
|
||||
|
||||
// --- Handlers ---
|
||||
const clearAllSelection = useCallback(() => {
|
||||
clearSelection();
|
||||
clearSearchSelection();
|
||||
}, [clearSearchSelection, clearSelection]);
|
||||
|
||||
const { doCreateDir: doCreateDirInCurrentDir, doCreateFile: doCreateFileInCurrentDir } = useFileActions({
|
||||
path,
|
||||
refresh,
|
||||
clearSelection,
|
||||
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
|
||||
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
|
||||
});
|
||||
|
||||
const { doDelete, doRename, doDownload, doShare, doGetDirectLink, doMove, doCopy } = useFileActions({
|
||||
path: entryBasePath,
|
||||
refresh: effectiveRefresh,
|
||||
clearSelection: clearAllSelection,
|
||||
onShare: (entriesToShare) => setSharingEntries(entriesToShare),
|
||||
onGetDirectLink: (entry) => setDirectLinkEntry(entry),
|
||||
});
|
||||
|
||||
const processorHook = useProcessor({ path: entryBasePath, processorTypes, refresh: effectiveRefresh });
|
||||
|
||||
const handleOpenEntry = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
const next = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
||||
navigateTo(next.replace(/\/+/g, '/'));
|
||||
} else {
|
||||
openFileWithDefaultApp(entry, entryBasePath);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (entry: VfsEntry) => {
|
||||
setDetailEntry(entry);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const fullPath = (entryBasePath === '/' ? '' : entryBasePath) + '/' + entry.name;
|
||||
const stat = await vfsApi.stat(fullPath);
|
||||
setDetailData(stat as Record<string, unknown>);
|
||||
} catch (error) {
|
||||
const messageText = error instanceof Error ? error.message : String(error);
|
||||
setDetailData({ error: messageText });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildDefaultDestination = useCallback((targetEntries: VfsEntry[]) => {
|
||||
if (!targetEntries || targetEntries.length === 0) return '';
|
||||
if (targetEntries.length > 1) {
|
||||
return entryBasePath || '/';
|
||||
}
|
||||
const entry = targetEntries[0];
|
||||
const base = entryBasePath === '/' ? '' : entryBasePath;
|
||||
const segments = [base, entry.name].filter(Boolean);
|
||||
const joined = segments.join('/');
|
||||
if (!joined) {
|
||||
return '/';
|
||||
}
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
}, [entryBasePath]);
|
||||
const showFsPagination = !isSearching && pagination.total > 0;
|
||||
const shouldReserveBottomBar = showSearchPagination || showFsPagination;
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current++;
|
||||
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
void handleFileDrop(e.dataTransfer);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: token.colorBgContainer,
|
||||
border: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRadius: token.borderRadius,
|
||||
height: 'calc(100vh - 88px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}
|
||||
onClick={closeContextMenus}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Header
|
||||
navKey={navKey}
|
||||
path={path}
|
||||
loading={isSearching ? searchLoading : loading}
|
||||
viewMode={viewMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onGoUp={goUp}
|
||||
onNavigate={navigateTo}
|
||||
onRefresh={effectiveRefresh}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onUploadFile={openFilePicker}
|
||||
onUploadDirectory={openDirectoryPicker}
|
||||
onSetViewMode={setViewMode}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={uploader.fileInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
multiple
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
<input
|
||||
ref={uploader.directoryInputRef}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
multiple
|
||||
onChange={handleDirectoryInputChange}
|
||||
/>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: shouldReserveBottomBar ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{isSearching ? (
|
||||
<SearchResultsView
|
||||
viewMode={viewMode}
|
||||
loading={searchLoading}
|
||||
mode={searchMode}
|
||||
query={searchQuery}
|
||||
items={searchItems}
|
||||
selectedPaths={searchSelectedPaths}
|
||||
entrySnapshot={searchEntrySnapshot}
|
||||
onClearSearch={clearSearchParams}
|
||||
onSelect={selectSearchResult}
|
||||
onOpen={(fullPath) => { void openSearchResult(fullPath); }}
|
||||
onContextMenu={(e, fullPath) => { void openSearchContextMenu(e, fullPath); }}
|
||||
/>
|
||||
) : showSkeleton && loading && (entries.length === 0 || path !== routePath) ? (
|
||||
<LoadingSkeleton mode={viewMode} />
|
||||
) : !loading && entries.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} /></div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<GridView
|
||||
entries={entries}
|
||||
thumbs={thumbs}
|
||||
selectedEntries={selectedEntries}
|
||||
path={path}
|
||||
onSelect={handleSelect}
|
||||
onSelectRange={handleSelectRange}
|
||||
onOpen={handleOpenEntry}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
) : (
|
||||
<FileListView
|
||||
entries={entries}
|
||||
selectedEntries={selectedEntries}
|
||||
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
||||
onSelectionChange={setSelectedEntries}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, path)}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entry) => doDelete([entry])}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showFsPagination && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||
<Pagination {...pagination} onChange={handlePaginationChange} onShowSizeChange={handlePaginationChange} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSearchPagination && (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '12px 16px', background: token.colorBgContainer, borderTop: `1px solid ${token.colorBorderSecondary}`, textAlign: 'center', zIndex: 10 }}>
|
||||
<Pagination
|
||||
current={searchPage}
|
||||
pageSize={searchPageSize}
|
||||
total={Math.max(searchTotalItems, 1)}
|
||||
showSizeChanger={false}
|
||||
size="small"
|
||||
onChange={(nextPage) => updateSearchPage(nextPage)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Modals & Context Menus --- */}
|
||||
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDirInCurrentDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(false)} />
|
||||
<CreateFileModal open={creatingFile} onOk={(name) => { doCreateFileInCurrentDir(name); setCreatingFile(false); }} onCancel={() => setCreatingFile(false)} />
|
||||
<RenameModal entry={renaming} onOk={(entry, newName) => { doRename(entry, newName); setRenaming(null); }} onCancel={() => setRenaming(null)} />
|
||||
<FileDetailModal entry={detailEntry} loading={detailLoading} data={detailData} onClose={() => setDetailEntry(null)} />
|
||||
<MoveCopyModal
|
||||
mode="move"
|
||||
entries={movingEntries}
|
||||
open={movingEntries.length > 0}
|
||||
defaultPath={buildDefaultDestination(movingEntries)}
|
||||
onOk={async (destination) => {
|
||||
if (movingEntries.length > 0) {
|
||||
await doMove(movingEntries, destination);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setMovingEntries([])}
|
||||
/>
|
||||
<MoveCopyModal
|
||||
mode="copy"
|
||||
entries={copyingEntries}
|
||||
open={copyingEntries.length > 0}
|
||||
defaultPath={buildDefaultDestination(copyingEntries)}
|
||||
onOk={async (destination) => {
|
||||
if (copyingEntries.length > 0) {
|
||||
await doCopy(copyingEntries, destination);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setCopyingEntries([])}
|
||||
/>
|
||||
{sharingEntries.length > 0 && (
|
||||
<ShareModal
|
||||
path={entryBasePath}
|
||||
entries={sharingEntries}
|
||||
open={sharingEntries.length > 0}
|
||||
onOk={() => setSharingEntries([])}
|
||||
onCancel={() => setSharingEntries([])}
|
||||
/>
|
||||
)}
|
||||
<DirectLinkModal
|
||||
entry={directLinkEntry}
|
||||
path={entryBasePath}
|
||||
open={!!directLinkEntry}
|
||||
onCancel={() => setDirectLinkEntry(null)}
|
||||
/>
|
||||
<ProcessorModal
|
||||
entry={processorHook.processorModal.entry}
|
||||
visible={processorHook.processorModal.visible}
|
||||
loading={processorHook.processorLoading}
|
||||
processorTypes={processorTypes}
|
||||
selectedProcessor={processorHook.selectedProcessor}
|
||||
config={processorHook.processorConfig}
|
||||
savingPath={processorHook.processorSavingPath}
|
||||
overwrite={processorHook.processorOverwrite}
|
||||
onOk={processorHook.handleProcessorOk}
|
||||
onCancel={processorHook.handleProcessorCancel}
|
||||
onSelectedProcessorChange={processorHook.setSelectedProcessor}
|
||||
onConfigChange={processorHook.setProcessorConfig}
|
||||
onSavingPathChange={processorHook.setProcessorSavingPath}
|
||||
onOverwriteChange={processorHook.setProcessorOverwrite}
|
||||
/>
|
||||
|
||||
{(ctxMenu || blankCtxMenu) && (
|
||||
<ContextMenu
|
||||
x={ctxMenu?.x || blankCtxMenu!.x}
|
||||
y={ctxMenu?.y || blankCtxMenu!.y}
|
||||
entry={ctxMenu?.entry}
|
||||
entries={isSearching ? searchContextEntries : entries}
|
||||
selectedEntries={isSearching ? searchSelectedNames : selectedEntries}
|
||||
processorTypes={processorTypes}
|
||||
onClose={closeContextMenus}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, appKey, entryBasePath)}
|
||||
onDownload={doDownload}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||
onDetail={openDetail}
|
||||
onProcess={(entry, type) => {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUploadFile={openFilePicker}
|
||||
onUploadDirectory={openDirectoryPicker}
|
||||
onCreateFile={() => setCreatingFile(true)}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onShare={doShare}
|
||||
onGetDirectLink={doGetDirectLink}
|
||||
onMove={(entriesToMove) => setMovingEntries(entriesToMove)}
|
||||
onCopy={(entriesToCopy) => setCopyingEntries(entriesToCopy)}
|
||||
/>
|
||||
)}
|
||||
<UploadModal
|
||||
visible={uploader.isModalVisible}
|
||||
files={uploader.files}
|
||||
isUploading={uploader.isUploading}
|
||||
totalProgress={uploader.totalProgress}
|
||||
totalFileBytes={uploader.totalFileBytes}
|
||||
uploadedFileBytes={uploader.uploadedFileBytes}
|
||||
conflict={uploader.conflict}
|
||||
onClose={uploader.closeModal}
|
||||
onStartUpload={uploader.startUpload}
|
||||
onResolveConflict={uploader.confirmConflict}
|
||||
/>
|
||||
<DropzoneOverlay visible={isDragging} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileExplorerPage;
|
||||
266
web/src/pages/FileExplorerPage/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Menu, theme } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { ProcessorTypeMeta } from '../../../api/processors';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import {
|
||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined,
|
||||
ShareAltOutlined, LinkOutlined, CopyOutlined, SwapOutlined, FileAddOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
entry?: VfsEntry;
|
||||
entries: VfsEntry[];
|
||||
selectedEntries: string[];
|
||||
processorTypes: ProcessorTypeMeta[];
|
||||
onClose: () => void;
|
||||
onOpen: (entry: VfsEntry) => void;
|
||||
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
||||
onDownload: (entry: VfsEntry) => void;
|
||||
onRename: (entry: VfsEntry) => void;
|
||||
onDelete: (entries: VfsEntry[]) => void;
|
||||
onDetail: (entry: VfsEntry) => void;
|
||||
onProcess: (entry: VfsEntry, processorType: string) => void;
|
||||
onUploadFile: () => void;
|
||||
onUploadDirectory: () => void;
|
||||
onCreateFile: () => void;
|
||||
onCreateDir: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
onMove: (entries: VfsEntry[]) => void;
|
||||
onCopy: (entries: VfsEntry[]) => void;
|
||||
}
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
interface ActionMenuItem {
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
danger?: boolean;
|
||||
onClick?: () => void;
|
||||
children?: ActionMenuItem[];
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ left: x, top: y });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setPosition({ left: x, top: y });
|
||||
}, [x, y]);
|
||||
|
||||
const getContextMenuItems = (): ActionMenuItem[] => {
|
||||
if (!entry) { // Blank context menu
|
||||
return [
|
||||
{
|
||||
key: 'upload',
|
||||
label: t('Upload'),
|
||||
icon: <UploadOutlined />,
|
||||
children: [
|
||||
{ key: 'upload-file', label: t('Upload Files'), onClick: actions.onUploadFile },
|
||||
{ key: 'upload-folder', label: t('Upload Folder'), onClick: actions.onUploadDirectory },
|
||||
],
|
||||
},
|
||||
{ key: 'new-file', label: t('New File'), icon: <FileAddOutlined />, onClick: actions.onCreateFile },
|
||||
{ key: 'mkdir', label: t('New Folder'), icon: <PlusOutlined />, onClick: actions.onCreateDir },
|
||||
];
|
||||
}
|
||||
|
||||
// Entry context menu
|
||||
const apps = getAppsForEntry(entry);
|
||||
const defaultApp = getDefaultAppForEntry(entry);
|
||||
const targetNames = selectedEntries.includes(entry.name) ? selectedEntries : [entry.name];
|
||||
const targetEntries = entries.filter(e => targetNames.includes(e.name));
|
||||
|
||||
let processorSubMenu: ActionMenuItem[] = [];
|
||||
if (processorTypes.length > 0) {
|
||||
if (entry.is_dir) {
|
||||
processorSubMenu = processorTypes
|
||||
.filter(pt => !!pt.supports_directory)
|
||||
.map(pt => ({
|
||||
key: 'processor-' + pt.type,
|
||||
label: pt.name,
|
||||
onClick: () => actions.onProcess(entry, pt.type),
|
||||
}));
|
||||
} else {
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
processorSubMenu = processorTypes
|
||||
.filter(pt => {
|
||||
const exts = pt.supported_exts;
|
||||
if (!Array.isArray(exts) || exts.length === 0) return true;
|
||||
return exts.includes(ext);
|
||||
})
|
||||
.map(pt => ({
|
||||
key: 'processor-' + pt.type,
|
||||
label: pt.name,
|
||||
onClick: () => actions.onProcess(entry, pt.type),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const menuItems: (ActionMenuItem | null)[] = [
|
||||
(entry.is_dir || apps.length > 0) ? {
|
||||
key: 'open',
|
||||
label: t('Open'),
|
||||
icon: <FolderFilled />,
|
||||
onClick: () => actions.onOpen(entry),
|
||||
} : null,
|
||||
!entry.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: t('Open With'),
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
|
||||
onClick: () => actions.onOpenWith(entry, a.key),
|
||||
})),
|
||||
} : null,
|
||||
processorSubMenu.length > 0 ? {
|
||||
key: 'process',
|
||||
label: t('Processor'),
|
||||
icon: <AppstoreAddOutlined />,
|
||||
children: processorSubMenu,
|
||||
} : null,
|
||||
{
|
||||
key: 'share',
|
||||
label: t('Share'),
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => actions.onShare(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'directLink',
|
||||
label: t('Get Direct Link'),
|
||||
icon: <LinkOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].is_dir,
|
||||
onClick: () => actions.onGetDirectLink(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: t('Download'),
|
||||
icon: <DownloadOutlined />,
|
||||
disabled: targetEntries.some(t => t.is_dir) || targetEntries.length > 1,
|
||||
onClick: () => actions.onDownload(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
label: t('Rename'),
|
||||
icon: <EditOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||
onClick: () => actions.onRename(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'move',
|
||||
label: t('Move'),
|
||||
icon: <SwapOutlined />,
|
||||
disabled: targetEntries.length === 0 || targetEntries.some(t => t.type === 'mount'),
|
||||
onClick: () => actions.onMove(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'copy',
|
||||
label: t('Copy'),
|
||||
icon: <CopyOutlined />,
|
||||
disabled: targetEntries.length === 0 || targetEntries.some(t => t.type === 'mount'),
|
||||
onClick: () => actions.onCopy(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('Delete'),
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
disabled: targetEntries.some(t => t.type === 'mount'),
|
||||
onClick: () => actions.onDelete(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
label: t('Details'),
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => actions.onDetail(entry),
|
||||
},
|
||||
];
|
||||
|
||||
return menuItems.filter((item): item is ActionMenuItem => item !== null);
|
||||
};
|
||||
|
||||
const actionItems = getContextMenuItems();
|
||||
|
||||
const handlerMap = new Map<string, () => void>();
|
||||
|
||||
const mapItems = (source: ActionMenuItem[]): MenuItem[] =>
|
||||
source.map<MenuItem>((item) => {
|
||||
if (item.onClick) handlerMap.set(item.key, item.onClick);
|
||||
const mappedChildren = item.children && item.children.length > 0 ? mapItems(item.children) : undefined;
|
||||
|
||||
const transformed = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
disabled: item.disabled,
|
||||
danger: item.danger,
|
||||
...(mappedChildren ? { children: mappedChildren } : {}),
|
||||
} as MenuItem;
|
||||
return transformed;
|
||||
});
|
||||
|
||||
const items = mapItems(actionItems);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const node = containerRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const margin = 8;
|
||||
const { offsetWidth, offsetHeight } = node;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let nextLeft = position.left;
|
||||
let nextTop = position.top;
|
||||
|
||||
if (nextLeft + offsetWidth + margin > viewportWidth) {
|
||||
nextLeft = Math.max(margin, viewportWidth - offsetWidth - margin);
|
||||
}
|
||||
if (nextTop + offsetHeight + margin > viewportHeight) {
|
||||
nextTop = Math.max(margin, viewportHeight - offsetHeight - margin);
|
||||
}
|
||||
if (nextLeft < margin) {
|
||||
nextLeft = margin;
|
||||
}
|
||||
if (nextTop < margin) {
|
||||
nextTop = margin;
|
||||
}
|
||||
|
||||
if (nextLeft !== position.left || nextTop !== position.top) {
|
||||
setPosition({ left: nextLeft, top: nextTop });
|
||||
}
|
||||
}, [position.left, position.top, items.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ position: 'fixed', top: position.top, left: position.left, zIndex: 9999, boxShadow: '0 4px 16px rgba(0,0,0,.15)', borderRadius: token.borderRadius, background: token.colorBgElevated }}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onClick={onClose} // Close on any click inside the menu area
|
||||
>
|
||||
<Menu
|
||||
items={items}
|
||||
selectable={false}
|
||||
onClick={({ key }) => {
|
||||
const handler = handlerMap.get(String(key));
|
||||
if (handler) handler();
|
||||
onClose();
|
||||
}}
|
||||
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { memo } from 'react';
|
||||
import { theme } from 'antd';
|
||||
|
||||
interface DropzoneOverlayProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export const DropzoneOverlay = memo(function DropzoneOverlay({ visible }: DropzoneOverlayProps) {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 100,
|
||||
borderColor: token.colorPrimary,
|
||||
borderStyle: 'dashed',
|
||||
borderWidth: 4,
|
||||
borderRadius: token.borderRadius,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: 'white', fontSize: 24, fontWeight: 'bold' }}>
|
||||
将文件拖放到此处以上传
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
24
web/src/pages/FileExplorerPage/components/EmptyState.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Typography, theme } from 'antd';
|
||||
import { FolderOpenOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
interface Props {
|
||||
isRoot: boolean;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({ isRoot }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', padding:isRoot? '80px 40px':'60px 40px', minHeight: isRoot? '400px':'300px', color: token.colorTextSecondary }}>
|
||||
<FolderOpenOutlined style={{ fontSize:64, color: token.colorTextQuaternary, marginBottom:16 }} />
|
||||
<Typography.Title level={4} style={{ color: token.colorTextSecondary, marginBottom:8, fontWeight:400 }}>
|
||||
{isRoot ? t('No files yet here') : t('This folder is empty')}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
|
||||
{isRoot ? t('Start uploading files or create folders to organize your content') : t('You can create folders or upload files here')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
386
web/src/pages/FileExplorerPage/components/FileDetailModal.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
import React from 'react';
|
||||
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message, Collapse, Tag } from 'antd';
|
||||
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined, DatabaseOutlined } from '@ant-design/icons';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
interface Props {
|
||||
entry: VfsEntry | null;
|
||||
loading: boolean;
|
||||
data: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function getExifFieldMap(t: (k: string)=>string): Record<string, { label: string; format?: (v: any) => string }> {
|
||||
return {
|
||||
'271': { label: t('Camera Make') },
|
||||
'272': { label: t('Camera Model') },
|
||||
'306': { label: t('Capture Time') },
|
||||
'282': { label: t('X Resolution'), format: v => `${v} dpi` },
|
||||
'283': { label: t('Y Resolution'), format: v => `${v} dpi` },
|
||||
'33434': { label: t('Exposure Time'), format: v => `${v} s` },
|
||||
'33437': { label: t('Aperture'), format: v => `f/${v}` },
|
||||
'34855': { label: 'ISO' },
|
||||
'37377': { label: t('Focal Length'), format: v => `${v} mm` },
|
||||
'40962': { label: t('Width'), format: v => `${v} px` },
|
||||
'40963': { label: t('Height'), format: v => `${v} px` },
|
||||
};
|
||||
}
|
||||
|
||||
function renderExif(exif: Record<string, any>, t: (k: string)=>string) {
|
||||
const exifFieldMap = getExifFieldMap(t);
|
||||
const items = Object.entries(exifFieldMap)
|
||||
.filter(([key]) => exif[key] !== undefined)
|
||||
.map(([key, { label, format }]) => ({
|
||||
key,
|
||||
label,
|
||||
value: format ? format(exif[key]) : exif[key]
|
||||
}));
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: 24, color: 'var(--ant-color-text-tertiary, #999)' }}>
|
||||
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
|
||||
<div>{t('No common EXIF info')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
bordered
|
||||
items={items.map(item => ({
|
||||
key: item.key,
|
||||
label: <span style={{ fontWeight: 500, color: 'var(--ant-color-text-secondary, #595959)' }}>{item.label}</span>,
|
||||
children: <span style={{ color: 'var(--ant-color-text, #262626)' }}>{item.value}</span>
|
||||
}))}
|
||||
contentStyle={{ padding: '8px 12px' }}
|
||||
labelStyle={{ padding: '8px 12px', backgroundColor: 'var(--ant-color-fill-tertiary, #fafafa)', width: '30%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number | string, t: (k: string)=>string): string {
|
||||
if (typeof size !== 'number') return String(size);
|
||||
|
||||
const units = [t('Bytes'), 'KB', 'MB', 'GB'];
|
||||
let index = 0;
|
||||
let fileSize = size;
|
||||
|
||||
while (fileSize >= 1024 && index < units.length - 1) {
|
||||
fileSize /= 1024;
|
||||
index++;
|
||||
}
|
||||
|
||||
return `${fileSize.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export const FileDetailModal: React.FC<Props> = ({ entry, loading, data, onClose }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const vectorIndex = data?.vector_index;
|
||||
const vectorEntries = Array.isArray(vectorIndex?.entries) ? vectorIndex.entries : [];
|
||||
const primaryIndexEntries = vectorEntries.slice(0, 3);
|
||||
const remainingIndexEntries = vectorEntries.slice(3);
|
||||
|
||||
const renderIndexEntry = (entry: any, idx: number, total: number) => {
|
||||
const key = entry?.chunk_id ?? entry?.vector_id ?? idx;
|
||||
const hasOffsets = entry?.start_offset !== undefined || entry?.end_offset !== undefined;
|
||||
const previewText = entry?.preview;
|
||||
const previewTruncated = Boolean(entry?.preview_truncated && previewText);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(key)}
|
||||
style={{
|
||||
padding: '12px 0',
|
||||
borderBottom: idx === total - 1 ? 'none' : `1px solid ${token.colorSplit}`,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{entry?.chunk_id && (
|
||||
<Tag color="blue">{t('Chunk ID')}: {entry.chunk_id}</Tag>
|
||||
)}
|
||||
{entry?.type && (
|
||||
<Tag>{entry.type}</Tag>
|
||||
)}
|
||||
{entry?.mime && (
|
||||
<Tag color="geekblue">{entry.mime}</Tag>
|
||||
)}
|
||||
{entry?.name && !previewText && (
|
||||
<Tag color="purple">{entry.name}</Tag>
|
||||
)}
|
||||
</Space>
|
||||
{hasOffsets && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('Offset Range')}: {entry?.start_offset ?? '-'} ~ {entry?.end_offset ?? '-'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{entry?.vector_id && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('Vector ID')}: {entry.vector_id}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{previewText && (
|
||||
<Typography.Paragraph
|
||||
style={{ marginBottom: 0 }}
|
||||
ellipsis={{ rows: 3, expandable: previewTruncated }}
|
||||
>
|
||||
{previewText}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<InfoCircleOutlined style={{ color: token.colorPrimary }} />
|
||||
<span>{t('File Properties')}</span>
|
||||
{entry && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
|
||||
- {entry.name}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
open={!!entry}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={800}
|
||||
styles={{
|
||||
body: { padding: '20px 0px' }
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
<div style={{ marginTop: 16, color: token.colorTextSecondary }}>{t('Loading file info...')}</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
data.error ? (
|
||||
<div style={{ textAlign: 'center', padding: 32 }}>
|
||||
<Typography.Text type="danger" style={{ fontSize: 16 }}>
|
||||
{data.error}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
|
||||
{/* 左侧:基本信息 */}
|
||||
<div style={{ flex: 1 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
{data.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
||||
{t('Basic Info')}
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'name',
|
||||
label: t('Name'),
|
||||
children: <Typography.Text strong>{data.name}</Typography.Text>
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: t('Type'),
|
||||
children: (
|
||||
<Badge
|
||||
status={data.is_dir ? 'processing' : 'default'}
|
||||
text={data.type || (data.is_dir ? t('Folder') : t('File'))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: t('Size'),
|
||||
children: formatFileSize(data.size, t)
|
||||
},
|
||||
{
|
||||
key: 'mtime',
|
||||
label: t('Modified Time'),
|
||||
children: data.mtime ? (
|
||||
typeof data.mtime === 'number'
|
||||
? new Date(data.mtime * 1000).toLocaleString()
|
||||
: data.mtime
|
||||
) : '-'
|
||||
},
|
||||
{
|
||||
key: 'path',
|
||||
label: t('Path'),
|
||||
children: (
|
||||
<Typography.Text style={{ display: 'block', marginTop: 4 }}>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(data.path).then(() => {
|
||||
message.success(t('Path copied to clipboard'));
|
||||
}).catch(() => {
|
||||
message.error(t('Copy failed'));
|
||||
});
|
||||
} else {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = data.path;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
message[ok ? 'success' : 'error'](ok ? t('Path copied to clipboard') : t('Copy failed'));
|
||||
}
|
||||
} catch {
|
||||
message.error(t('Copy failed'));
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
wordBreak: 'break-all',
|
||||
backgroundColor: token.colorFillAlter,
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
display: 'inline-block'
|
||||
}}
|
||||
>
|
||||
{data.path}
|
||||
</a>
|
||||
</Typography.Text>
|
||||
)
|
||||
}
|
||||
]}
|
||||
contentStyle={{
|
||||
fontSize: 14,
|
||||
color: token.colorText
|
||||
}}
|
||||
labelStyle={{
|
||||
fontWeight: 500,
|
||||
color: token.colorTextSecondary,
|
||||
width: '30%'
|
||||
}}
|
||||
/>
|
||||
{data.mode !== undefined && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, color: token.colorTextSecondary }}>{t('Permissions')}:</span>
|
||||
<Typography.Text code>{data.mode.toString(8)}</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!data.is_dir && vectorIndex && (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ borderRadius: 8, marginTop: 16 }}
|
||||
title={
|
||||
<Space>
|
||||
<DatabaseOutlined />
|
||||
{t('Index Info')}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'total',
|
||||
label: t('Indexed Items'),
|
||||
children: vectorIndex.total ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'types',
|
||||
label: t('Indexed Types'),
|
||||
children: Object.keys(vectorIndex.by_type || {}).length > 0 ? (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{Object.entries(vectorIndex.by_type || {}).map(([type, count]) => (
|
||||
<Tag key={type}>{type} ({count as number})</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">{t('No index data')}</Typography.Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
contentStyle={{ fontSize: 14 }}
|
||||
labelStyle={{ fontWeight: 500, color: token.colorTextSecondary, width: '30%' }}
|
||||
/>
|
||||
|
||||
{vectorIndex.total ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text strong style={{ marginBottom: 8, display: 'block' }}>
|
||||
{t('Indexed Chunks')}
|
||||
</Typography.Text>
|
||||
<div style={{ maxHeight: '40vh', overflowY: 'auto', paddingRight: 8 }}>
|
||||
{primaryIndexEntries.map((entry: any, idx: number) => renderIndexEntry(entry, idx, primaryIndexEntries.length))}
|
||||
{remainingIndexEntries.length > 0 && (
|
||||
<Collapse
|
||||
bordered={false}
|
||||
size="small"
|
||||
items={[{
|
||||
key: 'more',
|
||||
label: t('More Indexed Chunks'),
|
||||
children: (
|
||||
<div>
|
||||
{remainingIndexEntries.map((entry: any, idx: number) => renderIndexEntry(entry, idx, remainingIndexEntries.length))}
|
||||
</div>
|
||||
),
|
||||
}]}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{vectorIndex.has_more && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('Showing first {count} entries', { count: vectorEntries.length })}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text type="secondary">{t('No index data')}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧:EXIF 信息 */}
|
||||
{data.exif && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<CameraOutlined />
|
||||
{t('EXIF Info')}
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
>
|
||||
{renderExif(data.exif, t)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDetailModal;
|
||||
55
web/src/pages/FileExplorerPage/components/FileIcons.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FileOutlined,
|
||||
FileImageOutlined,
|
||||
VideoCameraOutlined,
|
||||
AudioOutlined,
|
||||
FileTextOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
FileExcelOutlined,
|
||||
FilePptOutlined,
|
||||
FileZipOutlined,
|
||||
CodeOutlined,
|
||||
FileMarkdownOutlined,
|
||||
SettingOutlined,
|
||||
DatabaseOutlined,
|
||||
FontSizeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
const s = hex.replace('#', '');
|
||||
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
const num = parseInt(n, 16);
|
||||
if (Number.isNaN(num) || n.length !== 6) return hex;
|
||||
const r = (num >> 16) & 255;
|
||||
const g = (num >> 8) & 255;
|
||||
const b = num & 255;
|
||||
const mix = (c: number) => Math.round(c + (255 - c) * amount);
|
||||
const toHex = (v: number) => v.toString(16).padStart(2, '0');
|
||||
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
|
||||
};
|
||||
|
||||
export const getFileIcon = (fileName: string, size: number = 16, resolvedMode: 'light' | 'dark' | 'system' = 'light') => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 };
|
||||
|
||||
const adj = (color: string) => (resolvedMode === 'dark' ? lightenColor(color, 0.3) : color);
|
||||
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, color: adj(color) } });
|
||||
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext)) return make(<FileImageOutlined />, '#52c41a');
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', '3gp'].includes(ext)) return make(<VideoCameraOutlined />, '#fa541c');
|
||||
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'].includes(ext)) return make(<AudioOutlined />, '#722ed1');
|
||||
if (['pdf'].includes(ext)) return make(<FilePdfOutlined />, '#f5222d');
|
||||
if (['doc', 'docx'].includes(ext)) return make(<FileWordOutlined />, '#1890ff');
|
||||
if (['xls', 'xlsx'].includes(ext)) return make(<FileExcelOutlined />, '#52c41a');
|
||||
if (['ppt', 'pptx'].includes(ext)) return make(<FilePptOutlined />, '#fa8c16');
|
||||
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) return make(<FileZipOutlined />, '#faad14');
|
||||
if (['js','jsx','ts','tsx','vue','html','htm','css','scss','sass','less','json','xml','yaml','yml','py','java','cpp','cc','cxx','c','h','hpp','hxx','php','rb','go','rs','rust','swift','kt','scala','clj','cljs','cs','vb','fs','pl','pm','r','lua','dart','elm'].includes(ext)) return make(<CodeOutlined />, '#13c2c2');
|
||||
if (['md', 'markdown'].includes(ext)) return make(<FileMarkdownOutlined />, '#1890ff');
|
||||
if (['txt', 'log', 'ini', 'cfg', 'conf', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', 'dockerfile', 'makefile', 'gradle', 'cmake', 'gitignore', 'gitattributes', 'editorconfig', 'prettierrc'].includes(ext)) return make(<FileTextOutlined />, '#8c8c8c');
|
||||
if (['ttf', 'otf', 'woff', 'woff2', 'eot'].includes(ext)) return make(<FontSizeOutlined />, '#eb2f96');
|
||||
if (['db', 'sqlite', 'sql'].includes(ext)) return make(<DatabaseOutlined />, '#fa541c');
|
||||
if (['env', 'config', 'properties', 'toml'].includes(ext)) return make(<SettingOutlined />, '#faad14');
|
||||
return make(<FileOutlined />, '#8c8c8c');
|
||||
};
|
||||
121
web/src/pages/FileExplorerPage/components/FileListView.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { Table, Dropdown, Button, Tooltip, theme } from 'antd';
|
||||
import { FolderFilled, MoreOutlined, EditOutlined, DeleteOutlined, AppstoreOutlined, FolderOpenOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
interface FileListViewProps {
|
||||
entries: VfsEntry[];
|
||||
selectedEntries: string[];
|
||||
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => void;
|
||||
onSelectionChange: (selectedKeys: string[]) => void;
|
||||
onOpen: (entry: VfsEntry) => void;
|
||||
onOpenWith: (entry: VfsEntry, appKey: string) => void;
|
||||
onRename: (entry: VfsEntry) => void;
|
||||
onDelete: (entry: VfsEntry) => void;
|
||||
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||
}
|
||||
|
||||
export const FileListView: React.FC<FileListViewProps> = ({
|
||||
entries,
|
||||
selectedEntries,
|
||||
onRowClick,
|
||||
onSelectionChange,
|
||||
onOpen,
|
||||
onOpenWith,
|
||||
onRename,
|
||||
onDelete,
|
||||
onContextMenu,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
const s = hex.replace('#', '');
|
||||
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
const num = parseInt(n, 16);
|
||||
if (Number.isNaN(num) || n.length !== 6) return hex;
|
||||
const r = (num >> 16) & 255;
|
||||
const g = (num >> 8) & 255;
|
||||
const b = num & 255;
|
||||
const mix = (c: number) => Math.round(c + (255 - c) * amount);
|
||||
const toHex = (v: number) => v.toString(16).padStart(2, '0');
|
||||
return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('Name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (_: any, r: VfsEntry) => (
|
||||
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
|
||||
{r.is_dir ? (
|
||||
<FolderFilled style={{ color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary, marginRight: 6 }} />
|
||||
) : (
|
||||
getFileIcon(r.name, 16, resolvedMode)
|
||||
)}
|
||||
{r.name}
|
||||
{r.type === 'mount' && <Tooltip title={t('Mount Point')}><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ title: t('Size'), dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||
{ title: t('Modified Time'), dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||
{
|
||||
title: t('Actions'),
|
||||
key: 'actions',
|
||||
width: 110,
|
||||
render: (_: any, r: VfsEntry) => {
|
||||
const apps = getAppsForEntry(r);
|
||||
const defaultApp = getDefaultAppForEntry(r);
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
(r.is_dir || apps.length > 0) ? { key: 'open', label: defaultApp ? `${t('Open')}(${defaultApp.name})` : t('Open'), icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
|
||||
!r.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: t('Open With'),
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ` (${t('Default')})` : ''),
|
||||
onClick: () => onOpenWith(r, a.key)
|
||||
}))
|
||||
} : null,
|
||||
{ key: 'rename', label: t('Rename'), icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
|
||||
{ key: 'delete', label: t('Delete'), icon: <DeleteOutlined />, danger: true, disabled: r.type === 'mount', onClick: () => onDelete(r) }
|
||||
].filter(Boolean) as any[]
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<MoreOutlined />} />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="fx-file-table"
|
||||
rowKey={r => r.name}
|
||||
dataSource={entries}
|
||||
columns={columns as any}
|
||||
pagination={false}
|
||||
onRow={(r) => ({
|
||||
onClick: (e: any) => onRowClick(r, e),
|
||||
onDoubleClick: () => onOpen(r),
|
||||
onContextMenu: (e) => onContextMenu(e, r)
|
||||
})}
|
||||
rowClassName={(r) => selectedEntries.includes(r.name) ? 'row-selected' : ''}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedEntries,
|
||||
onChange: (keys) => onSelectionChange(keys as string[]),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
208
web/src/pages/FileExplorerPage/components/GridView.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Tooltip, theme } from 'antd';
|
||||
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
|
||||
interface Props {
|
||||
entries: VfsEntry[];
|
||||
thumbs: Record<string, string>;
|
||||
selectedEntries: string[];
|
||||
path: string;
|
||||
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
||||
onSelectRange: (names: string[]) => void;
|
||||
onOpen: (e: VfsEntry) => void;
|
||||
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||
}
|
||||
|
||||
const formatSize = (size: number) => {
|
||||
if (size < 1024) return size + ' B';
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
|
||||
if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB';
|
||||
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||
};
|
||||
|
||||
export const GridView: React.FC<Props> = ({ entries, thumbs, selectedEntries, path, onSelect, onSelectRange, onOpen, onContextMenu }) => {
|
||||
const { token } = theme.useToken();
|
||||
const { resolvedMode } = useTheme();
|
||||
const lightenColor = (hex: string, amount: number) => {
|
||||
const parseHex = (h: string) => {
|
||||
const s = h.replace('#', '');
|
||||
const n = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
const num = parseInt(n, 16);
|
||||
if (Number.isNaN(num) || n.length !== 6) return null;
|
||||
return {
|
||||
r: (num >> 16) & 255,
|
||||
g: (num >> 8) & 255,
|
||||
b: num & 255,
|
||||
};
|
||||
};
|
||||
const rgb = parseHex(hex);
|
||||
if (!rgb) return hex;
|
||||
const mix = (c: number) => Math.round(c + (255 - c) * amount);
|
||||
const r = mix(rgb.r);
|
||||
const g = mix(rgb.g);
|
||||
const b = mix(rgb.b);
|
||||
const toHex = (v: number) => v.toString(16).padStart(2, '0');
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
};
|
||||
const toRgba = (hex: string, alpha: number) => {
|
||||
const s = hex.replace('#', '');
|
||||
const normalized = s.length === 3 ? s.split('').map(c => c + c).join('') : s;
|
||||
const num = parseInt(normalized, 16);
|
||||
if (Number.isNaN(num) || normalized.length !== 6) {
|
||||
return `rgba(22, 119, 255, ${alpha})`;
|
||||
}
|
||||
const r = (num >> 16) & 255;
|
||||
const g = (num >> 8) & 255;
|
||||
const b = num & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const itemRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const startRef = useRef<{ x: number, y: number } | null>(null);
|
||||
const [rect, setRect] = useState<{ left: number, top: number, width: number, height: number } | null>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const grid = containerRef.current;
|
||||
const scrollContainer = grid?.parentElement;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const onBlankMouseDown = (e: MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target !== scrollContainer) return;
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
setSelecting(true);
|
||||
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('mousedown', onBlankMouseDown);
|
||||
return () => scrollContainer.removeEventListener('mousedown', onBlankMouseDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const cx = ev.clientX;
|
||||
const cy = ev.clientY;
|
||||
const s = startRef.current;
|
||||
const left = Math.min(s.x, cx);
|
||||
const top = Math.min(s.y, cy);
|
||||
const width = Math.abs(cx - s.x);
|
||||
const height = Math.abs(cy - s.y);
|
||||
setRect({ left, top, width, height });
|
||||
};
|
||||
const onUp = () => {
|
||||
if (!startRef.current) return;
|
||||
setSelecting(false);
|
||||
const r = rect;
|
||||
if (r) {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const sel: string[] = [];
|
||||
entries.forEach(ent => {
|
||||
const el = itemRefs.current[ent.name];
|
||||
if (!el) return;
|
||||
const br = el.getBoundingClientRect();
|
||||
const rr = { left: r.left, top: r.top, right: r.left + r.width, bottom: r.top + r.height };
|
||||
const br2 = { left: br.left, top: br.top, right: br.right, bottom: br.bottom };
|
||||
const intersect = !(br2.left > rr.right || br2.right < rr.left || br2.top > rr.bottom || br2.bottom < rr.top);
|
||||
if (intersect) sel.push(ent.name);
|
||||
});
|
||||
if (sel.length > 0) onSelectRange(sel);
|
||||
}
|
||||
}
|
||||
startRef.current = null;
|
||||
setRect(null);
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
if (selecting) {
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [selecting, rect, entries, onSelectRange]);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.fx-grid-item')) {
|
||||
return;
|
||||
}
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
setSelecting(true);
|
||||
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fx-grid" style={{ padding: 16 }} ref={containerRef} onMouseDown={handleMouseDown}>
|
||||
{entries.map(ent => {
|
||||
const isImg = thumbs[ent.name];
|
||||
const ext = ent.name.split('.').pop()?.toLowerCase();
|
||||
const isPictureType = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext || '');
|
||||
const isSelected = selectedEntries.includes(ent.name);
|
||||
return (
|
||||
<div
|
||||
key={ent.name}
|
||||
ref={(el) => { itemRefs.current[ent.name] = el; }}
|
||||
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir ? 'dir' : 'file'].join(' ')}
|
||||
onClick={(ev) => {
|
||||
const additive = ev.ctrlKey || ev.metaKey;
|
||||
onSelect(ent, additive);
|
||||
}}
|
||||
onDoubleClick={() => onOpen(ent)}
|
||||
onContextMenu={(e) => onContextMenu(e, ent)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
{ent.is_dir && (
|
||||
<FolderFilled
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : token.colorPrimary,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!ent.is_dir && (
|
||||
isImg ? (
|
||||
<img src={isImg} alt={ent.name} style={{ maxWidth: '100%', maxHeight: '100%' }} />
|
||||
) : isPictureType ? (
|
||||
<PictureOutlined style={{ fontSize: 32, color: resolvedMode === 'dark' ? lightenColor(String(token.colorPrimary || '#111111'), 0.72) : 'var(--ant-color-text-tertiary, #8c8c8c)' }} />
|
||||
) : (
|
||||
getFileIcon(ent.name, 32, resolvedMode)
|
||||
)
|
||||
)}
|
||||
{ent.type === 'mount' && <span className="badge">M</span>}
|
||||
</div>
|
||||
<Tooltip title={ent.name}><div className="name ellipsis" style={{ userSelect: 'none' }}>{ent.name}</div></Tooltip>
|
||||
<div className="meta ellipsis" style={{ fontSize: 11, color: token.colorTextSecondary, userSelect: 'none' }}>{ent.is_dir ? '目录' : formatSize(ent.size)}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{rect && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
border: '1px dashed var(--ant-color-border, rgba(0,0,0,0.4))',
|
||||
background: toRgba(String(token.colorPrimary || '#1677ff'), 0.16),
|
||||
zIndex: 999
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{entries.length === 0 && <EmptyState isRoot={path === '/'} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
194
web/src/pages/FileExplorerPage/components/Header.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme, Dropdown } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import { Select } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { ViewMode } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
navKey: string;
|
||||
path: string;
|
||||
loading: boolean;
|
||||
viewMode: ViewMode;
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
onGoUp: () => void;
|
||||
onNavigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateDir: () => void;
|
||||
onUploadFile: () => void;
|
||||
onUploadDirectory: () => void;
|
||||
onSetViewMode: (mode: ViewMode) => void;
|
||||
onSortChange: (sortBy: string, sortOrder: string) => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
path,
|
||||
loading,
|
||||
viewMode,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onGoUp,
|
||||
onNavigate,
|
||||
onRefresh,
|
||||
onCreateDir,
|
||||
onUploadFile,
|
||||
onUploadDirectory,
|
||||
onSetViewMode,
|
||||
onSortChange,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [pathInputValue, setPathInputValue] = useState('');
|
||||
const clickTimerRef = useRef<number | null>(null);
|
||||
const pathEditorHeight = token.fontSizeSM * token.lineHeight + token.paddingXXS * 2;
|
||||
|
||||
const clearClickTimer = () => {
|
||||
if (clickTimerRef.current !== null) {
|
||||
window.clearTimeout(clickTimerRef.current);
|
||||
clickTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleNavigate = (nextPath: string) => {
|
||||
clearClickTimer();
|
||||
clickTimerRef.current = window.setTimeout(() => {
|
||||
onNavigate(nextPath);
|
||||
clickTimerRef.current = null;
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handlePathEdit = () => {
|
||||
clearClickTimer();
|
||||
setEditingPath(true);
|
||||
setPathInputValue(path);
|
||||
};
|
||||
|
||||
const handlePathSubmit = () => {
|
||||
const trimmed = pathInputValue.trim();
|
||||
if (trimmed && trimmed !== path) {
|
||||
onNavigate(trimmed);
|
||||
}
|
||||
setEditingPath(false);
|
||||
};
|
||||
|
||||
const handlePathCancel = () => {
|
||||
setEditingPath(false);
|
||||
setPathInputValue('');
|
||||
};
|
||||
|
||||
const handleBreadcrumbDoubleClick = () => {
|
||||
handlePathEdit();
|
||||
};
|
||||
|
||||
const renderBreadcrumb = () => {
|
||||
if (editingPath) {
|
||||
return (
|
||||
<Input
|
||||
size="small"
|
||||
value={pathInputValue}
|
||||
onChange={(e) => setPathInputValue(e.target.value)}
|
||||
onPressEnter={handlePathSubmit}
|
||||
onBlur={handlePathCancel}
|
||||
onKeyDown={(e) => e.key === 'Escape' && handlePathCancel()}
|
||||
autoFocus
|
||||
style={{ flex: 1, height: pathEditorHeight, boxSizing: 'border-box' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => scheduleNavigate('/')}>{t('Home')}</span> },
|
||||
...path.split('/').filter(Boolean).map((segment, index, arr) => {
|
||||
const segmentPath = '/' + arr.slice(0, index + 1).join('/');
|
||||
return {
|
||||
key: segmentPath,
|
||||
title: <span style={{ cursor: 'pointer' }} onClick={() => scheduleNavigate(segmentPath)}>{segment}</span>
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
cursor: 'text',
|
||||
padding: `${token.paddingXXS}px ${token.paddingXS}px`,
|
||||
borderRadius: token.borderRadius,
|
||||
transition: 'background-color 0.2s',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
height: pathEditorHeight,
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillTertiary; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
onDoubleClick={handleBreadcrumbDoubleClick}
|
||||
>
|
||||
<Breadcrumb items={breadcrumbItems} separator="/" style={{ fontSize: token.fontSizeSM }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" style={{ padding: '10px 16px', borderBottom: `1px solid ${token.colorBorderSecondary}`, gap: 12 }}>
|
||||
<Flex align="center" gap={8} style={{ flexWrap: 'wrap', flex: 1, overflow: 'hidden' }}>
|
||||
<Button size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} disabled={path === '/'} />
|
||||
<Typography.Text strong>{t('File Manager')}</Typography.Text>
|
||||
<Divider type="vertical" />
|
||||
{renderBreadcrumb()}
|
||||
</Flex>
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>{t('Refresh')}</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>{t('New Folder')}</Button>
|
||||
<Dropdown.Button
|
||||
size="small"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={onUploadFile}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'file', label: t('Upload Files') },
|
||||
{ key: 'folder', label: t('Upload Folder') },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'folder') {
|
||||
onUploadDirectory();
|
||||
} else {
|
||||
onUploadFile();
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{t('Upload')}
|
||||
</Dropdown.Button>
|
||||
<Select
|
||||
size="small"
|
||||
value={sortBy}
|
||||
onChange={(val) => onSortChange(val, sortOrder)}
|
||||
style={{ width: 80 }}
|
||||
options={[
|
||||
{ value: 'name', label: t('Name') },
|
||||
{ value: 'size', label: t('Size') },
|
||||
{ value: 'mtime', label: t('Modified Time') },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={sortOrder === 'asc' ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
onClick={() => onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||
/>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={value => onSetViewMode(value as ViewMode)}
|
||||
options={[
|
||||
{ label: <Tooltip title={t('Grid')}><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title={t('List')}><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FC } from 'react';
|
||||
import { Skeleton, theme } from 'antd';
|
||||
|
||||
type LoadingMode = 'grid' | 'list';
|
||||
|
||||
interface LoadingSkeletonProps {
|
||||
mode: LoadingMode;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
const createArray = (length: number) => Array.from({ length }, (_, index) => index);
|
||||
|
||||
export const LoadingSkeleton: FC<LoadingSkeletonProps> = ({ mode, count }) => {
|
||||
const { token } = theme.useToken();
|
||||
const fallbackCount = mode === 'grid' ? 50 : 30;
|
||||
const items = createArray(count ?? fallbackCount);
|
||||
|
||||
if (mode === 'grid') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||
gap: 16,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{items.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
background: token.colorBgElevated,
|
||||
borderRadius: token.borderRadius,
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Button active block style={{ height: 96, borderRadius: token.borderRadiusLG }} />
|
||||
<Skeleton active title={false} paragraph={{ rows: 2, width: ['80%', '60%'] }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0 16px' }}>
|
||||
{items.map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '48px 1fr',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
}}
|
||||
>
|
||||
<Skeleton.Avatar active shape="square" size={32} />
|
||||
<div style={{ paddingLeft: 16 }}>
|
||||
<Skeleton active title={false} paragraph={{ rows: 1, width: '60%' }} />
|
||||
<Skeleton active title={false} paragraph={{ rows: 1, width: '40%' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface CreateDirModalProps {
|
||||
open: boolean;
|
||||
onOk: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOk = () => {
|
||||
onOk(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('New Folder')}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={{ disabled: !name.trim() }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Input
|
||||
placeholder={t('Folder Name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Input, Modal } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface CreateFileModalProps {
|
||||
open: boolean;
|
||||
onOk: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreateFileModal: React.FC<CreateFileModalProps> = ({ open, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOk = () => {
|
||||
onOk(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('New File')}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={{ disabled: !name.trim() }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Input
|
||||
placeholder={t('Filename')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Radio, message, Button, Typography, Input, Space } from 'antd';
|
||||
import { CopyOutlined, FileMarkdownOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import { vfsApi } from '../../../../api/client';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface DirectLinkModalProps {
|
||||
entry: VfsEntry | null;
|
||||
path: string;
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Helper function to check if a file is an image
|
||||
const isImageFile = (fileName: string): boolean => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'tiff'].includes(ext);
|
||||
};
|
||||
|
||||
// Helper function to generate Markdown formatted link
|
||||
const generateMarkdownLink = (fileName: string, url: string): string => {
|
||||
if (isImageFile(fileName)) {
|
||||
return ``;
|
||||
} else {
|
||||
return `[${fileName}](${url})`;
|
||||
}
|
||||
};
|
||||
|
||||
export const DirectLinkModal = memo(function DirectLinkModal({ entry, path, open, onCancel }: DirectLinkModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresIn, setExpiresIn] = useState(3600);
|
||||
const [link, setLink] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const generateLink = useCallback(async () => {
|
||||
if (!entry) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const res = await vfsApi.getTempLinkToken(fullPath, expiresIn);
|
||||
let url = res.url;
|
||||
if (url && !url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
const origin = window.location.origin;
|
||||
url = url.startsWith('/') ? origin + url : origin + '/' + url;
|
||||
}
|
||||
setLink(url);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Failed to generate link'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entry, expiresIn, path, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && entry) {
|
||||
setLink('');
|
||||
void generateLink();
|
||||
}
|
||||
}, [open, entry, generateLink]);
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const handleCopyMarkdown = () => {
|
||||
if (!entry || !link) return;
|
||||
const markdownText = generateMarkdownLink(entry.name, link);
|
||||
navigator.clipboard.writeText(markdownText);
|
||||
message.success(t('Markdown copied to clipboard'));
|
||||
};
|
||||
|
||||
const handleExpiresChange = (e: any) => {
|
||||
setExpiresIn(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Get Direct Link')}
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="back" onClick={onCancel}>
|
||||
{t('Close')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Typography.Paragraph>
|
||||
{t('Generate a direct link for {name}', { name: entry?.name || '' })}
|
||||
</Typography.Paragraph>
|
||||
<Radio.Group value={expiresIn} onChange={handleExpiresChange} style={{ marginBottom: 16 }}>
|
||||
<Radio.Button value={3600}>{t('1 hour')}</Radio.Button>
|
||||
<Radio.Button value={86400}>{t('1 day')}</Radio.Button>
|
||||
<Radio.Button value={604800}>{t('7 days')}</Radio.Button>
|
||||
<Radio.Button value={0}>{t('Forever')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={link} disabled={loading} placeholder={loading ? t('Generating link...') : t('Link will appear here')} />
|
||||
<Space.Compact>
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(link)} disabled={!link || loading}>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
<Button icon={<FileMarkdownOutlined />} onClick={handleCopyMarkdown} disabled={!link || loading}>
|
||||
{t('Copy Markdown')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Modal, Input, message, Space } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import PathSelectorModal from '../../../../components/PathSelectorModal';
|
||||
|
||||
interface MoveCopyModalProps {
|
||||
mode: 'move' | 'copy';
|
||||
entries: VfsEntry[];
|
||||
open: boolean;
|
||||
defaultPath: string;
|
||||
onOk: (destination: string) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MoveCopyModal({ mode, entries, open, defaultPath, onOk, onCancel }: MoveCopyModalProps) {
|
||||
const { t } = useI18n();
|
||||
const [value, setValue] = useState(defaultPath);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectorOpen, setSelectorOpen] = useState(false);
|
||||
|
||||
const entryName = useMemo(() => entries.length === 1 ? entries[0]?.name ?? '' : '', [entries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValue(defaultPath);
|
||||
} else {
|
||||
setValue('');
|
||||
}
|
||||
setLoading(false);
|
||||
setSelectorOpen(false);
|
||||
}, [open, defaultPath]);
|
||||
|
||||
const handleOk = async () => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
message.warning(t('Please input destination path'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await onOk(trimmed);
|
||||
onCancel();
|
||||
} catch { void 0; } finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === 'move' ? t('Move to') : t('Copy to');
|
||||
const okText = mode === 'move' ? t('Move') : t('Copy');
|
||||
|
||||
const normalizeSelectedPath = (dirPath: string) => {
|
||||
const collapse = (p: string) => p.replace(/\/+/g, '/');
|
||||
const ensureLeading = (p: string) => (p.startsWith('/') ? p : `/${p}`);
|
||||
const normalizeDir = (() => {
|
||||
if (!dirPath || dirPath === '/') return '/';
|
||||
const replaced = dirPath.replace(/\\/g, '/');
|
||||
const trimmed = replaced.endsWith('/') ? replaced.replace(/\/+$/, '') : replaced;
|
||||
return ensureLeading(collapse(trimmed));
|
||||
})();
|
||||
|
||||
if (!entryName) {
|
||||
return normalizeDir || '/';
|
||||
}
|
||||
|
||||
const base = normalizeDir === '/' ? '' : normalizeDir;
|
||||
const combined = `${base}/${entryName}`;
|
||||
return ensureLeading(collapse(combined));
|
||||
};
|
||||
|
||||
const handleBrowse = (selectedPath: string) => {
|
||||
const finalPath = normalizeSelectedPath(selectedPath);
|
||||
setValue(finalPath);
|
||||
setSelectorOpen(false);
|
||||
};
|
||||
|
||||
const selectorInitialPath = useMemo(() => {
|
||||
if (!value) return '/';
|
||||
const normalized = value.replace(/\\/g, '/');
|
||||
if (!normalized || normalized === '/') return '/';
|
||||
const trimmed = normalized.endsWith('/') ? normalized.replace(/\/+$/, '') : normalized;
|
||||
const parts = trimmed.split('/');
|
||||
if (parts.length <= 1) return '/';
|
||||
parts.pop();
|
||||
const parent = parts.join('/') || '/';
|
||||
return parent.startsWith('/') ? parent : `/${parent}`;
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
open={open && entries.length > 0}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
okText={okText}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Input
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder={t('Destination path')}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
/>
|
||||
<Button onClick={() => setSelectorOpen(true)}>{t('Select destination')}</Button>
|
||||
</Space.Compact>
|
||||
<PathSelectorModal
|
||||
open={selectorOpen}
|
||||
mode="directory"
|
||||
initialPath={selectorInitialPath}
|
||||
onOk={handleBrowse}
|
||||
onCancel={() => setSelectorOpen(false)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Select, Input, Checkbox } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import type { ProcessorTypeMeta } from '../../../../api/processors';
|
||||
import { ProcessorConfigForm } from '../../../../components/ProcessorConfigForm';
|
||||
|
||||
interface ProcessorModalProps {
|
||||
entry: VfsEntry | null;
|
||||
visible: boolean;
|
||||
loading: boolean;
|
||||
processorTypes: ProcessorTypeMeta[];
|
||||
selectedProcessor: string;
|
||||
config: any;
|
||||
savingPath: string;
|
||||
overwrite: boolean;
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
onSelectedProcessorChange: (type: string) => void;
|
||||
onConfigChange: (key: string, value: any) => void;
|
||||
onSavingPathChange: (path: string) => void;
|
||||
onOverwriteChange: (overwrite: boolean) => void;
|
||||
}
|
||||
|
||||
export const ProcessorModal: React.FC<ProcessorModalProps> = (props) => {
|
||||
const {
|
||||
entry, visible, loading, processorTypes, selectedProcessor, config,
|
||||
savingPath, overwrite, onOk, onCancel, onSelectedProcessorChange,
|
||||
onConfigChange, onSavingPathChange, onOverwriteChange
|
||||
} = props;
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useI18n();
|
||||
|
||||
const availableProcessors = React.useMemo(() => {
|
||||
if (!entry) return processorTypes;
|
||||
if (entry.is_dir) {
|
||||
return processorTypes.filter(pt => !!pt.supports_directory);
|
||||
}
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return processorTypes.filter(pt => {
|
||||
const exts = pt.supported_exts;
|
||||
if (!Array.isArray(exts) || exts.length === 0) return true;
|
||||
return exts.includes(ext);
|
||||
});
|
||||
}, [entry, processorTypes]);
|
||||
|
||||
const selectedProcessorMeta = processorTypes.find(pt => pt.type === selectedProcessor);
|
||||
|
||||
// Sync form when modal opens or selected processor changes
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
form.setFieldsValue({
|
||||
processor_type: selectedProcessor,
|
||||
config: config,
|
||||
});
|
||||
}
|
||||
}, [visible, selectedProcessor, config, form]);
|
||||
|
||||
const handleFormValuesChange = (changedValues: any) => {
|
||||
if (changedValues.config) {
|
||||
for (const key in changedValues.config) {
|
||||
onConfigChange(key, changedValues.config[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Process file with processor') + (entry ? `: ${entry.name}` : '')}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={loading}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="processor_type" label={t('Processor')} required>
|
||||
<Select
|
||||
onChange={onSelectedProcessorChange}
|
||||
options={availableProcessors.map(pt => ({ value: pt.type, label: pt.name }))}
|
||||
placeholder={t('Select a processor')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ProcessorConfigForm
|
||||
processorMeta={selectedProcessorMeta}
|
||||
form={form}
|
||||
configPath={['config']}
|
||||
/>
|
||||
{selectedProcessorMeta?.produces_file && (
|
||||
<>
|
||||
<Form.Item>
|
||||
<Checkbox checked={overwrite} onChange={e => onOverwriteChange(e.target.checked)}>
|
||||
{t('Overwrite original file')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{!overwrite && (
|
||||
<Form.Item label={t('Save as new file')}>
|
||||
<Input
|
||||
value={savingPath}
|
||||
onChange={e => onSavingPathChange(e.target.value)}
|
||||
placeholder={t('e.g. /newfile.jpg, leave blank to only return result')}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
|
||||
interface RenameModalProps {
|
||||
entry: VfsEntry | null;
|
||||
onOk: (entry: VfsEntry, newName: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const RenameModal: React.FC<RenameModalProps> = ({ entry, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (entry) {
|
||||
setName(entry.name);
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (entry) {
|
||||
onOk(entry, name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('Rename')}
|
||||
open={!!entry}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={{ disabled: !name.trim() || name.trim() === entry?.name }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Input
|
||||
placeholder={t('New Name')}
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
140
web/src/pages/FileExplorerPage/components/Modals/ShareModal.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Radio, InputNumber, message, Button, Typography } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry, ShareInfoWithPassword } from '../../../../api/client';
|
||||
import { shareApi } from '../../../../api/share';
|
||||
import { useSystemStatus } from '../../../../contexts/SystemContext';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface ShareModalProps {
|
||||
entries: VfsEntry[];
|
||||
path: string;
|
||||
open: boolean;
|
||||
onOk: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ShareModal = memo(function ShareModal({ entries, path, open, onOk, onCancel }: ShareModalProps) {
|
||||
const systemStatus = useSystemStatus();
|
||||
const { t } = useI18n();
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accessType, setAccessType] = useState('public');
|
||||
const [createdShare, setCreatedShare] = useState<ShareInfoWithPassword | null>(null);
|
||||
|
||||
const defaultName = entries.length > 1
|
||||
? t('Share {count} items', { count: entries.length.toString() })
|
||||
: (entries.length === 1 ? entries[0].name : '');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCreatedShare(null);
|
||||
form.setFieldsValue({
|
||||
name: defaultName,
|
||||
accessType: 'public',
|
||||
expiresInDays: 7,
|
||||
password: '',
|
||||
});
|
||||
setAccessType('public');
|
||||
}
|
||||
}, [open, defaultName, form]);
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
const fullPaths = entries.map(e => {
|
||||
const p = path === '/' ? '' : path;
|
||||
return `${p}/${e.name}`;
|
||||
});
|
||||
|
||||
const result = await shareApi.create({
|
||||
name: values.name,
|
||||
paths: fullPaths,
|
||||
access_type: values.accessType,
|
||||
password: values.password,
|
||||
expires_in_days: values.expiresInDays,
|
||||
});
|
||||
message.success(t('Share link created'));
|
||||
setCreatedShare(result);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Create failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const baseUrl = systemStatus?.app_domain || window.location.origin;
|
||||
const shareUrl = createdShare ? new URL(`/share/${createdShare.token}`, baseUrl).href : '';
|
||||
|
||||
const renderForm = () => (
|
||||
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>
|
||||
<Form.Item name="name" label={t('Share Name')} rules={[{ required: true }]} >
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessType" label={t('Access')}>
|
||||
<Radio.Group onChange={(e) => setAccessType(e.target.value)}>
|
||||
<Radio value="public">{t('Public')}</Radio>
|
||||
<Radio value="password">{t('By Password')}</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{accessType === 'password' && (
|
||||
<Form.Item name="password" label={t('Please enter password')} rules={[{ required: true, message: t('Please enter password') }]} >
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="expiresInDays" label={t('Expiration (days)')} help={t('Set 0 or negative for forever')}>
|
||||
<InputNumber min={-1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
|
||||
const renderSuccess = () => (
|
||||
<div>
|
||||
<Typography.Paragraph>{t('Share link created successfully!')}</Typography.Paragraph>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label={t('Share Link')}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={shareUrl} style={{ flex: 1 }} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(shareUrl)}>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
{createdShare?.password && (
|
||||
<Form.Item label={t('Password')}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Input readOnly value={createdShare.password} style={{ flex: 1 }} />
|
||||
<Button icon={<CopyOutlined />} onClick={() => handleCopy(createdShare.password!)}>
|
||||
{t('Copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<Typography.Text type="secondary">
|
||||
{t('Expires At')}: {createdShare?.expires_at ? new Date(createdShare.expires_at).toLocaleString() : t('Forever')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={createdShare ? t('Share created') : t('Create Share')}
|
||||
open={open}
|
||||
onOk={createdShare ? onOk : handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
destroyOnHidden
|
||||
okText={createdShare ? t('Done') : t('Create')}
|
||||
>
|
||||
{createdShare ? renderSuccess() : renderForm()}
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
220
web/src/pages/FileExplorerPage/components/Modals/UploadModal.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Modal, Button, List, Progress, Typography, message, Flex, Tag, Space } from 'antd';
|
||||
import { CopyOutlined, CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
import type { ConflictDecision, UploadConflict, UploadFile } from '../../hooks/useUploader';
|
||||
import { useI18n } from '../../../../i18n';
|
||||
|
||||
interface UploadModalProps {
|
||||
visible: boolean;
|
||||
files: UploadFile[];
|
||||
isUploading: boolean;
|
||||
totalProgress: number;
|
||||
totalFileBytes: number;
|
||||
uploadedFileBytes: number;
|
||||
conflict: UploadConflict | null;
|
||||
onClose: () => void;
|
||||
onStartUpload: () => void;
|
||||
onResolveConflict: (decision: ConflictDecision) => void;
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
const value = bytes / (1024 ** index);
|
||||
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const UploadModal: React.FC<UploadModalProps> = ({
|
||||
visible,
|
||||
files,
|
||||
isUploading,
|
||||
totalProgress,
|
||||
totalFileBytes,
|
||||
uploadedFileBytes,
|
||||
conflict,
|
||||
onClose,
|
||||
onStartUpload,
|
||||
onResolveConflict,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const total = files.length;
|
||||
const completed = files.filter(f => ['success', 'skipped'].includes(f.status)).length;
|
||||
const failures = files.filter(f => f.status === 'error').length;
|
||||
const pending = files.filter(f => ['pending', 'waiting', 'uploading'].includes(f.status)).length;
|
||||
return { total, completed, failures, pending };
|
||||
}, [files]);
|
||||
|
||||
const allFinished = files.length > 0 && files.every(f => ['success', 'error', 'skipped'].includes(f.status));
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && files.length > 0 && files.some(f => f.status === 'pending')) {
|
||||
onStartUpload();
|
||||
}
|
||||
}, [visible, files, onStartUpload]);
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
message.success(t('Copied to clipboard'));
|
||||
};
|
||||
|
||||
const renderStatus = (file: UploadFile) => {
|
||||
if (file.type === 'directory') {
|
||||
if (file.status === 'uploading') {
|
||||
return <Typography.Text type="secondary">{t('Creating directory...')}</Typography.Text>;
|
||||
}
|
||||
if (file.status === 'success') {
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
|
||||
<Typography.Text type="secondary">{t('Directory ready')}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (file.status === 'error') {
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
|
||||
<Typography.Text type="danger" title={file.error}>{t('Create directory failed')}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return <Typography.Text type="secondary">{t('Waiting to create')}</Typography.Text>;
|
||||
}
|
||||
|
||||
switch (file.status) {
|
||||
case 'uploading':
|
||||
return <Progress percent={Math.round(file.progress)} size="small" />;
|
||||
case 'success':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CheckCircleFilled style={{ color: 'var(--ant-color-success, #52c41a)' }} />
|
||||
<Typography.Text type="secondary" style={{ verticalAlign: 'middle' }}>{t('Upload succeeded')}</Typography.Text>
|
||||
<Button icon={<CopyOutlined />} size="small" onClick={() => handleCopy(file.permanentLink!)} type="text" />
|
||||
</Flex>
|
||||
);
|
||||
case 'waiting':
|
||||
return <Typography.Text type="warning">{t('Waiting for overwrite decision')}</Typography.Text>;
|
||||
case 'skipped':
|
||||
return <Typography.Text type="secondary">{t('Skipped')}</Typography.Text>;
|
||||
case 'error':
|
||||
return (
|
||||
<Flex align="center" gap={8}>
|
||||
<CloseCircleFilled style={{ color: 'var(--ant-color-error, #ff4d4f)' }} />
|
||||
<Typography.Text type="danger" title={file.error}>{t('Upload failed')}</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
default:
|
||||
return <Typography.Text type="secondary">{t('Waiting to upload')}</Typography.Text>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={visible}
|
||||
title={t('Upload File')}
|
||||
width={600}
|
||||
closable={!isUploading}
|
||||
maskClosable={!isUploading}
|
||||
onCancel={onClose}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose} disabled={!allFinished || isUploading}>
|
||||
{allFinished ? t('Close') : t('Done')}
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<div>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Typography.Text strong>
|
||||
{t('Total progress')}:
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{t('Upload bytes summary', {
|
||||
uploaded: formatBytes(uploadedFileBytes),
|
||||
total: formatBytes(totalFileBytes),
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Progress percent={Math.round(totalProgress)} showInfo />
|
||||
<Typography.Text type="secondary">
|
||||
{t('Upload task summary', {
|
||||
completed: summary.completed,
|
||||
total: summary.total,
|
||||
pending: summary.pending,
|
||||
failures: summary.failures,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{conflict && (
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--ant-color-warning-border, #faad14)',
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
background: 'var(--ant-color-warning-bg, rgba(250,173,20,0.1))',
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{t('Overwrite confirmation required')}
|
||||
</Typography.Text>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{t('Target already exists: {path}', { path: conflict.relativePath })}
|
||||
</Typography.Paragraph>
|
||||
<Flex gap={8} wrap="wrap">
|
||||
<Button size="small" type="primary" onClick={() => onResolveConflict('overwrite')}>
|
||||
{t('Overwrite')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => onResolveConflict('skip')}>
|
||||
{t('Skip')}
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={() => onResolveConflict('overwriteAll')}>
|
||||
{t('Overwrite All')}
|
||||
</Button>
|
||||
<Button size="small" onClick={() => onResolveConflict('skipAll')}>
|
||||
{t('Skip All')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<List
|
||||
dataSource={files}
|
||||
itemLayout="horizontal"
|
||||
renderItem={file => (
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '12px 8px',
|
||||
borderRadius: 8,
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--ant-color-fill-tertiary, #f0f0f0)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||
<Flex align="center" gap={8} style={{ maxWidth: '60%', overflow: 'hidden' }}>
|
||||
<Typography.Text ellipsis={{ tooltip: file.relativePath }} style={{ maxWidth: '100%' }}>
|
||||
{file.relativePath}
|
||||
</Typography.Text>
|
||||
{file.type === 'directory' ? (
|
||||
<Tag color="blue">{t('Directory')}</Tag>
|
||||
) : (
|
||||
<Tag color="geekblue">{formatBytes(file.size)}</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
<div style={{ minWidth: 180, textAlign: 'right', flexShrink: 0 }}>
|
||||
{renderStatus(file)}
|
||||
</div>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadModal;
|
||||
192
web/src/pages/FileExplorerPage/components/SearchResultsView.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import React from 'react';
|
||||
import { Empty, Flex, Spin, Tag, Typography, theme } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { ViewMode } from '../types';
|
||||
import type { SearchDisplayItem, SearchMode } from '../hooks/useFileSearch';
|
||||
|
||||
interface SearchResultsViewProps {
|
||||
viewMode: ViewMode;
|
||||
loading: boolean;
|
||||
mode: SearchMode;
|
||||
query: string;
|
||||
items: SearchDisplayItem[];
|
||||
selectedPaths: string[];
|
||||
entrySnapshot: Record<string, VfsEntry>;
|
||||
onClearSearch: () => void;
|
||||
onSelect: (fullPath: string, additive: boolean) => void;
|
||||
onOpen: (fullPath: string) => void;
|
||||
onContextMenu: (e: React.MouseEvent, fullPath: string) => void;
|
||||
}
|
||||
|
||||
export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
|
||||
viewMode,
|
||||
loading,
|
||||
mode,
|
||||
query,
|
||||
items,
|
||||
selectedPaths,
|
||||
entrySnapshot,
|
||||
onClearSearch,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onContextMenu,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const { t } = useI18n();
|
||||
|
||||
const renderSourceLabel = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return t('Vector Search');
|
||||
case 'filename':
|
||||
return t('Name Search');
|
||||
case 'text':
|
||||
return t('Text Chunk');
|
||||
case 'image':
|
||||
return t('Image Description');
|
||||
default:
|
||||
return t('Vector Search');
|
||||
}
|
||||
};
|
||||
|
||||
const sourceColor = (value?: string) => {
|
||||
switch ((value || '').toLowerCase()) {
|
||||
case 'vector':
|
||||
return 'blue';
|
||||
case 'filename':
|
||||
return 'green';
|
||||
case 'image':
|
||||
return 'volcano';
|
||||
case 'text':
|
||||
return 'geekblue';
|
||||
default:
|
||||
return 'purple';
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSnippet = (rawSnippet: string | undefined, name: string, fullPath: string) => {
|
||||
const snippet = (rawSnippet || '').trim();
|
||||
if (!snippet) return '';
|
||||
if (snippet === name) return '';
|
||||
if (snippet === fullPath) return '';
|
||||
if (snippet === fullPath.replace(/^\/+/, '')) return '';
|
||||
return snippet;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16 }}>
|
||||
<Flex align="center" justify="space-between" style={{ marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
|
||||
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<Typography.Text strong>{t('Search Results')}</Typography.Text>
|
||||
<Tag color={mode === 'filename' ? 'green' : 'blue'}>
|
||||
{mode === 'filename' ? t('Name Search') : t('Smart Search')}
|
||||
</Tag>
|
||||
<Tag closable onClose={(ev) => { ev.preventDefault(); onClearSearch(); }}>
|
||||
{query}
|
||||
</Tag>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Flex align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
) : items.length === 0 ? (
|
||||
<Flex align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Flex>
|
||||
) : viewMode === 'grid' ? (
|
||||
<div
|
||||
className="fx-grid"
|
||||
style={{ padding: 0, gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))' }}
|
||||
>
|
||||
{items.map(({ item, fullPath, dir, name }) => {
|
||||
const selected = selectedPaths.includes(fullPath);
|
||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||
const isDir = Boolean(entrySnapshot[fullPath]?.is_dir);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fullPath}
|
||||
className={['fx-grid-item', selected ? 'selected' : '', 'file'].join(' ')}
|
||||
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
|
||||
onDoubleClick={() => onOpen(fullPath)}
|
||||
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
|
||||
<span className="badge score-badge">{scoreText}</span>
|
||||
{isDir
|
||||
? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text>
|
||||
: <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
|
||||
</div>
|
||||
<div className="name ellipsis">{name}</div>
|
||||
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
|
||||
{dir}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map(({ item, fullPath, name }) => {
|
||||
const selected = selectedPaths.includes(fullPath);
|
||||
const retrieval = item.metadata?.retrieval_source || item.source_type;
|
||||
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
|
||||
const snippet = normalizeSnippet(item.snippet, name, fullPath);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fullPath}
|
||||
className={selected ? 'row-selected' : ''}
|
||||
onClick={(ev) => onSelect(fullPath, ev.ctrlKey || ev.metaKey)}
|
||||
onDoubleClick={() => onOpen(fullPath)}
|
||||
onContextMenu={(ev) => onContextMenu(ev, fullPath)}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: token.borderRadius,
|
||||
background: token.colorFillTertiary,
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Flex vertical style={{ gap: 6 }}>
|
||||
<Typography.Text strong className="ellipsis">
|
||||
{name}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
|
||||
{fullPath}
|
||||
</Typography.Text>
|
||||
{snippet ? (
|
||||
<Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>
|
||||
{snippet}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
{retrieval ? (
|
||||
<Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>
|
||||
{renderSourceLabel(retrieval)}
|
||||
</Tag>
|
||||
) : null}
|
||||
<Tag
|
||||
style={{
|
||||
marginRight: 0,
|
||||
background: token.colorBgContainer,
|
||||
borderColor: token.colorBorderSecondary,
|
||||
color: token.colorText,
|
||||
}}
|
||||
>
|
||||
{scoreText}
|
||||
</Tag>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
97
web/src/pages/FileExplorerPage/hooks/useAppWindows.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Checkbox } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { AppDescriptor } from '../../../apps/registry';
|
||||
import type { AppWindow } from '../types';
|
||||
import { getAppsForEntry, getDefaultAppForEntry, getAppByKey } from '../../../apps/registry';
|
||||
|
||||
export function useAppWindows(path: string) {
|
||||
const { t } = useI18n();
|
||||
const [appWindows, setAppWindows] = useState<AppWindow[]>([]);
|
||||
|
||||
const openWithApp = useCallback((entry: VfsEntry, app: AppDescriptor) => {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
setAppWindows(ws => {
|
||||
const idx = ws.length;
|
||||
const bounds = app.defaultBounds || {};
|
||||
const baseX = bounds.x ?? (160 + idx * 32);
|
||||
const baseY = bounds.y ?? (100 + idx * 28);
|
||||
const baseW = bounds.width ?? 640;
|
||||
const baseH = bounds.height ?? 480;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const finalW = Math.min(baseW, vw - 40);
|
||||
const finalH = Math.min(baseH, vh - 60);
|
||||
const finalX = Math.min(Math.max(0, baseX), vw - finalW - 8);
|
||||
const finalY = Math.min(Math.max(0, baseY), vh - finalH - 8);
|
||||
return [...ws, {
|
||||
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
|
||||
app,
|
||||
entry,
|
||||
filePath: fullPath,
|
||||
maximized: !!app.defaultMaximized,
|
||||
x: finalX,
|
||||
y: finalY,
|
||||
width: finalW,
|
||||
height: finalH
|
||||
}];
|
||||
});
|
||||
}, [path]);
|
||||
|
||||
const openFileWithDefaultApp = useCallback((entry: VfsEntry) => {
|
||||
const apps = getAppsForEntry(entry);
|
||||
if (!apps.length) {
|
||||
Modal.error({ title: t('Cannot open file: no available app') });
|
||||
return;
|
||||
}
|
||||
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
|
||||
openWithApp(entry, defaultApp);
|
||||
}, [openWithApp, t]);
|
||||
|
||||
const confirmOpenWithApp = useCallback((entry: VfsEntry, appKey: string) => {
|
||||
const app = getAppByKey(appKey);
|
||||
if (!app) {
|
||||
Modal.error({ title: t('Error'), content: t('App "{key}" not found.', { key: appKey }) });
|
||||
return;
|
||||
}
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
let setDefault = false;
|
||||
Modal.confirm({
|
||||
title: t('Open with {app}', { app: app.name }),
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>{t('File')}: {entry.name}</div>
|
||||
<Checkbox onChange={e => setDefault = e.target.checked}>{t('Set as default for .{ext}', { ext })}</Checkbox>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
if (setDefault && ext) {
|
||||
localStorage.setItem(`app.default.${ext}`, app.key);
|
||||
}
|
||||
openWithApp(entry, app);
|
||||
}
|
||||
});
|
||||
}, [openWithApp, t]);
|
||||
|
||||
const closeWindow = (id: string) => setAppWindows(ws => ws.filter(w => w.id !== id));
|
||||
const toggleMax = (id: string) => setAppWindows(ws => ws.map(w => w.id === id ? { ...w, maximized: !w.maximized } : w));
|
||||
const bringToFront = (id: string) => setAppWindows(ws => {
|
||||
const target = ws.find(w => w.id === id);
|
||||
if (!target) return ws;
|
||||
return [...ws.filter(w => w.id !== id), target];
|
||||
});
|
||||
const updateWindow = (id: string, patch: Partial<Omit<AppWindow, 'id' | 'app' | 'entry' | 'filePath'>>) =>
|
||||
setAppWindows(ws => ws.map(w => w.id === id ? { ...w, ...patch } : w));
|
||||
|
||||
return {
|
||||
appWindows,
|
||||
openWithApp,
|
||||
openFileWithDefaultApp,
|
||||
confirmOpenWithApp,
|
||||
closeWindow,
|
||||
toggleMax,
|
||||
bringToFront,
|
||||
updateWindow,
|
||||
};
|
||||
}
|
||||
30
web/src/pages/FileExplorerPage/hooks/useContextMenu.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
export function useContextMenu() {
|
||||
const [ctxMenu, setCtxMenu] = useState<{ entry: VfsEntry; x: number; y: number } | null>(null);
|
||||
const [blankCtxMenu, setBlankCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, entry: VfsEntry) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ entry, x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const openBlankContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setBlankCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const closeContextMenus = useCallback(() => {
|
||||
setCtxMenu(null);
|
||||
setBlankCtxMenu(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
ctxMenu,
|
||||
blankCtxMenu,
|
||||
openContextMenu,
|
||||
openBlankContextMenu,
|
||||
closeContextMenus,
|
||||
};
|
||||
}
|
||||
219
web/src/pages/FileExplorerPage/hooks/useFileActions.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useCallback } from 'react';
|
||||
import { message, Modal } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
|
||||
interface FileActionsParams {
|
||||
path: string;
|
||||
refresh: () => void;
|
||||
clearSelection: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
onGetDirectLink: (entry: VfsEntry) => void;
|
||||
}
|
||||
|
||||
export function useFileActions({ path, refresh, clearSelection, onShare, onGetDirectLink }: FileActionsParams) {
|
||||
const { t } = useI18n();
|
||||
const normalizeFullPath = useCallback((name: string) => {
|
||||
const base = path === '/' ? '' : path;
|
||||
return `${base}/${name}`.replace(/\/{2,}/g, '/');
|
||||
}, [path]);
|
||||
|
||||
const normalizeDestination = useCallback((dest: string) => {
|
||||
const trimmed = dest.trim();
|
||||
if (!trimmed) return '';
|
||||
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
return normalized.replace(/\/{2,}/g, '/');
|
||||
}, []);
|
||||
const doCreateDir = useCallback(async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
message.warning(t('Please input name'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vfsApi.mkdir((path === '/' ? '' : path) + '/' + name.trim());
|
||||
refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}, [path, refresh, t]);
|
||||
|
||||
const doCreateFile = useCallback(async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
message.warning(t('Please input name'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + name.trim();
|
||||
await vfsApi.uploadFile(fullPath, new Blob([]));
|
||||
refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}, [path, refresh, t]);
|
||||
|
||||
const doDelete = useCallback(async (entries: VfsEntry[]) => {
|
||||
Modal.confirm({
|
||||
title: t('Confirm delete {name}?', { name: entries.length > 1 ? `${entries.length} ${t('items')}` : entries[0].name }),
|
||||
content: entries.length > 1 ? <div style={{ maxHeight: 180, overflow: 'auto' }}>{entries.map(it => <div key={it.name}>{it.name}{it.type === 'mount' && ` (${t('Mount Point')})`}</div>)}</div> : null,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await Promise.all(entries.map(it => vfsApi.deletePath((path === '/' ? '' : path) + '/' + it.name)));
|
||||
clearSelection();
|
||||
refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [path, refresh, clearSelection, t]);
|
||||
|
||||
const doRename = useCallback(async (entry: VfsEntry, newName: string) => {
|
||||
if (!newName.trim() || newName.trim() === entry.name) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vfsApi.rename(
|
||||
(path === '/' ? '' : path) + '/' + entry.name,
|
||||
(path === '/' ? '' : path) + '/' + newName.trim()
|
||||
);
|
||||
refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}, [path, refresh]);
|
||||
|
||||
const buildEntryDestination = useCallback((base: string, name: string) => {
|
||||
const normalizedBase = base.replace(/\/+$/, '') || '/';
|
||||
const prefix = normalizedBase === '/' ? '' : normalizedBase;
|
||||
const combined = `${prefix}/${name}`.replace(/\/{2,}/g, '/');
|
||||
return combined.startsWith('/') ? combined : `/${combined}`;
|
||||
}, []);
|
||||
|
||||
const doMove = useCallback(async (entriesToMove: VfsEntry[], destination: string, overwrite: boolean = false) => {
|
||||
if (!entriesToMove || entriesToMove.length === 0) return;
|
||||
const normalized = normalizeDestination(destination);
|
||||
if (!normalized) {
|
||||
message.warning(t('Please input destination path'));
|
||||
return;
|
||||
}
|
||||
|
||||
const multiple = entriesToMove.length > 1;
|
||||
const targetDir = multiple ? (normalized === '/' ? '/' : normalized.replace(/\/+$/, '') || '/') : normalized;
|
||||
let completedCount = 0;
|
||||
let queuedCount = 0;
|
||||
|
||||
for (const entry of entriesToMove) {
|
||||
const src = normalizeFullPath(entry.name);
|
||||
const dst = multiple ? buildEntryDestination(targetDir, entry.name) : normalized;
|
||||
try {
|
||||
const result = await vfsApi.move(src, dst, { overwrite });
|
||||
if (result?.queued) {
|
||||
queuedCount += 1;
|
||||
} else {
|
||||
completedCount += 1;
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount > 0) {
|
||||
message.success(t('Move completed'));
|
||||
}
|
||||
if (queuedCount > 0) {
|
||||
message.info(t('Move task queued'));
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
refresh();
|
||||
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, clearSelection, refresh]);
|
||||
|
||||
const doCopy = useCallback(async (entriesToCopy: VfsEntry[], destination: string, overwrite: boolean = false) => {
|
||||
if (!entriesToCopy || entriesToCopy.length === 0) return;
|
||||
const normalized = normalizeDestination(destination);
|
||||
if (!normalized) {
|
||||
message.warning(t('Please input destination path'));
|
||||
return;
|
||||
}
|
||||
|
||||
const multiple = entriesToCopy.length > 1;
|
||||
const targetDir = multiple ? (normalized === '/' ? '/' : normalized.replace(/\/+$/, '') || '/') : normalized;
|
||||
let completedCount = 0;
|
||||
let queuedCount = 0;
|
||||
|
||||
for (const entry of entriesToCopy) {
|
||||
const src = normalizeFullPath(entry.name);
|
||||
const dst = multiple ? buildEntryDestination(targetDir, entry.name) : normalized;
|
||||
try {
|
||||
const result = await vfsApi.copy(src, dst, { overwrite });
|
||||
if (result?.queued) {
|
||||
queuedCount += 1;
|
||||
} else {
|
||||
completedCount += 1;
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (completedCount > 0) {
|
||||
message.success(t('Copy completed'));
|
||||
}
|
||||
if (queuedCount > 0) {
|
||||
message.info(t('Copy task queued'));
|
||||
}
|
||||
|
||||
refresh();
|
||||
}, [normalizeDestination, normalizeFullPath, t, buildEntryDestination, refresh]);
|
||||
|
||||
const doDownload = useCallback(async (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning(t('Downloading folders is not supported'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buf = await vfsApi.readFile((path === '/' ? '' : path) + '/' + entry.name);
|
||||
const blob = new Blob([buf]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = entry.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Download failed'));
|
||||
}
|
||||
}, [path, t]);
|
||||
|
||||
const doShare = useCallback((entries: VfsEntry[]) => {
|
||||
if (entries.length === 0) {
|
||||
message.warning(t('Please select files or folders to share'));
|
||||
return;
|
||||
}
|
||||
onShare(entries);
|
||||
}, [onShare, t]);
|
||||
|
||||
const doGetDirectLink = useCallback((entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning(t('Direct links for folders are not supported'));
|
||||
return;
|
||||
}
|
||||
onGetDirectLink(entry);
|
||||
}, [onGetDirectLink, t]);
|
||||
|
||||
return {
|
||||
doCreateDir,
|
||||
doCreateFile,
|
||||
doDelete,
|
||||
doRename,
|
||||
doDownload,
|
||||
doShare,
|
||||
doGetDirectLink,
|
||||
doMove,
|
||||
doCopy,
|
||||
};
|
||||
}
|
||||
122
web/src/pages/FileExplorerPage/hooks/useFileExplorer.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { message } from 'antd';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
|
||||
type ExplorerSnapshot = {
|
||||
path: string;
|
||||
entries: VfsEntry[];
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
sortBy: string;
|
||||
sortOrder: string;
|
||||
timestamp: number;
|
||||
};
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
|
||||
|
||||
export function useFileExplorer(navKey: string) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [path, setPath] = useState<string>("/");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [processorTypes, setProcessorTypes] = useState<ProcessorTypeMeta[]>([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 50,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total: number, range: [number, number]) => `${total} ${'items'} ${range[0]}-${range[1]}`,
|
||||
pageSizeOptions: ['20', '50', '100', '200']
|
||||
});
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
const [sortOrder, setSortOrder] = useState('asc');
|
||||
|
||||
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50, sb = sortBy, so = sortOrder) => {
|
||||
const canonical = p === '' ? '/' : (p.startsWith('/') ? p : '/' + p);
|
||||
setLoading(true);
|
||||
try {
|
||||
// Load entries and processor types concurrently
|
||||
const [res, processors] = await Promise.all([
|
||||
vfsApi.list(canonical === '/' ? '' : canonical, page, pageSize, sb, so),
|
||||
processorsApi.list()
|
||||
]);
|
||||
setEntries(res.entries);
|
||||
const resolvedPath = res.path || canonical;
|
||||
setPath(resolvedPath);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: res.pagination!.page,
|
||||
pageSize: res.pagination!.page_size,
|
||||
total: res.pagination!.total
|
||||
}));
|
||||
setProcessorTypes(processors);
|
||||
if (typeof window !== 'undefined') {
|
||||
const snapshot: ExplorerSnapshot = {
|
||||
path: resolvedPath,
|
||||
entries: res.entries,
|
||||
pagination: res.pagination,
|
||||
sortBy: sb,
|
||||
sortOrder: so,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const explorerWindow = window as Window & { __FOXEL_LAST_EXPLORER_PAGE__?: ExplorerSnapshot };
|
||||
explorerWindow.__FOXEL_LAST_EXPLORER_PAGE__ = snapshot;
|
||||
window.dispatchEvent(new CustomEvent<ExplorerSnapshot>('foxel:file-explorer-page', { detail: snapshot }));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Load failed';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sortBy, sortOrder]);
|
||||
|
||||
const navigateTo = useCallback((p: string) => {
|
||||
const canonical = p === '' || p === '/' ? '/' : (p.startsWith('/') ? p : '/' + p);
|
||||
const target = `/${navKey}${canonical === '/' ? '' : canonical}`;
|
||||
if (location.pathname !== target) navigate(target);
|
||||
}, [navKey, navigate, location.pathname]);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
if (path === '/') return;
|
||||
const parent = path.replace(/\/$/, '').split('/').slice(0, -1).join('/') || '/';
|
||||
navigateTo(parent);
|
||||
}, [path, navigateTo]);
|
||||
|
||||
const handlePaginationChange = (page: number, pageSize: number) => {
|
||||
load(path, page, pageSize, sortBy, sortOrder);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
load(path, pagination.current, pagination.pageSize, sortBy, sortOrder);
|
||||
}
|
||||
|
||||
const handleSortChange = (sb: string, so: string) => {
|
||||
setSortBy(sb);
|
||||
setSortOrder(so);
|
||||
load(path, 1, pagination.pageSize, sb, so);
|
||||
};
|
||||
|
||||
return {
|
||||
path,
|
||||
entries,
|
||||
loading,
|
||||
pagination,
|
||||
processorTypes,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
load,
|
||||
navigateTo,
|
||||
goUp,
|
||||
handlePaginationChange,
|
||||
refresh,
|
||||
handleSortChange
|
||||
};
|
||||
}
|
||||
314
web/src/pages/FileExplorerPage/hooks/useFileSearch.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { message } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
import type { SearchResultItem } from '../../../api/vfs';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
export type SearchMode = 'vector' | 'filename';
|
||||
|
||||
export type SearchDisplayItem = {
|
||||
item: SearchResultItem;
|
||||
fullPath: string;
|
||||
dir: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
interface UseFileSearchParams {
|
||||
currentPath: string;
|
||||
navigateTo: (path: string) => void;
|
||||
openFileWithDefaultApp: (entry: VfsEntry, currentPath: string) => void;
|
||||
openContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||
closeContextMenus: () => void;
|
||||
activeEntry?: VfsEntry | null;
|
||||
}
|
||||
|
||||
export function useFileSearch({
|
||||
currentPath,
|
||||
navigateTo,
|
||||
openFileWithDefaultApp,
|
||||
openContextMenu,
|
||||
closeContextMenus,
|
||||
activeEntry,
|
||||
}: UseFileSearchParams) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const query = (searchParams.get('q') || '').trim();
|
||||
const mode = (searchParams.get('mode') === 'filename' ? 'filename' : 'vector') as SearchMode;
|
||||
const page = Math.max(1, Number(searchParams.get('page') || '1') || 1);
|
||||
const isSearching = Boolean(query);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const entryCacheRef = useRef<Map<string, VfsEntry>>(new Map());
|
||||
const [entrySnapshot, setEntrySnapshot] = useState<Record<string, VfsEntry>>({});
|
||||
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
|
||||
const [actionPath, setActionPath] = useState<string>('/');
|
||||
|
||||
const normalizeFullPath = useCallback((fullPath: string) => {
|
||||
const raw = (fullPath || '').replace(/\/+$/, '');
|
||||
if (!raw) return '/';
|
||||
const leading = raw.startsWith('/') ? raw : `/${raw}`;
|
||||
return leading.replace(/\/{2,}/g, '/');
|
||||
}, []);
|
||||
|
||||
const splitPath = useCallback((fullPath: string) => {
|
||||
const normalized = normalizeFullPath(fullPath);
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
const name = parts.pop() || '';
|
||||
const dir = parts.length > 0 ? `/${parts.join('/')}` : '/';
|
||||
return { fullPath: normalized, dir, name };
|
||||
}, [normalizeFullPath]);
|
||||
|
||||
const runSearch = useCallback(async (requestId: number) => {
|
||||
try {
|
||||
const res = await vfsApi.searchFiles(
|
||||
query,
|
||||
mode === 'filename' ? PAGE_SIZE : 10,
|
||||
mode,
|
||||
mode === 'filename' ? page : undefined,
|
||||
mode === 'filename' ? PAGE_SIZE : undefined,
|
||||
);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setResults(res.items || []);
|
||||
if (mode === 'filename') {
|
||||
setHasMore(Boolean(res.pagination?.has_more));
|
||||
} else {
|
||||
setHasMore(false);
|
||||
}
|
||||
} catch (e) {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : t('Load failed');
|
||||
message.error(msg);
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [mode, page, query, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching) {
|
||||
setResults([]);
|
||||
setHasMore(false);
|
||||
setLoading(false);
|
||||
requestIdRef.current += 1;
|
||||
setSelectedPaths([]);
|
||||
entryCacheRef.current.clear();
|
||||
setEntrySnapshot({});
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
setLoading(true);
|
||||
setSelectedPaths([]);
|
||||
closeContextMenus();
|
||||
void runSearch(requestId);
|
||||
}, [closeContextMenus, isSearching, runSearch]);
|
||||
|
||||
const ensureEntry = useCallback(async (fullPath: string, defaultName: string): Promise<VfsEntry> => {
|
||||
const normalized = normalizeFullPath(fullPath);
|
||||
const cached = entryCacheRef.current.get(normalized);
|
||||
if (cached) {
|
||||
return { ...cached, name: cached.name || defaultName };
|
||||
}
|
||||
try {
|
||||
const stat = await vfsApi.stat(normalized);
|
||||
const entry: VfsEntry = {
|
||||
name: (stat as any)?.name || defaultName,
|
||||
is_dir: Boolean((stat as any)?.is_dir),
|
||||
size: Number((stat as any)?.size ?? 0),
|
||||
mtime: Number((stat as any)?.mtime ?? (stat as any)?.mtime_ms ?? 0),
|
||||
type: (stat as any)?.type,
|
||||
has_thumbnail: Boolean((stat as any)?.has_thumbnail),
|
||||
};
|
||||
entryCacheRef.current.set(normalized, entry);
|
||||
setEntrySnapshot(prev => ({ ...prev, [normalized]: entry }));
|
||||
return entry;
|
||||
} catch {
|
||||
const fallback: VfsEntry = { name: defaultName, is_dir: false, size: 0, mtime: 0 };
|
||||
entryCacheRef.current.set(normalized, fallback);
|
||||
setEntrySnapshot(prev => ({ ...prev, [normalized]: fallback }));
|
||||
return fallback;
|
||||
}
|
||||
}, [normalizeFullPath]);
|
||||
|
||||
const refreshSearch = useCallback(() => {
|
||||
if (!isSearching) return;
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
setLoading(true);
|
||||
void runSearch(requestId);
|
||||
}, [isSearching, runSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching) {
|
||||
setActionPath(currentPath || '/');
|
||||
return;
|
||||
}
|
||||
if (actionPath === '/' && currentPath) {
|
||||
setActionPath(currentPath);
|
||||
}
|
||||
}, [actionPath, currentPath, isSearching]);
|
||||
|
||||
const displayItems = useMemo(() => {
|
||||
if (!isSearching) return [];
|
||||
return (results || []).map((item) => {
|
||||
const { fullPath, dir, name } = splitPath(item.path || '');
|
||||
return { item, fullPath, dir, name };
|
||||
}).filter(it => it.fullPath && it.fullPath !== '/' && it.name);
|
||||
}, [isSearching, results, splitPath]);
|
||||
|
||||
const itemByPath = useMemo(() => {
|
||||
const map = new Map<string, SearchDisplayItem>();
|
||||
for (const it of displayItems) {
|
||||
map.set(it.fullPath, it);
|
||||
}
|
||||
return map;
|
||||
}, [displayItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearching) return;
|
||||
selectedPaths.forEach((p) => {
|
||||
const info = itemByPath.get(p);
|
||||
if (info) {
|
||||
void ensureEntry(info.fullPath, info.name);
|
||||
}
|
||||
});
|
||||
}, [ensureEntry, isSearching, itemByPath, selectedPaths]);
|
||||
|
||||
const updateSearchPage = useCallback((nextPage: number) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (nextPage <= 1) params.delete('page');
|
||||
else params.set('page', String(nextPage));
|
||||
const next = params.toString();
|
||||
navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
const clearSearchParams = useCallback(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete('q');
|
||||
params.delete('mode');
|
||||
params.delete('page');
|
||||
const next = params.toString();
|
||||
navigate(`${location.pathname}${next ? `?${next}` : ''}`, { replace: true });
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
const openResult = useCallback(async (fullPath: string) => {
|
||||
const info = itemByPath.get(fullPath);
|
||||
if (!info) return;
|
||||
setActionPath(info.dir);
|
||||
const entry = await ensureEntry(info.fullPath, info.name);
|
||||
if (entry.is_dir) {
|
||||
navigateTo(info.fullPath);
|
||||
return;
|
||||
}
|
||||
openFileWithDefaultApp(entry, info.dir);
|
||||
}, [ensureEntry, itemByPath, navigateTo, openFileWithDefaultApp]);
|
||||
|
||||
const selectResult = useCallback((fullPath: string, additive: boolean) => {
|
||||
const info = itemByPath.get(fullPath);
|
||||
if (!info) return;
|
||||
if (actionPath !== info.dir) {
|
||||
setActionPath(info.dir);
|
||||
setSelectedPaths([fullPath]);
|
||||
return;
|
||||
}
|
||||
if (!additive) {
|
||||
setSelectedPaths([fullPath]);
|
||||
return;
|
||||
}
|
||||
setSelectedPaths((prev) => {
|
||||
const exists = prev.includes(fullPath);
|
||||
return exists ? prev.filter(p => p !== fullPath) : [...prev, fullPath];
|
||||
});
|
||||
}, [actionPath, itemByPath]);
|
||||
|
||||
const openResultContextMenu = useCallback(async (e: React.MouseEvent, fullPath: string) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const info = itemByPath.get(fullPath);
|
||||
if (!info) return;
|
||||
setActionPath(info.dir);
|
||||
setSelectedPaths((prev) => {
|
||||
if (actionPath !== info.dir) {
|
||||
return [fullPath];
|
||||
}
|
||||
return prev.includes(fullPath) ? prev : [fullPath];
|
||||
});
|
||||
const entry = await ensureEntry(info.fullPath, info.name);
|
||||
openContextMenu(e, entry);
|
||||
}, [actionPath, ensureEntry, itemByPath, openContextMenu]);
|
||||
|
||||
const selectedNames = useMemo(() => {
|
||||
const names: string[] = [];
|
||||
for (const p of selectedPaths) {
|
||||
const info = itemByPath.get(p);
|
||||
if (info && info.dir === actionPath) {
|
||||
names.push(info.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}, [actionPath, itemByPath, selectedPaths]);
|
||||
|
||||
const contextEntries = useMemo(() => {
|
||||
if (!isSearching) return [];
|
||||
const map = new Map<string, VfsEntry>();
|
||||
for (const p of selectedPaths) {
|
||||
const info = itemByPath.get(p);
|
||||
if (!info || info.dir !== actionPath) continue;
|
||||
const cached = entrySnapshot[info.fullPath];
|
||||
map.set(info.name, cached || { name: info.name, is_dir: false, size: 0, mtime: 0 });
|
||||
}
|
||||
if (activeEntry) {
|
||||
map.set(activeEntry.name, activeEntry);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, [actionPath, activeEntry, entrySnapshot, isSearching, itemByPath, selectedPaths]);
|
||||
|
||||
const totalItems = useMemo(() => {
|
||||
if (mode !== 'filename') return results.length;
|
||||
if (hasMore) return page * PAGE_SIZE + 1;
|
||||
return (page - 1) * PAGE_SIZE + results.length;
|
||||
}, [hasMore, mode, page, results.length]);
|
||||
|
||||
const showPagination = isSearching && mode === 'filename' && results.length > 0;
|
||||
|
||||
const clearSelection = useCallback(() => setSelectedPaths([]), []);
|
||||
|
||||
return {
|
||||
isSearching,
|
||||
query,
|
||||
mode,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
loading,
|
||||
displayItems,
|
||||
selectedPaths,
|
||||
selectedNames,
|
||||
contextEntries,
|
||||
entrySnapshot,
|
||||
actionPath,
|
||||
showPagination,
|
||||
totalItems,
|
||||
clearSearchParams,
|
||||
updateSearchPage,
|
||||
refreshSearch,
|
||||
openResult,
|
||||
selectResult,
|
||||
openResultContextMenu,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
36
web/src/pages/FileExplorerPage/hooks/useFileSelection.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
export function useFileSelection() {
|
||||
const [selectedEntries, setSelectedEntries] = useState<string[]>([]);
|
||||
|
||||
const handleSelect = useCallback((entry: VfsEntry, additive: boolean = false) => {
|
||||
const name = entry.name;
|
||||
if (additive) {
|
||||
// Toggle selection
|
||||
setSelectedEntries(prev => {
|
||||
const exists = prev.includes(name);
|
||||
return exists ? prev.filter(n => n !== name) : [...prev, name];
|
||||
});
|
||||
} else {
|
||||
// Replace selection
|
||||
setSelectedEntries([name]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectRange = useCallback((names: string[]) => {
|
||||
setSelectedEntries(names);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedEntries([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedEntries,
|
||||
setSelectedEntries,
|
||||
handleSelect,
|
||||
handleSelectRange,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
105
web/src/pages/FileExplorerPage/hooks/useProcessor.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { useI18n } from '../../../i18n';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../../../api/processors';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
interface ProcessorParams {
|
||||
path: string;
|
||||
processorTypes: ProcessorTypeMeta[];
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function useProcessor({ path, processorTypes, refresh }: ProcessorParams) {
|
||||
const { t } = useI18n();
|
||||
const [modal, setModal] = useState<{ entry: VfsEntry | null; visible: boolean }>({ entry: null, visible: false });
|
||||
const [selectedProcessor, setSelectedProcessor] = useState<string>('');
|
||||
const [config, setConfig] = useState<any>({});
|
||||
const [savingPath, setSavingPath] = useState('');
|
||||
const [overwrite, setOverwrite] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openModal = useCallback((entry: VfsEntry) => {
|
||||
const ptMeta = processorTypes.find(p => p.type === selectedProcessor);
|
||||
setModal({ entry, visible: true });
|
||||
setSavingPath((path === '/' ? '' : path) + '/' + entry.name);
|
||||
setOverwrite(!!ptMeta?.produces_file);
|
||||
}, [path, selectedProcessor, processorTypes]);
|
||||
|
||||
const handleOk = useCallback(async () => {
|
||||
if (!modal.entry || !selectedProcessor) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const schema = processorTypes.find(pt => pt.type === selectedProcessor)?.config_schema || [];
|
||||
const finalConfig: any = {};
|
||||
schema.forEach(field => {
|
||||
let val = config[field.key];
|
||||
if ((field.type as any) === 'object' && typeof val === 'string') {
|
||||
try { val = JSON.parse(val); } catch { /* ignore */ }
|
||||
}
|
||||
if (val === undefined) val = field.default;
|
||||
finalConfig[field.key] = val;
|
||||
});
|
||||
|
||||
const params = {
|
||||
path: (path === '/' ? '' : path) + '/' + modal.entry.name,
|
||||
processor_type: selectedProcessor,
|
||||
config: finalConfig,
|
||||
save_to: overwrite ? undefined : savingPath || undefined,
|
||||
overwrite: overwrite ? true : undefined,
|
||||
};
|
||||
|
||||
const resp = await processorsApi.process(params);
|
||||
message.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||
setModal({ entry: null, visible: false });
|
||||
if (overwrite || savingPath) refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('Processing failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [modal.entry, selectedProcessor, processorTypes, config, path, overwrite, savingPath, refresh, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setModal({ entry: null, visible: false });
|
||||
setSelectedProcessor('');
|
||||
setConfig({});
|
||||
setSavingPath('');
|
||||
setOverwrite(false);
|
||||
}, []);
|
||||
|
||||
const handleConfigChange = useCallback((key: string, value: any) => {
|
||||
setConfig((c: any) => ({ ...c, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const handleProcessorTypeChange = useCallback((type: string) => {
|
||||
setSelectedProcessor(type);
|
||||
const meta = processorTypes.find(p => p.type === type);
|
||||
const newConfig: any = {};
|
||||
if (meta?.config_schema) {
|
||||
for (const field of meta.config_schema) {
|
||||
if (field.default !== undefined) {
|
||||
newConfig[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
setConfig(newConfig);
|
||||
setOverwrite(!!meta?.produces_file);
|
||||
}, [processorTypes]);
|
||||
|
||||
return {
|
||||
processorModal: modal,
|
||||
selectedProcessor,
|
||||
processorConfig: config,
|
||||
processorSavingPath: savingPath,
|
||||
processorOverwrite: overwrite,
|
||||
processorLoading: loading,
|
||||
openProcessorModal: openModal,
|
||||
handleProcessorOk: handleOk,
|
||||
handleProcessorCancel: handleCancel,
|
||||
setSelectedProcessor: handleProcessorTypeChange,
|
||||
setProcessorConfig: handleConfigChange,
|
||||
setProcessorSavingPath: setSavingPath,
|
||||
setProcessorOverwrite: setOverwrite,
|
||||
};
|
||||
}
|
||||
40
web/src/pages/FileExplorerPage/hooks/useThumbnails.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { API_BASE_URL } from '../../../api/client';
|
||||
|
||||
const buildThumbUrl = (filePath: string, w = 256, h = 256, fit = 'cover') => {
|
||||
const origin = API_BASE_URL.replace(/\/+$/, '');
|
||||
const cleanPath = filePath.replace(/^\/+/, '');
|
||||
return `${origin}/fs/thumb/${encodeURI(cleanPath)}?w=${w}&h=${h}&fit=${encodeURIComponent(fit)}`;
|
||||
};
|
||||
|
||||
export function useThumbnails(entries: VfsEntry[], path: string) {
|
||||
const [thumbs, setThumbs] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const newThumbs: Record<string, string> = {};
|
||||
const targets = entries.filter(e => !e.is_dir && (e as any).has_thumbnail && !thumbs[e.name]);
|
||||
|
||||
if (targets.length > 0) {
|
||||
targets.forEach(ent => {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + ent.name;
|
||||
newThumbs[ent.name] = buildThumbUrl(fullPath, 256, 256, 'cover');
|
||||
});
|
||||
setThumbs(prev => ({ ...prev, ...newThumbs }));
|
||||
}
|
||||
|
||||
// Clean up old thumbs
|
||||
const currentEntryNames = new Set(entries.map(e => e.name));
|
||||
const toRemove = Object.keys(thumbs).filter(key => !currentEntryNames.has(key));
|
||||
|
||||
if (toRemove.length > 0) {
|
||||
setThumbs(prev => {
|
||||
const next = { ...prev };
|
||||
toRemove.forEach(key => delete next[key]);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [entries, path, thumbs]);
|
||||
|
||||
return { thumbs };
|
||||
}
|
||||
604
web/src/pages/FileExplorerPage/hooks/useUploader.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import type { ChangeEvent, RefObject } from 'react';
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { vfsApi } from '../../../api/client';
|
||||
import { useI18n } from '../../../i18n';
|
||||
|
||||
type UploadStatus = 'pending' | 'waiting' | 'uploading' | 'success' | 'error' | 'skipped';
|
||||
|
||||
export interface UploadFile {
|
||||
id: string;
|
||||
name: string;
|
||||
relativePath: string;
|
||||
targetPath: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
loadedBytes: number;
|
||||
status: UploadStatus;
|
||||
progress: number;
|
||||
error?: string;
|
||||
permanentLink?: string;
|
||||
file?: File;
|
||||
}
|
||||
|
||||
export type ConflictDecision = 'overwrite' | 'skip' | 'overwriteAll' | 'skipAll';
|
||||
|
||||
export interface UploadConflict {
|
||||
taskId: string;
|
||||
relativePath: string;
|
||||
targetPath: string;
|
||||
type: 'file' | 'directory';
|
||||
}
|
||||
|
||||
interface RawUploadFile {
|
||||
kind: 'file';
|
||||
relativePath: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
interface RawUploadDirectory {
|
||||
kind: 'directory';
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
type RawUploadItem = RawUploadFile | RawUploadDirectory;
|
||||
|
||||
const generateId = (() => {
|
||||
const cryptoApi = typeof crypto !== 'undefined' ? crypto : undefined;
|
||||
return () => {
|
||||
if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
|
||||
return `upload-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`;
|
||||
};
|
||||
})();
|
||||
|
||||
const normalizeRelativePath = (path: string) => path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
|
||||
|
||||
const joinWithBasePath = (base: string, relative: string) => {
|
||||
const cleanedBase = base === '/' ? '' : base.replace(/\/+$/, '');
|
||||
const cleanedRelative = normalizeRelativePath(relative);
|
||||
const parts = [cleanedBase, cleanedRelative].filter(Boolean);
|
||||
const joined = parts.join('/');
|
||||
return joined.startsWith('/') ? joined : `/${joined}`;
|
||||
};
|
||||
|
||||
const collectParentDirectories = (relativePath: string) => {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized) return [];
|
||||
const segments = normalized.split('/').slice(0, -1);
|
||||
const dirs: string[] = [];
|
||||
for (let i = 1; i <= segments.length; i += 1) {
|
||||
const dir = segments.slice(0, i).join('/');
|
||||
if (dir) dirs.push(dir);
|
||||
}
|
||||
return dirs;
|
||||
};
|
||||
|
||||
const collectAllDirectories = (items: RawUploadItem[]) => {
|
||||
const directories = new Set<string>();
|
||||
items.forEach((item) => {
|
||||
if (item.kind === 'directory') {
|
||||
const normalized = normalizeRelativePath(item.relativePath);
|
||||
if (normalized) directories.add(normalized);
|
||||
} else {
|
||||
collectParentDirectories(item.relativePath).forEach((dir) => directories.add(dir));
|
||||
}
|
||||
});
|
||||
return Array.from(directories).sort((a, b) => a.localeCompare(b));
|
||||
};
|
||||
|
||||
interface WebkitFileSystemFileEntry {
|
||||
isFile: true;
|
||||
isDirectory: false;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file: (
|
||||
successCallback: (file: File) => void,
|
||||
errorCallback?: (err: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface WebkitFileSystemDirectoryReader {
|
||||
readEntries: (
|
||||
successCallback: (entries: WebkitFileSystemEntry[]) => void,
|
||||
errorCallback?: (err: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface WebkitFileSystemDirectoryEntry {
|
||||
isFile: false;
|
||||
isDirectory: true;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
createReader: () => WebkitFileSystemDirectoryReader;
|
||||
}
|
||||
|
||||
type WebkitFileSystemEntry = WebkitFileSystemFileEntry | WebkitFileSystemDirectoryEntry;
|
||||
|
||||
const safeStat = async (fullPath: string): Promise<{ is_dir?: boolean } | null> => {
|
||||
try {
|
||||
return await vfsApi.stat(fullPath) as { is_dir?: boolean };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readAllDirectoryEntries = (directoryEntry: WebkitFileSystemDirectoryEntry): Promise<WebkitFileSystemEntry[]> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = directoryEntry.createReader();
|
||||
const entries: WebkitFileSystemEntry[] = [];
|
||||
const readBatch = () => {
|
||||
reader.readEntries(
|
||||
(batch: WebkitFileSystemEntry[]) => {
|
||||
if (batch.length === 0) {
|
||||
resolve(entries);
|
||||
} else {
|
||||
entries.push(...batch);
|
||||
readBatch();
|
||||
}
|
||||
},
|
||||
(err: DOMException) => reject(err),
|
||||
);
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
|
||||
const traverseEntry = async (
|
||||
entry: WebkitFileSystemEntry,
|
||||
parentPath: string,
|
||||
bucket: RawUploadItem[],
|
||||
) => {
|
||||
if (!entry) return;
|
||||
const currentPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
|
||||
if (entry.isFile) {
|
||||
const file: File = await new Promise((resolve, reject) => {
|
||||
entry.file(
|
||||
(f: File) => resolve(f),
|
||||
(err: DOMException) => reject(err),
|
||||
);
|
||||
});
|
||||
bucket.push({
|
||||
kind: 'file',
|
||||
relativePath: currentPath,
|
||||
file,
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
bucket.push({
|
||||
kind: 'directory',
|
||||
relativePath: currentPath,
|
||||
});
|
||||
const entries = await readAllDirectoryEntries(entry);
|
||||
for (const child of entries) {
|
||||
await traverseEntry(child, currentPath, bucket);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const collectFromFileList = async (list: FileList): Promise<RawUploadItem[]> => {
|
||||
const items: RawUploadItem[] = [];
|
||||
for (const file of Array.from(list)) {
|
||||
const fileWithPath = file as File & { webkitRelativePath?: string };
|
||||
const relativePath = fileWithPath.webkitRelativePath || file.name;
|
||||
items.push({
|
||||
kind: 'file',
|
||||
relativePath,
|
||||
file,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const collectFromDataTransfer = async (dataTransfer: DataTransfer): Promise<RawUploadItem[]> => {
|
||||
const items: RawUploadItem[] = [];
|
||||
if (dataTransfer.items && dataTransfer.items.length > 0) {
|
||||
for (const item of Array.from(dataTransfer.items)) {
|
||||
const itemWithEntry = item as DataTransferItem & {
|
||||
webkitGetAsEntry?: () => FileSystemEntry | null;
|
||||
};
|
||||
const entry = itemWithEntry.webkitGetAsEntry ? (itemWithEntry.webkitGetAsEntry() as unknown as WebkitFileSystemEntry) : null;
|
||||
if (entry) {
|
||||
await traverseEntry(entry, '', items);
|
||||
} else if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
items.push({
|
||||
kind: 'file',
|
||||
relativePath: file.name,
|
||||
file,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (dataTransfer.files && dataTransfer.files.length > 0) {
|
||||
return collectFromFileList(dataTransfer.files);
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const createUploadTasks = (basePath: string, items: RawUploadItem[]): UploadFile[] => {
|
||||
const idGenerator = generateId;
|
||||
const directories = collectAllDirectories(items);
|
||||
const directoryTasks: UploadFile[] = directories.map((relativePath) => {
|
||||
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||
const segments = normalizeRelativePath(relativePath).split('/');
|
||||
const name = segments[segments.length - 1] || targetPath;
|
||||
return {
|
||||
id: idGenerator(),
|
||||
name,
|
||||
relativePath,
|
||||
targetPath,
|
||||
type: 'directory',
|
||||
size: 0,
|
||||
loadedBytes: 0,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const fileTasks: UploadFile[] = items
|
||||
.filter((item): item is RawUploadFile => item.kind === 'file')
|
||||
.map((item) => {
|
||||
const relativePath = normalizeRelativePath(item.relativePath) || item.file.name;
|
||||
const targetPath = joinWithBasePath(basePath, relativePath);
|
||||
return {
|
||||
id: idGenerator(),
|
||||
name: item.file.name,
|
||||
relativePath,
|
||||
targetPath,
|
||||
type: 'file',
|
||||
size: item.file.size,
|
||||
loadedBytes: 0,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
file: item.file,
|
||||
};
|
||||
});
|
||||
|
||||
return [...directoryTasks, ...fileTasks];
|
||||
};
|
||||
|
||||
export function useUploader(path: string, onUploadComplete: () => void) {
|
||||
const { t } = useI18n();
|
||||
const [files, setFiles] = useState<UploadFile[]>([]);
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [conflict, setConflict] = useState<UploadConflict | null>(null);
|
||||
const conflictResolverRef = useRef<((decision: ConflictDecision) => void) | null>(null);
|
||||
const overwriteAllRef = useRef(false);
|
||||
const skipAllRef = useRef(false);
|
||||
const createdDirsRef = useRef<Set<string>>(new Set());
|
||||
const filesRef = useRef<UploadFile[]>(files);
|
||||
const isUploadingRef = useRef(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const directoryInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = directoryInputRef.current;
|
||||
if (!node) return;
|
||||
node.setAttribute('webkitdirectory', '');
|
||||
node.setAttribute('directory', '');
|
||||
}, []);
|
||||
|
||||
const mutateFiles = useCallback((updater: (prev: UploadFile[]) => UploadFile[]) => {
|
||||
setFiles((prev) => {
|
||||
const next = updater(prev);
|
||||
filesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const replaceFiles = useCallback((next: UploadFile[]) => {
|
||||
filesRef.current = next;
|
||||
setFiles(next);
|
||||
}, []);
|
||||
|
||||
const updateFile = useCallback((id: string, patch: Partial<UploadFile>) => {
|
||||
mutateFiles((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
|
||||
}, [mutateFiles]);
|
||||
|
||||
const resetOverwriteDecisions = useCallback(() => {
|
||||
overwriteAllRef.current = false;
|
||||
skipAllRef.current = false;
|
||||
}, []);
|
||||
|
||||
const openFilePicker = useCallback(() => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openDirectoryPicker = useCallback(() => {
|
||||
if (directoryInputRef.current) {
|
||||
directoryInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
if (isUploadingRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsModalVisible(false);
|
||||
replaceFiles([]);
|
||||
resetOverwriteDecisions();
|
||||
setConflict(null);
|
||||
conflictResolverRef.current = null;
|
||||
createdDirsRef.current = new Set();
|
||||
}, [replaceFiles, resetOverwriteDecisions]);
|
||||
|
||||
const prepareQueue = useCallback((items: RawUploadItem[]) => {
|
||||
if (!items.length) {
|
||||
message.info(t('No items selected for upload'));
|
||||
return;
|
||||
}
|
||||
const tasks = createUploadTasks(path, items);
|
||||
if (!tasks.length) {
|
||||
message.info(t('No uploadable files or directories found'));
|
||||
return;
|
||||
}
|
||||
replaceFiles(tasks);
|
||||
resetOverwriteDecisions();
|
||||
createdDirsRef.current = new Set();
|
||||
setIsModalVisible(true);
|
||||
}, [path, replaceFiles, resetOverwriteDecisions, t]);
|
||||
|
||||
const handleInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>, ref: RefObject<HTMLInputElement | null>) => {
|
||||
const selectedFiles = event.target.files;
|
||||
if (!selectedFiles || selectedFiles.length === 0) {
|
||||
return;
|
||||
}
|
||||
const items = await collectFromFileList(selectedFiles);
|
||||
prepareQueue(items);
|
||||
if (ref.current) {
|
||||
ref.current.value = '';
|
||||
}
|
||||
}, [prepareQueue]);
|
||||
|
||||
const handleFileInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
await handleInputChange(event, fileInputRef);
|
||||
}, [handleInputChange]);
|
||||
|
||||
const handleDirectoryInputChange = useCallback(async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
await handleInputChange(event, directoryInputRef);
|
||||
}, [handleInputChange]);
|
||||
|
||||
const handleFileDrop = useCallback(async (data: DataTransfer) => {
|
||||
const items = await collectFromDataTransfer(data);
|
||||
prepareQueue(items);
|
||||
}, [prepareQueue]);
|
||||
|
||||
const awaitConflictDecision = useCallback(async (task: UploadFile): Promise<'overwrite' | 'skip'> => {
|
||||
if (overwriteAllRef.current) {
|
||||
return 'overwrite';
|
||||
}
|
||||
if (skipAllRef.current) {
|
||||
return 'skip';
|
||||
}
|
||||
return new Promise<'overwrite' | 'skip'>((resolve) => {
|
||||
updateFile(task.id, { status: 'waiting' });
|
||||
setConflict({
|
||||
taskId: task.id,
|
||||
relativePath: task.relativePath,
|
||||
targetPath: task.targetPath,
|
||||
type: task.type,
|
||||
});
|
||||
conflictResolverRef.current = (decision: ConflictDecision) => {
|
||||
if (decision === 'overwriteAll') {
|
||||
overwriteAllRef.current = true;
|
||||
resolve('overwrite');
|
||||
} else if (decision === 'skipAll') {
|
||||
skipAllRef.current = true;
|
||||
resolve('skip');
|
||||
} else if (decision === 'overwrite') {
|
||||
resolve('overwrite');
|
||||
} else {
|
||||
resolve('skip');
|
||||
}
|
||||
};
|
||||
});
|
||||
}, [updateFile]);
|
||||
|
||||
const confirmConflict = useCallback((decision: ConflictDecision) => {
|
||||
if (!conflictResolverRef.current) {
|
||||
return;
|
||||
}
|
||||
const resolver = conflictResolverRef.current;
|
||||
conflictResolverRef.current = null;
|
||||
setConflict(null);
|
||||
resolver(decision);
|
||||
}, []);
|
||||
|
||||
const ensureDirectory = useCallback(async (fullPath: string) => {
|
||||
const normalized = fullPath.replace(/\/+/g, '/');
|
||||
if (!normalized || normalized === '/') {
|
||||
return;
|
||||
}
|
||||
if (createdDirsRef.current.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vfsApi.mkdir(normalized);
|
||||
} catch (err: unknown) {
|
||||
const messageText = err instanceof Error ? err.message : String(err);
|
||||
if (!/exist/i.test(messageText)) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
createdDirsRef.current.add(normalized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const ensureDirectoryTree = useCallback(async (targetDir: string) => {
|
||||
if (!targetDir || targetDir === '/') return;
|
||||
const normalized = targetDir.replace(/\/+/g, '/');
|
||||
const segments = normalized.replace(/^\/+/, '').split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
current = `${current}/${segment}`;
|
||||
await ensureDirectory(current.startsWith('/') ? current : `/${current}`);
|
||||
}
|
||||
}, [ensureDirectory]);
|
||||
|
||||
const processDirectoryTask = useCallback(async (task: UploadFile) => {
|
||||
updateFile(task.id, { status: 'uploading', progress: 10 });
|
||||
const stat = await safeStat(task.targetPath);
|
||||
if (stat && !stat.is_dir) {
|
||||
const error = t('Directory conflicts with existing file');
|
||||
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||
message.error(`${task.relativePath}: ${error}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ensureDirectory(task.targetPath);
|
||||
updateFile(task.id, { status: 'success', progress: 100 });
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : t('Create directory failed');
|
||||
updateFile(task.id, { status: 'error', progress: 0, error });
|
||||
message.error(`${task.relativePath}: ${error}`);
|
||||
}
|
||||
}, [ensureDirectory, updateFile, t]);
|
||||
|
||||
const processFileTask = useCallback(async (task: UploadFile) => {
|
||||
if (!task.file) {
|
||||
updateFile(task.id, { status: 'error', error: t('Missing file content') });
|
||||
return;
|
||||
}
|
||||
|
||||
if (skipAllRef.current) {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldOverwrite = overwriteAllRef.current;
|
||||
if (!shouldOverwrite) {
|
||||
const stat = await safeStat(task.targetPath);
|
||||
if (stat) {
|
||||
const decision = await awaitConflictDecision(task);
|
||||
if (decision === 'skip') {
|
||||
updateFile(task.id, { status: 'skipped', progress: 0 });
|
||||
return;
|
||||
}
|
||||
shouldOverwrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
setConflict(null);
|
||||
updateFile(task.id, { status: 'uploading', progress: 0, loadedBytes: 0 });
|
||||
|
||||
const parentDir = task.targetPath.replace(/\/[^/]+$/, '') || '/';
|
||||
try {
|
||||
await ensureDirectoryTree(parentDir);
|
||||
const uploadResult = await vfsApi.uploadStream(task.targetPath, task.file, shouldOverwrite, (loaded, total) => {
|
||||
mutateFiles((prev) => prev.map((f) => {
|
||||
if (f.id !== task.id) return f;
|
||||
const effectiveTotal = total > 0 ? total : f.size;
|
||||
const size = Math.max(f.size, effectiveTotal, loaded);
|
||||
const percent = size > 0 ? Math.min(100, Math.round((loaded / size) * 100)) : 0;
|
||||
return {
|
||||
...f,
|
||||
size,
|
||||
loadedBytes: loaded,
|
||||
progress: percent,
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
const actualPath = uploadResult?.path || task.targetPath;
|
||||
const finalSize = typeof uploadResult?.size === 'number' && uploadResult.size > 0
|
||||
? uploadResult.size
|
||||
: task.size;
|
||||
const link = await vfsApi.getTempLinkToken(actualPath, 60 * 60 * 24 * 365 * 10);
|
||||
const permanentLink = vfsApi.getTempPublicUrl(link.token);
|
||||
updateFile(task.id, {
|
||||
status: 'success',
|
||||
progress: 100,
|
||||
loadedBytes: finalSize,
|
||||
size: finalSize,
|
||||
targetPath: actualPath,
|
||||
permanentLink,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : t('Upload failed');
|
||||
updateFile(task.id, { status: 'error', error, progress: 0 });
|
||||
message.error(`${task.relativePath}: ${error}`);
|
||||
}
|
||||
}, [ensureDirectoryTree, awaitConflictDecision, mutateFiles, updateFile, t]);
|
||||
|
||||
const startUpload = useCallback(async () => {
|
||||
if (isUploadingRef.current) return;
|
||||
if (!filesRef.current.length) return;
|
||||
|
||||
isUploadingRef.current = true;
|
||||
setIsUploading(true);
|
||||
try {
|
||||
for (const task of filesRef.current) {
|
||||
if (task.status !== 'pending' && task.status !== 'waiting') {
|
||||
continue;
|
||||
}
|
||||
if (task.type === 'directory') {
|
||||
await processDirectoryTask(task);
|
||||
} else {
|
||||
await processFileTask(task);
|
||||
}
|
||||
}
|
||||
onUploadComplete();
|
||||
} finally {
|
||||
isUploadingRef.current = false;
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [onUploadComplete, processDirectoryTask, processFileTask]);
|
||||
|
||||
const totalFileBytes = useMemo(
|
||||
() => files.reduce((acc, f) => acc + (f.type === 'file' ? f.size : 0), 0),
|
||||
[files],
|
||||
);
|
||||
|
||||
const uploadedFileBytes = useMemo(
|
||||
() => files.reduce((acc, f) => {
|
||||
if (f.type !== 'file') return acc;
|
||||
const loaded = Math.min(f.loadedBytes, f.size);
|
||||
if (f.status === 'success') {
|
||||
return acc + (f.size || loaded);
|
||||
}
|
||||
if (f.status === 'uploading' || f.status === 'waiting') {
|
||||
return acc + loaded;
|
||||
}
|
||||
return acc;
|
||||
}, 0),
|
||||
[files],
|
||||
);
|
||||
|
||||
const directoryCounts = useMemo(() => {
|
||||
const directories = files.filter((f) => f.type === 'directory');
|
||||
const completed = directories.filter((f) => f.status === 'success').length;
|
||||
return {
|
||||
total: directories.length,
|
||||
completed,
|
||||
};
|
||||
}, [files]);
|
||||
|
||||
const totalWeight = totalFileBytes + directoryCounts.total;
|
||||
const totalProgress = totalWeight === 0
|
||||
? 0
|
||||
: ((uploadedFileBytes + directoryCounts.completed) / totalWeight) * 100;
|
||||
|
||||
return {
|
||||
files,
|
||||
isModalVisible,
|
||||
isUploading,
|
||||
totalProgress: Math.min(100, Math.max(0, totalProgress)),
|
||||
totalFileBytes,
|
||||
uploadedFileBytes,
|
||||
conflict,
|
||||
confirmConflict,
|
||||
resetOverwriteDecisions,
|
||||
fileInputRef,
|
||||
directoryInputRef,
|
||||
openFilePicker,
|
||||
openDirectoryPicker,
|
||||
closeModal,
|
||||
handleFileInputChange,
|
||||
handleDirectoryInputChange,
|
||||
handleFileDrop,
|
||||
startUpload,
|
||||
};
|
||||
}
|
||||
16
web/src/pages/FileExplorerPage/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { VfsEntry } from '../../api/client';
|
||||
import type { AppDescriptor } from '../../apps/registry';
|
||||
|
||||
export type ViewMode = 'list' | 'grid';
|
||||
|
||||
export interface AppWindow {
|
||||
id: string;
|
||||
app: AppDescriptor;
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
maximized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
104
web/src/pages/ForgotPasswordPage.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, message } from 'antd';
|
||||
import { MailOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { authApi } from '../api/auth';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useI18n();
|
||||
const navigate = useNavigate();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
const handleSubmit = async (values: { email: string }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await authApi.requestPasswordReset({ email: values.email });
|
||||
message.success(t('If the email exists, a reset link has been sent.'));
|
||||
setSent(true);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || t('Request failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px 16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 460,
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(15,23,42,0.12)',
|
||||
border: '1px solid rgba(99,102,241,0.12)',
|
||||
}}
|
||||
styles={{ body: { padding: '40px 36px' } }}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<div style={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: '50%',
|
||||
margin: '0 auto 16px',
|
||||
background: 'linear-gradient(135deg,#6366f1,#8b5cf6)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#fff',
|
||||
fontSize: 28,
|
||||
}}>
|
||||
<MailOutlined />
|
||||
</div>
|
||||
<Title level={3} style={{ marginBottom: 8 }}>{t('Reset Your Password')}</Title>
|
||||
<Text type="secondary">
|
||||
{t('Enter the email linked to your account and we will send a reset link.')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" size="large" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={t('Email')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input recipient email') },
|
||||
{ type: 'email', message: t('Please input a valid email!') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="me@example.com" autoComplete="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginTop: 32 }}>
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block>
|
||||
{sent ? t('Resend Link') : t('Send Reset Link')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Button
|
||||
type="link"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={() => navigate('/login')}
|
||||
style={{ padding: 0 }}
|
||||
>
|
||||
{t('Back to login')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, Form, Input, Button, Typography, Space, Alert } from 'antd';
|
||||
import { UserOutlined, LockOutlined, GithubOutlined, SendOutlined, WechatOutlined, CloudSyncOutlined, SearchOutlined, ShareAltOutlined, ApartmentOutlined } from '@ant-design/icons';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSystemStatus } from '../contexts/SystemContext';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useI18n } from '../i18n';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
import WeChatModal from '../components/WeChatModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function LoginPage() {
|
||||
const status = useSystemStatus();
|
||||
const { login } = useAuth();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wechatModalOpen, setWechatModalOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const u = username.trim();
|
||||
const p = password;
|
||||
if (!u || !p) {
|
||||
setErr(t('Please enter username and password'));
|
||||
return;
|
||||
}
|
||||
console.debug('[LoginPage] submit ->', { username: u, passwordLength: p.length });
|
||||
setErr('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(u, p);
|
||||
navigate('/');
|
||||
} catch (e: any) {
|
||||
console.error('[LoginPage] login failed:', e);
|
||||
setErr(e.message || t('Login failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(to right, var(--ant-color-bg-layout, #f0f2f5), var(--ant-color-fill-secondary, #d7d7d7))'
|
||||
}}>
|
||||
<div style={{ position: 'fixed', top: 12, right: 12, zIndex: 1000 }}>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
height: '70%',
|
||||
maxHeight: '700px',
|
||||
backgroundColor: 'var(--ant-color-bg-container, #fff)',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: 'blur(5px)',
|
||||
border: '1px solid var(--ant-color-border-secondary, #e5e5e5)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px'
|
||||
}}>
|
||||
<div style={{ width: 360 }}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginBottom: 8 }}>
|
||||
<img src={status?.logo} alt="Foxel Logo" style={{ width: 32, marginRight: 16 }} />
|
||||
<Title level={2} style={{ margin: 0, color: 'var(--ant-color-text, #111)' }}>{t('Welcome Back')}</Title>
|
||||
</div>
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>{t('Sign in to your Foxel account')}</Text>
|
||||
</div>
|
||||
|
||||
{err && <Alert message={err} type="error" showIcon style={{ marginBottom: 24 }} />}
|
||||
|
||||
<Form onFinish={handleSubmit} layout="vertical" size="large">
|
||||
<Form.Item>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('Username / Email')}
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('Password')}
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 8, textAlign: 'right' }}>
|
||||
<Button type="link" onClick={() => navigate('/forgot-password')} style={{ padding: 0 }}>
|
||||
{t('Forgot Password?')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{t('Sign In')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0, textAlign: 'center' }}>
|
||||
<Button type="link" onClick={() => navigate('/register')} style={{ padding: 0 }}>
|
||||
{t('Sign Up')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '50%',
|
||||
backgroundColor: 'var(--ant-color-fill-tertiary, #f0f2f5)',
|
||||
backgroundImage: `radial-gradient(var(--ant-color-fill-secondary, #d7d7d7) 1px, transparent 1px)`,
|
||||
backgroundSize: '16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px'
|
||||
}}>
|
||||
<div style={{ maxWidth: '500px' }}>
|
||||
<Title level={3}>{t('Your next-generation file manager')}</Title>
|
||||
<Text type="secondary" style={{ fontSize: '16px', lineHeight: '1.8' }}>
|
||||
Foxel 旨在提供一个安全、高效且智能的文件管理解决方案,帮助您轻松组织、访问和共享您的数字资产。
|
||||
</Text>
|
||||
<div style={{ marginTop: '32px' }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<CloudSyncOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>{t('Cross-platform sync, access anywhere')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<SearchOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>{t('AI-powered search for quick find')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<ShareAltOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>{t('Flexible sharing and collaboration')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'var(--ant-color-bg-container)' }}>
|
||||
<Space>
|
||||
<ApartmentOutlined style={{ fontSize: '20px', color: 'var(--ant-color-primary, #1677ff)' }} />
|
||||
<Text>{t('Powerful automation to simplify tasks')}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ marginTop: '48px', textAlign: 'center' }}>
|
||||
<Text type="secondary">{t('Join our community:')}</Text>
|
||||
<Button type="text" icon={<GithubOutlined />} href="https://github.com/DrizzleTime/Foxel" target="_blank">GitHub</Button>
|
||||
<Button type="text" icon={<SendOutlined />} href="https://t.me/+thDsBfyqJxZkNTU1" target="_blank">Telegram</Button>
|
||||
<Button type="text" icon={<WechatOutlined />} onClick={() => setWechatModalOpen(true)}>微信</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<WeChatModal open={wechatModalOpen} onClose={() => setWechatModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
web/src/pages/OfflineDownloadPage.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Form, Input, Modal, message, Table, Tag, Typography, Space } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { FolderOpenOutlined } from '@ant-design/icons';
|
||||
|
||||
import PathSelectorModal from '../components/PathSelectorModal';
|
||||
import { offlineDownloadsApi, type OfflineDownloadTask } from '../api/client';
|
||||
import { useI18n } from '../i18n';
|
||||
import PageCard from '../components/PageCard';
|
||||
|
||||
interface TableRow extends OfflineDownloadTask {
|
||||
key: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes?: number | null): string {
|
||||
if (bytes === undefined || bytes === null) return '--';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const idx = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const val = bytes / Math.pow(1024, idx);
|
||||
return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`;
|
||||
}
|
||||
|
||||
function stageLabel(t: (key: string) => string, stage?: string | null): string {
|
||||
if (!stage) return '--';
|
||||
const map: Record<string, string> = {
|
||||
queued: t('Queued'),
|
||||
downloading: t('Downloading'),
|
||||
transferring: t('Transferring'),
|
||||
completed: t('Completed'),
|
||||
};
|
||||
return map[stage] ?? stage;
|
||||
}
|
||||
|
||||
function statusTag(status: OfflineDownloadTask['status'], t: (key: string) => string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <Tag color="green">{t('Success')}</Tag>;
|
||||
case 'failed':
|
||||
return <Tag color="red">{t('Failed')}</Tag>;
|
||||
case 'running':
|
||||
return <Tag color="blue">{t('Running')}</Tag>;
|
||||
default:
|
||||
return <Tag color="default">{t('Pending')}</Tag>;
|
||||
}
|
||||
}
|
||||
|
||||
const OfflineDownloadPage = memo(function OfflineDownloadPage() {
|
||||
const { t } = useI18n();
|
||||
const [form] = Form.useForm();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [tasks, setTasks] = useState<TableRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pathModalOpen, setPathModalOpen] = useState(false);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
|
||||
const loadTasks = useCallback(async (withSpinner = false) => {
|
||||
if (withSpinner) setLoading(true);
|
||||
try {
|
||||
const list = await offlineDownloadsApi.list();
|
||||
setTasks(list.map(item => ({ ...item, key: item.id })));
|
||||
} catch (err: any) {
|
||||
messageApi.error(err?.message || t('Load failed'));
|
||||
} finally {
|
||||
if (withSpinner) setLoading(false);
|
||||
}
|
||||
}, [messageApi, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks(true);
|
||||
const timer = window.setInterval(() => {
|
||||
loadTasks().catch(() => {});
|
||||
}, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTasks]);
|
||||
|
||||
const handleSelectFolder = useCallback((path: string) => {
|
||||
form.setFieldsValue({ dest_dir: path });
|
||||
setPathModalOpen(false);
|
||||
}, [form]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
const resp = await offlineDownloadsApi.create(values);
|
||||
messageApi.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||
form.resetFields();
|
||||
await loadTasks(true);
|
||||
setCreateModalOpen(false);
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) return;
|
||||
messageApi.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [form, loadTasks, messageApi, t]);
|
||||
|
||||
const columns: TableColumnsType<TableRow> = useMemo(() => [
|
||||
{
|
||||
title: t('Filename'),
|
||||
dataIndex: ['meta', 'filename'],
|
||||
render: (_: any, record) => record.meta?.filename || record.task_info?.filename || '--',
|
||||
},
|
||||
{
|
||||
title: t('Stage'),
|
||||
dataIndex: ['progress', 'stage'],
|
||||
render: (_: any, record) => stageLabel(t, record.progress?.stage),
|
||||
},
|
||||
{
|
||||
title: t('Progress'),
|
||||
dataIndex: ['progress', 'percent'],
|
||||
render: (_: any, record) => {
|
||||
const percent = record.progress?.percent;
|
||||
return percent !== undefined && percent !== null ? `${percent.toFixed(1)}%` : '--';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('Bytes'),
|
||||
render: (_: any, record) => {
|
||||
const done = record.progress?.bytes_done;
|
||||
const total = record.progress?.bytes_total;
|
||||
if (done === undefined && total === undefined) return '--';
|
||||
if (total) {
|
||||
return `${formatBytes(done)} / ${formatBytes(total)}`;
|
||||
}
|
||||
return formatBytes(done);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('Status'),
|
||||
dataIndex: 'status',
|
||||
render: (status: TableRow['status'], record) => (
|
||||
<Space>
|
||||
{statusTag(status, t)}
|
||||
{status === 'failed' && record.error ? <Typography.Text type="danger">{record.error}</Typography.Text> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('Save Path'),
|
||||
dataIndex: ['meta', 'final_path'],
|
||||
render: (value: string | undefined, record) => value || record.task_info?.dest_dir || '--',
|
||||
},
|
||||
], [t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<PageCard
|
||||
title={t('Offline Downloads')}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => { form.resetFields(); setCreateModalOpen(true); }}>
|
||||
{t('Create Offline Download')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={tasks}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('No offline download tasks') }}
|
||||
rowKey="id"
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
</PageCard>
|
||||
|
||||
<Modal
|
||||
title={t('Create Offline Download')}
|
||||
open={createModalOpen}
|
||||
onCancel={() => { setCreateModalOpen(false); form.resetFields(); }}
|
||||
onOk={handleSubmit}
|
||||
okText={t('Start Download')}
|
||||
okButtonProps={{ loading: submitting }}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="url" label={t('URL')} rules={[{ required: true, message: t('Please input URL') }]}>
|
||||
<Input placeholder="https://example.com/file" />
|
||||
</Form.Item>
|
||||
{(() => {
|
||||
const errors = form.getFieldError('dest_dir');
|
||||
const hasError = errors.length > 0;
|
||||
return (
|
||||
<Form.Item
|
||||
label={t('Destination Folder')}
|
||||
required
|
||||
validateStatus={hasError ? 'error' : undefined}
|
||||
help={hasError ? errors[0] : undefined}
|
||||
>
|
||||
<Input.Group compact style={{ display: 'flex' }}>
|
||||
<Form.Item
|
||||
name="dest_dir"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t('Select destination') }]}
|
||||
>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder={t('Select destination')}
|
||||
style={{ width: 'calc(100% - 120px)', cursor: 'pointer' }}
|
||||
onClick={() => setPathModalOpen(true)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<FolderOpenOutlined />}
|
||||
onClick={() => setPathModalOpen(true)}
|
||||
style={{ width: 120 }}
|
||||
>
|
||||
{t('Select Folder')}
|
||||
</Button>
|
||||
</Input.Group>
|
||||
</Form.Item>
|
||||
);
|
||||
})()}
|
||||
<Form.Item name="filename" label={t('Filename')} rules={[{ required: true, message: t('Please input filename') }]}>
|
||||
<Input placeholder="example.zip" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<PathSelectorModal
|
||||
open={pathModalOpen}
|
||||
mode="directory"
|
||||
initialPath={form.getFieldValue('dest_dir') || '/'}
|
||||
onOk={handleSelectFolder}
|
||||
onCancel={() => setPathModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default OfflineDownloadPage;
|
||||
981
web/src/pages/PluginsPage.tsx
Normal file
@@ -0,0 +1,981 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { Alert, Button, Card, Collapse, Empty, List, Modal, Popconfirm, Progress, Skeleton, Space, Tabs, Tag, Typography, Upload, message, theme } from 'antd';
|
||||
import { GithubOutlined, LinkOutlined, UploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { pluginsApi, type PluginItem } from '../api/plugins';
|
||||
import { getAppByKey, reloadPluginApps } from '../apps/registry';
|
||||
import { useI18n } from '../i18n';
|
||||
import { useAppWindows } from '../contexts/AppWindowsContext';
|
||||
import { getPluginAssetUrl } from '../plugins/runtime';
|
||||
import { fetchFoxelCoreAppDetail, fetchFoxelCoreApps, downloadFoxelCoreApp, type FoxelCoreApp, type FoxelCoreAppDetail } from '../api/pluginCenter';
|
||||
import type { UploadFile } from 'antd';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
type InstallStatus = 'pending' | 'installing' | 'success' | 'failed' | 'skipped';
|
||||
type InstallState = Partial<{
|
||||
status: InstallStatus;
|
||||
message: string;
|
||||
errors: string[];
|
||||
}>;
|
||||
|
||||
type CenterCardProps = {
|
||||
iconUrl: string;
|
||||
iconAlt: string;
|
||||
name: ReactNode;
|
||||
version?: ReactNode;
|
||||
description?: ReactNode;
|
||||
metaLeft?: ReactNode;
|
||||
metaRight?: ReactNode;
|
||||
pills?: string[];
|
||||
footerLeft?: ReactNode;
|
||||
footerRight?: ReactNode;
|
||||
onIconClick?: () => void;
|
||||
onTitleClick?: () => void;
|
||||
};
|
||||
|
||||
function buildCardPills(items: Array<string | null | undefined> | null | undefined, fallback?: string) {
|
||||
const cleaned = (items || []).map(v => (v || '').trim()).filter(Boolean);
|
||||
if (cleaned.length === 0) return fallback ? [fallback] : [];
|
||||
if (cleaned.length <= 2) return cleaned;
|
||||
return [cleaned[0], `+${cleaned.length - 1}`];
|
||||
}
|
||||
|
||||
const CenterCard = memo(function CenterCard(props: CenterCardProps) {
|
||||
const {
|
||||
iconUrl,
|
||||
iconAlt,
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
metaLeft,
|
||||
metaRight,
|
||||
pills,
|
||||
footerLeft,
|
||||
footerRight,
|
||||
onIconClick,
|
||||
onTitleClick,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="fx-center-card">
|
||||
<div className="fx-center-card-hero">
|
||||
<div
|
||||
className="fx-center-card-iconbox"
|
||||
style={onIconClick ? { cursor: 'pointer' } : undefined}
|
||||
role={onIconClick ? 'button' : undefined}
|
||||
tabIndex={onIconClick ? 0 : undefined}
|
||||
onClick={onIconClick}
|
||||
onKeyDown={onIconClick ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onIconClick();
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
<img
|
||||
className="fx-center-card-icon"
|
||||
src={iconUrl}
|
||||
alt={iconAlt}
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/logo.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
{pills && pills.length > 0 ? (
|
||||
<div className="fx-center-card-pills">
|
||||
{pills.slice(0, 2).map(pill => (
|
||||
<span key={pill} className="fx-center-pill">
|
||||
{pill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="fx-center-card-body">
|
||||
<div className="fx-center-card-titleRow">
|
||||
<div
|
||||
className="fx-center-card-title"
|
||||
title={typeof name === 'string' ? name : undefined}
|
||||
style={onTitleClick ? { cursor: 'pointer' } : undefined}
|
||||
role={onTitleClick ? 'button' : undefined}
|
||||
tabIndex={onTitleClick ? 0 : undefined}
|
||||
onClick={onTitleClick}
|
||||
onKeyDown={onTitleClick ? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTitleClick();
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{version ? <div className="fx-center-card-version">{version}</div> : null}
|
||||
</div>
|
||||
|
||||
{metaLeft || metaRight ? (
|
||||
<div className="fx-center-card-metaRow">
|
||||
<span className="fx-center-card-metaLeft">{metaLeft}</span>
|
||||
<span className="fx-center-card-metaRight">{metaRight}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{description ? <div className="fx-center-card-desc">{description}</div> : null}
|
||||
</div>
|
||||
|
||||
{footerLeft || footerRight ? (
|
||||
<div className="fx-center-card-footer">
|
||||
<div className="fx-center-card-footerLeft">{footerLeft}</div>
|
||||
<div className="fx-center-card-footerRight">{footerRight}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const PluginsPage = memo(function PluginsPage() {
|
||||
const [data, setData] = useState<PluginItem[]>([]);
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [q, setQ] = useState('');
|
||||
const [tab, setTab] = useState<'installed' | 'discover'>('installed');
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [installModalOpen, setInstallModalOpen] = useState(false);
|
||||
const [installDone, setInstallDone] = useState(false);
|
||||
const [installStopReason, setInstallStopReason] = useState<string | undefined>(undefined);
|
||||
const [installFileState, setInstallFileState] = useState<Record<string, InstallState>>({});
|
||||
|
||||
// 应用发现相关状态
|
||||
const [discoveryApps, setDiscoveryApps] = useState<FoxelCoreApp[]>([]);
|
||||
const [discoveryLoading, setDiscoveryLoading] = useState(false);
|
||||
const [discoveryError, setDiscoveryError] = useState<string | undefined>(undefined);
|
||||
const [discoverySearch, setDiscoverySearch] = useState('');
|
||||
const [downloadingApps, setDownloadingApps] = useState<Set<string>>(new Set());
|
||||
|
||||
// 应用详情弹窗状态
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailKey, setDetailKey] = useState<string | null>(null);
|
||||
const [detailSummary, setDetailSummary] = useState<FoxelCoreApp | null>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailError, setDetailError] = useState<string | undefined>(undefined);
|
||||
const [detailApp, setDetailApp] = useState<FoxelCoreAppDetail | null>(null);
|
||||
const [detailReloadId, setDetailReloadId] = useState(0);
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const { t, lang } = useI18n();
|
||||
const { openApp } = useAppWindows();
|
||||
const location = useLocation();
|
||||
const discoverySearchInitializedRef = useRef(false);
|
||||
const discoverySearchTimerRef = useRef<number | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try { setLoading(true); setData(await pluginsApi.list()); } finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
|
||||
// 加载应用发现列表(带搜索)
|
||||
const loadDiscoveryApps = useCallback(async (query: string) => {
|
||||
try {
|
||||
setDiscoveryLoading(true);
|
||||
setDiscoveryError(undefined);
|
||||
const apps = await fetchFoxelCoreApps(query);
|
||||
setDiscoveryApps(apps);
|
||||
} catch (e: any) {
|
||||
setDiscoveryError(e?.message || t('Failed to load apps'));
|
||||
} finally {
|
||||
setDiscoveryLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const tabParam = params.get('tab');
|
||||
const queryParam = (params.get('query') ?? params.get('q') ?? '').trim();
|
||||
|
||||
if (tabParam === 'installed' || tabParam === 'discover') {
|
||||
setTab(tabParam);
|
||||
} else if (queryParam) {
|
||||
setTab('discover');
|
||||
}
|
||||
|
||||
setDiscoverySearch(queryParam);
|
||||
}, [location.search]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== 'discover') {
|
||||
discoverySearchInitializedRef.current = false;
|
||||
if (discoverySearchTimerRef.current !== null) {
|
||||
window.clearTimeout(discoverySearchTimerRef.current);
|
||||
discoverySearchTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = discoverySearchInitializedRef.current ? 500 : 0;
|
||||
if (discoverySearchTimerRef.current !== null) {
|
||||
window.clearTimeout(discoverySearchTimerRef.current);
|
||||
}
|
||||
|
||||
discoverySearchTimerRef.current = window.setTimeout(() => {
|
||||
discoverySearchInitializedRef.current = true;
|
||||
void loadDiscoveryApps(discoverySearch);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (discoverySearchTimerRef.current !== null) {
|
||||
window.clearTimeout(discoverySearchTimerRef.current);
|
||||
discoverySearchTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [discoverySearch, loadDiscoveryApps, tab]);
|
||||
|
||||
const closeDetailModal = () => {
|
||||
setDetailOpen(false);
|
||||
setDetailKey(null);
|
||||
setDetailSummary(null);
|
||||
setDetailLoading(false);
|
||||
setDetailError(undefined);
|
||||
setDetailApp(null);
|
||||
};
|
||||
|
||||
const openDiscoveryAppDetail = useCallback((app: FoxelCoreApp) => {
|
||||
setDetailOpen(true);
|
||||
setDetailKey(app.key);
|
||||
setDetailSummary(app);
|
||||
setDetailError(undefined);
|
||||
setDetailApp(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailOpen || !detailKey) return;
|
||||
|
||||
let cancelled = false;
|
||||
const currentKey = detailKey;
|
||||
|
||||
const run = async () => {
|
||||
setDetailLoading(true);
|
||||
setDetailError(undefined);
|
||||
try {
|
||||
const app = await fetchFoxelCoreAppDetail(currentKey);
|
||||
if (cancelled) return;
|
||||
setDetailApp(app);
|
||||
} catch (e: any) {
|
||||
if (cancelled) return;
|
||||
setDetailError(e?.message || t('Load failed'));
|
||||
} finally {
|
||||
if (!cancelled) setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [detailKey, detailOpen, detailReloadId, t]);
|
||||
|
||||
const resetInstallUi = () => {
|
||||
setInstallDone(false);
|
||||
setInstallStopReason(undefined);
|
||||
setInstallFileState({});
|
||||
};
|
||||
|
||||
const closeInstallModal = () => {
|
||||
if (installing) return;
|
||||
setInstallModalOpen(false);
|
||||
setFileList([]);
|
||||
resetInstallUi();
|
||||
};
|
||||
|
||||
const removeSelectedFile = (uid: string) => {
|
||||
if (installing || installDone) return;
|
||||
setFileList(prev => {
|
||||
const next = prev.filter(f => f.uid !== uid);
|
||||
if (next.length === 0) {
|
||||
setInstallModalOpen(false);
|
||||
resetInstallUi();
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setInstallFileState(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[uid];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (fileList.length === 0) {
|
||||
message.warning(t('Please select a .foxpkg file'));
|
||||
return;
|
||||
}
|
||||
|
||||
const setState = (uid: string, patch: InstallState) => {
|
||||
setInstallFileState(prev => ({
|
||||
...prev,
|
||||
[uid]: { ...(prev[uid] || {}), ...patch },
|
||||
}));
|
||||
};
|
||||
|
||||
try {
|
||||
setInstalling(true);
|
||||
setInstallDone(false);
|
||||
setInstallStopReason(undefined);
|
||||
|
||||
let stopReason: string | undefined;
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i];
|
||||
const uid = file.uid;
|
||||
setState(uid, { status: 'installing' });
|
||||
|
||||
if (!file.originFileObj) {
|
||||
stopReason = t('Invalid file');
|
||||
setState(uid, { status: 'failed', message: stopReason });
|
||||
for (let j = i + 1; j < fileList.length; j++) {
|
||||
setState(fileList[j].uid, { status: 'skipped' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pluginsApi.install(file.originFileObj);
|
||||
if (result.success) {
|
||||
setState(uid, {
|
||||
status: 'success',
|
||||
message: result.message || t('Installed successfully'),
|
||||
errors: result.errors,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
stopReason = result.message || t('Installation failed');
|
||||
setState(uid, {
|
||||
status: 'failed',
|
||||
message: stopReason,
|
||||
errors: result.errors,
|
||||
});
|
||||
for (let j = i + 1; j < fileList.length; j++) {
|
||||
setState(fileList[j].uid, { status: 'skipped' });
|
||||
}
|
||||
break;
|
||||
} catch (e: any) {
|
||||
stopReason = e?.message || t('Installation failed');
|
||||
setState(uid, { status: 'failed', message: stopReason });
|
||||
for (let j = i + 1; j < fileList.length; j++) {
|
||||
setState(fileList[j].uid, { status: 'skipped' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await reload();
|
||||
await reloadPluginApps();
|
||||
|
||||
setInstallStopReason(stopReason);
|
||||
setInstallDone(true);
|
||||
if (stopReason) {
|
||||
message.error(stopReason);
|
||||
} else {
|
||||
message.success(t('Installed successfully'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || t('Installation failed');
|
||||
setInstallStopReason(msg);
|
||||
setInstallDone(true);
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析插件图标 URL
|
||||
*/
|
||||
const resolvePluginIcon = (p: PluginItem): string => {
|
||||
if (!p.icon) return '/logo.svg';
|
||||
if (p.icon.startsWith('http://') || p.icon.startsWith('https://') || p.icon.startsWith('/')) {
|
||||
return p.icon;
|
||||
}
|
||||
return getPluginAssetUrl(p.key, p.icon);
|
||||
};
|
||||
|
||||
/**
|
||||
* 按当前语言解析插件文案(name/description)
|
||||
*/
|
||||
const resolvePluginTexts = useCallback((p: PluginItem): { name?: string; description?: string } => {
|
||||
const i18n = (p.manifest as any)?.i18n as any;
|
||||
const entry = i18n?.[lang] as any;
|
||||
return {
|
||||
name: entry?.name || p.name || undefined,
|
||||
description: entry?.description || p.description || undefined,
|
||||
};
|
||||
}, [lang]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = q.trim().toLowerCase();
|
||||
if (!s) return data;
|
||||
return data.filter(p => (
|
||||
(resolvePluginTexts(p).name || '').toLowerCase().includes(s)
|
||||
|| (p.author || '').toLowerCase().includes(s)
|
||||
|| (p.key || '').toLowerCase().includes(s)
|
||||
|| (resolvePluginTexts(p).description || '').toLowerCase().includes(s)
|
||||
|| (p.supported_exts || []).some(e => e.toLowerCase().includes(s))
|
||||
));
|
||||
}, [data, q, resolvePluginTexts]);
|
||||
|
||||
// 检查应用是否已安装
|
||||
const isAppInstalled = (appKey: string) => {
|
||||
return data.some(p => p.key === appKey);
|
||||
};
|
||||
|
||||
// 下载并安装应用
|
||||
const handleDownloadAndInstall = async (app: Pick<FoxelCoreApp, 'key' | 'version' | 'downloadUrl'>) => {
|
||||
if (downloadingApps.has(app.key)) return;
|
||||
|
||||
try {
|
||||
setDownloadingApps(prev => new Set(prev).add(app.key));
|
||||
message.loading({ content: t('Downloading'), key: app.key, duration: 0 });
|
||||
|
||||
const file = await downloadFoxelCoreApp(app);
|
||||
message.destroy(app.key);
|
||||
message.loading({ content: t('Installing'), key: app.key, duration: 0 });
|
||||
|
||||
const result = await pluginsApi.install(file);
|
||||
message.destroy(app.key);
|
||||
|
||||
if (result.success) {
|
||||
message.success(t('Installed successfully'));
|
||||
await reload();
|
||||
await reloadPluginApps();
|
||||
} else {
|
||||
message.error(result.message || t('Installation failed'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.destroy(app.key);
|
||||
message.error(e?.message || t('Installation failed'));
|
||||
} finally {
|
||||
setDownloadingApps(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(app.key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderCard = (p: PluginItem) => {
|
||||
const texts = resolvePluginTexts(p);
|
||||
const icon = resolvePluginIcon(p);
|
||||
const name = texts.name || `${t('Plugin')} ${p.key}`;
|
||||
const appKey = `plugin:${p.key}`;
|
||||
const app = getAppByKey(appKey);
|
||||
const canOpenApp = !!p.open_app;
|
||||
return (
|
||||
<CenterCard
|
||||
key={p.key}
|
||||
iconUrl={icon}
|
||||
iconAlt={name}
|
||||
name={name}
|
||||
version={p.version || undefined}
|
||||
description={texts.description || t('No description')}
|
||||
metaLeft={p.author || undefined}
|
||||
metaRight={p.key}
|
||||
pills={buildCardPills(p.supported_exts, t('Any'))}
|
||||
footerLeft={(p.github || p.website) ? (
|
||||
<div className="fx-center-card-links">
|
||||
{p.github ? (
|
||||
<a className="fx-center-card-iconLink" href={p.github || undefined} target="_blank" rel="noreferrer" title="GitHub" aria-label="GitHub">
|
||||
<GithubOutlined />
|
||||
</a>
|
||||
) : null}
|
||||
{p.website ? (
|
||||
<a className="fx-center-card-iconLink" href={p.website || undefined} target="_blank" rel="noreferrer" title={t('Website')} aria-label={t('Website')}>
|
||||
<LinkOutlined />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
footerRight={
|
||||
<div className="fx-center-card-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
disabled={!canOpenApp}
|
||||
onClick={async () => {
|
||||
let target = app || getAppByKey(appKey);
|
||||
if (!target) {
|
||||
await reloadPluginApps();
|
||||
target = getAppByKey(appKey);
|
||||
}
|
||||
if (target?.openAppComponent) openApp(target);
|
||||
}}
|
||||
>
|
||||
{t('Open App')}
|
||||
</Button>
|
||||
<Popconfirm title={t('Confirm delete this plugin?')} onConfirm={async () => { await pluginsApi.remove(p.key); await reload(); await reloadPluginApps(); }}>
|
||||
<Button type="text" danger size="small">{t('Uninstall')}</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: 'calc(100vh - 88px)', display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||
<Upload
|
||||
accept=".foxpkg"
|
||||
multiple
|
||||
disabled={installing}
|
||||
fileList={fileList}
|
||||
onChange={({ fileList: newFileList }) => {
|
||||
setFileList(newFileList);
|
||||
if (newFileList.length > 0) {
|
||||
setInstallModalOpen(true);
|
||||
resetInstallUi();
|
||||
}
|
||||
}}
|
||||
beforeUpload={() => false}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} type="primary">{t('Install Plugin')}</Button>
|
||||
</Upload>
|
||||
{tab === 'installed' && <Button onClick={reload} loading={loading} disabled={installing}>{t('Refresh')}</Button>}
|
||||
<div style={{ marginLeft: 'auto' }} />
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={(k) => setTab(k as any)}
|
||||
className="plugins-tabs"
|
||||
items={[
|
||||
{
|
||||
key: 'installed',
|
||||
label: t('Installed'),
|
||||
children: (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Search name/author/extension')}
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
style={{
|
||||
maxWidth: 360,
|
||||
padding: '4px 11px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
outline: 'none',
|
||||
background: token.colorBgContainer,
|
||||
color: token.colorText,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: 4 }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, justifyContent: 'start' }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} style={{ borderRadius: 10 }}>
|
||||
<Skeleton active avatar paragraph={{ rows: 3 }} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<Empty description={t('No plugins')} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, justifyContent: 'start' }}>
|
||||
{filtered.map(renderCard)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'discover',
|
||||
label: t('Discover'),
|
||||
children: (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Search apps')}
|
||||
value={discoverySearch}
|
||||
onChange={e => setDiscoverySearch(e.target.value)}
|
||||
style={{
|
||||
maxWidth: 360,
|
||||
padding: '4px 11px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
outline: 'none',
|
||||
background: token.colorBgContainer,
|
||||
color: token.colorText,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => { void loadDiscoveryApps(discoverySearch); }}
|
||||
loading={discoveryLoading}
|
||||
>
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: 4 }}>
|
||||
{discoveryLoading && discoveryApps.length === 0 ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, justifyContent: 'start' }}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Card key={i} style={{ borderRadius: 10 }}>
|
||||
<Skeleton active avatar paragraph={{ rows: 3 }} />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : discoveryError ? (
|
||||
<Empty description={discoveryError}>
|
||||
<Button onClick={() => { void loadDiscoveryApps(discoverySearch); }}>{t('Refresh')}</Button>
|
||||
</Empty>
|
||||
) : discoveryApps.length === 0 ? (
|
||||
<Empty description={t('No results')} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 12, justifyContent: 'start' }}>
|
||||
{discoveryApps.map(app => {
|
||||
const name = lang === 'zh' ? app.name.zh : app.name.en;
|
||||
const description = lang === 'zh' ? app.description.zh : app.description.en;
|
||||
const tags = lang === 'zh' ? app.tags.zh : app.tags.en;
|
||||
const iconUrl = `https://foxel.cc/api/apps/${encodeURIComponent(app.key)}/icon`;
|
||||
const installed = isAppInstalled(app.key);
|
||||
const downloading = downloadingApps.has(app.key);
|
||||
const approvedDate = new Date(app.approvedAt).toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US');
|
||||
const onOpenDetail = () => openDiscoveryAppDetail(app);
|
||||
|
||||
return (
|
||||
<CenterCard
|
||||
key={app.key}
|
||||
iconUrl={iconUrl}
|
||||
iconAlt={name}
|
||||
name={name}
|
||||
version={app.version || undefined}
|
||||
description={description}
|
||||
metaLeft={app.author || undefined}
|
||||
metaRight={lang === 'zh' ? `审核于 ${approvedDate}` : `Approved ${approvedDate}`}
|
||||
pills={buildCardPills(tags)}
|
||||
onIconClick={onOpenDetail}
|
||||
onTitleClick={onOpenDetail}
|
||||
footerLeft={app.website ? (
|
||||
<a className="fx-center-card-iconLink" href={app.website} target="_blank" rel="noreferrer" title={t('Website')} aria-label={t('Website')}>
|
||||
<LinkOutlined />
|
||||
</a>
|
||||
) : null}
|
||||
footerRight={installed ? (
|
||||
<span className="fx-center-pill fx-center-pill-success">{t('Installed already')}</span>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={downloading}
|
||||
onClick={() => handleDownloadAndInstall(app)}
|
||||
>
|
||||
{downloading ? t('Downloading') : t('Download and Install')}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={(() => {
|
||||
if (detailApp) {
|
||||
return `${lang === 'zh' ? detailApp.latest.name.zh : detailApp.latest.name.en} · ${t('Details')}`;
|
||||
}
|
||||
if (detailSummary) {
|
||||
return `${lang === 'zh' ? detailSummary.name.zh : detailSummary.name.en} · ${t('Details')}`;
|
||||
}
|
||||
return t('Details');
|
||||
})()}
|
||||
open={detailOpen}
|
||||
onCancel={closeDetailModal}
|
||||
width={860}
|
||||
footer={(() => {
|
||||
const key = detailKey || detailApp?.key || detailSummary?.key;
|
||||
const installed = key ? isAppInstalled(key) : false;
|
||||
const downloading = key ? downloadingApps.has(key) : false;
|
||||
const downloadUrl = detailApp?.latest.downloadUrl || detailSummary?.downloadUrl;
|
||||
const version = detailApp?.latest.version || detailSummary?.version;
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Button onClick={closeDetailModal}>{t('Close')}</Button>
|
||||
{installed ? (
|
||||
<Button disabled>{t('Installed already')}</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
size="middle"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={downloading}
|
||||
disabled={!key || !downloadUrl || !version || detailLoading}
|
||||
onClick={() => {
|
||||
if (!key || !downloadUrl || !version) return;
|
||||
void handleDownloadAndInstall({ key, version, downloadUrl });
|
||||
}}
|
||||
>
|
||||
{t('Download and Install')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
})()}
|
||||
>
|
||||
<div style={{ maxHeight: '70vh', overflowY: 'auto', paddingRight: 6 }}>
|
||||
{detailLoading && !detailApp ? (
|
||||
<Skeleton active avatar paragraph={{ rows: 8 }} />
|
||||
) : detailError ? (
|
||||
<Empty description={detailError}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!detailKey) return;
|
||||
setDetailApp(null);
|
||||
setDetailError(undefined);
|
||||
setDetailReloadId(v => v + 1);
|
||||
}}
|
||||
>
|
||||
{t('Refresh')}
|
||||
</Button>
|
||||
</Empty>
|
||||
) : detailApp ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ width: 64, height: 64, borderRadius: 14, overflow: 'hidden', border: `1px solid ${token.colorBorder}` }}>
|
||||
<img
|
||||
src={`https://foxel.cc/api/apps/${encodeURIComponent(detailApp.key)}/icon`}
|
||||
alt={lang === 'zh' ? detailApp.latest.name.zh : detailApp.latest.name.en}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain', background: token.colorBgContainer }}
|
||||
loading="lazy"
|
||||
onError={(e) => { (e.currentTarget as HTMLImageElement).src = '/logo.svg'; }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{lang === 'zh' ? detailApp.latest.name.zh : detailApp.latest.name.en}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{t('Version')}: {detailApp.latest.version}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, display: 'flex', flexWrap: 'wrap', gap: 10, color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
<span>{detailApp.latest.author}</span>
|
||||
<span>{detailApp.key}</span>
|
||||
<span>
|
||||
{lang === 'zh'
|
||||
? `审核于 ${new Date(detailApp.latest.approvedAt).toLocaleDateString('zh-CN')}`
|
||||
: `Approved ${new Date(detailApp.latest.approvedAt).toLocaleDateString('en-US')}`}
|
||||
</span>
|
||||
{detailApp.latest.website ? (
|
||||
<a href={detailApp.latest.website} target="_blank" rel="noreferrer" style={{ color: token.colorTextTertiary }}>
|
||||
<LinkOutlined style={{ marginRight: 6 }} />
|
||||
{t('Website')}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Space size={[4, 4]} wrap>
|
||||
{(lang === 'zh' ? detailApp.latest.tags.zh : detailApp.latest.tags.en).map(tag => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="details"
|
||||
items={[
|
||||
{
|
||||
key: 'details',
|
||||
label: t('Details'),
|
||||
children: (
|
||||
<Typography.Paragraph style={{ marginBottom: 0, whiteSpace: 'pre-wrap' }}>
|
||||
{lang === 'zh' ? detailApp.latest.description.zh : detailApp.latest.description.en}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'versions',
|
||||
label: t('Version'),
|
||||
children: (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={detailApp.versions.map(v => {
|
||||
const approvedDate = new Date(v.approvedAt).toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US');
|
||||
return {
|
||||
key: v.version,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{v.version}</span>
|
||||
<span style={{ color: token.colorTextTertiary, fontSize: 12 }}>
|
||||
{lang === 'zh' ? `审核于 ${approvedDate}` : `Approved ${approvedDate}`}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
children: v.releaseNotesMd ? (
|
||||
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
|
||||
<ReactMarkdown>{v.releaseNotesMd}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
{lang === 'zh' ? '暂无更新记录' : 'No release notes'}
|
||||
</Typography.Text>
|
||||
),
|
||||
};
|
||||
})}
|
||||
style={{ background: 'transparent' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description={t('No results')} />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={t('Confirm Install')}
|
||||
open={installModalOpen}
|
||||
onCancel={closeInstallModal}
|
||||
maskClosable={!installing}
|
||||
closable={!installing}
|
||||
width={640}
|
||||
footer={(() => {
|
||||
if (fileList.length === 0) {
|
||||
return <Button type="primary" onClick={closeInstallModal}>{t('Close')}</Button>;
|
||||
}
|
||||
if (installing) {
|
||||
return <Button type="primary" loading>{t('Installing')}</Button>;
|
||||
}
|
||||
if (installDone) {
|
||||
return <Button type="primary" onClick={closeInstallModal}>{t('Close')}</Button>;
|
||||
}
|
||||
return (
|
||||
<Space>
|
||||
<Button onClick={closeInstallModal}>{t('Cancel')}</Button>
|
||||
<Button type="primary" onClick={handleInstall}>{t('Confirm Install')}</Button>
|
||||
</Space>
|
||||
);
|
||||
})()}
|
||||
>
|
||||
{fileList.length === 0 ? (
|
||||
<Empty description={t('Please select a .foxpkg file')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<Typography.Text>{t('Selected {count} files', { count: fileList.length })}</Typography.Text>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Typography.Text type="secondary">{t('Installation will stop on first failure')}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{installStopReason ? (
|
||||
<Alert type="error" showIcon message={installStopReason} />
|
||||
) : null}
|
||||
|
||||
<Progress
|
||||
percent={(() => {
|
||||
const finished = fileList.filter(f => {
|
||||
const status = installFileState[f.uid]?.status;
|
||||
return status === 'success' || status === 'failed';
|
||||
}).length;
|
||||
return fileList.length === 0 ? 0 : Math.round((finished / fileList.length) * 100);
|
||||
})()}
|
||||
status={installStopReason ? 'exception' : (installDone ? 'success' : (installing ? 'active' : 'normal'))}
|
||||
format={() => {
|
||||
const finished = fileList.filter(f => {
|
||||
const status = installFileState[f.uid]?.status;
|
||||
return status === 'success' || status === 'failed';
|
||||
}).length;
|
||||
return `${finished}/${fileList.length}`;
|
||||
}}
|
||||
/>
|
||||
|
||||
<List
|
||||
dataSource={fileList}
|
||||
bordered
|
||||
renderItem={(file) => {
|
||||
const st = (installFileState[file.uid]?.status || 'pending') as InstallStatus;
|
||||
const msg = installFileState[file.uid]?.message;
|
||||
const errors = installFileState[file.uid]?.errors || [];
|
||||
|
||||
const statusText = (() => {
|
||||
switch (st) {
|
||||
case 'pending': return t('Pending');
|
||||
case 'installing': return t('Installing');
|
||||
case 'success': return t('Success');
|
||||
case 'failed': return t('Failed');
|
||||
case 'skipped': return t('Skipped');
|
||||
default: return t('Pending');
|
||||
}
|
||||
})();
|
||||
|
||||
const statusColor = (() => {
|
||||
switch (st) {
|
||||
case 'installing': return 'blue';
|
||||
case 'success': return 'green';
|
||||
case 'failed': return 'red';
|
||||
case 'skipped': return 'orange';
|
||||
default: return 'default';
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
actions={[
|
||||
(!installing && !installDone) ? (
|
||||
<Button key="remove" type="link" danger onClick={() => removeSelectedFile(file.uid)}>
|
||||
{t('Remove')}
|
||||
</Button>
|
||||
) : null,
|
||||
<Tag key="status" color={statusColor}>{statusText}</Tag>,
|
||||
].filter(Boolean) as any}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={file.name}
|
||||
description={
|
||||
(msg || errors.length > 0) ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{msg ? (
|
||||
<Typography.Text type={st === 'failed' ? 'danger' : (st === 'success' ? 'success' : 'secondary')}>
|
||||
{msg}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
{errors.length > 0 ? (
|
||||
<Typography.Text type={st === 'failed' ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>
|
||||
{errors.join('; ')}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PluginsPage;
|
||||
608
web/src/pages/ProcessorsPage.tsx
Normal file
@@ -0,0 +1,608 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Alert,
|
||||
Card,
|
||||
Empty,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
message,
|
||||
Modal,
|
||||
Segmented,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'antd';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||
import PathSelectorModal, { type PathSelectorMode } from '../components/PathSelectorModal';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type TabKey = 'editor' | 'runner';
|
||||
|
||||
const ProcessorsPage = memo(function ProcessorsPage() {
|
||||
const { t } = useI18n();
|
||||
const { token } = theme.useToken();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [processors, setProcessors] = useState<ProcessorTypeMeta[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<string>('');
|
||||
const [source, setSource] = useState('');
|
||||
const [initialSource, setInitialSource] = useState('');
|
||||
const [modulePath, setModulePath] = useState('');
|
||||
const [sourceLoading, setSourceLoading] = useState(false);
|
||||
const [savingSource, setSavingSource] = useState(false);
|
||||
const [reloading, setReloading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [running, setRunning] = useState(false);
|
||||
const [isDirectory, setIsDirectory] = useState(false);
|
||||
const [pathModalOpen, setPathModalOpen] = useState(false);
|
||||
const [pathModalMode, setPathModalMode] = useState<PathSelectorMode>('file');
|
||||
const [pathModalField, setPathModalField] = useState<'path' | 'save_to'>('path');
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('editor');
|
||||
|
||||
const isDirty = source !== initialSource;
|
||||
|
||||
const selectedProcessorMeta = useMemo(
|
||||
() => processors.find(p => p.type === selectedType),
|
||||
[processors, selectedType]
|
||||
);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoadingList(true);
|
||||
try {
|
||||
const list = await processorsApi.list();
|
||||
setProcessors(list);
|
||||
} catch (err: any) {
|
||||
messageApi.error(err?.message || t('Load failed'));
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}, [messageApi, t]);
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!processors.length) {
|
||||
setSelectedType('');
|
||||
return;
|
||||
}
|
||||
if (!selectedType) {
|
||||
setSelectedType(processors[0].type);
|
||||
} else if (!processors.some(p => p.type === selectedType)) {
|
||||
setSelectedType(processors[0].type);
|
||||
}
|
||||
}, [processors, selectedType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedType) {
|
||||
setSource('');
|
||||
setInitialSource('');
|
||||
setModulePath('');
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setSource('');
|
||||
setInitialSource('');
|
||||
setModulePath('');
|
||||
setSourceLoading(true);
|
||||
processorsApi.getSource(selectedType)
|
||||
.then(resp => {
|
||||
if (controller.signal.aborted) return;
|
||||
setSource(resp.source ?? '');
|
||||
setInitialSource(resp.source ?? '');
|
||||
setModulePath(resp.module_path ?? '');
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (controller.signal.aborted) return;
|
||||
messageApi.error(err?.message || t('Load failed'));
|
||||
setSource('');
|
||||
setInitialSource('');
|
||||
setModulePath('');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setSourceLoading(false);
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [messageApi, selectedType, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProcessorMeta) {
|
||||
form.resetFields();
|
||||
setIsDirectory(false);
|
||||
return;
|
||||
}
|
||||
form.resetFields();
|
||||
const defaults: Record<string, any> = {};
|
||||
selectedProcessorMeta.config_schema?.forEach(field => {
|
||||
if (field.default !== undefined) {
|
||||
defaults[field.key] = field.default;
|
||||
}
|
||||
});
|
||||
form.setFieldsValue({
|
||||
path: '',
|
||||
overwrite: !!selectedProcessorMeta.produces_file,
|
||||
save_to: undefined,
|
||||
config: defaults,
|
||||
directory_scope: 'current',
|
||||
max_depth: undefined,
|
||||
suffix: undefined,
|
||||
});
|
||||
setIsDirectory(false);
|
||||
}, [selectedProcessorMeta, form]);
|
||||
|
||||
const producesFile = selectedProcessorMeta?.produces_file ?? false;
|
||||
const supportsDirectory = selectedProcessorMeta?.supports_directory ?? false;
|
||||
const overwriteWatch = Form.useWatch('overwrite', form);
|
||||
const overwriteValue = producesFile ? !!overwriteWatch : false;
|
||||
const directoryScope = Form.useWatch('directory_scope', form) ?? 'current';
|
||||
|
||||
useEffect(() => {
|
||||
if (overwriteValue) {
|
||||
form.setFieldsValue({ save_to: undefined, suffix: undefined });
|
||||
}
|
||||
}, [overwriteValue, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDirectory) {
|
||||
form.setFieldsValue({
|
||||
overwrite: producesFile ? true : false,
|
||||
save_to: undefined,
|
||||
directory_scope: 'current',
|
||||
max_depth: undefined,
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue({ suffix: undefined });
|
||||
}
|
||||
}, [isDirectory, form, producesFile]);
|
||||
|
||||
const handleSelectProcessor = useCallback((type: string) => {
|
||||
if (type === selectedType) return;
|
||||
if (isDirty) {
|
||||
Modal.confirm({
|
||||
title: t('Unsaved changes'),
|
||||
content: t('Switching processor will discard unsaved changes. Continue?'),
|
||||
okText: t('Confirm'),
|
||||
cancelText: t('Cancel'),
|
||||
onOk: () => {
|
||||
setSelectedType(type);
|
||||
setActiveTab('editor');
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setSelectedType(type);
|
||||
setActiveTab('editor');
|
||||
}
|
||||
}, [isDirty, selectedType, t]);
|
||||
|
||||
const handleSaveSource = useCallback(async () => {
|
||||
if (!selectedType) return;
|
||||
try {
|
||||
setSavingSource(true);
|
||||
await processorsApi.updateSource(selectedType, source);
|
||||
setInitialSource(source);
|
||||
messageApi.success(t('Source saved'));
|
||||
} catch (err: any) {
|
||||
messageApi.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
setSavingSource(false);
|
||||
}
|
||||
}, [messageApi, selectedType, source, t]);
|
||||
|
||||
const handleReloadProcessors = useCallback(async () => {
|
||||
try {
|
||||
setReloading(true);
|
||||
await processorsApi.reload();
|
||||
messageApi.success(t('Processors reloaded'));
|
||||
await loadList();
|
||||
} catch (err: any) {
|
||||
messageApi.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
setReloading(false);
|
||||
}
|
||||
}, [loadList, messageApi, t]);
|
||||
|
||||
const openPathSelector = useCallback((field: 'path' | 'save_to', mode: PathSelectorMode) => {
|
||||
setPathModalField(field);
|
||||
setPathModalMode(mode);
|
||||
setPathModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePathSelected = useCallback((selectedPath: string) => {
|
||||
if (pathModalField === 'path') {
|
||||
form.setFieldsValue({ path: selectedPath });
|
||||
setIsDirectory(pathModalMode === 'directory');
|
||||
} else {
|
||||
form.setFieldsValue({ save_to: selectedPath });
|
||||
}
|
||||
setPathModalOpen(false);
|
||||
}, [form, pathModalField, pathModalMode]);
|
||||
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!selectedType) {
|
||||
messageApi.warning(t('Please select a processor'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const schema = selectedProcessorMeta?.config_schema || [];
|
||||
const finalConfig: Record<string, any> = {};
|
||||
schema.forEach(field => {
|
||||
const value = values.config?.[field.key];
|
||||
if (value === undefined) {
|
||||
finalConfig[field.key] = field.default;
|
||||
} else {
|
||||
finalConfig[field.key] = value;
|
||||
}
|
||||
});
|
||||
setRunning(true);
|
||||
const overwriteFlag = producesFile ? !!values.overwrite : false;
|
||||
if (isDirectory) {
|
||||
if (supportsDirectory) {
|
||||
const resp = await processorsApi.process({
|
||||
path: values.path,
|
||||
processor_type: selectedType,
|
||||
config: finalConfig,
|
||||
overwrite: overwriteFlag,
|
||||
});
|
||||
messageApi.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||
} else {
|
||||
const scope: 'current' | 'recursive' = values.directory_scope || 'current';
|
||||
let maxDepth: number | null = scope === 'current' ? 0 : null;
|
||||
if (scope === 'recursive' && typeof values.max_depth === 'number') {
|
||||
maxDepth = values.max_depth;
|
||||
}
|
||||
const suffixValue = producesFile && !overwriteFlag && typeof values.suffix === 'string'
|
||||
? values.suffix.trim() || null
|
||||
: null;
|
||||
const resp = await processorsApi.processDirectory({
|
||||
path: values.path,
|
||||
processor_type: selectedType,
|
||||
config: finalConfig,
|
||||
overwrite: overwriteFlag,
|
||||
max_depth: maxDepth,
|
||||
suffix: suffixValue,
|
||||
});
|
||||
messageApi.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||
}
|
||||
} else {
|
||||
const payload: any = {
|
||||
path: values.path,
|
||||
processor_type: selectedType,
|
||||
config: finalConfig,
|
||||
overwrite: overwriteFlag,
|
||||
};
|
||||
if (values.save_to && !overwriteFlag) {
|
||||
payload.save_to = values.save_to;
|
||||
}
|
||||
const resp = await processorsApi.process(payload);
|
||||
messageApi.success(`${t('Task submitted')}: ${resp.task_id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.errorFields) {
|
||||
return;
|
||||
}
|
||||
messageApi.error(err?.message || t('Operation failed'));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}, [form, isDirectory, messageApi, producesFile, selectedType, supportsDirectory, selectedProcessorMeta, t]);
|
||||
|
||||
const selectedConfigPath = pathModalField === 'path'
|
||||
? (selectedType ? form.getFieldValue('path') : undefined) || '/'
|
||||
: (selectedType ? form.getFieldValue('save_to') : undefined) || '/';
|
||||
|
||||
const renderProcessorList = () => {
|
||||
if (loadingList) {
|
||||
return (
|
||||
<Flex align="center" justify="center" style={{ height: '100%', width: '100%' }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (!processors.length) {
|
||||
return (
|
||||
<Flex align="center" justify="center" style={{ height: '100%', width: '100%' }}>
|
||||
<Empty description={t('No data')} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ padding: 8, overflowY: 'auto', height: '100%' }}>
|
||||
{processors.map(item => {
|
||||
const selected = item.type === selectedType;
|
||||
const onClick = () => handleSelectProcessor(item.type);
|
||||
return (
|
||||
<div
|
||||
key={item.type}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
border: `1px solid ${selected ? token.colorPrimary : token.colorBorderSecondary}`,
|
||||
background: selected ? token.colorPrimaryBg : token.colorBgContainer,
|
||||
borderRadius: 10,
|
||||
padding: 12,
|
||||
marginBottom: 8,
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space size={8} align="center">
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: selected ? token.colorPrimary : token.colorBorderSecondary,
|
||||
display: 'inline-block',
|
||||
}}
|
||||
/>
|
||||
<Text strong>{item.name}</Text>
|
||||
</Space>
|
||||
<Tag color={selected ? token.colorPrimary : token.colorBorderSecondary}>{item.type}</Tag>
|
||||
</Flex>
|
||||
<Space direction="vertical" size={6} style={{ marginTop: 8 }}>
|
||||
<div>
|
||||
<Text type="secondary" style={{ marginRight: 8 }}>{t('Supported Extensions')}:</Text>
|
||||
{item.supported_exts?.length ? (
|
||||
<Space wrap size={[4, 4]}>
|
||||
{item.supported_exts.map(ext => (
|
||||
<Tag key={ext}>{ext}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<Tag>{t('All')}</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
{t('Produces File')}: {item.produces_file ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'editor',
|
||||
label: t('Source Editor'),
|
||||
children: selectedType ? (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
|
||||
{modulePath ? (
|
||||
<Space size={8}>
|
||||
<Text type="secondary">{t('Module Path')}:</Text>
|
||||
<Text code>{modulePath}</Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">{t('No module path')}</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{sourceLoading ? (
|
||||
<Flex align="center" justify="center" style={{ height: '100%', width: '100%' }}>
|
||||
<Spin />
|
||||
</Flex>
|
||||
) : (
|
||||
<Editor
|
||||
language="python"
|
||||
value={source}
|
||||
onChange={(val) => setSource(val ?? '')}
|
||||
height="100%"
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
scrollBeyondLastLine: false,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'runner',
|
||||
label: t('Run Processor'),
|
||||
forceRender: true,
|
||||
children: (
|
||||
<Form form={form} layout="vertical" disabled={!selectedType} style={{ padding: '12px 0' }}>
|
||||
{selectedType ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('Target Path')}
|
||||
required
|
||||
>
|
||||
<Flex gap={8} align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item
|
||||
name="path"
|
||||
rules={[{ required: true, message: t('Please select a path') }]}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder={t('Select a path')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Button onClick={() => openPathSelector('path', 'file')}>{t('Select File')}</Button>
|
||||
<Button onClick={() => openPathSelector('path', 'directory')}>{t('Select Directory')}</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
{isDirectory && supportsDirectory && (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('Directory execution will enqueue one task for the directory itself')}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
{isDirectory && !supportsDirectory && (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%', marginBottom: 12 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('Directory execution will enqueue one task per file')}
|
||||
/>
|
||||
<Form.Item name="directory_scope" label={t('Directory scope')} initialValue="current">
|
||||
<Segmented
|
||||
options={[
|
||||
{ label: t('Current level only'), value: 'current' },
|
||||
{ label: t('Include subdirectories'), value: 'recursive' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{directoryScope === 'recursive' && (
|
||||
<Form.Item
|
||||
name="max_depth"
|
||||
label={t('Max depth')}
|
||||
extra={t('Leave empty to traverse all subdirectories')}
|
||||
rules={[{
|
||||
validator: async (_: any, value: number | null) => {
|
||||
if (value === undefined || value === null) return;
|
||||
if (value < 0) throw new Error(t('Depth must be greater or equal to 0'));
|
||||
},
|
||||
}]}
|
||||
>
|
||||
<InputNumber min={0} placeholder={t('Unlimited')} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{producesFile && (
|
||||
<Form.Item
|
||||
name="overwrite"
|
||||
label={t('Overwrite original')}
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{isDirectory && !supportsDirectory && producesFile && !overwriteValue && (
|
||||
<Form.Item
|
||||
name="suffix"
|
||||
label={t('Output suffix')}
|
||||
rules={[
|
||||
{ required: true, message: t('Please input a suffix') },
|
||||
{
|
||||
validator: async (_: any, value: string) => {
|
||||
if (typeof value !== 'string') return;
|
||||
if (!value.trim()) {
|
||||
throw new Error(t('Suffix cannot be empty'));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
extra={t('Suffix will be inserted before the file extension, e.g. demo_processed.mp4')}
|
||||
>
|
||||
<Input placeholder={t('Suffix such as _processed')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{!isDirectory && producesFile && !overwriteValue && (
|
||||
<Form.Item label={t('Save To')}>
|
||||
<Flex gap={8} align="center">
|
||||
<div style={{ flex: 1 }}>
|
||||
<Form.Item name="save_to" noStyle>
|
||||
<Input placeholder={t('Optional output path')} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Button onClick={() => openPathSelector('save_to', 'any')}>{t('Select')}</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<ProcessorConfigForm
|
||||
processorMeta={selectedProcessorMeta}
|
||||
form={form}
|
||||
configPath={['config']}
|
||||
/>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" onClick={handleRun} loading={running} disabled={!selectedType}>
|
||||
{t('Run')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Empty style={{ marginTop: 64 }} description={t('Select a processor')} />
|
||||
)}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Flex gap={16} style={{ height: 'calc(100vh - 88px)' }}>
|
||||
<Card
|
||||
style={{ flex: '0 0 320px', minWidth: 280, display: 'flex', flexDirection: 'column' }}
|
||||
title={t('Processor List')}
|
||||
extra={
|
||||
<Space size={8}>
|
||||
<Button size="small" onClick={loadList} loading={loadingList}>{t('Refresh')}</Button>
|
||||
<Button size="small" onClick={handleReloadProcessors} loading={reloading}>{t('Reload')}</Button>
|
||||
</Space>
|
||||
}
|
||||
styles={{ body: { padding: 0, flex: 1, display: 'flex' } }}
|
||||
>
|
||||
{renderProcessorList()}
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}
|
||||
title={selectedProcessorMeta ? `${selectedProcessorMeta.name} (${selectedProcessorMeta.type})` : t('Select a processor')}
|
||||
extra={
|
||||
<Space size={8}>
|
||||
<Button size="small" onClick={handleSaveSource} loading={savingSource} disabled={!selectedType || !isDirty}>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
<Button size="small" onClick={handleReloadProcessors} loading={reloading} disabled={!selectedType}>
|
||||
{t('Reload')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={key => setActiveTab(key as TabKey)}
|
||||
items={tabs as any}
|
||||
className="processors-tabs"
|
||||
tabBarGutter={32}
|
||||
/>
|
||||
</Card>
|
||||
</Flex>
|
||||
|
||||
<PathSelectorModal
|
||||
open={pathModalOpen}
|
||||
mode={pathModalMode}
|
||||
initialPath={selectedConfigPath}
|
||||
onOk={handlePathSelected}
|
||||
onCancel={() => setPathModalOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProcessorsPage;
|
||||
118
web/src/pages/PublicSharePage/DirectoryViewer.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Card, List, Typography, Button, Empty, Breadcrumb } from 'antd';
|
||||
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { shareApi, type ShareInfo } from '../../api/share';
|
||||
import { type VfsEntry } from '../../api/vfs';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { useI18n } from '../../i18n';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface DirectoryViewerProps {
|
||||
token: string;
|
||||
shareInfo: ShareInfo;
|
||||
password?: string;
|
||||
onFileClick: (entry: VfsEntry, path: string) => void;
|
||||
}
|
||||
|
||||
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password, onFileClick }: DirectoryViewerProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [error, setError] = useState('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const loadData = useCallback(async (p: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const listing = await shareApi.listDir(token, p, password);
|
||||
setEntries(listing.entries || []);
|
||||
setCurrentPath(p);
|
||||
} catch (e: any) {
|
||||
setError(e.message || t('Share load failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [password, t, token]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData(currentPath);
|
||||
}, [loadData, currentPath]);
|
||||
|
||||
const handleEntryClick = (entry: VfsEntry) => {
|
||||
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||
if (entry.is_dir) {
|
||||
loadData(newPath);
|
||||
} else {
|
||||
onFileClick(entry, newPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = (path: string) => {
|
||||
loadData(path);
|
||||
};
|
||||
|
||||
const renderBreadcrumb = () => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const items = [{ title: t('Root'), path: '/' }];
|
||||
parts.forEach((part, i) => {
|
||||
const path = '/' + parts.slice(0, i + 1).join('/');
|
||||
items.push({ title: part, path });
|
||||
});
|
||||
return (
|
||||
<Breadcrumb>
|
||||
{items.map((item, i) => (
|
||||
<Breadcrumb.Item key={i}>
|
||||
{i === items.length - 1 ? (
|
||||
<span>{item.title}</span>
|
||||
) : (
|
||||
<a onClick={() => handleBreadcrumbClick(item.path)}>{item.title}</a>
|
||||
)}
|
||||
</Breadcrumb.Item>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
||||
<Card>
|
||||
<Title level={4}>{shareInfo?.name}</Title>
|
||||
<Text type="secondary">
|
||||
{t('Created on {date}', { date: format(parseISO(shareInfo.created_at), 'yyyy-MM-dd') })}
|
||||
{shareInfo?.expires_at ? (
|
||||
<>
|
||||
{' '}
|
||||
{t('Expires on {date}', { date: format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd') })}
|
||||
</>
|
||||
) : null}
|
||||
</Text>
|
||||
<div style={{ margin: '16px 0' }}>
|
||||
{renderBreadcrumb()}
|
||||
</div>
|
||||
<List
|
||||
loading={loading}
|
||||
dataSource={entries}
|
||||
renderItem={item => (
|
||||
<List.Item
|
||||
actions={[
|
||||
!item.is_dir ? <Button type="text" icon={<DownloadOutlined />} href={shareApi.downloadUrl(token!, (currentPath === '/' ? '' : currentPath) + '/' + item.name, password)} download /> : null
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={item.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
||||
title={<a onClick={() => handleEntryClick(item)}>{item.name}</a>}
|
||||
description={!item.is_dir ? `${(item.size / 1024).toFixed(2)} KB` : ''}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
});
|
||||