Initial commit

This commit is contained in:
shiyu
2026-02-09 13:19:28 +08:00
commit 17d13999b0
300 changed files with 48565 additions and 0 deletions

868
web/bun.lock Normal file
View 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
View 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
View 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
View 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
View 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>

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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);
},
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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();
},
};

View 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}`),
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()}`);
},
};

View 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>
);
})}
</>
);
};

View 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
View 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
View 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;
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
})}
</>
);
};

View 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;

View 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>
);
}

View 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;
}

View 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);
}

View 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;
};

View 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
View 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;
}

View 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
View 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;
}

View 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"
}

View 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 TokenJSON",
"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": "存储适配器"
}

View 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
View 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;

View 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
View 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
View 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>
);

View 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;

View 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;

View 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;

View 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>
);
};

View File

@@ -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>
);
});

View 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>
);
};

View 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;

View 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');
};

View 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[]),
}}
/>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 `![${fileName}](${url})`;
} 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>
);
});

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
});

View 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;

View 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>
);
};

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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
};
}

View 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,
};
}

View 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,
};
}

View 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,
};
}

View 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 };
}

View 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,
};
}

View 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;
}

View 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
View 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>
);
}

View 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;

View 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;

View 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;

View 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>
);
});

Some files were not shown because too many files have changed in this diff Show More