mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-07-12 16:03:27 +08:00
Initial commit
This commit is contained in:
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
69
web/README.md
Normal file
69
web/README.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
934
web/bun.lock
Normal file
934
web/bun.lock
Normal file
@@ -0,0 +1,934 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "web",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "5.x",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^5.27.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.8.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="],
|
||||
|
||||
"@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
|
||||
|
||||
"@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@1.1.3", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.0", "@babel/runtime": "^7.23.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg=="],
|
||||
|
||||
"@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="],
|
||||
|
||||
"@ant-design/icons": ["@ant-design/icons@5.6.1", "", { "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", "@babel/runtime": "^7.24.8", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg=="],
|
||||
|
||||
"@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@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.10.4", "classnames": "^2.2.5", "json2mq": "^0.2.0", "resize-observer-polyfill": "^1.5.1", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": ">=16.9.0" } }, "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA=="],
|
||||
|
||||
"@ant-design/v5-patch-for-react-19": ["@ant-design/v5-patch-for-react-19@1.0.3", "", { "peerDependencies": { "antd": ">=5.22.6", "react": ">=19.0.0", "react-dom": ">=19.0.0" } }, "sha512-iWfZuSUl5kuhqLUw7jJXUQFMMkM7XpW7apmKzQBQHU0cpifYW4A79xIBt9YVO5IBajKpPG5UKP87Ft7Yrw1p/w=="],
|
||||
|
||||
"@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.0", "", {}, "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.28.3", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.3", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.3", "@babel/types": "^7.28.2", "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-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="],
|
||||
|
||||
"@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.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.3", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" } }, "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.28.3", "", { "dependencies": { "@babel/types": "^7.28.2" }, "bin": "./bin/babel-parser.js" }, "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA=="],
|
||||
|
||||
"@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.3", "", {}, "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA=="],
|
||||
|
||||
"@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.3", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.3", "@babel/template": "^7.27.2", "@babel/types": "^7.28.2", "debug": "^4.3.1" } }, "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.2", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ=="],
|
||||
|
||||
"@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.25.9", "", { "os": "aix", "cpu": "ppc64" }, "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.9", "", { "os": "android", "cpu": "arm" }, "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.9", "", { "os": "android", "cpu": "arm64" }, "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.9", "", { "os": "android", "cpu": "x64" }, "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.9", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.9", "", { "os": "linux", "cpu": "arm" }, "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.9", "", { "os": "linux", "cpu": "ia32" }, "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.9", "", { "os": "linux", "cpu": "none" }, "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.9", "", { "os": "linux", "cpu": "none" }, "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.9", "", { "os": "linux", "cpu": "none" }, "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.9", "", { "os": "linux", "cpu": "x64" }, "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.9", "", { "os": "none", "cpu": "arm64" }, "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.9", "", { "os": "none", "cpu": "x64" }, "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.9", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.9", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.9", "", { "os": "none", "cpu": "arm64" }, "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.9", "", { "os": "sunos", "cpu": "x64" }, "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.9", "", { "os": "win32", "cpu": "ia32" }, "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="],
|
||||
|
||||
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
|
||||
|
||||
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
|
||||
|
||||
"@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="],
|
||||
|
||||
"@eslint/config-helpers": ["@eslint/config-helpers@0.3.1", "", {}, "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA=="],
|
||||
|
||||
"@eslint/core": ["@eslint/core@0.15.2", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg=="],
|
||||
|
||||
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "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.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="],
|
||||
|
||||
"@eslint/js": ["@eslint/js@9.33.0", "", {}, "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A=="],
|
||||
|
||||
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.5", "", { "dependencies": { "@eslint/core": "^0.15.2", "levn": "^0.4.1" } }, "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
|
||||
"@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/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.30", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@rc-component/async-validator": ["@rc-component/async-validator@5.0.4", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg=="],
|
||||
|
||||
"@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="],
|
||||
|
||||
"@rc-component/context": ["@rc-component/context@1.4.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w=="],
|
||||
|
||||
"@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ=="],
|
||||
|
||||
"@rc-component/mutate-observer": ["@rc-component/mutate-observer@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw=="],
|
||||
|
||||
"@rc-component/portal": ["@rc-component/portal@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg=="],
|
||||
|
||||
"@rc-component/qrcode": ["@rc-component/qrcode@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg=="],
|
||||
|
||||
"@rc-component/tour": ["@rc-component/tour@1.15.1", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/portal": "^1.0.0-9", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ=="],
|
||||
|
||||
"@rc-component/trigger": ["@rc-component/trigger@2.3.0", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.30", "", {}, "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.46.2", "", { "os": "android", "cpu": "arm" }, "sha512-Zj3Hl6sN34xJtMv7Anwb5Gu01yujyE/cLBDB2gnHTAHaWS1Z38L7kuSG+oAh0giZMqG060f/YBStXtMH6FvPMA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.46.2", "", { "os": "android", "cpu": "arm64" }, "sha512-nTeCWY83kN64oQ5MGz3CgtPx8NSOhC5lWtsjTs+8JAJNLcP3QbLCtDDgUKQc/Ro/frpMq4SHUaHN6AMltcEoLQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.46.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.46.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SSj8TlYV5nJixSsm/y3QXfhspSiLYP11zpfwp6G/YDXctf3Xkdnk4woJIF5VQe0of2OjzTt8EsxnJDCdHd2xMA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.46.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ZyrsG4TIT9xnOlLsSSi9w/X29tCbK1yegE49RYm3tu3wF1L/B6LVMqnEWyDB26d9Ecx9zrmXCiPmIabVuLmNSg=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.46.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-pCgHFoOECwVCJ5GFq8+gR8SBKnMO+xe5UEqbemxBpCKYQddRQMgomv1104RnLSg7nNvgKy05sLsY51+OVRyiVw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.46.2", "", { "os": "linux", "cpu": "arm" }, "sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.46.2", "", { "os": "linux", "cpu": "arm" }, "sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.46.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.46.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg=="],
|
||||
|
||||
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.46.2", "", { "os": "linux", "cpu": "none" }, "sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.46.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.46.2", "", { "os": "linux", "cpu": "none" }, "sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.46.2", "", { "os": "linux", "cpu": "none" }, "sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.46.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.46.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.46.2", "", { "os": "linux", "cpu": "x64" }, "sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.46.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.46.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-gBgaUDESVzMgWZhcyjfs9QFK16D8K6QZpwAaVNJxYDLHWayOta4ZMjGm/vsAEy3hvlS2GosVFlBlP9/Wb85DqQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.46.2", "", { "os": "win32", "cpu": "x64" }, "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg=="],
|
||||
|
||||
"@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/prismjs": ["@types/prismjs@1.26.5", "", {}, "sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.7", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.39.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/type-utils": "8.39.1", "@typescript-eslint/utils": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.39.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g=="],
|
||||
|
||||
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.39.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg=="],
|
||||
|
||||
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.39.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.39.1", "@typescript-eslint/types": "^8.39.1", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw=="],
|
||||
|
||||
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1" } }, "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw=="],
|
||||
|
||||
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.39.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA=="],
|
||||
|
||||
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA=="],
|
||||
|
||||
"@typescript-eslint/types": ["@typescript-eslint/types@8.39.1", "", {}, "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.39.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.39.1", "@typescript-eslint/tsconfig-utils": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/visitor-keys": "8.39.1", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw=="],
|
||||
|
||||
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.39.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg=="],
|
||||
|
||||
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.39.1", "", { "dependencies": { "@typescript-eslint/types": "8.39.1", "eslint-visitor-keys": "^4.2.1" } }, "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A=="],
|
||||
|
||||
"@uiw/copy-to-clipboard": ["@uiw/copy-to-clipboard@1.0.17", "", {}, "sha512-O2GUHV90Iw2VrSLVLK0OmNIMdZ5fgEg4NhvtwINsX+eZ/Wf6DWD0TdsK9xwV7dNRnK/UI2mQtl0a2/kRgm1m1A=="],
|
||||
|
||||
"@uiw/react-markdown-preview": ["@uiw/react-markdown-preview@5.1.5", "", { "dependencies": { "@babel/runtime": "^7.17.2", "@uiw/copy-to-clipboard": "~1.0.12", "react-markdown": "~9.0.1", "rehype-attr": "~3.0.1", "rehype-autolink-headings": "~7.1.0", "rehype-ignore": "^2.0.0", "rehype-prism-plus": "2.0.0", "rehype-raw": "^7.0.0", "rehype-rewrite": "~4.0.0", "rehype-slug": "~6.0.0", "remark-gfm": "~4.0.0", "remark-github-blockquote-alert": "^1.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-DNOqx1a6gJR7Btt57zpGEKTfHRlb7rWbtctMRO2f82wWcuoJsxPBrM+JWebDdOD0LfD8oe2CQvW2ICQJKHQhZg=="],
|
||||
|
||||
"@uiw/react-md-editor": ["@uiw/react-md-editor@4.0.8", "", { "dependencies": { "@babel/runtime": "^7.14.6", "@uiw/react-markdown-preview": "^5.0.6", "rehype": "~13.0.0", "rehype-prism-plus": "~2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-S3mOzZeGmJNhzdXJxRTCwsFMDp8nBWeQUf59cK3L6QHzDUHnRoHpcmWpfVRyKGKSg8zaI2+meU5cYWf8kYn3mQ=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.30", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw=="],
|
||||
|
||||
"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@5.27.0", "", { "dependencies": { "@ant-design/colors": "^7.2.1", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.0.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.3.0", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.3.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.8", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.51.1", "rc-tabs": "~15.7.0", "rc-textarea": "~1.10.2", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.9.2", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-o54dmpooLOc08RSGCkeEQBYAGPxUSmnhmYJKCNTHH46vzjOVxdteu+wPTRVkRbAkDTbs2VcNr5VL7Lu67rPIiA=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.25.2", "", { "dependencies": { "caniuse-lite": "^1.0.30001733", "electron-to-chromium": "^1.5.199", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA=="],
|
||||
|
||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001735", "", {}, "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="],
|
||||
|
||||
"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.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
|
||||
|
||||
"copy-to-clipboard": ["copy-to-clipboard@3.3.3", "", { "dependencies": { "toggle-selection": "^1.0.6" } }, "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-selector-parser": ["css-selector-parser@3.1.3", "", {}, "sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
|
||||
|
||||
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"direction": ["direction@2.0.1", "", { "bin": { "direction": "cli.js" } }, "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.201", "", {}, "sha512-ZG65vsrLClodGqywuigc+7m0gr4ISoTQttfVh7nfpLv0M7SIwF4WbFNEOywcqTiujs12AUeeXbFyQieDICAIxg=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"eslint": ["eslint@9.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.33.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "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-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA=="],
|
||||
|
||||
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="],
|
||||
|
||||
"eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="],
|
||||
|
||||
"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-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="],
|
||||
|
||||
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="],
|
||||
|
||||
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
|
||||
|
||||
"hast-util-has-property": ["hast-util-has-property@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA=="],
|
||||
|
||||
"hast-util-heading-rank": ["hast-util-heading-rank@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA=="],
|
||||
|
||||
"hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="],
|
||||
|
||||
"hast-util-parse-selector": ["hast-util-parse-selector@3.1.1", "", { "dependencies": { "@types/hast": "^2.0.0" } }, "sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA=="],
|
||||
|
||||
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
|
||||
|
||||
"hast-util-select": ["hast-util-select@6.0.4", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "bcp-47-match": "^2.0.0", "comma-separated-tokens": "^2.0.0", "css-selector-parser": "^3.0.0", "devlop": "^1.0.0", "direction": "^2.0.0", "hast-util-has-property": "^3.0.0", "hast-util-to-string": "^3.0.0", "hast-util-whitespace": "^3.0.0", "nth-check": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"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-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="],
|
||||
|
||||
"hast-util-to-string": ["hast-util-to-string@3.0.1", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hastscript": ["hastscript@7.2.0", "", { "dependencies": { "@types/hast": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^3.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw=="],
|
||||
|
||||
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"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.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="],
|
||||
|
||||
"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-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"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.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
"json-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"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-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"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.0", "", { "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-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"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-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "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-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "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-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
|
||||
|
||||
"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.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"parse-numeric-range": ["parse-numeric-range@1.3.0", "", {}, "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="],
|
||||
|
||||
"rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="],
|
||||
|
||||
"rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||
|
||||
"rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="],
|
||||
|
||||
"rc-drawer": ["rc-drawer@7.3.0", "", { "dependencies": { "@babel/runtime": "^7.23.9", "@rc-component/portal": "^1.1.1", "classnames": "^2.2.6", "rc-motion": "^2.6.1", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg=="],
|
||||
|
||||
"rc-dropdown": ["rc-dropdown@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-util": "^5.44.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA=="],
|
||||
|
||||
"rc-field-form": ["rc-field-form@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/async-validator": "^5.0.3", "rc-util": "^5.32.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA=="],
|
||||
|
||||
"rc-image": ["rc-image@7.12.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/portal": "^1.0.2", "classnames": "^2.2.6", "rc-dialog": "~9.6.0", "rc-motion": "^2.6.2", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q=="],
|
||||
|
||||
"rc-input": ["rc-input@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.18.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA=="],
|
||||
|
||||
"rc-input-number": ["rc-input-number@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", "classnames": "^2.2.5", "rc-input": "~1.8.0", "rc-util": "^5.40.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag=="],
|
||||
|
||||
"rc-mentions": ["rc-mentions@2.20.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-input": "~1.8.0", "rc-menu": "~9.16.0", "rc-textarea": "~1.10.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ=="],
|
||||
|
||||
"rc-menu": ["rc-menu@9.16.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.0.0", "classnames": "2.x", "rc-motion": "^2.4.3", "rc-overflow": "^1.3.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg=="],
|
||||
|
||||
"rc-motion": ["rc-motion@2.9.5", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA=="],
|
||||
|
||||
"rc-notification": ["rc-notification@5.6.4", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.9.0", "rc-util": "^5.20.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw=="],
|
||||
|
||||
"rc-overflow": ["rc-overflow@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-resize-observer": "^1.0.0", "rc-util": "^5.37.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw=="],
|
||||
|
||||
"rc-pagination": ["rc-pagination@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ=="],
|
||||
|
||||
"rc-picker": ["rc-picker@4.11.3", "", { "dependencies": { "@babel/runtime": "^7.24.7", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.1", "rc-overflow": "^1.3.2", "rc-resize-observer": "^1.4.0", "rc-util": "^5.43.0" }, "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-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg=="],
|
||||
|
||||
"rc-progress": ["rc-progress@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.6", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw=="],
|
||||
|
||||
"rc-rate": ["rc-rate@2.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.0.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q=="],
|
||||
|
||||
"rc-resize-observer": ["rc-resize-observer@1.4.3", "", { "dependencies": { "@babel/runtime": "^7.20.7", "classnames": "^2.2.1", "rc-util": "^5.44.1", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ=="],
|
||||
|
||||
"rc-segmented": ["rc-segmented@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-motion": "^2.4.4", "rc-util": "^5.17.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA=="],
|
||||
|
||||
"rc-select": ["rc-select@14.16.8", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.1.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-overflow": "^1.3.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.2" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg=="],
|
||||
|
||||
"rc-slider": ["rc-slider@11.1.8", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ=="],
|
||||
|
||||
"rc-steps": ["rc-steps@6.0.1", "", { "dependencies": { "@babel/runtime": "^7.16.7", "classnames": "^2.2.3", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g=="],
|
||||
|
||||
"rc-switch": ["rc-switch@4.1.0", "", { "dependencies": { "@babel/runtime": "^7.21.0", "classnames": "^2.2.1", "rc-util": "^5.30.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg=="],
|
||||
|
||||
"rc-table": ["rc-table@7.51.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/context": "^1.4.0", "classnames": "^2.2.5", "rc-resize-observer": "^1.1.0", "rc-util": "^5.44.3", "rc-virtual-list": "^3.14.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-5iq15mTHhvC42TlBLRCoCBLoCmGlbRZAlyF21FonFnS/DIC8DeRqnmdyVREwt2CFbPceM0zSNdEeVfiGaqYsKw=="],
|
||||
|
||||
"rc-tabs": ["rc-tabs@15.7.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "classnames": "2.x", "rc-dropdown": "~4.2.0", "rc-menu": "~9.16.0", "rc-motion": "^2.6.2", "rc-resize-observer": "^1.0.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA=="],
|
||||
|
||||
"rc-textarea": ["rc-textarea@1.10.2", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", "rc-input": "~1.8.0", "rc-resize-observer": "^1.0.0", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ=="],
|
||||
|
||||
"rc-tooltip": ["rc-tooltip@6.4.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.1", "rc-util": "^5.44.3" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g=="],
|
||||
|
||||
"rc-tree": ["rc-tree@5.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A=="],
|
||||
|
||||
"rc-tree-select": ["rc-tree-select@5.27.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "2.x", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww=="],
|
||||
|
||||
"rc-upload": ["rc-upload@4.9.2", "", { "dependencies": { "@babel/runtime": "^7.18.3", "classnames": "^2.2.5", "rc-util": "^5.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-nHx+9rbd1FKMiMRYsqQ3NkXUv7COHPBo3X1Obwq9SWS6/diF/A0aJ5OHubvwUAIDs+4RMleljV0pcrNUc823GQ=="],
|
||||
|
||||
"rc-util": ["rc-util@5.44.4", "", { "dependencies": { "@babel/runtime": "^7.18.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w=="],
|
||||
|
||||
"rc-virtual-list": ["rc-virtual-list@3.19.1", "", { "dependencies": { "@babel/runtime": "^7.20.0", "classnames": "^2.2.6", "rc-resize-observer": "^1.0.0", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-DCapO2oyPqmooGhxBuXHM4lFuX+sshQwWqqkuyFA+4rShLe//+GEPVwiDgO+jKtKHtbeYwZoNvetwfHdOf+iUQ=="],
|
||||
|
||||
"react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-markdown": ["react-markdown@9.0.3", "", { "dependencies": { "@types/hast": "^3.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-Yk7Z94dbgYTOrdk41Z74GoKA7rThnsbbqBTRYuxoe08qvfQ9tJVhmAKw6BJS/ZORG7kTy/s1QvYzSuaoBA1qfw=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-router": ["react-router@7.8.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg=="],
|
||||
|
||||
"refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="],
|
||||
|
||||
"rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="],
|
||||
|
||||
"rehype-attr": ["rehype-attr@3.0.3", "", { "dependencies": { "unified": "~11.0.0", "unist-util-visit": "~5.0.0" } }, "sha512-Up50Xfra8tyxnkJdCzLBIBtxOcB2M1xdeKe1324U06RAvSjYm7ULSeoM+b/nYPQPVd7jsXJ9+39IG1WAJPXONw=="],
|
||||
|
||||
"rehype-autolink-headings": ["rehype-autolink-headings@7.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-is-element": "^3.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw=="],
|
||||
|
||||
"rehype-ignore": ["rehype-ignore@2.0.2", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-BpAT/3lU9DMJ2siYVD/dSR0A/zQgD6Fb+fxkJd4j+wDVy6TYbYpK+FZqu8eM9EuNKGvi4BJR7XTZ/+zF02Dq8w=="],
|
||||
|
||||
"rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="],
|
||||
|
||||
"rehype-prism-plus": ["rehype-prism-plus@2.0.1", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Wglct0OW12tksTUseAPyWPo3srjBOY7xKlql/DPKi7HbsdZTyaLCAoO58QBKSczFQxElTsQlOY3JDOFzB/K++Q=="],
|
||||
|
||||
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
|
||||
|
||||
"rehype-rewrite": ["rehype-rewrite@4.0.2", "", { "dependencies": { "hast-util-select": "^6.0.0", "unified": "^11.0.3", "unist-util-visit": "^5.0.0" } }, "sha512-rjLJ3z6fIV11phwCqHp/KRo8xuUCO8o9bFJCNw5o6O2wlLk6g8r323aRswdGBQwfXPFYeSuZdAjp4tzo6RGqEg=="],
|
||||
|
||||
"rehype-slug": ["rehype-slug@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "github-slugger": "^2.0.0", "hast-util-heading-rank": "^3.0.0", "hast-util-to-string": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A=="],
|
||||
|
||||
"rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-github-blockquote-alert": ["remark-github-blockquote-alert@1.3.1", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-OPNnimcKeozWN1w8KVQEuHOxgN3L4rah8geMOLhA5vN9wITqU4FWD+G26tkEsCGHiOVDbISx+Se5rGZ+D1p0Jg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="],
|
||||
|
||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.46.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.46.2", "@rollup/rollup-android-arm64": "4.46.2", "@rollup/rollup-darwin-arm64": "4.46.2", "@rollup/rollup-darwin-x64": "4.46.2", "@rollup/rollup-freebsd-arm64": "4.46.2", "@rollup/rollup-freebsd-x64": "4.46.2", "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", "@rollup/rollup-linux-arm-musleabihf": "4.46.2", "@rollup/rollup-linux-arm64-gnu": "4.46.2", "@rollup/rollup-linux-arm64-musl": "4.46.2", "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", "@rollup/rollup-linux-ppc64-gnu": "4.46.2", "@rollup/rollup-linux-riscv64-gnu": "4.46.2", "@rollup/rollup-linux-riscv64-musl": "4.46.2", "@rollup/rollup-linux-s390x-gnu": "4.46.2", "@rollup/rollup-linux-x64-gnu": "4.46.2", "@rollup/rollup-linux-x64-musl": "4.46.2", "@rollup/rollup-win32-arm64-msvc": "4.46.2", "@rollup/rollup-win32-ia32-msvc": "4.46.2", "@rollup/rollup-win32-x64-msvc": "4.46.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"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.1", "", {}, "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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.17", "", { "dependencies": { "style-to-object": "1.0.9" } }, "sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.9", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw=="],
|
||||
|
||||
"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.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"toggle-selection": ["toggle-selection@1.0.6", "", {}, "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="],
|
||||
|
||||
"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.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="],
|
||||
|
||||
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"typescript-eslint": ["typescript-eslint@8.39.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.39.1", "@typescript-eslint/parser": "8.39.1", "@typescript-eslint/typescript-estree": "8.39.1", "@typescript-eslint/utils": "8.39.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg=="],
|
||||
|
||||
"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-filter": ["unist-util-filter@5.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-pHx7D4Zt6+TsfwylH9+lYhBhzyhEnCXs/lbq/Hstxno5z4gVdyc2WEW0asfjGKPyG4pEKrnBv5hdkO6+aRnQJw=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
|
||||
|
||||
"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.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
||||
|
||||
"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.1.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.14" }, "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-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
|
||||
|
||||
"@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.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"@uiw/react-markdown-preview/rehype-prism-plus": ["rehype-prism-plus@2.0.0", "", { "dependencies": { "hast-util-to-string": "^3.0.0", "parse-numeric-range": "^1.3.0", "refractor": "^4.8.0", "rehype-parse": "^9.0.0", "unist-util-filter": "^5.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-FeM/9V2N7EvDZVdR2dqhAzlw5YI49m9Tgn7ZrYJeYHIahM6gcXpH0K1y2gNnKanZCydOMluJvX2cB9z3lhY8XQ=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||
|
||||
"hast-util-parse-selector/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
|
||||
"hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
||||
|
||||
"hastscript/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
|
||||
"hastscript/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="],
|
||||
|
||||
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"refractor/@types/hast": ["@types/hast@2.3.10", "", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"hast-util-from-parse5/hastscript/hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||
|
||||
"hast-util-parse-selector/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"refractor/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
}
|
||||
}
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
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,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
15
web/index.html
Normal file
15
web/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Foxel</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
35
web/package.json
Normal file
35
web/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"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": "5.x",
|
||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||
"@uiw/react-md-editor": "^4.0.8",
|
||||
"antd": "^5.27.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.33.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"eslint": "^9.33.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.39.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
7
web/public/logo.svg
Normal file
7
web/public/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.6 KiB |
54
web/src/App.tsx
Normal file
54
web/src/App.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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 { Spin } from 'antd';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import SetupPage from './pages/SetupPage.tsx';
|
||||
|
||||
function App() {
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
useEffect(() => {
|
||||
async function checkInitialization() {
|
||||
try {
|
||||
const status = await getStatus();
|
||||
setStatus(status);
|
||||
document.title = status.title;
|
||||
const favicon = document.querySelector("link[rel*='icon']") as HTMLLinkElement;
|
||||
if (favicon) {
|
||||
favicon.href = 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>
|
||||
{!status.is_initialized ? (
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route path="*" element={<Navigate to="/setup" replace />} />
|
||||
</Routes>
|
||||
) : (
|
||||
<AppRouter />
|
||||
)}
|
||||
</AuthProvider>
|
||||
</SystemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
34
web/src/api/adapters.ts
Normal file
34
web/src/api/adapters.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import request from './client';
|
||||
|
||||
export interface AdapterItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
config: any;
|
||||
enabled: boolean;
|
||||
mount_path?: string | null;
|
||||
sub_path?: string | null;
|
||||
}
|
||||
|
||||
export interface AdapterTypeField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'password' | 'number';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
default?: any;
|
||||
}
|
||||
|
||||
export interface AdapterTypeMeta {
|
||||
type: string;
|
||||
name: 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'),
|
||||
};
|
||||
45
web/src/api/auth.ts
Normal file
45
web/src/api/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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 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');
|
||||
},
|
||||
};
|
||||
38
web/src/api/backup.ts
Normal file
38
web/src/api/backup.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import request from './client';
|
||||
|
||||
export const backupApi = {
|
||||
export: async () => {
|
||||
const response = await request('/backup/export', {
|
||||
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) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return request('/backup/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
},
|
||||
};
|
||||
76
web/src/api/client.ts
Normal file
76
web/src/api/client.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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 (_) { }
|
||||
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 } from './share';
|
||||
export default request;
|
||||
27
web/src/api/config.ts
Normal file
27
web/src/api/config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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) {
|
||||
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 interface SystemStatus {
|
||||
version: string;
|
||||
title: string;
|
||||
logo: string;
|
||||
is_initialized: boolean;
|
||||
}
|
||||
|
||||
export async function status() {
|
||||
return request<SystemStatus>('/config/status');
|
||||
}
|
||||
54
web/src/api/logs.ts
Normal file
54
web/src/api/logs.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import request from './client';
|
||||
|
||||
export interface LogItem {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
level: string;
|
||||
source: string;
|
||||
message: string;
|
||||
details: Record<string, any>;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedLogs {
|
||||
items: LogItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface GetLogsParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
level?: string;
|
||||
source?: string;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export interface ClearLogsParams {
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
}
|
||||
|
||||
export const logsApi = {
|
||||
list: (params: GetLogsParams = {}) => {
|
||||
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.level) query.append('level', params.level);
|
||||
if (params.source) query.append('source', params.source);
|
||||
if (params.start_time) query.append('start_time', params.start_time);
|
||||
if (params.end_time) query.append('end_time', params.end_time);
|
||||
return request<PaginatedLogs>(`/logs?${query.toString()}`);
|
||||
},
|
||||
clear: (params: ClearLogsParams = {}) => {
|
||||
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);
|
||||
return request<{ deleted_count: number }>(`/logs?${query.toString()}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
};
|
||||
39
web/src/api/processors.ts
Normal file
39
web/src/api/processors.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const processorsApi = {
|
||||
list: () => request<ProcessorTypeMeta[]>('/processors', {
|
||||
method: 'GET'
|
||||
}),
|
||||
process: (params: {
|
||||
path: string;
|
||||
processor_type: string;
|
||||
config: any;
|
||||
save_to?: string;
|
||||
overwrite?: boolean;
|
||||
}) =>
|
||||
request<any>('/processors/process', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}),
|
||||
};
|
||||
39
web/src/api/share.ts
Normal file
39
web/src/api/share.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
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 ShareCreatePayload {
|
||||
name: string;
|
||||
paths: string[];
|
||||
expires_in_days?: number;
|
||||
access_type: 'public' | 'password';
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export const shareApi = {
|
||||
create: (payload: ShareCreatePayload) => request<ShareInfo>('/shares', { method: 'POST', json: payload }),
|
||||
list: () => request<ShareInfo[]>('/shares'),
|
||||
remove: (shareId: number) => request<void>(`/shares/${shareId}`, { 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;
|
||||
},
|
||||
};
|
||||
22
web/src/api/tasks.ts
Normal file
22
web/src/api/tasks.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import request from './client';
|
||||
|
||||
export interface AutomationTask {
|
||||
id: number;
|
||||
name: string;
|
||||
event: string;
|
||||
path_pattern?: string;
|
||||
filename_regex?: string;
|
||||
processor_type: string;
|
||||
processor_config: Record<string, any>;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export type AutomationTaskCreate = Omit<AutomationTask, 'id'>;
|
||||
export type AutomationTaskUpdate = Partial<AutomationTaskCreate>;
|
||||
|
||||
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' }),
|
||||
};
|
||||
92
web/src/api/vfs.ts
Normal file
92
web/src/api/vfs.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import request, { API_BASE_URL } from './client';
|
||||
|
||||
export interface VfsEntry {
|
||||
name: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
mtime: number;
|
||||
type?: string;
|
||||
is_image?: boolean;
|
||||
}
|
||||
|
||||
export interface DirListing {
|
||||
path: string;
|
||||
entries: VfsEntry[];
|
||||
pagination?: {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
id: number;
|
||||
path: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export const vfsApi = {
|
||||
list: (path: string, page: number = 1, pageSize: number = 50) => {
|
||||
const cleaned = path.replace(/\\/g, '/');
|
||||
const trimmed = cleaned === '/' ? '' : cleaned.replace(/^\/+/, '');
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString()
|
||||
});
|
||||
return request<DirListing>(`/fs/${encodeURI(trimmed)}?${params}`);
|
||||
},
|
||||
readFile: (path: string) => request<ArrayBuffer>(`/fs/file/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||
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) => request('/fs/move', { 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) => request<{token: string}>(`/fs/temp-link/${encodeURI(path.replace(/^\/+/, ''))}`),
|
||||
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 (e) {
|
||||
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 (_) {}
|
||||
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') =>
|
||||
request<{ items: SearchResultItem[]; query: string }>(`/search?q=${encodeURIComponent(q)}&top_k=${top_k}&mode=${mode}`),
|
||||
};
|
||||
347
web/src/apps/AppWindowsLayer.tsx
Normal file
347
web/src/apps/AppWindowsLayer.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import { Space, Button } from 'antd';
|
||||
import { FullscreenExitOutlined, FullscreenOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import type { AppDescriptor, AppComponentProps } from './types';
|
||||
import type { VfsEntry } from '../api/client';
|
||||
|
||||
export interface AppWindowItem {
|
||||
id: string;
|
||||
app: AppDescriptor;
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
maximized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface AppWindowsLayerProps {
|
||||
windows: AppWindowItem[];
|
||||
onClose: (id: string) => void;
|
||||
onToggleMax: (id: string) => void;
|
||||
onBringToFront: (id: string) => void;
|
||||
onUpdateWindow: (id: string, patch: Partial<AppWindowItem>) => 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;
|
||||
let newX = Math.max(0, originX + dx);
|
||||
let newY = Math.max(48, 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'
|
||||
}}
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{windows.map((w, idx) => {
|
||||
const AppComp = w.app.component as React.FC<AppComponentProps>;
|
||||
const useSystemWindow = w.app.useSystemWindow !== false; // 默认为 true
|
||||
if (!useSystemWindow) {
|
||||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
ref={el => { windowEls.current[w.id] = el; }}
|
||||
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'
|
||||
}}
|
||||
>
|
||||
<AppComp
|
||||
filePath={w.filePath}
|
||||
entry={w.entry}
|
||||
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: 'rgba(240, 242, 245, 0.7)', // Semi-transparent background
|
||||
border: '1px solid 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(20px) saturate(180%)', // Enhanced blur effect
|
||||
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: 'rgba(0, 0, 0, 0.25)', // Lighter, transparent title bar
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: '#333', // Darker text for readability
|
||||
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
|
||||
}}
|
||||
>
|
||||
{w.app.name} - {w.entry.name}
|
||||
</span>
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={w.maximized ? '还原' : '最大化'}
|
||||
icon={w.maximized ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
|
||||
onClick={() => onToggleMax(w.id)}
|
||||
style={{
|
||||
color: '#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: '#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)}
|
||||
<AppComp
|
||||
filePath={w.filePath}
|
||||
entry={w.entry}
|
||||
onRequestClose={() => onClose(w.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
394
web/src/apps/ImageViewer/ImageViewer.tsx
Normal file
394
web/src/apps/ImageViewer/ImageViewer.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { Spin, Typography, Button, Tooltip } from 'antd';
|
||||
import { ZoomInOutlined, ZoomOutOutlined, ReloadOutlined, CompressOutlined, CloseOutlined, RotateRightOutlined } from '@ant-design/icons';
|
||||
|
||||
export const ImageViewerApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [rotate, setRotate] = useState(0);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const lastPointer = useRef<{ x: number; y: number } | null>(null);
|
||||
const lastDistance = useRef<number | null>(null);
|
||||
const transitionRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true); setErr(undefined);
|
||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
const publicUrl = vfsApi.getTempPublicUrl(res.token);
|
||||
setUrl(publicUrl);
|
||||
})
|
||||
.catch(e => !cancelled && setErr(e.message || '加载失败'))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => { cancelled = true; };
|
||||
}, [filePath]);
|
||||
|
||||
useEffect(() => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setRotate(0);
|
||||
}, [url]);
|
||||
|
||||
const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
|
||||
const applyOffset = (next: { x: number; y: number }) => {
|
||||
setOffset(next);
|
||||
};
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
transitionRef.current = false;
|
||||
};
|
||||
const onMouseMove = (e: React.MouseEvent) => {
|
||||
if (!isDragging || !lastPointer.current) return;
|
||||
e.preventDefault();
|
||||
const dx = e.clientX - lastPointer.current.x;
|
||||
const dy = e.clientY - lastPointer.current.y;
|
||||
lastPointer.current = { x: e.clientX, y: e.clientY };
|
||||
applyOffset({ x: offset.x + dx, y: offset.y + dy });
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
lastPointer.current = null;
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const cont = containerRef.current;
|
||||
const img = imgRef.current;
|
||||
if (!cont || !img) return;
|
||||
const rect = cont.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
const nextScale = scale > 1.5 ? 1 : 2.5;
|
||||
const ratio = nextScale / scale;
|
||||
const nextOffset = { x: offset.x - cx * (ratio - 1), y: offset.y - cy * (ratio - 1) };
|
||||
setScale(nextScale);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 200);
|
||||
applyOffset(nextOffset);
|
||||
};
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY;
|
||||
const zoomFactor = delta > 0 ? 1.12 : 0.88;
|
||||
const cont = containerRef.current;
|
||||
if (!cont) return;
|
||||
const rect = cont.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left - rect.width / 2;
|
||||
const cy = e.clientY - rect.top - rect.height / 2;
|
||||
|
||||
const nextScale = clamp(scale * zoomFactor, 0.5, 5);
|
||||
const ratio = nextScale / scale;
|
||||
const nextOffset = { x: offset.x - cx * (ratio - 1), y: offset.y - cy * (ratio - 1) };
|
||||
setScale(nextScale);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 120);
|
||||
applyOffset(nextOffset);
|
||||
};
|
||||
|
||||
const getTouchDistance = (t1: { clientX: number; clientY: number }, t2: { clientX: number; clientY: number }) =>
|
||||
Math.hypot(t1.clientX - t2.clientX, t1.clientY - t2.clientY);
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1) {
|
||||
const t = e.touches[0];
|
||||
lastPointer.current = { x: t.clientX, y: t.clientY };
|
||||
} else if (e.touches.length === 2) {
|
||||
lastDistance.current = getTouchDistance(e.touches[0], e.touches[1]);
|
||||
}
|
||||
transitionRef.current = false;
|
||||
};
|
||||
const onTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 1 && lastPointer.current) {
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - lastPointer.current.x;
|
||||
const dy = t.clientY - lastPointer.current.y;
|
||||
lastPointer.current = { x: t.clientX, y: t.clientY };
|
||||
applyOffset({ x: offset.x + dx, y: offset.y + dy });
|
||||
} else if (e.touches.length === 2 && lastDistance.current) {
|
||||
const d = getTouchDistance(e.touches[0], e.touches[1]);
|
||||
const ratio = d / lastDistance.current;
|
||||
const nextScale = clamp(scale * ratio, 0.5, 5);
|
||||
setScale(nextScale);
|
||||
lastDistance.current = d;
|
||||
}
|
||||
};
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 0) {
|
||||
lastPointer.current = null;
|
||||
lastDistance.current = null;
|
||||
}
|
||||
};
|
||||
const doZoom = (factor: number) => {
|
||||
const nextScale = clamp(scale * factor, 0.5, 5);
|
||||
setScale(nextScale);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 120);
|
||||
applyOffset(offset);
|
||||
};
|
||||
const resetView = () => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setRotate(0);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 150);
|
||||
};
|
||||
const fitToContainer = () => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
setRotate(0);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 150);
|
||||
};
|
||||
const doRotate = () => {
|
||||
setRotate(r => (r + 90) % 360);
|
||||
transitionRef.current = true;
|
||||
setTimeout(() => transitionRef.current = false, 180);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
}}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (err) {
|
||||
return (
|
||||
<div style={{
|
||||
color: '#f5222d',
|
||||
padding: 16,
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
}}>
|
||||
加载失败: {err}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!url) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: 16,
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)'
|
||||
}}>
|
||||
无内容
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onWheel={onWheel}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
onMouseDown={onMouseDown}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
background: 'rgba(20,20,20,0.8)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
>
|
||||
{/* 顶部栏:文件名和关闭按钮 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 32,
|
||||
left: 32,
|
||||
right: 32,
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<Typography.Paragraph
|
||||
style={{
|
||||
color: '#fff',
|
||||
margin: 0,
|
||||
fontSize: 15,
|
||||
background: 'rgba(0,0,0,0.32)',
|
||||
padding: '7px 18px',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
maxWidth: '60vw',
|
||||
textAlign: 'left',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
ellipsis
|
||||
>
|
||||
{entry.name} <span style={{ opacity: 0.7, fontSize: 13 }}>({(entry.size / 1024).toFixed(1)} KB)</span>
|
||||
</Typography.Paragraph>
|
||||
<Tooltip title="关闭">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
type="text"
|
||||
onClick={() => onRequestClose && onRequestClose()}
|
||||
icon={<CloseOutlined />}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* 图片居中显示 */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={url}
|
||||
alt={entry.name}
|
||||
draggable={false}
|
||||
onDragStart={e => e.preventDefault()}
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotate}deg)`,
|
||||
transition: transitionRef.current ? 'transform 0.18s cubic-bezier(.4,.8,.4,1)' : undefined,
|
||||
maxWidth: '80vw',
|
||||
maxHeight: '80vh',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 18,
|
||||
boxShadow: '0 8px 40px 0 rgba(0,0,0,0.45)',
|
||||
cursor: isDragging ? 'grabbing' : (scale > 1 ? 'grab' : 'zoom-in'),
|
||||
willChange: 'transform'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 操作按钮组 */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 32,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
display: 'flex',
|
||||
gap: 18,
|
||||
zIndex: 80
|
||||
}}>
|
||||
<Tooltip title="缩小">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ZoomOutOutlined style={{ fontSize: 22 }} />}
|
||||
onClick={() => doZoom(0.8)}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="放大">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ZoomInOutlined style={{ fontSize: 22 }} />}
|
||||
onClick={() => doZoom(1.25)}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="旋转">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<RotateRightOutlined style={{ fontSize: 20 }} />}
|
||||
onClick={doRotate}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重置">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<ReloadOutlined style={{ fontSize: 20 }} />}
|
||||
onClick={resetView}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="适应窗口">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
icon={<CompressOutlined style={{ fontSize: 20 }} />}
|
||||
onClick={fitToContainer}
|
||||
style={{
|
||||
color: '#fff',
|
||||
background: 'rgba(30,30,30,0.55)',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.18)',
|
||||
border: 'none',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
17
web/src/apps/ImageViewer/index.ts
Normal file
17
web/src/apps/ImageViewer/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { ImageViewerApp } from './ImageViewer.tsx';
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'image-viewer',
|
||||
name: '图片查看器',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'].includes(ext);
|
||||
},
|
||||
component: ImageViewerApp,
|
||||
default: true,
|
||||
defaultMaximized:true,
|
||||
useSystemWindow:false,
|
||||
defaultBounds: { width: 820, height: 620, x: 140, y: 96 }
|
||||
};
|
||||
80
web/src/apps/OfficeViewer/OfficeViewer.tsx
Normal file
80
web/src/apps/OfficeViewer/OfficeViewer.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { Spin, Result, Button } from 'antd';
|
||||
|
||||
export const OfficeViewerApp: React.FC<AppComponentProps> = ({ filePath, onRequestClose }) => {
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setErr(undefined);
|
||||
setUrl(undefined);
|
||||
|
||||
vfsApi.getTempLinkToken(filePath.replace(/^\/+/, ''))
|
||||
.then(res => {
|
||||
if (cancelled) return;
|
||||
// 注意:vfsApi.getTempPublicUrl 返回的是相对路径,我们需要构建完整的 URL
|
||||
const fullUrl = new URL(vfsApi.getTempPublicUrl(res.token), window.location.origin).href;
|
||||
const officeUrl = `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
|
||||
setUrl(officeUrl);
|
||||
})
|
||||
.catch(e => {
|
||||
if (!cancelled) {
|
||||
setErr(e.message || '加载文档链接失败');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filePath]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin tip="正在准备文档..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="无法加载文档"
|
||||
subTitle={err}
|
||||
extra={<Button type="primary" onClick={onRequestClose}>关闭</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: '#fff' }}>
|
||||
{url ? (
|
||||
<iframe
|
||||
src={url}
|
||||
width="100%"
|
||||
height="100%"
|
||||
frameBorder="0"
|
||||
title="Office Document Viewer"
|
||||
/>
|
||||
) : (
|
||||
<Result
|
||||
status="warning"
|
||||
title="文档链接无效"
|
||||
subTitle="未能成功生成文档的在线查看链接。"
|
||||
extra={<Button type="primary" onClick={onRequestClose}>关闭</Button>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
15
web/src/apps/OfficeViewer/index.ts
Normal file
15
web/src/apps/OfficeViewer/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { OfficeViewerApp } from './OfficeViewer.tsx';
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'office-viewer',
|
||||
name: 'Office 文档查看器',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ['docx', 'xlsx', 'pptx', 'doc', 'xls', 'ppt'].includes(ext);
|
||||
},
|
||||
component: OfficeViewerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 150, y: 100 }
|
||||
};
|
||||
104
web/src/apps/TextEditor/TextEditor.tsx
Normal file
104
web/src/apps/TextEditor/TextEditor.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Layout, Spin, Button, Space, message } from 'antd';
|
||||
import MDEditor from '@uiw/react-md-editor';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { vfsApi } from '../../api/vfs';
|
||||
|
||||
const { Header, Content } = Layout;
|
||||
|
||||
export const TextEditorApp: React.FC<AppComponentProps> = ({ filePath, entry, onRequestClose }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
const [initialContent, setInitialContent] = useState('');
|
||||
const isDirty = content !== initialContent;
|
||||
|
||||
// 使用 ref 来持有最新的 onRequestClose 函数,避免它成为 effect 的依赖项
|
||||
const onRequestCloseRef = useRef(onRequestClose);
|
||||
onRequestCloseRef.current = onRequestClose;
|
||||
|
||||
useEffect(() => {
|
||||
const loadFile = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await vfsApi.readFile(filePath);
|
||||
const text = typeof data === 'string' ? data : new TextDecoder().decode(data);
|
||||
setContent(text);
|
||||
setInitialContent(text);
|
||||
} catch (error) {
|
||||
message.error(`加载文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
onRequestCloseRef.current();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
loadFile();
|
||||
}, [filePath]); // effect 只依赖 filePath,因此只在文件路径变化时执行一次
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isDirty) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
await vfsApi.uploadFile(filePath, blob);
|
||||
setInitialContent(content);
|
||||
message.success('保存成功');
|
||||
} catch (error) {
|
||||
message.error(`保存文件失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [content, filePath, isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
||||
event.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [handleSave]);
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100%', background: '#ffffff' }}>
|
||||
<Header
|
||||
style={{
|
||||
background: '#f0f2f5',
|
||||
padding: '0 16px',
|
||||
height: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
borderBottom: '1px solid #d9d9d9'
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'rgba(0, 0, 0, 0.88)' }}>
|
||||
{entry.name} {isDirty && '*'}
|
||||
</span>
|
||||
<Space>
|
||||
<Button type="primary" size="small" onClick={handleSave} loading={saving} disabled={!isDirty}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</Header>
|
||||
<Content style={{ position: 'relative', overflow: 'auto', height: 'calc(100% - 40px)' }}>
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : (
|
||||
<MDEditor
|
||||
value={content}
|
||||
onChange={(val) => setContent(val || '')}
|
||||
height="100%"
|
||||
preview="live"
|
||||
/>
|
||||
)}
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
16
web/src/apps/TextEditor/index.ts
Normal file
16
web/src/apps/TextEditor/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { TextEditorApp } from './TextEditor.tsx';
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'text-editor',
|
||||
name: '文本编辑器',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
// Supports common text and markdown formats
|
||||
return ['txt', 'md', 'markdown', 'json', 'yaml', 'yml', 'xml', 'html', 'css', 'js', 'ts', 'py', 'sh', 'log'].includes(ext);
|
||||
},
|
||||
component: TextEditorApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 1024, height: 768, x: 120, y: 80 }
|
||||
};
|
||||
408
web/src/apps/VideoPlayer/VideoPlayer.tsx
Normal file
408
web/src/apps/VideoPlayer/VideoPlayer.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { vfsApi } from '../../api/client';
|
||||
import type { AppComponentProps } from '../types';
|
||||
import { Spin, Button } from 'antd';
|
||||
import {
|
||||
PauseOutlined,
|
||||
CaretRightOutlined,
|
||||
SoundOutlined,
|
||||
FullscreenOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export const VideoPlayerApp: React.FC<AppComponentProps> = ({ filePath }) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const progressBarRef = useRef<HTMLDivElement | null>(null);
|
||||
const progressRef = useRef<HTMLDivElement | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(0.7);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string>();
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [retryKey, setRetryKey] = useState(0);
|
||||
const controlsTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (controlsTimerRef.current) {
|
||||
window.clearTimeout(controlsTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const safePath = filePath.replace(/^\/+/, '').split('#').map((seg, idx) => idx === 0 ? seg : encodeURIComponent('#') + seg).join('');
|
||||
const u = vfsApi.streamUrl(safePath);
|
||||
setUrl(u);
|
||||
setErr(undefined);
|
||||
setLoading(true);
|
||||
}, [filePath, retryKey]);
|
||||
|
||||
// 处理视频事件
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (isMountedRef.current) {
|
||||
setDuration(video.duration);
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (isMountedRef.current) {
|
||||
setCurrentTime(video.currentTime);
|
||||
updateProgressBar();
|
||||
}
|
||||
};
|
||||
|
||||
const onCanPlay = () => {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
if (isMountedRef.current) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
setErr('视频加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
const onPlay = () => {
|
||||
if (isMountedRef.current) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onPause = () => {
|
||||
if (isMountedRef.current) {
|
||||
setIsPlaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onProgress = () => {
|
||||
// 监听缓冲进度
|
||||
if (video.buffered.length > 0) {
|
||||
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
|
||||
if (progressBarRef.current) {
|
||||
const bufferProgress = bufferedEnd / video.duration * 100;
|
||||
progressBarRef.current.style.setProperty('--buffer-width', `${bufferProgress}%`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('timeupdate', onTimeUpdate);
|
||||
video.addEventListener('canplay', onCanPlay);
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.addEventListener('error', onError);
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('progress', onProgress);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.removeEventListener('canplay', onCanPlay);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
video.removeEventListener('error', onError);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('progress', onProgress);
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
// 处理进度条更新
|
||||
const updateProgressBar = () => {
|
||||
const video = videoRef.current;
|
||||
const progress = progressRef.current;
|
||||
|
||||
if (video && progress && duration > 0) {
|
||||
const percentage = (video.currentTime / duration) * 100;
|
||||
progress.style.width = `${percentage}%`;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理进度条点击
|
||||
const handleProgressBarClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const progressBar = progressBarRef.current;
|
||||
const video = videoRef.current;
|
||||
|
||||
if (progressBar && video) {
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const clickPosition = e.clientX - rect.left;
|
||||
const percentage = clickPosition / rect.width;
|
||||
const newTime = percentage * duration;
|
||||
|
||||
video.currentTime = newTime;
|
||||
setCurrentTime(newTime);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放/暂停
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
video.play().catch(error => {
|
||||
console.error('播放失败:', error);
|
||||
setErr('播放失败');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 全屏
|
||||
const toggleFullscreen = () => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
if (!document.fullscreenElement) {
|
||||
container.requestFullscreen().catch(err => {
|
||||
console.error('全屏失败:', err);
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
// 音量控制
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseFloat(e.target.value);
|
||||
setVolume(newVolume);
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.volume = newVolume;
|
||||
setIsMuted(newVolume === 0);
|
||||
}
|
||||
};
|
||||
|
||||
// 静音切换
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const newMuted = !isMuted;
|
||||
setIsMuted(newMuted);
|
||||
video.muted = newMuted;
|
||||
};
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (isNaN(seconds)) return '00:00';
|
||||
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// 控制栏自动隐藏
|
||||
const resetControlsTimer = () => {
|
||||
if (controlsTimerRef.current) {
|
||||
window.clearTimeout(controlsTimerRef.current);
|
||||
}
|
||||
|
||||
setShowControls(true);
|
||||
|
||||
controlsTimerRef.current = window.setTimeout(() => {
|
||||
if (isPlaying && isMountedRef.current) {
|
||||
setShowControls(false);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
resetControlsTimer();
|
||||
};
|
||||
|
||||
const retry = () => setRetryKey(k => k + 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: '#000' }}
|
||||
ref={containerRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<div style={{ flex: 1, position: 'relative', backgroundColor: '#000', overflow: 'hidden' }}>
|
||||
{/* 视频元素 */}
|
||||
<video
|
||||
ref={videoRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
src={url}
|
||||
controlsList="nodownload"
|
||||
crossOrigin="anonymous"
|
||||
preload="metadata"
|
||||
onClick={togglePlay}
|
||||
/>
|
||||
|
||||
{/* 加载指示器 */}
|
||||
{loading && !err && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.35)', gap: 12 }}>
|
||||
<Spin />
|
||||
<span style={{ fontSize: 12, color: '#aaa' }}>正在缓冲...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误显示 */}
|
||||
{err && (
|
||||
<div style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)', gap: 12 }}>
|
||||
<span style={{ color: '#ff4d4f', fontSize: 13 }}>{err}</span>
|
||||
<Button icon={<ReloadOutlined />} size="small" onClick={retry}>重试</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制栏 */}
|
||||
{showControls && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
|
||||
padding: '30px 15px 10px',
|
||||
transition: 'opacity 0.3s',
|
||||
opacity: showControls ? 1 : 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
onClick={handleProgressBarClick}
|
||||
style={{
|
||||
height: '4px',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
borderRadius: '2px',
|
||||
'--buffer-width': '0%'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
width: 'var(--buffer-width)',
|
||||
backgroundColor: 'rgba(255,255,255,0.4)',
|
||||
borderRadius: '2px'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={progressRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '0%',
|
||||
backgroundColor: '#1890ff',
|
||||
position: 'relative',
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '-6px',
|
||||
top: '-4px',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#1890ff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isPlaying ? <PauseOutlined /> : <CaretRightOutlined />}
|
||||
onClick={togglePlay}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', width: '100px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SoundOutlined />}
|
||||
onClick={toggleMute}
|
||||
style={{ color: isMuted ? '#888' : '#fff' }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
style={{ width: '60px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#fff', fontSize: '12px' }}>
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FullscreenOutlined />}
|
||||
onClick={toggleFullscreen}
|
||||
style={{ color: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPlaying && !loading && !err && (
|
||||
<div
|
||||
onClick={togglePlay}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
<CaretRightOutlined style={{ fontSize: '24px', color: '#fff' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
15
web/src/apps/VideoPlayer/index.ts
Normal file
15
web/src/apps/VideoPlayer/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AppDescriptor } from '../types';
|
||||
import { VideoPlayerApp } from './VideoPlayer.tsx';
|
||||
|
||||
export const descriptor: AppDescriptor = {
|
||||
key: 'video-player',
|
||||
name: '视频播放器',
|
||||
supported: (entry) => {
|
||||
if (entry.is_dir) return false;
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
return ['mp4','webm','ogg','m4v','mov'].includes(ext);
|
||||
},
|
||||
component: VideoPlayerApp,
|
||||
default: true,
|
||||
defaultBounds: { width: 960, height: 600, x: 180, y: 120 }
|
||||
};
|
||||
41
web/src/apps/registry.ts
Normal file
41
web/src/apps/registry.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { VfsEntry } from '../api/client';
|
||||
import type { AppDescriptor } from './types';
|
||||
const apps: AppDescriptor[] = [];
|
||||
|
||||
// 使用 import.meta.glob 动态导入所有应用
|
||||
// vite-glob-ignore
|
||||
const appModules = import.meta.glob('./*/index.ts');
|
||||
|
||||
async function loadApps() {
|
||||
for (const path in appModules) {
|
||||
const module = await appModules[path]();
|
||||
if (module && typeof module === 'object' && 'descriptor' in module) {
|
||||
const descriptor = (module as { descriptor: AppDescriptor }).descriptor;
|
||||
if (!apps.find(a => a.key === descriptor.key)) {
|
||||
apps.push(descriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 立即加载并注册所有应用
|
||||
loadApps();
|
||||
|
||||
|
||||
export function getAppsForEntry(entry: VfsEntry): AppDescriptor[] {
|
||||
return apps.filter(a => a.supported(entry));
|
||||
}
|
||||
|
||||
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';
|
||||
29
web/src/apps/types.ts
Normal file
29
web/src/apps/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { VfsEntry } from '../api/client';
|
||||
|
||||
export interface AppComponentProps {
|
||||
filePath: string;
|
||||
entry: VfsEntry;
|
||||
onRequestClose: () => void;
|
||||
}
|
||||
|
||||
export interface AppDescriptor {
|
||||
key: string;
|
||||
name: string;
|
||||
supported: (entry: VfsEntry) => boolean;
|
||||
component: React.ComponentType<AppComponentProps>;
|
||||
default?: boolean;
|
||||
defaultMaximized?: boolean;
|
||||
/**
|
||||
* 应用窗口的默认位置与尺寸(非最大化时生效)
|
||||
* 任意字段缺省则按系统默认/级联偏移。
|
||||
*/
|
||||
defaultBounds?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
// 新增:是否使用系统窗口(带标题栏、拖拽与缩放)
|
||||
// 默认为 true;若设为 false 则渲染为无壳的嵌入式视图。
|
||||
useSystemWindow?: boolean;
|
||||
}
|
||||
61
web/src/components/ProcessorConfigForm.tsx
Normal file
61
web/src/components/ProcessorConfigForm.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Select, Typography } from 'antd';
|
||||
import type { ProcessorTypeMeta } from '../api/processors';
|
||||
|
||||
interface ProcessorConfigFormProps {
|
||||
processorMeta: ProcessorTypeMeta | undefined;
|
||||
form: any;
|
||||
configPath: string[];
|
||||
}
|
||||
|
||||
export const ProcessorConfigForm: React.FC<ProcessorConfigFormProps> = ({ processorMeta, configPath }) => {
|
||||
if (!processorMeta) {
|
||||
return <Typography.Text type="secondary">请先选择处理器</Typography.Text>;
|
||||
}
|
||||
if (!processorMeta.config_schema?.length) {
|
||||
return <Typography.Text type="secondary">该处理器无配置项</Typography.Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{processorMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: `请输入${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 || '请选择'}>
|
||||
{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={field.label}
|
||||
rules={rules}
|
||||
initialValue={field.default}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
46
web/src/contexts/AuthContext.tsx
Normal file
46
web/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { authApi } 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>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({} as any);
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(() => localStorage.getItem('token'));
|
||||
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);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
const register = async (username: string, password: string, email?: string, full_name?: string) => {
|
||||
await authApi.register(username, password, email, full_name);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ token, isAuthenticated, login, logout, register }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
12
web/src/contexts/SystemContext.tsx
Normal file
12
web/src/contexts/SystemContext.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { SystemStatus } from '../api/config';
|
||||
|
||||
export const SystemContext = createContext<SystemStatus | null>(null);
|
||||
|
||||
export const useSystemStatus = () => {
|
||||
const context = useContext(SystemContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSystemStatus must be used within a SystemProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
41
web/src/global.css
Normal file
41
web/src/global.css
Normal file
@@ -0,0 +1,41 @@
|
||||
html,body,#root { height: 100%; }
|
||||
body { font-family: system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,'Noto Sans',sans-serif; background:#f9f9f9; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #d9d9d9; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #bfbfbf; }
|
||||
|
||||
.fx-surface { background:#fff; border:1px solid #eaeaea; border-radius:12px; }
|
||||
.fx-card { background:linear-gradient(#fff,#fafafa); border:1px solid #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:#555; }
|
||||
|
||||
.fx-quiet-btn.ant-btn-text:not(:hover) { color:#666; }
|
||||
|
||||
.ant-layout { background:#f9f9f9; }
|
||||
|
||||
/* Menu compact spacing adjustments */
|
||||
.ant-menu-inline .ant-menu-item { margin-block:2px; }
|
||||
|
||||
/* Sidebar high-contrast selection */
|
||||
.sider-menu .ant-menu-item-selected {
|
||||
background:#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:#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:flex; flex-wrap:wrap; gap:20px; }
|
||||
.fx-grid-item { width:160px; cursor:pointer; border-radius:14px; padding:12px 12px 10px; background:#f5f5f5; position:relative; display:flex; flex-direction:column; align-items:stretch; gap:6px; transition:.18s box-shadow,.18s background; }
|
||||
.fx-grid-item.dir { background:#f3f3f3; }
|
||||
.fx-grid-item.selected { box-shadow:0 0 0 2px var(--ant-color-primary); background:#acc0c0; }
|
||||
.fx-grid-item:hover { background:#d2d1d1a7; box-shadow:0 1px 4px rgba(0,0,0,.06); }
|
||||
.fx-grid-item .thumb { height:120px; border-radius:10px; background:#fff; display:flex; align-items:center; justify-content:center; overflow:hidden; position:relative; box-shadow: inset 0 0 0 1px #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:#111; color:#fff; font-size:10px; padding:2px 4px; border-radius:6px; line-height:1; letter-spacing:.5px; }
|
||||
.fx-grid-item .name { font-weight:600; font-size:13px; }
|
||||
.ellipsis { overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
|
||||
128
web/src/layout/SearchDialog.tsx
Normal file
128
web/src/layout/SearchDialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Modal, Input, List, Divider, Spin, Select, Space } from 'antd';
|
||||
import { SearchOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { vfsApi, type SearchResultItem } from '../api/vfs';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
|
||||
interface SearchDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SEARCH_MODES = [
|
||||
{ label: '智能搜索', value: 'vector' },
|
||||
{ label: '名称搜索', value: 'filename' },
|
||||
];
|
||||
|
||||
const SearchDialog: React.FC<SearchDialogProps> = ({ open, onClose }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<'vector' | 'filename'>('vector');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!search.trim()) return;
|
||||
setLoading(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const res = await vfsApi.searchFiles(search, 10, searchMode);
|
||||
setResults(res.items);
|
||||
} catch (e) {
|
||||
setResults([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={600}
|
||||
centered
|
||||
title={null}
|
||||
closable={false}
|
||||
>
|
||||
<Space.Compact style={{ marginBottom: 0, width: '100%' }}>
|
||||
<Select
|
||||
options={SEARCH_MODES}
|
||||
value={searchMode}
|
||||
onChange={v => setSearchMode(v as 'vector' | 'filename')}
|
||||
style={{
|
||||
width: 120,
|
||||
fontSize: 18,
|
||||
height: 40,
|
||||
lineHeight: '40px',
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderRight: 0,
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
styles={{ popup: { root: { fontSize: 18 } } }}
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
<Input
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索文件 / 标签 / 类型"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
style={{
|
||||
fontSize: 18,
|
||||
height: 40,
|
||||
width: 'calc(100% - 120px)',
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
verticalAlign: 'top',
|
||||
}}
|
||||
autoFocus
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
</Space.Compact>
|
||||
{searched && (
|
||||
<>
|
||||
<Divider style={{ margin: '12px 0' }}>搜索结果</Divider>
|
||||
{loading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<List
|
||||
itemLayout="horizontal"
|
||||
dataSource={results}
|
||||
locale={{ emptyText: '未找到相关文件' }}
|
||||
renderItem={item => {
|
||||
const fullPath = item.path || '';
|
||||
const trimmed = fullPath.replace(/\/+$/, '');
|
||||
const parts = trimmed.split('/');
|
||||
const filename = parts.pop() || '';
|
||||
const dir = parts.length ? '/' + parts.join('/') : '/';
|
||||
return (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
avatar={<FileTextOutlined />}
|
||||
title={
|
||||
<a
|
||||
onClick={() => {
|
||||
navigate(`/files${dir === '/' ? '' : dir}`, { state: { highlight: { name: filename } } });
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{fullPath}
|
||||
</a>
|
||||
}
|
||||
description={`相关度: ${item.score.toFixed(2)}`}
|
||||
/>
|
||||
</List.Item>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDialog;
|
||||
97
web/src/layout/SideNav.tsx
Normal file
97
web/src/layout/SideNav.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Layout, Menu, theme, Button } from 'antd';
|
||||
import { navGroups } from './nav.ts';
|
||||
import type { NavItem, NavGroup } from './nav.ts';
|
||||
import { memo } from 'react';
|
||||
import { useSystemStatus } from '../contexts/SystemContext.tsx';
|
||||
import { MenuFoldOutlined } from '@ant-design/icons';
|
||||
import '../styles/sider-menu.css';
|
||||
|
||||
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();
|
||||
return (
|
||||
<Sider
|
||||
collapsedWidth={60}
|
||||
collapsible
|
||||
trigger={null}
|
||||
collapsed={collapsed}
|
||||
width={208}
|
||||
style={{ background: token.colorBgContainer, borderRight: `1px solid ${token.colorBorderSecondary}` }}
|
||||
>
|
||||
<div style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: collapsed ? 'center' : 'space-between',
|
||||
padding: '0 14px',
|
||||
fontWeight: 600,
|
||||
fontSize: 18,
|
||||
letterSpacing: .5
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<img
|
||||
src={status?.logo}
|
||||
alt="Foxel"
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
objectFit: 'contain',
|
||||
marginRight: collapsed ? 0 : 8,
|
||||
...(status?.logo?.endsWith('.svg') && { filter: 'brightness(0) saturate(100%)' })
|
||||
}}
|
||||
/>
|
||||
{!collapsed && <span style={{ fontWeight: 700 }}>{status?.title}</span>}
|
||||
</div>
|
||||
{/* 展开时显示收缩按钮 */}
|
||||
{!collapsed && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<MenuFoldOutlined />}
|
||||
onClick={onToggle}
|
||||
style={{ fontSize: 18 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 分组渲染 */}
|
||||
<div style={{ overflowY: 'auto', height: 'calc(100% - 56px)', padding: '4px 4px 8px' }}>
|
||||
{navGroups.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'
|
||||
}}
|
||||
>{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: i.label }))}
|
||||
style={{ borderInline: 'none', background: 'transparent' }}
|
||||
className="sider-menu-group foxel-sider-menu"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Sider>
|
||||
);
|
||||
});
|
||||
|
||||
export default SideNav;
|
||||
58
web/src/layout/TopHeader.tsx
Normal file
58
web/src/layout/TopHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Layout, Button, Dropdown, theme, Flex } from 'antd';
|
||||
import { SearchOutlined, UserOutlined, MenuUnfoldOutlined, LogoutOutlined } 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';
|
||||
|
||||
const { Header } = Layout;
|
||||
|
||||
export interface TopHeaderProps {
|
||||
collapsed: boolean;
|
||||
onToggle(): void;
|
||||
}
|
||||
|
||||
const TopHeader = memo(function TopHeader({ collapsed, onToggle }: TopHeaderProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogout = () => {
|
||||
authApi.logout();
|
||||
navigate('/login', { replace: 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)}
|
||||
>
|
||||
搜索文件 / 标签 / 类型
|
||||
</Button>
|
||||
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
|
||||
<Flex style={{ marginLeft: 'auto' }} align="center" gap={12}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'logout', label: '退出登录', icon: <LogoutOutlined />, onClick: handleLogout }
|
||||
]
|
||||
}}
|
||||
>
|
||||
<Button icon={<UserOutlined />}>管理员</Button>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
||||
export default TopHeader;
|
||||
46
web/src/layout/nav.ts
Normal file
46
web/src/layout/nav.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FolderOpenOutlined,
|
||||
ApiOutlined,
|
||||
ShareAltOutlined,
|
||||
CloudDownloadOutlined,
|
||||
SettingOutlined,
|
||||
RobotOutlined,
|
||||
BugOutlined,
|
||||
DatabaseOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface NavItem { key: string; icon: ReactNode; label: string; }
|
||||
export interface NavGroup { key: string; title?: string; children: NavItem[]; }
|
||||
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
key: 'library',
|
||||
title: '',
|
||||
children: [
|
||||
{ key: 'files', icon: React.createElement(FolderOpenOutlined), label: '全部文件' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'manage',
|
||||
title: '管理',
|
||||
children: [
|
||||
{ key: 'tasks', icon: React.createElement(RobotOutlined), label: '自动化' },
|
||||
{ key: 'share', icon: React.createElement(ShareAltOutlined), label: '我的分享' },
|
||||
{ key: 'offline', icon: React.createElement(CloudDownloadOutlined), label: '离线下载' },
|
||||
{ key: 'adapters', icon: React.createElement(ApiOutlined), label: '存储挂载' },
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
title: '系统',
|
||||
children: [
|
||||
{ key: 'settings', icon: React.createElement(SettingOutlined), label: '系统设置' },
|
||||
{ key: 'backup', icon: React.createElement(DatabaseOutlined), label: '备份恢复' },
|
||||
{ key: 'logs', icon: React.createElement(BugOutlined), label: '系统日志' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const primaryNav: NavItem[] = navGroups.flatMap(g => g.children);
|
||||
17
web/src/main.tsx
Normal file
17
web/src/main.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import '@ant-design/v5-patch-for-react-19';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import 'antd/dist/reset.css';
|
||||
import foxelTheme from './theme';
|
||||
import './global.css';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider locale={zhCN} theme={foxelTheme}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
);
|
||||
281
web/src/pages/AdaptersPage.tsx
Normal file
281
web/src/pages/AdaptersPage.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select, Card } from 'antd';
|
||||
import { adaptersApi } from '../api/client';
|
||||
|
||||
interface AdapterItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
config: any;
|
||||
enabled: boolean;
|
||||
mount_path?: string | null;
|
||||
sub_path?: string | null;
|
||||
}
|
||||
|
||||
interface AdapterTypeField {
|
||||
key: string;
|
||||
label: string;
|
||||
type: 'string' | 'password' | 'number';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
default?: any;
|
||||
}
|
||||
interface AdapterTypeMeta {
|
||||
type: string;
|
||||
name: string;
|
||||
config_schema: AdapterTypeField[];
|
||||
}
|
||||
|
||||
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 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 || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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,
|
||||
mount_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,
|
||||
mount_path: rec.mount_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('缺少必填配置: ' + miss.join(', '));
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
name: values.name.trim(),
|
||||
type: values.type,
|
||||
mount_path: values.mount_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('更新成功');
|
||||
} else {
|
||||
await adaptersApi.create(body as any);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return; // 表单校验
|
||||
message.error(e.message || '操作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async (rec: AdapterItem) => {
|
||||
try {
|
||||
await adaptersApi.remove(rec.id);
|
||||
message.success('已删除');
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnabled = async (rec: AdapterItem, checked: boolean) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await adaptersApi.update(rec.id, { ...rec, enabled: checked });
|
||||
message.success('状态已更新');
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '类型', dataIndex: 'type', width: 100 },
|
||||
{ title: '挂载路径', dataIndex: 'mount_path', width: 140, render: (v: string) => v || '-' },
|
||||
{ title: '子路径', dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (v: boolean, rec: AdapterItem) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
size="small"
|
||||
loading={loading}
|
||||
onChange={checked => handleToggleEnabled(rec, checked)}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_: any, rec: AdapterItem) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEdit(rec)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>删除</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">无配置项</Typography.Text>;
|
||||
return currentTypeMeta.config_schema.map(field => {
|
||||
const rules = field.required ? [{ required: true, message: `请输入${field.label}` }] : [];
|
||||
let inputNode: any = <Input placeholder={field.placeholder} />;
|
||||
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
|
||||
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={['config', field.key]}
|
||||
label={field.label}
|
||||
rules={rules}
|
||||
>
|
||||
{inputNode}
|
||||
</Form.Item>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="存储适配器"
|
||||
style={{ margin: 0 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button type="primary" onClick={openCreate}>新建适配器</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
columns={columns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Drawer
|
||||
title={editing ? `编辑: ${editing.name}` : '新建适配器'}
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={() => { setOpen(false); setEditing(null); }}
|
||||
destroyOnClose
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>取消</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>提交</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ enabled: true }}
|
||||
>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input placeholder="唯一名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择适配器类型"
|
||||
options={availableTypes.map(t => ({ value: t.type, label: `${t.name} (${t.type})` }))}
|
||||
onChange={() => {
|
||||
// 切换类型时刷新默认 config
|
||||
const t = availableTypes.find(v => v.type === form.getFieldValue('type'));
|
||||
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="mount_path" label="挂载路径" rules={[{ required: true, message: '请输入挂载路径' }]}>
|
||||
<Input placeholder="/或/drive" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sub_path" label="子路径(可选)">
|
||||
<Input placeholder="适配器内部子目录" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>适配器配置</Typography.Title>
|
||||
{renderConfigFields()}
|
||||
</Form>
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default AdaptersPage;
|
||||
204
web/src/pages/FileExplorerPage/FileExplorerPage.tsx
Normal file
204
web/src/pages/FileExplorerPage/FileExplorerPage.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { theme, Pagination } from 'antd';
|
||||
import { AppWindowsLayer } from '../../apps/AppWindowsLayer';
|
||||
import { useFileExplorer } from './hooks/useFileExplorer';
|
||||
import { useFileSelection } from './hooks/useFileSelection';
|
||||
import { useFileActions } from './hooks/useFileActions.tsx';
|
||||
import { useAppWindows } from './hooks/useAppWindows.tsx';
|
||||
import { useContextMenu } from './hooks/useContextMenu';
|
||||
import { useProcessor } from './hooks/useProcessor';
|
||||
import { useThumbnails } from './hooks/useThumbnails';
|
||||
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 { CreateDirModal } from './components/Modals/CreateDirModal';
|
||||
import { RenameModal } from './components/Modals/RenameModal';
|
||||
import { ProcessorModal } from './components/Modals/ProcessorModal';
|
||||
import { ShareModal } from './components/Modals/ShareModal';
|
||||
import { FileDetailModal } from './components/FileDetailModal';
|
||||
import type { ViewMode } from './types';
|
||||
import { vfsApi, type VfsEntry } from '../../api/client';
|
||||
|
||||
const FileExplorerPage = memo(function FileExplorerPage() {
|
||||
const { navKey = 'files', '*': restPath = '' } = useParams();
|
||||
const { token } = theme.useToken();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||
|
||||
// --- Hooks ---
|
||||
const { path, entries, loading, pagination, processorTypes, load, navigateTo, goUp, handlePaginationChange, refresh } = useFileExplorer(navKey);
|
||||
const { selectedEntries, handleSelect, handleSelectRange, clearSelection } = useFileSelection();
|
||||
const { uploading, fileInputRef, doCreateDir, doDelete, doRename, doDownload, doShare, handleUploadClick, handleFilesSelected } = useFileActions({ path, refresh, clearSelection, onShare: (entries) => setSharingEntries(entries) });
|
||||
const { appWindows, openFileWithDefaultApp, confirmOpenWithApp, closeWindow, toggleMax, bringToFront, updateWindow } = useAppWindows(path);
|
||||
const { ctxMenu, blankCtxMenu, openContextMenu, openBlankContextMenu, closeContextMenus } = useContextMenu();
|
||||
const processorHook = useProcessor({ path, processorTypes, refresh });
|
||||
const { thumbs } = useThumbnails(entries, path);
|
||||
|
||||
// --- State for Modals ---
|
||||
const [creatingDir, setCreatingDir] = useState(false);
|
||||
const [renaming, setRenaming] = useState<VfsEntry | null>(null);
|
||||
const [sharingEntries, setSharingEntries] = useState<VfsEntry[]>([]);
|
||||
const [detailEntry, setDetailEntry] = useState<VfsEntry | null>(null);
|
||||
const [detailData, setDetailData] = useState<any>(null);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// --- Effects ---
|
||||
useEffect(() => {
|
||||
const routeP = '/' + (restPath || '').replace(/^\/+/, '');
|
||||
load(routeP, 1, pagination.pageSize);
|
||||
}, [restPath, navKey, load, pagination.pageSize]);
|
||||
|
||||
// --- Handlers ---
|
||||
const handleOpenEntry = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
const next = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
navigateTo(next.replace(/\/+/g, '/'));
|
||||
} else {
|
||||
openFileWithDefaultApp(entry);
|
||||
}
|
||||
};
|
||||
|
||||
const openDetail = async (entry: VfsEntry) => {
|
||||
setDetailEntry(entry);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const fullPath = (path === '/' ? '' : path) + '/' + entry.name;
|
||||
const stat = await vfsApi.stat(fullPath);
|
||||
setDetailData(stat);
|
||||
} catch (e: any) {
|
||||
setDetailData({ error: e.message });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
<Header
|
||||
navKey={navKey}
|
||||
path={path}
|
||||
loading={loading}
|
||||
uploading={uploading}
|
||||
viewMode={viewMode}
|
||||
onGoUp={goUp}
|
||||
onNavigate={navigateTo}
|
||||
onRefresh={refresh}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onUpload={handleUploadClick}
|
||||
onSetViewMode={setViewMode}
|
||||
/>
|
||||
|
||||
<input ref={fileInputRef} type="file" style={{ display: 'none' }} multiple onChange={handleFilesSelected} />
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', paddingBottom: pagination.total > 0 ? '80px' : '0' }} onContextMenu={openBlankContextMenu}>
|
||||
{loading && entries.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}><EmptyState isRoot={path === '/'} onCreateDir={() => setCreatingDir(true)} onGoUp={goUp} /></div>
|
||||
) : viewMode === 'grid' ? (
|
||||
<GridView
|
||||
entries={entries}
|
||||
thumbs={thumbs}
|
||||
selectedEntries={selectedEntries}
|
||||
loading={loading}
|
||||
path={path}
|
||||
onSelect={handleSelect}
|
||||
onSelectRange={handleSelectRange}
|
||||
onOpen={handleOpenEntry}
|
||||
onContextMenu={openContextMenu}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onGoUp={goUp}
|
||||
/>
|
||||
) : (
|
||||
<FileListView
|
||||
entries={entries}
|
||||
loading={loading}
|
||||
selectedEntries={selectedEntries}
|
||||
onRowClick={(r, e) => handleSelect(r, e.ctrlKey || e.metaKey)}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, { key: appKey, name: '' } as any)}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entry) => doDelete([entry])}
|
||||
onContextMenu={openContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* --- Modals & Context Menus --- */}
|
||||
<CreateDirModal open={creatingDir} onOk={(name) => { doCreateDir(name); setCreatingDir(false); }} onCancel={() => setCreatingDir(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)} />
|
||||
{sharingEntries.length > 0 && (
|
||||
<ShareModal
|
||||
path={path}
|
||||
entries={sharingEntries}
|
||||
open={sharingEntries.length > 0}
|
||||
onOk={() => setSharingEntries([])}
|
||||
onCancel={() => setSharingEntries([])}
|
||||
/>
|
||||
)}
|
||||
<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={entries}
|
||||
selectedEntries={selectedEntries}
|
||||
processorTypes={processorTypes}
|
||||
onClose={closeContextMenus}
|
||||
onOpen={handleOpenEntry}
|
||||
onOpenWith={(entry, appKey) => confirmOpenWithApp(entry, { key: appKey, name: '' } as any)}
|
||||
onDownload={doDownload}
|
||||
onRename={setRenaming}
|
||||
onDelete={(entriesToDelete) => doDelete(entriesToDelete)}
|
||||
onDetail={openDetail}
|
||||
onProcess={(entry, type) => {
|
||||
processorHook.setSelectedProcessor(type);
|
||||
processorHook.openProcessorModal(entry);
|
||||
}}
|
||||
onUpload={handleUploadClick}
|
||||
onCreateDir={() => setCreatingDir(true)}
|
||||
onShare={doShare}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AppWindowsLayer windows={appWindows} onClose={closeWindow} onToggleMax={toggleMax} onBringToFront={bringToFront} onUpdateWindow={updateWindow} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileExplorerPage;
|
||||
143
web/src/pages/FileExplorerPage/components/ContextMenu.tsx
Normal file
143
web/src/pages/FileExplorerPage/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react';
|
||||
import { Menu, theme } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
import {
|
||||
FolderFilled, AppstoreOutlined, AppstoreAddOutlined, DownloadOutlined,
|
||||
EditOutlined, DeleteOutlined, InfoCircleOutlined, UploadOutlined, PlusOutlined, ShareAltOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface ContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
entry?: VfsEntry;
|
||||
entries: VfsEntry[];
|
||||
selectedEntries: string[];
|
||||
processorTypes: any[];
|
||||
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;
|
||||
onUpload: () => void;
|
||||
onCreateDir: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
}
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = (props) => {
|
||||
const { token } = theme.useToken();
|
||||
const { x, y, entry, entries, selectedEntries, processorTypes, onClose, ...actions } = props;
|
||||
|
||||
const getContextMenuItems = () => {
|
||||
if (!entry) { // Blank context menu
|
||||
return [
|
||||
{ key: 'upload', label: '上传文件', icon: <UploadOutlined />, onClick: actions.onUpload },
|
||||
{ key: 'mkdir', label: '新建目录', 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: any[] = [];
|
||||
if (!entry.is_dir && processorTypes.length > 0) {
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
processorSubMenu = processorTypes
|
||||
.filter(pt => pt.supported_exts.includes(ext))
|
||||
.map(pt => ({
|
||||
key: 'processor-' + pt.type,
|
||||
label: pt.name,
|
||||
onClick: () => actions.onProcess(entry, pt.type),
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
(entry.is_dir || apps.length > 0) ? {
|
||||
key: 'open',
|
||||
label: defaultApp ? `打开 (${defaultApp.name})` : '打开',
|
||||
icon: <FolderFilled />,
|
||||
onClick: () => actions.onOpen(entry),
|
||||
} : null,
|
||||
!entry.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: '打开方式',
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
|
||||
onClick: () => actions.onOpenWith(entry, a.key),
|
||||
})),
|
||||
} : null,
|
||||
!entry.is_dir && processorSubMenu.length > 0 ? {
|
||||
key: 'process',
|
||||
label: '处理器',
|
||||
icon: <AppstoreAddOutlined />,
|
||||
children: processorSubMenu,
|
||||
} : null,
|
||||
{
|
||||
key: 'share',
|
||||
label: '分享',
|
||||
icon: <ShareAltOutlined />,
|
||||
onClick: () => actions.onShare(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
label: '下载',
|
||||
icon: <DownloadOutlined />,
|
||||
disabled: targetEntries.some(t => t.is_dir) || targetEntries.length > 1,
|
||||
onClick: () => actions.onDownload(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
label: '重命名',
|
||||
icon: <EditOutlined />,
|
||||
disabled: targetEntries.length !== 1 || targetEntries[0].type === 'mount',
|
||||
onClick: () => actions.onRename(targetEntries[0]),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: <DeleteOutlined />,
|
||||
danger: true,
|
||||
disabled: targetEntries.some(t => t.type === 'mount'),
|
||||
onClick: () => actions.onDelete(targetEntries),
|
||||
},
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => actions.onDetail(entry),
|
||||
},
|
||||
].filter(Boolean);
|
||||
};
|
||||
|
||||
const items = getContextMenuItems()
|
||||
.filter(item => item !== null) // Ensure no null items
|
||||
.map(item => ({
|
||||
...item,
|
||||
onClick: () => {
|
||||
if (item.onClick) item.onClick();
|
||||
onClose();
|
||||
}
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'fixed', top: y, left: x, 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 as any[]}
|
||||
selectable={false}
|
||||
style={{ width: 160, borderRadius: token.borderRadius, background: 'transparent' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
33
web/src/pages/FileExplorerPage/components/EmptyState.tsx
Normal file
33
web/src/pages/FileExplorerPage/components/EmptyState.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Button, Space, Typography, theme } from 'antd';
|
||||
import { PlusOutlined, CloudUploadOutlined, ArrowUpOutlined, FolderOpenOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Props {
|
||||
isRoot: boolean;
|
||||
onCreateDir: () => void;
|
||||
onGoUp: () => void;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<Props> = ({ isRoot, onCreateDir, onGoUp }) => {
|
||||
const { token } = theme.useToken();
|
||||
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 ? '这里还没有任何文件' : '此目录为空'}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: token.colorTextTertiary, marginBottom:24, textAlign:'center', maxWidth:300, lineHeight:1.5 }}>
|
||||
{isRoot ? '开始上传文件或创建新目录来组织您的内容' : '您可以在此目录中创建新的文件夹或上传文件'}
|
||||
</Typography.Text>
|
||||
<Space size={12}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={onCreateDir}>新建目录</Button>
|
||||
<Button icon={<CloudUploadOutlined />} disabled>上传文件</Button>
|
||||
</Space>
|
||||
{!isRoot && (
|
||||
<div style={{ marginTop:16 }}>
|
||||
<Button type="link" size="small" icon={<ArrowUpOutlined />} onClick={onGoUp} style={{ color: token.colorTextTertiary }}>返回上级目录</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
249
web/src/pages/FileExplorerPage/components/FileDetailModal.tsx
Normal file
249
web/src/pages/FileExplorerPage/components/FileDetailModal.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React from 'react';
|
||||
import { Modal, Typography, Spin, theme, Card, Descriptions, Divider, Badge, Space, message } from 'antd';
|
||||
import { FileOutlined, FolderOutlined, CameraOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
interface Props {
|
||||
entry: VfsEntry | null;
|
||||
loading: boolean;
|
||||
data: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const exifFieldMap: Record<string, { label: string; format?: (v: any) => string }> = {
|
||||
'271': { label: '设备品牌' },
|
||||
'272': { label: '设备型号' },
|
||||
'306': { label: '拍摄时间' },
|
||||
'282': { label: '水平分辨率', format: v => `${v} dpi` },
|
||||
'283': { label: '垂直分辨率', format: v => `${v} dpi` },
|
||||
'33434': { label: '曝光时间', format: v => `${v} 秒` },
|
||||
'33437': { label: '光圈值', format: v => `f/${v}` },
|
||||
'34855': { label: 'ISO' },
|
||||
'37377': { label: '焦距', format: v => `${v} mm` },
|
||||
'40962': { label: '宽度', format: v => `${v} px` },
|
||||
'40963': { label: '高度', format: v => `${v} px` },
|
||||
};
|
||||
|
||||
function renderExif(exif: Record<string, any>) {
|
||||
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: '#999' }}>
|
||||
<InfoCircleOutlined style={{ fontSize: 20, marginBottom: 8 }} />
|
||||
<div>无常见EXIF信息</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Descriptions
|
||||
size="small"
|
||||
column={1}
|
||||
bordered
|
||||
items={items.map(item => ({
|
||||
key: item.key,
|
||||
label: <span style={{ fontWeight: 500, color: '#595959' }}>{item.label}</span>,
|
||||
children: <span style={{ color: '#262626' }}>{item.value}</span>
|
||||
}))}
|
||||
contentStyle={{ padding: '8px 12px' }}
|
||||
labelStyle={{ padding: '8px 12px', backgroundColor: '#fafafa', width: '30%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(size: number | string): string {
|
||||
if (typeof size !== 'number') return String(size);
|
||||
|
||||
const units = ['字节', '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();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<InfoCircleOutlined style={{ color: token.colorPrimary }} />
|
||||
<span>文件属性</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 }}>加载文件信息...</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 />}
|
||||
基本信息
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'name',
|
||||
label: '名称',
|
||||
children: <Typography.Text strong>{data.name}</Typography.Text>
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: '类型',
|
||||
children: (
|
||||
<Badge
|
||||
status={data.is_dir ? 'processing' : 'default'}
|
||||
text={data.type || (data.is_dir ? '文件夹' : '文件')}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: '大小',
|
||||
children: formatFileSize(data.size)
|
||||
},
|
||||
{
|
||||
key: 'mtime',
|
||||
label: '修改时间',
|
||||
children: data.mtime ? (
|
||||
typeof data.mtime === 'number'
|
||||
? new Date(data.mtime * 1000).toLocaleString('zh-CN')
|
||||
: data.mtime
|
||||
) : '-'
|
||||
},
|
||||
{
|
||||
key: 'path',
|
||||
label: '路径',
|
||||
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('路径已复制到剪贴板');
|
||||
}).catch(() => {
|
||||
message.error('复制失败');
|
||||
});
|
||||
} 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 ? '路径已复制到剪贴板' : '复制失败');
|
||||
}
|
||||
} catch {
|
||||
message.error('复制失败');
|
||||
}
|
||||
}}
|
||||
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 }}>权限:</span>
|
||||
<Typography.Text code>{data.mode.toString(8)}</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 右侧:EXIF 信息 */}
|
||||
{data.exif && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<CameraOutlined />
|
||||
EXIF信息
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 8, height: 'fit-content' }}
|
||||
>
|
||||
{renderExif(data.exif)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileDetailModal;
|
||||
41
web/src/pages/FileExplorerPage/components/FileIcons.tsx
Normal file
41
web/src/pages/FileExplorerPage/components/FileIcons.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FileOutlined,
|
||||
FileImageOutlined,
|
||||
VideoCameraOutlined,
|
||||
AudioOutlined,
|
||||
FileTextOutlined,
|
||||
FilePdfOutlined,
|
||||
FileWordOutlined,
|
||||
FileExcelOutlined,
|
||||
FilePptOutlined,
|
||||
FileZipOutlined,
|
||||
CodeOutlined,
|
||||
FileMarkdownOutlined,
|
||||
SettingOutlined,
|
||||
DatabaseOutlined,
|
||||
FontSizeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
export const getFileIcon = (fileName: string, size: number = 16) => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
const iconStyle: React.CSSProperties = { fontSize: size, marginRight: size === 16 ? 6 : 0 };
|
||||
|
||||
const make = (node: React.ReactNode, color: string) => React.cloneElement(node as any, { style: { ...iconStyle, 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','css','scss','less','json','xml','yaml','yml','py','java','cpp','c','h','php','rb','go','rs','swift','kt'].includes(ext)) return make(<CodeOutlined />, '#13c2c2');
|
||||
if (['md', 'markdown'].includes(ext)) return make(<FileMarkdownOutlined />, '#1890ff');
|
||||
if (['txt', 'log', 'ini', 'cfg', 'conf'].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');
|
||||
};
|
||||
107
web/src/pages/FileExplorerPage/components/FileListView.tsx
Normal file
107
web/src/pages/FileExplorerPage/components/FileListView.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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';
|
||||
|
||||
interface FileListViewProps {
|
||||
entries: VfsEntry[];
|
||||
loading: boolean;
|
||||
selectedEntries: string[];
|
||||
onRowClick: (entry: VfsEntry, e: React.MouseEvent) => 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,
|
||||
loading,
|
||||
selectedEntries,
|
||||
onRowClick,
|
||||
onOpen,
|
||||
onOpenWith,
|
||||
onRename,
|
||||
onDelete,
|
||||
onContextMenu,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (_: any, r: VfsEntry) => (
|
||||
<span style={{ cursor: 'pointer', userSelect: 'none' }} onDoubleClick={() => onOpen(r)}>
|
||||
{r.is_dir ? (
|
||||
<FolderFilled style={{ color: token.colorPrimary, marginRight: 6 }} />
|
||||
) : (
|
||||
getFileIcon(r.name, 16)
|
||||
)}
|
||||
{r.name}
|
||||
{r.type === 'mount' && <Tooltip title="挂载点"><span style={{ marginLeft: 6, fontSize: 10, padding: '0 4px', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 4 }}>MOUNT</span></Tooltip>}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{ title: '大小', dataIndex: 'size', width: 100, render: (v: number, r: VfsEntry) => r.is_dir ? '-' : v },
|
||||
{ title: '修改时间', dataIndex: 'mtime', width: 160, render: (v: number) => v ? new Date(v * 1000).toLocaleString() : '-' },
|
||||
{
|
||||
title: '操作',
|
||||
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 ? `打开(${defaultApp.name})` : '打开', icon: <FolderOpenOutlined />, onClick: () => onOpen(r) } : null,
|
||||
!r.is_dir && apps.length > 0 ? {
|
||||
key: 'openWith',
|
||||
label: '打开方式',
|
||||
icon: <AppstoreOutlined />,
|
||||
children: apps.map(a => ({
|
||||
key: 'openWith-' + a.key,
|
||||
label: a.name + (a.key === defaultApp?.key ? ' (默认)' : ''),
|
||||
onClick: () => onOpenWith(r, a.key)
|
||||
}))
|
||||
} : null,
|
||||
{ key: 'rename', label: '重命名', icon: <EditOutlined />, disabled: r.type === 'mount', onClick: () => onRename(r) },
|
||||
{ key: 'delete', label: '删除', 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}
|
||||
loading={loading}
|
||||
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: () => {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
155
web/src/pages/FileExplorerPage/components/GridView.tsx
Normal file
155
web/src/pages/FileExplorerPage/components/GridView.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Tooltip, Spin, theme } from 'antd';
|
||||
import { FolderFilled, PictureOutlined } from '@ant-design/icons';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { getFileIcon } from './FileIcons';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
interface Props {
|
||||
entries: VfsEntry[];
|
||||
thumbs: Record<string,string>;
|
||||
// ...existing code...
|
||||
// selected was single entry before; now use selectedEntries for multi-select
|
||||
selectedEntries: string[];
|
||||
loading: boolean;
|
||||
path: string;
|
||||
// onSelect: clicked entry, additive indicates Ctrl/Cmd click to toggle
|
||||
onSelect: (e: VfsEntry, additive?: boolean) => void;
|
||||
// onSelectRange: called when marquee/selecting multiple by box
|
||||
onSelectRange: (names: string[]) => void;
|
||||
onOpen: (e: VfsEntry) => void;
|
||||
onContextMenu: (e: React.MouseEvent, entry: VfsEntry) => void;
|
||||
onCreateDir: () => void;
|
||||
onGoUp: () => 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, loading, path, onSelect, onSelectRange, onOpen, onContextMenu, onCreateDir, onGoUp }) => {
|
||||
const { token } = theme.useToken();
|
||||
|
||||
// refs for marquee selection
|
||||
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 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 = () => { // 不需要 MouseEvent 参数,避免未使用警告
|
||||
if (!startRef.current) return;
|
||||
setSelecting(false);
|
||||
const r = rect;
|
||||
if (r) {
|
||||
// compute intersecting items
|
||||
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) => {
|
||||
// only left button and not on an item actionable element
|
||||
if (e.button !== 0) return;
|
||||
// start marquee if click on empty space inside container
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.fx-grid-item')) {
|
||||
return; // clicks on item handled separately
|
||||
}
|
||||
startRef.current = { x: e.clientX, y: e.clientY };
|
||||
setSelecting(true);
|
||||
setRect({ left: e.clientX, top: e.clientY, width: 0, height: 0 });
|
||||
// prevent text selection
|
||||
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; }} // 确保函数不返回值,匹配 Ref 类型
|
||||
className={['fx-grid-item', isSelected ? 'selected' : '', ent.is_dir? 'dir':'file'].join(' ')}
|
||||
onClick={(ev) => {
|
||||
// click selection: support ctrl/cmd to toggle
|
||||
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: ent.is_dir ? 'linear-gradient(#fafafa,#f2f2f2)' : '#fff' }}>
|
||||
{ent.is_dir && <FolderFilled style={{ fontSize:32, color: token.colorPrimary }} />}
|
||||
{!ent.is_dir && (isImg ? <img src={isImg} alt={ent.name} style={{ maxWidth:'100%', maxHeight:'100%'}} /> : isPictureType ? <PictureOutlined style={{ fontSize:32, color:'#8c8c8c' }} /> : getFileIcon(ent.name,32))}
|
||||
{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 rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0, 120, 212, 0.08)',
|
||||
zIndex: 999
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{loading && <div style={{ width:'100%', textAlign:'center', padding:40 }}><Spin /></div>}
|
||||
{!loading && entries.length === 0 && <EmptyState isRoot={path==='/' } onCreateDir={onCreateDir} onGoUp={onGoUp} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
118
web/src/pages/FileExplorerPage/components/Header.tsx
Normal file
118
web/src/pages/FileExplorerPage/components/Header.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Flex, Typography, Divider, Button, Space, Tooltip, Segmented, Breadcrumb, Input, theme } from 'antd';
|
||||
import { ArrowUpOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, AppstoreOutlined, UnorderedListOutlined } from '@ant-design/icons';
|
||||
import type { ViewMode } from '../types';
|
||||
|
||||
interface HeaderProps {
|
||||
navKey: string;
|
||||
path: string;
|
||||
loading: boolean;
|
||||
uploading: boolean;
|
||||
viewMode: ViewMode;
|
||||
onGoUp: () => void;
|
||||
onNavigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onCreateDir: () => void;
|
||||
onUpload: () => void;
|
||||
onSetViewMode: (mode: ViewMode) => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
navKey,
|
||||
path,
|
||||
loading,
|
||||
uploading,
|
||||
viewMode,
|
||||
onGoUp,
|
||||
onNavigate,
|
||||
onRefresh,
|
||||
onCreateDir,
|
||||
onUpload,
|
||||
onSetViewMode,
|
||||
}) => {
|
||||
const { token } = theme.useToken();
|
||||
const [editingPath, setEditingPath] = useState(false);
|
||||
const [pathInputValue, setPathInputValue] = useState('');
|
||||
|
||||
const handlePathEdit = () => {
|
||||
setEditingPath(true);
|
||||
setPathInputValue(path);
|
||||
};
|
||||
|
||||
const handlePathSubmit = () => {
|
||||
const trimmed = pathInputValue.trim();
|
||||
if (trimmed && trimmed !== path) {
|
||||
onNavigate(trimmed);
|
||||
}
|
||||
setEditingPath(false);
|
||||
};
|
||||
|
||||
const handlePathCancel = () => {
|
||||
setEditingPath(false);
|
||||
setPathInputValue('');
|
||||
};
|
||||
|
||||
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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ key: 'root', title: <span style={{ cursor: 'pointer' }} onClick={() => onNavigate('/')}>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={() => onNavigate(segmentPath)}>{segment}</span>
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ cursor: 'pointer', padding: '4px 8px', borderRadius: token.borderRadius, transition: 'background-color 0.2s', flex: 1, overflow: 'hidden' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = token.colorFillTertiary; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
onClick={handlePathEdit}
|
||||
>
|
||||
<Breadcrumb items={breadcrumbItems} separator="/" style={{ fontSize: 12 }} />
|
||||
</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>{navKey}</Typography.Text>
|
||||
<Divider type="vertical" />
|
||||
{renderBreadcrumb()}
|
||||
</Flex>
|
||||
<Space size={8} wrap>
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={onRefresh} loading={loading}>刷新</Button>
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={onCreateDir}>新建目录</Button>
|
||||
<Button size="small" icon={<UploadOutlined />} loading={uploading} onClick={onUpload}>上传</Button>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={viewMode}
|
||||
onChange={v => onSetViewMode(v as any)}
|
||||
options={[
|
||||
{ label: <Tooltip title="网格"><AppstoreOutlined /></Tooltip>, value: 'grid' },
|
||||
{ label: <Tooltip title="列表"><UnorderedListOutlined /></Tooltip>, value: 'list' }
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
|
||||
interface CreateDirModalProps {
|
||||
open: boolean;
|
||||
onOk: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const CreateDirModal: React.FC<CreateDirModalProps> = ({ open, onOk, onCancel }) => {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleOk = () => {
|
||||
onOk(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="新建目录"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={{ disabled: !name.trim() }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
placeholder="目录名称"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Select, Input, Checkbox } from 'antd';
|
||||
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 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={`使用处理器处理文件${entry ? `: ${entry.name}` : ''}`}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={onOk}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="processor_type" label="处理器" required>
|
||||
<Select
|
||||
onChange={onSelectedProcessorChange}
|
||||
options={processorTypes.map(pt => ({ value: pt.type, label: pt.name }))}
|
||||
placeholder="请选择处理器"
|
||||
/>
|
||||
</Form.Item>
|
||||
<ProcessorConfigForm
|
||||
processorMeta={selectedProcessorMeta}
|
||||
form={form}
|
||||
configPath={['config']}
|
||||
/>
|
||||
{selectedProcessorMeta?.produces_file && (
|
||||
<>
|
||||
<Form.Item>
|
||||
<Checkbox checked={overwrite} onChange={e => onOverwriteChange(e.target.checked)}>
|
||||
覆盖原文件
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
{!overwrite && (
|
||||
<Form.Item label="保存为新文件">
|
||||
<Input
|
||||
value={savingPath}
|
||||
onChange={e => onSavingPathChange(e.target.value)}
|
||||
placeholder="如 /newfile.jpg,不填则仅返回处理结果"
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Input } from 'antd';
|
||||
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('');
|
||||
|
||||
useEffect(() => {
|
||||
if (entry) {
|
||||
setName(entry.name);
|
||||
}
|
||||
}, [entry]);
|
||||
|
||||
const handleOk = () => {
|
||||
if (entry) {
|
||||
onOk(entry, name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="重命名"
|
||||
open={!!entry}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okButtonProps={{ disabled: !name.trim() || name.trim() === entry?.name }}
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
placeholder="新的名称"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
onPressEnter={handleOk}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { Modal, Form, Input, Radio, InputNumber, message } from 'antd';
|
||||
import type { VfsEntry } from '../../../../api/client';
|
||||
import { shareApi } from '../../../../api/share';
|
||||
|
||||
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 [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [accessType, setAccessType] = useState('public');
|
||||
|
||||
const defaultName = entries.length > 1
|
||||
? `分享 ${entries.length} 个项目`
|
||||
: (entries.length === 1 ? entries[0].name : '');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
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}`;
|
||||
});
|
||||
|
||||
await shareApi.create({
|
||||
name: values.name,
|
||||
paths: fullPaths,
|
||||
access_type: values.accessType,
|
||||
password: values.password,
|
||||
expires_in_days: values.expiresInDays,
|
||||
});
|
||||
message.success('分享链接已创建');
|
||||
onOk();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '创建失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="创建分享"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
confirmLoading={loading}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ name: defaultName, accessType: 'public', expiresInDays: 7 }}>
|
||||
<Form.Item name="name" label="分享名称" rules={[{ required: true }]} >
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessType" label="访问权限">
|
||||
<Radio.Group onChange={(e) => setAccessType(e.target.value)}>
|
||||
<Radio value="public">公开</Radio>
|
||||
<Radio value="password">密码访问</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{accessType === 'password' && (
|
||||
<Form.Item name="password" label="访问密码" rules={[{ required: true, message: '请输入密码' }]} >
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="expiresInDays" label="有效期 (天)" help="设置为 0 或负数表示永久有效">
|
||||
<InputNumber min={-1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
90
web/src/pages/FileExplorerPage/hooks/useAppWindows.tsx
Normal file
90
web/src/pages/FileExplorerPage/hooks/useAppWindows.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Checkbox } from 'antd';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import type { AppDescriptor } from '../../../apps/registry';
|
||||
import type { AppWindow } from '../types';
|
||||
import { getAppsForEntry, getDefaultAppForEntry } from '../../../apps/registry';
|
||||
|
||||
export function useAppWindows(path: string) {
|
||||
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(48, 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: '无法打开该文件:没有可用的应用' });
|
||||
return;
|
||||
}
|
||||
const defaultApp = getDefaultAppForEntry(entry) || apps[0];
|
||||
openWithApp(entry, defaultApp);
|
||||
}, [openWithApp]);
|
||||
|
||||
const confirmOpenWithApp = useCallback((entry: VfsEntry, app: AppDescriptor) => {
|
||||
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
let setDefault = false;
|
||||
Modal.confirm({
|
||||
title: `使用 ${app.name} 打开`,
|
||||
content: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 8 }}>文件: {entry.name}</div>
|
||||
<Checkbox onChange={e => setDefault = e.target.checked}>设为该类型(.{ext})默认应用</Checkbox>
|
||||
</div>
|
||||
),
|
||||
onOk: () => {
|
||||
if (setDefault && ext) {
|
||||
localStorage.setItem(`app.default.${ext}`, app.key);
|
||||
}
|
||||
openWithApp(entry, app);
|
||||
}
|
||||
});
|
||||
}, [openWithApp]);
|
||||
|
||||
const closeWindow = (id: string) => setAppWindows(ws => ws.filter(w => w.id !== id));
|
||||
const toggleMax = (id: string) => setAppWindows(ws => ws.map(w => w.id === id ? { ...w, maximized: !w.maximized } : w));
|
||||
const bringToFront = (id: string) => setAppWindows(ws => {
|
||||
const target = ws.find(w => w.id === id);
|
||||
if (!target) return ws;
|
||||
return [...ws.filter(w => w.id !== id), target];
|
||||
});
|
||||
const updateWindow = (id: string, patch: Partial<Omit<AppWindow, 'id' | 'app' | 'entry' | 'filePath'>>) =>
|
||||
setAppWindows(ws => ws.map(w => w.id === id ? { ...w, ...patch } : w));
|
||||
|
||||
return {
|
||||
appWindows,
|
||||
openWithApp,
|
||||
openFileWithDefaultApp,
|
||||
confirmOpenWithApp,
|
||||
closeWindow,
|
||||
toggleMax,
|
||||
bringToFront,
|
||||
updateWindow,
|
||||
};
|
||||
}
|
||||
30
web/src/pages/FileExplorerPage/hooks/useContextMenu.ts
Normal file
30
web/src/pages/FileExplorerPage/hooks/useContextMenu.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
export function useContextMenu() {
|
||||
const [ctxMenu, setCtxMenu] = useState<{ entry: VfsEntry; x: number; y: number } | null>(null);
|
||||
const [blankCtxMenu, setBlankCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, entry: VfsEntry) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ entry, x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const openBlankContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setBlankCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const closeContextMenus = useCallback(() => {
|
||||
setCtxMenu(null);
|
||||
setBlankCtxMenu(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
ctxMenu,
|
||||
blankCtxMenu,
|
||||
openContextMenu,
|
||||
openBlankContextMenu,
|
||||
closeContextMenus,
|
||||
};
|
||||
}
|
||||
136
web/src/pages/FileExplorerPage/hooks/useFileActions.tsx
Normal file
136
web/src/pages/FileExplorerPage/hooks/useFileActions.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { message, Modal } from 'antd';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
|
||||
interface FileActionsParams {
|
||||
path: string;
|
||||
refresh: () => void;
|
||||
clearSelection: () => void;
|
||||
onShare: (entries: VfsEntry[]) => void;
|
||||
}
|
||||
|
||||
export function useFileActions({ path, refresh, clearSelection, onShare }: FileActionsParams) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const doCreateDir = useCallback(async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
message.warning('请输入名称');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await vfsApi.mkdir((path === '/' ? '' : path) + '/' + name.trim());
|
||||
refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message);
|
||||
}
|
||||
}, [path, refresh]);
|
||||
|
||||
const doDelete = useCallback(async (entries: VfsEntry[]) => {
|
||||
Modal.confirm({
|
||||
title: `确认删除 ${entries.length > 1 ? `${entries.length} 项` : 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' && ' (挂载点)'}</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]);
|
||||
|
||||
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 doDownload = useCallback(async (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
message.warning('暂不支持下载目录');
|
||||
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 || '下载失败');
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
const handleUploadClick = useCallback(() => {
|
||||
if (uploading) return;
|
||||
fileInputRef.current?.click();
|
||||
}, [uploading]);
|
||||
|
||||
const handleFilesSelected = useCallback(async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = ev.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
const dir = path === '/' ? '' : path;
|
||||
setUploading(true);
|
||||
const uploadedNames: string[] = [];
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const dest = (dir + '/' + file.name).replace(/\/+/g, '/');
|
||||
const key = 'upload-' + file.name;
|
||||
await vfsApi.uploadStream(dest, file, true, (loaded, total) => {
|
||||
const pct = total ? (loaded / total * 100) : 0;
|
||||
message.open({
|
||||
key,
|
||||
type: 'loading',
|
||||
content: `上传 ${file.name} ${pct.toFixed(1)}%`
|
||||
});
|
||||
});
|
||||
message.open({ key, type: 'success', content: `上传完成: ${file.name}`, duration: 2 });
|
||||
uploadedNames.push(file.name);
|
||||
}
|
||||
refresh();
|
||||
// You might want to select the new files after upload, this can be handled in the main component
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}, [path, refresh]);
|
||||
|
||||
const doShare = useCallback((entries: VfsEntry[]) => {
|
||||
if (entries.length === 0) {
|
||||
message.warning('请选择要分享的文件或目录');
|
||||
return;
|
||||
}
|
||||
onShare(entries);
|
||||
}, [onShare]);
|
||||
|
||||
return {
|
||||
uploading,
|
||||
fileInputRef,
|
||||
doCreateDir,
|
||||
doDelete,
|
||||
doRename,
|
||||
doDownload,
|
||||
doShare,
|
||||
handleUploadClick,
|
||||
handleFilesSelected,
|
||||
};
|
||||
}
|
||||
82
web/src/pages/FileExplorerPage/hooks/useFileExplorer.ts
Normal file
82
web/src/pages/FileExplorerPage/hooks/useFileExplorer.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { message } from 'antd';
|
||||
import { vfsApi, type VfsEntry } from '../../../api/client';
|
||||
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} 项,第 ${range[0]}-${range[1]} 项`,
|
||||
pageSizeOptions: ['20', '50', '100', '200']
|
||||
});
|
||||
|
||||
const load = useCallback(async (p: string, page: number = 1, pageSize: number = 50) => {
|
||||
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),
|
||||
processorsApi.list()
|
||||
]);
|
||||
setEntries(res.entries);
|
||||
setPath(res.path || canonical);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: res.pagination!.page,
|
||||
pageSize: res.pagination!.page_size,
|
||||
total: res.pagination!.total
|
||||
}));
|
||||
setProcessorTypes(processors);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
load(path, pagination.current, pagination.pageSize);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
entries,
|
||||
loading,
|
||||
pagination,
|
||||
processorTypes,
|
||||
load,
|
||||
navigateTo,
|
||||
goUp,
|
||||
handlePaginationChange,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
36
web/src/pages/FileExplorerPage/hooks/useFileSelection.ts
Normal file
36
web/src/pages/FileExplorerPage/hooks/useFileSelection.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
|
||||
export function useFileSelection() {
|
||||
const [selectedEntries, setSelectedEntries] = useState<string[]>([]);
|
||||
|
||||
const handleSelect = useCallback((entry: VfsEntry, additive: boolean = false) => {
|
||||
const name = entry.name;
|
||||
if (additive) {
|
||||
// Toggle selection
|
||||
setSelectedEntries(prev => {
|
||||
const exists = prev.includes(name);
|
||||
return exists ? prev.filter(n => n !== name) : [...prev, name];
|
||||
});
|
||||
} else {
|
||||
// Replace selection
|
||||
setSelectedEntries([name]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelectRange = useCallback((names: string[]) => {
|
||||
setSelectedEntries(names);
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedEntries([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
selectedEntries,
|
||||
setSelectedEntries,
|
||||
handleSelect,
|
||||
handleSelectRange,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
103
web/src/pages/FileExplorerPage/hooks/useProcessor.ts
Normal file
103
web/src/pages/FileExplorerPage/hooks/useProcessor.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
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 [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,
|
||||
};
|
||||
|
||||
await processorsApi.process(params);
|
||||
message.success('处理完成');
|
||||
setModal({ entry: null, visible: false });
|
||||
if (overwrite || savingPath) refresh();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '处理失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [modal.entry, selectedProcessor, processorTypes, config, path, overwrite, savingPath, refresh]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setModal({ entry: null, visible: false });
|
||||
setSelectedProcessor('');
|
||||
setConfig({});
|
||||
setSavingPath('');
|
||||
setOverwrite(false);
|
||||
}, []);
|
||||
|
||||
const handleConfigChange = useCallback((key: string, value: any) => {
|
||||
setConfig((c: any) => ({ ...c, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const handleProcessorTypeChange = useCallback((type: string) => {
|
||||
setSelectedProcessor(type);
|
||||
const meta = processorTypes.find(p => p.type === type);
|
||||
const newConfig: any = {};
|
||||
if (meta?.config_schema) {
|
||||
for (const field of meta.config_schema) {
|
||||
if (field.default !== undefined) {
|
||||
newConfig[field.key] = field.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
setConfig(newConfig);
|
||||
setOverwrite(!!meta?.produces_file);
|
||||
}, [processorTypes]);
|
||||
|
||||
return {
|
||||
processorModal: modal,
|
||||
selectedProcessor,
|
||||
processorConfig: config,
|
||||
processorSavingPath: savingPath,
|
||||
processorOverwrite: overwrite,
|
||||
processorLoading: loading,
|
||||
openProcessorModal: openModal,
|
||||
handleProcessorOk: handleOk,
|
||||
handleProcessorCancel: handleCancel,
|
||||
setSelectedProcessor: handleProcessorTypeChange,
|
||||
setProcessorConfig: handleConfigChange,
|
||||
setProcessorSavingPath: setSavingPath,
|
||||
setProcessorOverwrite: setOverwrite,
|
||||
};
|
||||
}
|
||||
40
web/src/pages/FileExplorerPage/hooks/useThumbnails.ts
Normal file
40
web/src/pages/FileExplorerPage/hooks/useThumbnails.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { VfsEntry } from '../../../api/client';
|
||||
import { API_BASE_URL } from '../../../api/client';
|
||||
|
||||
const buildThumbUrl = (filePath: string, w = 256, h = 256, fit = 'cover') => {
|
||||
const origin = API_BASE_URL.replace(/\/+$/, '');
|
||||
const cleanPath = filePath.replace(/^\/+/, '');
|
||||
return `${origin}/fs/thumb/${encodeURI(cleanPath)}?w=${w}&h=${h}&fit=${encodeURIComponent(fit)}`;
|
||||
};
|
||||
|
||||
export function useThumbnails(entries: VfsEntry[], path: string) {
|
||||
const [thumbs, setThumbs] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const newThumbs: Record<string, string> = {};
|
||||
const targets = entries.filter(e => !e.is_dir && (e as any).is_image && !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 };
|
||||
}
|
||||
16
web/src/pages/FileExplorerPage/types.ts
Normal file
16
web/src/pages/FileExplorerPage/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { VfsEntry } from '../../api/client';
|
||||
import type { AppDescriptor } from '../../apps/registry';
|
||||
|
||||
export type ViewMode = 'list' | 'grid';
|
||||
|
||||
export interface AppWindow {
|
||||
id: string;
|
||||
app: AppDescriptor;
|
||||
entry: VfsEntry;
|
||||
filePath: string;
|
||||
maximized: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
171
web/src/pages/LoginPage.tsx
Normal file
171
web/src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
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';
|
||||
|
||||
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 navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const u = username.trim();
|
||||
const p = password;
|
||||
if (!u || !p) {
|
||||
setErr('请输入用户名与密码');
|
||||
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 || '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(to right, #f0f2f5, #d7d7d7)'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '80%',
|
||||
maxWidth: '1200px',
|
||||
height: '70%',
|
||||
maxHeight: '700px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||||
borderRadius: '20px',
|
||||
boxShadow: '0 4px 30px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: 'blur(5px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
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: '#111' }}>欢迎回来</Title>
|
||||
</div>
|
||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center' }}>登录到您的 Foxel 账户</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="用户名/邮箱"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
width: '50%',
|
||||
backgroundColor: '#f0f2f5',
|
||||
backgroundImage: `radial-gradient(#d7d7d7 1px, transparent 1px)`,
|
||||
backgroundSize: '16px 16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '48px'
|
||||
}}>
|
||||
<div style={{ maxWidth: '500px' }}>
|
||||
<Title level={3}>您的下一代文件管理系统</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: 'rgba(255,255,255,0.6)' }}>
|
||||
<Space>
|
||||
<CloudSyncOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<Text>跨平台同步,随时随地访问</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}>
|
||||
<Space>
|
||||
<SearchOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<Text>AI 驱动的智能搜索,快速定位文件</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}>
|
||||
<Space>
|
||||
<ShareAltOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<Text>灵活的分享与协作,提升团队效率</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card size="small" variant="borderless" style={{ backgroundColor: 'rgba(255,255,255,0.6)' }}>
|
||||
<Space>
|
||||
<ApartmentOutlined style={{ fontSize: '20px', color: '#1677ff' }} />
|
||||
<Text>强大的自动化工作流,简化繁琐任务</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ marginTop: '48px', textAlign: 'center' }}>
|
||||
<Text type="secondary">加入我们的社区:</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 />}>微信</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
web/src/pages/LogsPage.tsx
Normal file
155
web/src/pages/LogsPage.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Card, message, Tag, Input, Select, Button, Space, Modal, DatePicker } from 'antd';
|
||||
import { logsApi, type LogItem, type PaginatedLogs } from '../api/logs';
|
||||
import { format, formatISO } from 'date-fns';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const LOG_LEVELS = ['API', 'INFO', 'WARNING', 'ERROR'];
|
||||
|
||||
const LogsPage = memo(function LogsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<PaginatedLogs | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
level: '',
|
||||
source: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
});
|
||||
const [selectedLog, setSelectedLog] = useState<LogItem | null>(null);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { ...filters };
|
||||
if (!params.start_time) delete (params as any).start_time;
|
||||
if (!params.end_time) delete (params as any).end_time;
|
||||
|
||||
const res = await logsApi.list(params);
|
||||
setData(res);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const handleClearLogs = () => {
|
||||
Modal.confirm({
|
||||
title: '确认清理日志?',
|
||||
content: '该操作将删除选定时间范围内的所有日志,且不可恢复。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const params = { start_time: filters.start_time, end_time: filters.end_time };
|
||||
if (!params.start_time) delete (params as any).start_time;
|
||||
if (!params.end_time) delete (params as any).end_time;
|
||||
const res = await logsApi.clear(params);
|
||||
message.success(`成功清理 ${res.deleted_count} 条日志`);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '清理失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
width: 180,
|
||||
render: (ts: string) => format(new Date(ts), 'yyyy-MM-dd HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
width: 100,
|
||||
render: (level: string) => {
|
||||
const color = { API: 'blue', INFO: 'green', WARNING: 'orange', ERROR: 'red' }[level] || 'default';
|
||||
return <Tag color={color}>{level}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '来源', dataIndex: 'source', width: 180 },
|
||||
{ title: '消息', dataIndex: 'message', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: any, rec: LogItem) => (
|
||||
<Button size="small" onClick={() => setSelectedLog(rec)}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="系统日志"
|
||||
style={{ margin: 0 }}
|
||||
extra={
|
||||
<Space>
|
||||
<RangePicker
|
||||
showTime
|
||||
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="级别"
|
||||
allowClear
|
||||
value={filters.level || undefined}
|
||||
onChange={level => setFilters(f => ({ ...f, level: level || '', page: 1 }))}
|
||||
options={LOG_LEVELS.map(l => ({ value: l, label: l }))}
|
||||
/>
|
||||
<Input.Search
|
||||
style={{ width: 240 }}
|
||||
placeholder="搜索来源"
|
||||
onSearch={source => setFilters(f => ({ ...f, source, page: 1 }))}
|
||||
allowClear
|
||||
/>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button danger onClick={handleClearLogs}>清理</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 })),
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="日志详情"
|
||||
open={!!selectedLog}
|
||||
onCancel={() => setSelectedLog(null)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
{selectedLog && (
|
||||
<pre style={{ maxHeight: '60vh', overflow: 'auto', background: '#f5f5f5', padding: 12 }}>
|
||||
{JSON.stringify(selectedLog.details, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default LogsPage;
|
||||
5
web/src/pages/OfflineDownloadPage.tsx
Normal file
5
web/src/pages/OfflineDownloadPage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Empty } from 'antd';
|
||||
|
||||
export default function OfflineDownloadPage() {
|
||||
return <Empty description="暂无离线下载任务" />;
|
||||
}
|
||||
165
web/src/pages/PublicSharePage.tsx
Normal file
165
web/src/pages/PublicSharePage.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { Card, message, Spin, List, Typography, Button, Empty, Breadcrumb, Input, Form } 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';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const PublicSharePage = memo(function PublicSharePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
|
||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [error, setError] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [verified, setVerified] = useState(false);
|
||||
|
||||
const loadData = useCallback(async (p: string, pwd?: string) => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
let info = shareInfo;
|
||||
if (!info) {
|
||||
info = await shareApi.get(token);
|
||||
setShareInfo(info);
|
||||
}
|
||||
|
||||
if (info?.access_type === 'password' && !verified) {
|
||||
// Do not load files until password is verified
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPassword = pwd || password;
|
||||
const listing = await shareApi.listDir(token, p, currentPassword);
|
||||
setEntries(listing.entries || []);
|
||||
setCurrentPath(p);
|
||||
} catch (e: any) {
|
||||
setError(e.message || '加载分享失败');
|
||||
if (e.message === '需要密码') {
|
||||
setVerified(false);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, shareInfo, password, verified]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData(currentPath);
|
||||
}, [loadData, currentPath]);
|
||||
|
||||
const handleEntryClick = (entry: VfsEntry) => {
|
||||
if (entry.is_dir) {
|
||||
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||
loadData(newPath);
|
||||
} else {
|
||||
// Preview logic can be added here
|
||||
message.info('暂不支持预览');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBreadcrumbClick = (path: string) => {
|
||||
loadData(path);
|
||||
};
|
||||
|
||||
const renderBreadcrumb = () => {
|
||||
const parts = currentPath.split('/').filter(Boolean);
|
||||
const items = [{ title: '全部文件', 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>
|
||||
);
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = async (values: { password_input: string }) => {
|
||||
if (!token) return;
|
||||
try {
|
||||
await shareApi.verifyPassword(token, values.password_input);
|
||||
setPassword(values.password_input);
|
||||
setVerified(true);
|
||||
setError('');
|
||||
loadData(currentPath, values.password_input);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '密码错误');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && !shareInfo) {
|
||||
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
||||
}
|
||||
|
||||
if (error && !error.includes('需要密码')) {
|
||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
||||
}
|
||||
|
||||
if (shareInfo?.access_type === 'password' && !verified) {
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
|
||||
<Card title="需要密码">
|
||||
<Form onFinish={handlePasswordSubmit}>
|
||||
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password placeholder="请输入密码" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
确认
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
||||
<Card>
|
||||
<Title level={4}>{shareInfo?.name}</Title>
|
||||
<Text type="secondary">
|
||||
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
||||
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
export default PublicSharePage;
|
||||
238
web/src/pages/SetupPage.tsx
Normal file
238
web/src/pages/SetupPage.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { useState } from 'react';
|
||||
import { Form, Input, Button, Card, message, Steps, Select, Space, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined, HddOutlined } from '@ant-design/icons';
|
||||
import { adaptersApi } from '../api/adapters';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Step } = Steps;
|
||||
|
||||
const SetupPage = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [form] = Form.useForm();
|
||||
const { login, register } = useAuth();
|
||||
const onFinish = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(values.username, values.password, values.email, values.full_name);
|
||||
await login(values.username, values.password);
|
||||
message.success('初始化成功!正在为您登录,请不要刷新。');
|
||||
setTimeout(async () => {
|
||||
await adaptersApi.create({
|
||||
name: values.adapter_name,
|
||||
type: values.adapter_type,
|
||||
config: {
|
||||
root: values.root_dir
|
||||
},
|
||||
sub_path: null,
|
||||
mount_path: values.mount_path,
|
||||
enabled: true
|
||||
});
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
message.error(error.response?.data?.msg || '初始化失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepFields = [
|
||||
['db_driver', 'vector_db_driver'],
|
||||
['adapter_name', 'adapter_type', 'mount_path', 'root_dir'],
|
||||
['username', 'full_name', 'email', 'password', 'confirm'],
|
||||
]
|
||||
|
||||
const next = () => {
|
||||
form.validateFields(stepFields[currentStep]).then(() => {
|
||||
setCurrentStep(currentStep + 1);
|
||||
})
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
setCurrentStep(currentStep - 1);
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '数据库设置',
|
||||
content: (
|
||||
<>
|
||||
<Title level={4}>选择数据库驱动</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>选择用于存储系统数据的数据库和向量数据库。</Text>
|
||||
<Form.Item
|
||||
label="数据库驱动"
|
||||
name="db_driver"
|
||||
initialValue="sqlite"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select size="large" prefix={<HddOutlined />} disabled options={[{ label: 'SQLite', value: 'sqlite' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="向量数据库驱动"
|
||||
name="vector_db_driver"
|
||||
initialValue="milvus"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select size="large" disabled options={[{ label: 'Milvus', value: 'milvus' }]} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '初始化挂载',
|
||||
content: (
|
||||
<>
|
||||
<Title level={4}>配置初始存储</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>为您的文件创建第一个存储挂载点。</Text>
|
||||
<Form.Item
|
||||
label="挂载名称"
|
||||
name="adapter_name"
|
||||
initialValue="本地存储"
|
||||
rules={[{ required: true, message: '请输入挂载名称!' }]}
|
||||
>
|
||||
<Input size="large" prefix={<HddOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="存储类型"
|
||||
name="adapter_type"
|
||||
initialValue="local"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select size="large" disabled options={[{ label: '本地存储', value: 'local' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="挂载路径"
|
||||
name="mount_path"
|
||||
initialValue="/local"
|
||||
rules={[{ required: true, message: '请输入挂载路径!' }]}
|
||||
>
|
||||
<Input size="large" prefix={<HddOutlined />} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="根目录"
|
||||
name="root_dir"
|
||||
initialValue="data/mount"
|
||||
rules={[{ required: true, message: '请输入根目录!' }]}
|
||||
>
|
||||
<Input size="large" placeholder="例如: data/ 或 /var/foxel/data" />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '创建管理员',
|
||||
content: (
|
||||
<>
|
||||
<Title level={4}>创建管理员账户</Title>
|
||||
<Text type="secondary" style={{ marginBottom: 24, display: 'block' }}>这是系统的第一个账户,将拥有最高权限。</Text>
|
||||
<Form.Item
|
||||
label="用户名"
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名!' }]}
|
||||
>
|
||||
<Input size="large" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="昵称"
|
||||
name="full_name"
|
||||
>
|
||||
<Input size="large" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="邮箱"
|
||||
name="email"
|
||||
rules={[{ type: 'email', message: '请输入有效的邮箱地址!' }]}
|
||||
>
|
||||
<Input size="large" prefix={<UserOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="密码"
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码!' }]}
|
||||
>
|
||||
<Input.Password size="large" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="确认密码"
|
||||
name="confirm"
|
||||
dependencies={['password']}
|
||||
hasFeedback
|
||||
rules={[
|
||||
{ required: true, message: '请确认您的密码!' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('password') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error('两次输入的密码不一致!'));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password size="large" prefix={<LockOutlined />} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(to right, #f0f2f5, #d7d7d7)'
|
||||
}}>
|
||||
<Card style={{ width: 'clamp(400px, 40vw, 600px)', padding: '24px 16px' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 32 }}>
|
||||
<img src="/logo.svg" alt="Foxel Logo" style={{ width: 48, marginBottom: 16 }} />
|
||||
<Title level={2}>系统初始化</Title>
|
||||
</div>
|
||||
<Steps current={currentStep} style={{ marginBottom: 32 }}>
|
||||
{steps.map(item => (
|
||||
<Step key={item.title} title={item.title} />
|
||||
))}
|
||||
</Steps>
|
||||
|
||||
<Form form={form} name="setup" onFinish={onFinish} layout="vertical">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.title} style={{ display: currentStep === index ? 'block' : 'none' }}>
|
||||
{step.content}
|
||||
</div>
|
||||
))}
|
||||
</Form>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Space>
|
||||
{currentStep > 0 && (
|
||||
<Button style={{ margin: '0 8px' }} onClick={() => prev()}>
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep < steps.length - 1 && (
|
||||
<Button type="primary" onClick={() => next()}>
|
||||
下一步
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === steps.length - 1 && (
|
||||
<Button type="primary" htmlType="submit" loading={loading} onClick={() => form.submit()}>
|
||||
完成初始化
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupPage;
|
||||
111
web/src/pages/SharePage.tsx
Normal file
111
web/src/pages/SharePage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, message, Popconfirm, Card, Tag, Tooltip } from 'antd';
|
||||
import { shareApi, type ShareInfo } from '../api/share';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { LinkOutlined, CopyOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
const SharePage = memo(function SharePage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<ShareInfo[]>([]);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await shareApi.list();
|
||||
setData(list);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
const doCopy = (rec: ShareInfo) => {
|
||||
const shareUrl = `${window.location.origin}/share/${rec.token}`;
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
message.success('链接已复制');
|
||||
};
|
||||
|
||||
const doDelete = async (rec: ShareInfo) => {
|
||||
try {
|
||||
await shareApi.remove(rec.id);
|
||||
message.success('分享已取消');
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '取消失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '分享名称',
|
||||
dataIndex: 'name',
|
||||
render: (name: string, rec: ShareInfo) => (
|
||||
<a href={`/share/${rec.token}`} target="_blank" rel="noopener noreferrer">
|
||||
<LinkOutlined style={{ marginRight: 8 }} />
|
||||
{name}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '分享内容',
|
||||
dataIndex: 'paths',
|
||||
ellipsis: true,
|
||||
render: (paths: string[]) => (
|
||||
<Tooltip title={paths.join(', ')}>
|
||||
<span>{paths.join(', ')}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 180,
|
||||
render: (v: string) => format(parseISO(v), 'yyyy-MM-dd HH:mm')
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
dataIndex: 'expires_at',
|
||||
width: 180,
|
||||
render: (v?: string) => v ? <Tag color="orange">{format(parseISO(v), 'yyyy-MM-dd HH:mm')}</Tag> : <Tag>永久有效</Tag>
|
||||
},
|
||||
{
|
||||
title: '访问',
|
||||
dataIndex: 'access_type',
|
||||
width: 100,
|
||||
render: (v: 'public' | 'password') => v === 'password' ? <Tag color="red">密码</Tag> : <Tag color="green">公开</Tag>
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_: any, rec: ShareInfo) => (
|
||||
<Space size="small">
|
||||
<Button size="small" icon={<CopyOutlined />} onClick={() => doCopy(rec)}>复制</Button>
|
||||
<Popconfirm title="确认取消分享?" onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>取消</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="我的分享"
|
||||
style={{ margin: 0 }}
|
||||
extra={<Button onClick={fetchList} loading={loading}>刷新</Button>}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
columns={columns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default SharePage;
|
||||
90
web/src/pages/SystemSettingsPage/BackupPage.tsx
Normal file
90
web/src/pages/SystemSettingsPage/BackupPage.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { memo, useState } from 'react';
|
||||
import { Card, Button, Typography, Upload, message, Modal } from 'antd';
|
||||
import { UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { backupApi } from '../../api/backup';
|
||||
|
||||
const { Title, Paragraph, Text } = Typography;
|
||||
|
||||
const BackupPage = memo(function BackupPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await backupApi.export();
|
||||
message.success('导出已开始,请检查您的下载。');
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '导出失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = (file: File) => {
|
||||
Modal.confirm({
|
||||
title: '确认导入备份?',
|
||||
content: (
|
||||
<Typography>
|
||||
<Paragraph>您确定要从此文件导入数据吗?</Paragraph>
|
||||
<Paragraph strong>警告:此操作将覆盖当前数据库中的所有现有数据,包括用户(含密码)、设置、存储和任务。此操作不可逆!</Paragraph>
|
||||
</Typography>
|
||||
),
|
||||
okText: '确认导入',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await backupApi.import(file);
|
||||
message.success(response.message || '导入成功!页面将刷新。');
|
||||
setTimeout(() => window.location.reload(), 2000);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '导入失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
return false; // 阻止 antd 的 Upload 组件自动上传
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<Title level={4}>备份与恢复</Title>
|
||||
<Paragraph>
|
||||
在这里,您可以导出整个站点的数据作为备份,或从备份文件中恢复。
|
||||
</Paragraph>
|
||||
<div style={{ display: 'flex', gap: '16px' }}>
|
||||
<Card title="导出" style={{ flex: 1 }}>
|
||||
<Paragraph>
|
||||
点击下面的按钮将所有数据(包括存储、用户、自动化任务和分享)导出为一个 JSON 文件。
|
||||
<Text strong>请妥善保管您的备份文件。</Text>
|
||||
</Paragraph>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleExport}
|
||||
loading={loading}
|
||||
>
|
||||
导出备份
|
||||
</Button>
|
||||
</Card>
|
||||
<Card title="恢复" style={{ flex: 1 }}>
|
||||
<Paragraph>
|
||||
从之前导出的JSON文件恢复数据。
|
||||
<Text strong type="danger">警告:此操作将清除并覆盖现有数据。</Text>
|
||||
</Paragraph>
|
||||
<Upload
|
||||
beforeUpload={handleImport}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={loading}>
|
||||
选择文件并恢复
|
||||
</Button>
|
||||
</Upload>
|
||||
</Card>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default BackupPage;
|
||||
126
web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx
Normal file
126
web/src/pages/SystemSettingsPage/SystemSettingsPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Card, Form, Input, Button, message, Tabs, Space } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getAllConfig, setConfig } from '../../api/config';
|
||||
import { API_BASE_URL } from '../../api/client';
|
||||
import { AppstoreOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
|
||||
const APP_CONFIG_KEYS = [
|
||||
{ key: 'APP_NAME', label: '应用名称' },
|
||||
{ key: 'APP_LOGO', label: 'LOGO地址' },
|
||||
{ key: 'SERVER_URL', label: '服务端URL', default: API_BASE_URL },
|
||||
];
|
||||
|
||||
const AI_CONFIG_KEYS = [
|
||||
{ key: 'AI_API_URL', label: 'AI API地址' },
|
||||
{ key: 'AI_VISION_MODEL', label: '视觉模型' },
|
||||
{ key: 'AI_EMBED_MODEL', label: '嵌入模型' },
|
||||
{ key: 'AI_API_KEY', label: 'API Key' },
|
||||
];
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [config, setConfigState] = useState<Record<string, string> | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('app');
|
||||
|
||||
useEffect(() => {
|
||||
getAllConfig().then((data) => setConfigState(data as Record<string, string>));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (values: any) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await setConfig(key, String(value ?? ''));
|
||||
}
|
||||
message.success('保存成功');
|
||||
setConfigState({ ...config, ...values });
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '保存失败');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// 加载中时不渲染表单
|
||||
if (!config) {
|
||||
return <Card title='系统设置'><div>加载中...</div></Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title='系统设置'
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={32}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
centered
|
||||
tabPosition="left"
|
||||
items={[
|
||||
{
|
||||
key: 'app',
|
||||
label: (
|
||||
<span>
|
||||
<AppstoreOutlined style={{ marginRight: 8 }} />
|
||||
应用设置
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Form
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
...Object.fromEntries(APP_CONFIG_KEYS.map(({ key, default: def }) => [key, config[key] ?? def ?? ''])),
|
||||
}}
|
||||
onFinish={handleSave}
|
||||
style={{ marginTop: 24 }}
|
||||
key={JSON.stringify(config)}
|
||||
>
|
||||
{APP_CONFIG_KEYS.map(({ key, label }) => (
|
||||
<Form.Item key={key} name={key} label={label}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: (
|
||||
<span>
|
||||
<RobotOutlined style={{ marginRight: 8 }} />
|
||||
AI设置
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Form
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
...Object.fromEntries(AI_CONFIG_KEYS.map(({ key }) => [key, config[key] ?? ''])),
|
||||
}}
|
||||
onFinish={handleSave}
|
||||
style={{ marginTop: 24 }}
|
||||
key={JSON.stringify(config)} // 强制表单重置
|
||||
>
|
||||
{AI_CONFIG_KEYS.map(({ key, label }) => (
|
||||
<Form.Item key={key} name={key} label={label}>
|
||||
<Input size="large" />
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
181
web/src/pages/TasksPage.tsx
Normal file
181
web/src/pages/TasksPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { memo, useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select, Card } from 'antd';
|
||||
import { tasksApi, type AutomationTask } from '../api/tasks';
|
||||
import { processorsApi, type ProcessorTypeMeta } from '../api/processors';
|
||||
import { ProcessorConfigForm } from '../components/ProcessorConfigForm';
|
||||
|
||||
const TasksPage = memo(function TasksPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<AutomationTask[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<AutomationTask | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [availableProcessors, setAvailableProcessors] = useState<ProcessorTypeMeta[]>([]);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [list, processors] = await Promise.all([
|
||||
tasksApi.list(),
|
||||
processorsApi.list()
|
||||
]);
|
||||
setData(list);
|
||||
setAvailableProcessors(processors);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
name: '',
|
||||
event: 'file_written',
|
||||
enabled: true,
|
||||
processor_config: {}
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (rec: AutomationTask) => {
|
||||
setEditing(rec);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
...rec,
|
||||
processor_config: rec.processor_config || {}
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const body = { ...values };
|
||||
setLoading(true);
|
||||
if (editing) {
|
||||
await tasksApi.update(editing.id, body);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await tasksApi.create(body);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error(e.message || '操作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doDelete = async (rec: AutomationTask) => {
|
||||
try {
|
||||
await tasksApi.remove(rec.id);
|
||||
message.success('已删除');
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '触发事件', dataIndex: 'event', width: 120 },
|
||||
{ title: '处理器', dataIndex: 'processor_type', width: 180 },
|
||||
{ title: '启用', dataIndex: 'enabled', width: 80, render: (v: boolean) => <Switch checked={v} size="small" disabled /> },
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_: any, rec: AutomationTask) => (
|
||||
<Space size="small">
|
||||
<Button size="small" onClick={() => openEdit(rec)}>编辑</Button>
|
||||
<Popconfirm title="确认删除?" onConfirm={() => doDelete(rec)}>
|
||||
<Button size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
const selectedProcessor = Form.useWatch('processor_type', form);
|
||||
const currentProcessorMeta = availableProcessors.find(p => p.type === selectedProcessor);
|
||||
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="自动化任务"
|
||||
style={{ margin: 0 }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={fetchList} loading={loading}>刷新</Button>
|
||||
<Button type="primary" onClick={openCreate}>新建任务</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={data}
|
||||
columns={columns as any}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
style={{ marginBottom: 0 }}
|
||||
/>
|
||||
<Drawer
|
||||
title={editing ? `编辑任务: ${editing.name}` : '新建自动化任务'}
|
||||
width={480}
|
||||
open={open}
|
||||
onClose={() => { setOpen(false); setEditing(null); }}
|
||||
destroyOnClose
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null); }}>取消</Button>
|
||||
<Button type="primary" onClick={submit} loading={loading}>提交</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="任务名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="event" label="触发事件" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'file_written', label: '文件写入' },
|
||||
{ value: 'file_deleted', label: '文件删除' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>匹配规则</Typography.Title>
|
||||
<Form.Item name="path_pattern" label="路径前缀 (可选)">
|
||||
<Input placeholder="/images/screenshots" />
|
||||
</Form.Item>
|
||||
<Form.Item name="filename_regex" label="文件名正则 (可选)">
|
||||
<Input placeholder=".*\.png$" />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>执行动作</Typography.Title>
|
||||
<Form.Item name="processor_type" label="处理器" rules={[{ required: true }]}>
|
||||
<Select
|
||||
placeholder="选择一个处理器"
|
||||
options={availableProcessors.map(p => ({ value: p.type, label: `${p.name} (${p.type})` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<ProcessorConfigForm
|
||||
processorMeta={currentProcessorMeta}
|
||||
form={form}
|
||||
configPath={['processor_config']}
|
||||
/>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default TasksPage;
|
||||
49
web/src/router/LayoutShell.tsx
Normal file
49
web/src/router/LayoutShell.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Layout, Flex } from 'antd';
|
||||
import { memo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import SideNav from '../layout/SideNav.tsx';
|
||||
import TopHeader from '../layout/TopHeader.tsx';
|
||||
import FileExplorerPage from '../pages/FileExplorerPage/FileExplorerPage.tsx';
|
||||
import AdaptersPage from '../pages/AdaptersPage.tsx';
|
||||
import SharePage from '../pages/SharePage.tsx';
|
||||
import TasksPage from '../pages/TasksPage.tsx';
|
||||
import OfflineDownloadPage from '../pages/OfflineDownloadPage.tsx';
|
||||
import SystemSettingsPage from '../pages/SystemSettingsPage/SystemSettingsPage.tsx';
|
||||
import LogsPage from '../pages/LogsPage.tsx';
|
||||
import BackupPage from '../pages/SystemSettingsPage/BackupPage.tsx';
|
||||
|
||||
const LayoutShell = memo(function LayoutShell() {
|
||||
const { navKey = 'files' } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<SideNav
|
||||
collapsed={collapsed}
|
||||
onToggle={() => setCollapsed(c => !c)}
|
||||
activeKey={navKey}
|
||||
onChange={(key) => navigate(`/${key}`)}
|
||||
/>
|
||||
<Layout>
|
||||
<TopHeader collapsed={collapsed} onToggle={() => setCollapsed(c => !c)} />
|
||||
<Layout.Content style={{ padding: 16 }}>
|
||||
<div style={{ minHeight: 'calc(100vh - 56px - 32px)' }}>
|
||||
<Flex vertical gap={16}>
|
||||
{navKey === 'adapters' && <AdaptersPage />}
|
||||
{navKey === 'files' && <FileExplorerPage />}
|
||||
{navKey === 'share' && <SharePage />}
|
||||
{navKey === 'tasks' && <TasksPage />}
|
||||
{navKey === 'offline' && <OfflineDownloadPage />}
|
||||
{navKey === 'settings' && <SystemSettingsPage />}
|
||||
{navKey === 'logs' && <LogsPage />}
|
||||
{navKey === 'backup' && <BackupPage />}
|
||||
{!['adapters','files','image','video','doc','fav','recent','recycle','share','tasks','offline','settings', 'logs', 'backup'].includes(navKey!) && <FileExplorerPage />}
|
||||
</Flex>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
});
|
||||
|
||||
export default LayoutShell;
|
||||
39
web/src/router/index.tsx
Normal file
39
web/src/router/index.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Navigate, Routes, Route, useLocation } from 'react-router';
|
||||
import type { RouteObject } from 'react-router';
|
||||
import LayoutShell from './LayoutShell.tsx';
|
||||
import LoginPage from '../pages/LoginPage.tsx';
|
||||
import SetupPage from '../pages/SetupPage.tsx';
|
||||
import PublicSharePage from '../pages/PublicSharePage.tsx';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{ path: '/', element: <Navigate to="/files" replace /> },
|
||||
{ path: '/:navKey/*', element: <LayoutShell /> },
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/share/:token', element: <PublicSharePage /> },
|
||||
{ path: '/setup', element: <SetupPage /> },
|
||||
];
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const location = useLocation();
|
||||
if (!isAuthenticated && !location.pathname.startsWith('/share/') && location.pathname !== '/login' && location.pathname !== '/register') {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
export function AppRouter() {
|
||||
return (
|
||||
<RequireAuth>
|
||||
<Routes>
|
||||
{routes.map(r => (
|
||||
<Route key={r.path} path={r.path} element={r.element} />
|
||||
))}
|
||||
</Routes>
|
||||
</RequireAuth>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppRouter;
|
||||
23
web/src/styles/sider-menu.css
Normal file
23
web/src/styles/sider-menu.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.foxel-sider-menu .ant-menu-item {
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
transition: background .18s, color .18s;
|
||||
margin-block: 2px;
|
||||
}
|
||||
|
||||
.foxel-sider-menu .ant-menu-item-selected {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.foxel-sider-menu .ant-menu-item-selected,
|
||||
.foxel-sider-menu .ant-menu-item-selected .ant-menu-item-icon {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.foxel-sider-menu .ant-menu-item-selected::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.foxel-sider-menu .ant-menu-item .ant-menu-item-icon {
|
||||
transition: color .18s;
|
||||
}
|
||||
53
web/src/theme.ts
Normal file
53
web/src/theme.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { theme } from 'antd';
|
||||
import type { ThemeConfig } from 'antd/es/config-provider/context';
|
||||
|
||||
export const foxelTheme: ThemeConfig = {
|
||||
algorithm: [theme.defaultAlgorithm, theme.compactAlgorithm],
|
||||
token: {
|
||||
colorPrimary: '#111',
|
||||
colorInfo: '#111',
|
||||
colorText: '#111',
|
||||
colorTextSecondary: '#444',
|
||||
colorTextTertiary: '#666',
|
||||
colorBgBase: '#fff',
|
||||
colorBgLayout: '#f9f9f9',
|
||||
colorBgContainer: '#fff',
|
||||
colorBorder: '#e5e5e5',
|
||||
colorBorderSecondary: '#efefef',
|
||||
borderRadius: 10,
|
||||
fontSize: 16,
|
||||
controlHeight: 34,
|
||||
boxShadow: '0 2px 4px -2px rgba(0,0,0,0.06),0 4px 12px -2px rgba(0,0,0,0.04)'
|
||||
},
|
||||
components: {
|
||||
Layout: { headerHeight: 56, headerPadding: '0 20px' },
|
||||
Menu: {
|
||||
itemBorderRadius: 8,
|
||||
itemHeight: 40,
|
||||
horizontalItemBorderRadius: 8,
|
||||
itemHoverColor: '#111',
|
||||
itemHoverBg: '#f2f2f2',
|
||||
itemSelectedBg: '#111',
|
||||
itemSelectedColor: '#fff'
|
||||
},
|
||||
Button: {
|
||||
borderRadius: 8,
|
||||
controlHeight: 36,
|
||||
fontWeight: 500
|
||||
},
|
||||
Card: {
|
||||
borderRadiusLG: 16,
|
||||
padding: 16
|
||||
},
|
||||
Input: { borderRadius: 8 },
|
||||
Dropdown: { controlItemBgHover: '#f2f2f2' },
|
||||
Table: {
|
||||
cellPaddingBlock: 14,
|
||||
cellPaddingInline: 18,
|
||||
headerBg: '#fafafa',
|
||||
rowHoverBg: '#f5f5f5'
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default foxelTheme;
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
web/tsconfig.app.json
Normal file
27
web/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
web/tsconfig.node.json
Normal file
25
web/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
7
web/vite.config.ts
Normal file
7
web/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user