mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-09-07 16:46:48 +08:00
initialize project structure with View
This commit is contained in:
@@ -23,6 +23,5 @@ dist-ssr
|
|||||||
/bin
|
/bin
|
||||||
/obj
|
/obj
|
||||||
/Uploads
|
/Uploads
|
||||||
/View
|
|
||||||
/appsettings.Development.json
|
/appsettings.Development.json
|
||||||
/Foxel.sln.DotSettings.user
|
/Foxel.sln.DotSettings.user
|
||||||
+643
@@ -0,0 +1,643 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "foxel",
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
|
"@types/md5": "^2.3.5",
|
||||||
|
"antd": "^5.24.9",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router": "^7.5.3",
|
||||||
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"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.0", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A=="],
|
||||||
|
|
||||||
|
"@ant-design/cssinjs": ["@ant-design/cssinjs@1.23.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-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w=="],
|
||||||
|
|
||||||
|
"@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.27.2", "", {}, "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.27.1", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.1", "@babel/helper-compilation-targets": "^7.27.1", "@babel/helper-module-transforms": "^7.27.1", "@babel/helpers": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/template": "^7.27.1", "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1", "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-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="],
|
||||||
|
|
||||||
|
"@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-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.27.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g=="],
|
||||||
|
|
||||||
|
"@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.27.1", "", { "dependencies": { "@babel/template": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.27.2", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw=="],
|
||||||
|
|
||||||
|
"@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.27.1", "", {}, "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog=="],
|
||||||
|
|
||||||
|
"@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.27.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/template": "^7.27.1", "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="],
|
||||||
|
|
||||||
|
"@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.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="],
|
||||||
|
|
||||||
|
"@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.20.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ=="],
|
||||||
|
|
||||||
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.2.2", "", {}, "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg=="],
|
||||||
|
|
||||||
|
"@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="],
|
||||||
|
|
||||||
|
"@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.27.0", "", {}, "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA=="],
|
||||||
|
|
||||||
|
"@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="],
|
||||||
|
|
||||||
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.1", "", { "dependencies": { "@eslint/core": "^0.14.0", "levn": "^0.4.1" } }, "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w=="],
|
||||||
|
|
||||||
|
"@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.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||||
|
|
||||||
|
"@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.2.6", "", { "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-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.40.2", "", { "os": "android", "cpu": "arm" }, "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.40.2", "", { "os": "android", "cpu": "arm64" }, "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.40.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.40.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.40.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.40.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.40.2", "", { "os": "linux", "cpu": "arm" }, "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.40.2", "", { "os": "linux", "cpu": "arm" }, "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.40.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.40.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.40.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.40.2", "", { "os": "linux", "cpu": "none" }, "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.40.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.40.2", "", { "os": "linux", "cpu": "x64" }, "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.40.2", "", { "os": "linux", "cpu": "x64" }, "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.40.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.40.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA=="],
|
||||||
|
|
||||||
|
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.40.2", "", { "os": "win32", "cpu": "x64" }, "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA=="],
|
||||||
|
|
||||||
|
"@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.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.7", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
|
||||||
|
|
||||||
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
|
"@types/md5": ["@types/md5@2.3.5", "", {}, "sha512-/i42wjYNgE6wf0j2bcTX6kuowmdL/6PE4IVitMpm2eYKBUuYCprdcWVK+xEF0gcV6ufMCRhtxmReGfc6hIK7Jw=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.1.4", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g=="],
|
||||||
|
|
||||||
|
"@types/react-dom": ["@types/react-dom@19.1.5", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.32.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/type-utils": "8.32.1", "@typescript-eslint/utils": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.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.0.0 || ^8.0.0-alpha.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.32.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/types": "8.32.1", "@typescript-eslint/typescript-estree": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.32.1", "", { "dependencies": { "@typescript-eslint/types": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.1" } }, "sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.32.1", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.32.1", "@typescript-eslint/utils": "8.32.1", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.32.1", "", {}, "sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.32.1", "", { "dependencies": { "@typescript-eslint/types": "8.32.1", "@typescript-eslint/visitor-keys": "8.32.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 <5.9.0" } }, "sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.32.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.32.1", "@typescript-eslint/types": "8.32.1", "@typescript-eslint/typescript-estree": "8.32.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.32.1", "", { "dependencies": { "@typescript-eslint/types": "8.32.1", "eslint-visitor-keys": "^4.2.0" } }, "sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w=="],
|
||||||
|
|
||||||
|
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.4.1", "", { "dependencies": { "@babel/core": "^7.26.10", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.14.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg=="],
|
||||||
|
|
||||||
|
"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.25.1", "", { "dependencies": { "@ant-design/colors": "^7.2.0", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.0.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.2.6", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.2.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.7", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.50.4", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.9.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-4KC7KuPCjr0z3Vuw9DsF+ceqJaPLbuUI3lOX1sY8ix25ceamp+P8yxOmk3Y2JHCD2ZAhq+5IQ/DTJRN2adWYKQ=="],
|
||||||
|
|
||||||
|
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="],
|
||||||
|
|
||||||
|
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="],
|
||||||
|
|
||||||
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001718", "", {}, "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw=="],
|
||||||
|
|
||||||
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
|
"charenc": ["charenc@0.0.2", "", {}, "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"crypt": ["crypt@0.0.2", "", {}, "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||||
|
|
||||||
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.155", "", {}, "sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
|
"eslint": ["eslint@9.27.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.20.0", "@eslint/config-helpers": "^0.2.1", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.27.0", "@eslint/plugin-kit": "^0.3.1", "@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.3.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.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-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q=="],
|
||||||
|
|
||||||
|
"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.3.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ=="],
|
||||||
|
|
||||||
|
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.0", "", {}, "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw=="],
|
||||||
|
|
||||||
|
"espree": ["espree@10.3.0", "", { "dependencies": { "acorn": "^8.14.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.0" } }, "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"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.4.4", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
|
"globals": ["globals@16.1.0", "", {}, "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g=="],
|
||||||
|
|
||||||
|
"graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="],
|
||||||
|
|
||||||
|
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="],
|
||||||
|
|
||||||
|
"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-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"md5": ["md5@2.3.0", "", { "dependencies": { "charenc": "0.0.2", "crypt": "0.0.2", "is-buffer": "~1.1.6" } }, "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g=="],
|
||||||
|
|
||||||
|
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="],
|
||||||
|
|
||||||
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"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.2.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-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg=="],
|
||||||
|
|
||||||
|
"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.50.5", "", { "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-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A=="],
|
||||||
|
|
||||||
|
"rc-tabs": ["rc-tabs@15.6.1", "", { "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-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA=="],
|
||||||
|
|
||||||
|
"rc-textarea": ["rc-textarea@1.10.0", "", { "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-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA=="],
|
||||||
|
|
||||||
|
"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.0", "", { "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-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA=="],
|
||||||
|
|
||||||
|
"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.18.6", "", { "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-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA=="],
|
||||||
|
|
||||||
|
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="],
|
||||||
|
|
||||||
|
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||||
|
|
||||||
|
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||||
|
|
||||||
|
"react-router": ["react-router@7.6.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ=="],
|
||||||
|
|
||||||
|
"react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="],
|
||||||
|
|
||||||
|
"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.40.2", "", { "dependencies": { "@types/estree": "1.0.7" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.40.2", "@rollup/rollup-android-arm64": "4.40.2", "@rollup/rollup-darwin-arm64": "4.40.2", "@rollup/rollup-darwin-x64": "4.40.2", "@rollup/rollup-freebsd-arm64": "4.40.2", "@rollup/rollup-freebsd-x64": "4.40.2", "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", "@rollup/rollup-linux-arm-musleabihf": "4.40.2", "@rollup/rollup-linux-arm64-gnu": "4.40.2", "@rollup/rollup-linux-arm64-musl": "4.40.2", "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", "@rollup/rollup-linux-riscv64-gnu": "4.40.2", "@rollup/rollup-linux-riscv64-musl": "4.40.2", "@rollup/rollup-linux-s390x-gnu": "4.40.2", "@rollup/rollup-linux-x64-gnu": "4.40.2", "@rollup/rollup-linux-x64-musl": "4.40.2", "@rollup/rollup-win32-arm64-msvc": "4.40.2", "@rollup/rollup-win32-ia32-msvc": "4.40.2", "@rollup/rollup-win32-x64-msvc": "4.40.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="],
|
||||||
|
|
||||||
|
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
|
||||||
|
|
||||||
|
"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.13", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"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.32.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.32.1", "@typescript-eslint/parser": "8.32.1", "@typescript-eslint/utils": "8.32.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
|
||||||
|
|
||||||
|
"vite": ["vite@6.3.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "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-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ=="],
|
||||||
|
|
||||||
|
"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=="],
|
||||||
|
|
||||||
|
"@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
|
||||||
|
|
||||||
|
"@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.4", "", {}, "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A=="],
|
||||||
|
|
||||||
|
"@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=="],
|
||||||
|
|
||||||
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
|
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="ico" href="/favicon.ico" />
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
worker_processes 1;
|
||||||
|
|
||||||
|
events { worker_connections 1024; }
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
client_max_body_size 0;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /var/www/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection keep-alive;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection keep-alive;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "foxel",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
|
"@types/md5": "^2.3.5",
|
||||||
|
"antd": "^5.24.9",
|
||||||
|
"md5": "^2.3.0",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router": "^7.5.3",
|
||||||
|
"react-zoom-pan-pinch": "^3.7.0",
|
||||||
|
"uuid": "^11.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,4 @@
|
|||||||
|
@import "https://chinese-fonts-cdn.deno.dev/packages/maple-mono-cn/dist/MapleMono-CN-Regular/result.css";
|
||||||
|
body{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router';
|
||||||
|
import './App.css'
|
||||||
|
import MainLayout from './layouts/MainLayout';
|
||||||
|
import Login from './pages/login/Index';
|
||||||
|
import Register from './pages/register/Index';
|
||||||
|
import { isAuthenticated } from './api';
|
||||||
|
import type { JSX } from 'react';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import routes from './config/routeConfig';
|
||||||
|
import { AuthProvider } from './api/AuthContext'; // 导入 AuthProvider
|
||||||
|
import AnonymousPage from './pages/anonymous/Index';
|
||||||
|
|
||||||
|
const PrivateRoute = ({ children }: { children: JSX.Element }) => {
|
||||||
|
return isAuthenticated() ? children : <Navigate to="/login" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const customTheme = {
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#18181b',
|
||||||
|
colorLink: '#444444',
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
borderRadius: 10,
|
||||||
|
fontFamily: '"SF Pro Display", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
||||||
|
boxShadowTertiary: '0 4px 16px rgba(0,0,0,0.05)',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Button: {
|
||||||
|
colorPrimary: '#18181b',
|
||||||
|
algorithm: true,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
itemBg: 'transparent',
|
||||||
|
colorActiveBarBorderSize: 0,
|
||||||
|
itemHeight: 46,
|
||||||
|
itemMarginInline: 12,
|
||||||
|
iconSize: 17,
|
||||||
|
fontSize: 15,
|
||||||
|
itemSelectedColor: '#ffffff',
|
||||||
|
itemSelectedBg: '#18181b',
|
||||||
|
itemHoverColor: '#333333',
|
||||||
|
itemBorderRadius: 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<ConfigProvider theme={customTheme}>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/anonymous" element={<AnonymousPage />} />
|
||||||
|
|
||||||
|
<Route path="/" element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</PrivateRoute>
|
||||||
|
}>
|
||||||
|
{routes.map((route) => (
|
||||||
|
<Route key={route.key} path={route.path} element={route.element} />
|
||||||
|
))}
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</ConfigProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { createContext, useState, useEffect, useContext, useCallback } from 'react';
|
||||||
|
import { getCurrentUser, isAuthenticated, clearAuthData, getStoredUser } from './index';
|
||||||
|
import type { UserProfile } from './types';
|
||||||
|
import { UserRole } from './types';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: UserProfile | null;
|
||||||
|
loading: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
authError: string | null;
|
||||||
|
logout: () => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
|
hasRole: (requiredRole: UserRole) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType>({
|
||||||
|
user: null,
|
||||||
|
loading: true,
|
||||||
|
authenticated: false,
|
||||||
|
authError: null,
|
||||||
|
logout: () => {},
|
||||||
|
refreshUser: async () => {},
|
||||||
|
hasRole: () => false
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<UserProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshUser = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setAuthError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isLoggedIn = isAuthenticated();
|
||||||
|
|
||||||
|
if (isLoggedIn) {
|
||||||
|
const storedUser = getStoredUser();
|
||||||
|
if (storedUser) {
|
||||||
|
setUser(storedUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await getCurrentUser();
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setUser(response.data);
|
||||||
|
} else if (!storedUser) {
|
||||||
|
setAuthError(response.message || '获取用户信息失败');
|
||||||
|
clearAuthData();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearAuthData();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setAuthError(error.message || '获取用户信息时发生错误');
|
||||||
|
if (!getStoredUser()) {
|
||||||
|
clearAuthData();
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearAuthData();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasRole = useCallback((requiredRole: UserRole): boolean => {
|
||||||
|
if (!user?.roleName) return false;
|
||||||
|
|
||||||
|
// 管理员拥有所有权限
|
||||||
|
if (user.roleName === UserRole.Administrator) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 特定角色检查
|
||||||
|
return user.roleName === requiredRole;
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshUser();
|
||||||
|
}, [refreshUser]);
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
authenticated: !!user,
|
||||||
|
authError,
|
||||||
|
logout,
|
||||||
|
refreshUser,
|
||||||
|
hasRole
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
|
|
||||||
|
export default AuthContext;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type {
|
||||||
|
PaginatedResult,
|
||||||
|
AlbumResponse,
|
||||||
|
CreateAlbumRequest,
|
||||||
|
UpdateAlbumRequest,
|
||||||
|
AlbumPictureRequest,
|
||||||
|
AlbumPicturesRequest,
|
||||||
|
BaseResult
|
||||||
|
} from './types';
|
||||||
|
import { fetchApi, BASE_URL } from './fetchClient';
|
||||||
|
|
||||||
|
// 获取相册列表
|
||||||
|
export async function getAlbums(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
userId?: number
|
||||||
|
): Promise<PaginatedResult<AlbumResponse>> {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.append('page', page.toString());
|
||||||
|
queryParams.append('pageSize', pageSize.toString());
|
||||||
|
if (userId) {
|
||||||
|
queryParams.append('userId', userId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/album/get_albums?${queryParams.toString()}`;
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data as PaginatedResult<AlbumResponse>;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取相册列表失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '网络请求失败,请检查您的网络连接',
|
||||||
|
data: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
code: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个相册详情
|
||||||
|
export async function getAlbumById(id: number): Promise<BaseResult<AlbumResponse>> {
|
||||||
|
return fetchApi<AlbumResponse>(`/album/get_album/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建相册
|
||||||
|
export async function createAlbum(data: CreateAlbumRequest): Promise<BaseResult<AlbumResponse>> {
|
||||||
|
return fetchApi<AlbumResponse>('/album/create_album', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新相册
|
||||||
|
export async function updateAlbum(data: UpdateAlbumRequest): Promise<BaseResult<AlbumResponse>> {
|
||||||
|
return fetchApi<AlbumResponse>('/album/update_album', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除相册
|
||||||
|
export async function deleteAlbum(id: number): Promise<BaseResult<boolean>> {
|
||||||
|
return fetchApi<boolean>('/album/delete_album', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加多张图片到相册
|
||||||
|
export async function addPicturesToAlbum(albumId: number, pictureIds: number[]): Promise<BaseResult<boolean>> {
|
||||||
|
const data: AlbumPicturesRequest = {
|
||||||
|
albumId,
|
||||||
|
pictureIds,
|
||||||
|
};
|
||||||
|
return fetchApi<boolean>('/album/add_pictures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加图片到相册
|
||||||
|
export async function addPictureToAlbum(albumId: number, pictureId: number): Promise<BaseResult<boolean>> {
|
||||||
|
const data: AlbumPictureRequest = {
|
||||||
|
albumId,
|
||||||
|
pictureId,
|
||||||
|
};
|
||||||
|
return fetchApi<boolean>('/album/add_picture', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从相册移除图片
|
||||||
|
export async function removePictureFromAlbum(albumId: number, pictureId: number): Promise<BaseResult<boolean>> {
|
||||||
|
const data: AlbumPictureRequest = {
|
||||||
|
albumId,
|
||||||
|
pictureId,
|
||||||
|
};
|
||||||
|
return fetchApi<boolean>('/album/remove_picture', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { type BaseResult, type AuthResponse, type LoginRequest, type RegisterRequest, type UserProfile } from './types';
|
||||||
|
import { fetchApi, BASE_URL } from './fetchClient';
|
||||||
|
|
||||||
|
// 认证数据本地存储键
|
||||||
|
const TOKEN_KEY = 'token';
|
||||||
|
const USER_KEY = 'user';
|
||||||
|
|
||||||
|
// 用户注册
|
||||||
|
export async function register(data: RegisterRequest): Promise<BaseResult<AuthResponse>> {
|
||||||
|
return fetchApi<AuthResponse>('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户登录
|
||||||
|
export async function login(data: LoginRequest): Promise<BaseResult<AuthResponse>> {
|
||||||
|
const response = await fetchApi<AuthResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
clearAuthData(); // 清除旧的认证数据
|
||||||
|
console.log('登录成功,保存认证数据:', response.data);
|
||||||
|
saveAuthData(response.data); // 保存新的认证数据
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前登录用户
|
||||||
|
export async function getCurrentUser(): Promise<BaseResult<UserProfile>> {
|
||||||
|
try {
|
||||||
|
const token = getToken();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '用户未登录',
|
||||||
|
code: 401
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetchApi<UserProfile>('/auth/get_current_user');
|
||||||
|
|
||||||
|
// 如果成功获取到用户数据,更新本地存储
|
||||||
|
if (response.success && response.data) {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(response.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `获取用户信息失败: ${error.message}`,
|
||||||
|
code: 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存认证数据到本地存储
|
||||||
|
export const saveAuthData = (authData: AuthResponse): void => {
|
||||||
|
localStorage.setItem(TOKEN_KEY, authData.token);
|
||||||
|
if (authData.user) {
|
||||||
|
localStorage.setItem(USER_KEY, JSON.stringify(authData.user));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清除认证数据
|
||||||
|
export const clearAuthData = (): void => {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否已认证
|
||||||
|
export const isAuthenticated = (): boolean => {
|
||||||
|
return !!getToken();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取存储的用户信息
|
||||||
|
export const getStoredUser = (): UserProfile | null => {
|
||||||
|
try {
|
||||||
|
const userJson = localStorage.getItem(USER_KEY);
|
||||||
|
if (!userJson) return null;
|
||||||
|
|
||||||
|
return JSON.parse(userJson) as UserProfile;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取存储的令牌
|
||||||
|
export const getToken = (): string | null => {
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理GitHub OAuth回调,接收token并保存
|
||||||
|
export function handleOAuthCallback(): boolean {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const token = urlParams.get('token');
|
||||||
|
const error = urlParams.get('error');
|
||||||
|
|
||||||
|
if (error) return false;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const githubUser = parseJwt(token);
|
||||||
|
if (githubUser) {
|
||||||
|
// 保存token
|
||||||
|
localStorage.setItem('token', token);
|
||||||
|
|
||||||
|
// 保存用户信息
|
||||||
|
if (githubUser.unique_name && githubUser.email) {
|
||||||
|
const user: UserProfile = {
|
||||||
|
id: parseInt(githubUser.nameid),
|
||||||
|
userName: githubUser.unique_name,
|
||||||
|
email: githubUser.email,
|
||||||
|
roleName: ''
|
||||||
|
};
|
||||||
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除URL中的token参数
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('token');
|
||||||
|
window.history.replaceState({}, document.title, url.toString());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析JWT获取用户信息
|
||||||
|
function parseJwt(token: string) {
|
||||||
|
try {
|
||||||
|
const base64Url = token.split('.')[1];
|
||||||
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split('')
|
||||||
|
.map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join('')
|
||||||
|
);
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取GitHub登录URL
|
||||||
|
export function getGitHubLoginUrl(): string {
|
||||||
|
return `${BASE_URL}/auth/github/login?returnUrl=${window.location.origin}/api/auth/github/callback`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { fetchApi } from './fetchClient';
|
||||||
|
import type { BaseResult, PictureProcessingTask } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的所有处理任务
|
||||||
|
*/
|
||||||
|
export const getUserTasks = async (): Promise<BaseResult<PictureProcessingTask[]>> => {
|
||||||
|
return fetchApi<PictureProcessingTask[]>('/background-tasks/user-tasks');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取特定图片的处理状态
|
||||||
|
* @param pictureId 图片ID
|
||||||
|
*/
|
||||||
|
export const getPictureProcessingStatus = async (pictureId: number): Promise<BaseResult<PictureProcessingTask>> => {
|
||||||
|
return fetchApi<PictureProcessingTask>(`/background-tasks/picture-status/${pictureId}`);
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { UserRole, type BaseResult, type ConfigResponse, type SetConfigRequest } from './types';
|
||||||
|
import { fetchApi } from './fetchClient';
|
||||||
|
|
||||||
|
// 获取所有配置
|
||||||
|
export const getAllConfigs = async (): Promise<BaseResult<ConfigResponse[]>> => {
|
||||||
|
try {
|
||||||
|
return await fetchApi<ConfigResponse[]>('/config/get_configs');
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `获取配置失败: ${error.message}`,
|
||||||
|
code: 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取单个配置
|
||||||
|
export const getConfig = async (key: string): Promise<BaseResult<ConfigResponse>> => {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.append('key', key);
|
||||||
|
|
||||||
|
return await fetchApi<ConfigResponse>(`/config/get_config?${queryParams.toString()}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `获取配置失败: ${error.message}`,
|
||||||
|
code: 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置配置
|
||||||
|
export const setConfig = async (config: SetConfigRequest): Promise<BaseResult<ConfigResponse>> => {
|
||||||
|
try {
|
||||||
|
return await fetchApi<ConfigResponse>('/config/set_config', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `设置配置失败: ${error.message}`,
|
||||||
|
code: 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除配置
|
||||||
|
export const deleteConfig = async (key: string): Promise<BaseResult<boolean>> => {
|
||||||
|
try {
|
||||||
|
return await fetchApi<boolean>('/config/delete_config', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(key),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `删除配置失败: ${error.message}`,
|
||||||
|
code: 500
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 角色权限检查
|
||||||
|
export const hasRole = (userRole: string | undefined, requiredRole: UserRole): boolean => {
|
||||||
|
if (!userRole) return false;
|
||||||
|
|
||||||
|
// 如果是管理员,拥有所有权限
|
||||||
|
if (userRole === UserRole.Administrator) return true;
|
||||||
|
|
||||||
|
// 精确匹配角色
|
||||||
|
return userRole === requiredRole;
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { BaseResult } from './types';
|
||||||
|
export const BASE_URL = import.meta.env.PROD ? '/api' : 'http://localhost:5153/api';
|
||||||
|
|
||||||
|
export async function fetchApi<T = any>(
|
||||||
|
url: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<BaseResult<T>> {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers as Record<string, string>,
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(`${BASE_URL}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
return data as BaseResult<T>;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API请求错误:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '网络请求失败,请检查您的网络连接',
|
||||||
|
code: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 重新导出类型
|
||||||
|
export * from './authApi';
|
||||||
|
export * from './types';
|
||||||
|
|
||||||
|
// 导出fetch客户端
|
||||||
|
export { fetchApi, BASE_URL } from './fetchClient';
|
||||||
|
|
||||||
|
// 导出Auth API
|
||||||
|
export {
|
||||||
|
register,
|
||||||
|
login,
|
||||||
|
getCurrentUser,
|
||||||
|
saveAuthData,
|
||||||
|
clearAuthData,
|
||||||
|
isAuthenticated,
|
||||||
|
getStoredUser
|
||||||
|
} from './authApi';
|
||||||
|
|
||||||
|
// 导出Picture API
|
||||||
|
export {
|
||||||
|
getPictures,
|
||||||
|
favoritePicture,
|
||||||
|
unfavoritePicture,
|
||||||
|
getUserFavorites,
|
||||||
|
uploadPicture,
|
||||||
|
deleteMultiplePictures, // 添加导出删除图片函数
|
||||||
|
} from './pictureApi';
|
||||||
|
|
||||||
|
// 导出Album API
|
||||||
|
export {
|
||||||
|
getAlbums,
|
||||||
|
getAlbumById,
|
||||||
|
createAlbum,
|
||||||
|
updateAlbum,
|
||||||
|
deleteAlbum,
|
||||||
|
addPictureToAlbum,
|
||||||
|
addPicturesToAlbum,
|
||||||
|
removePictureFromAlbum
|
||||||
|
} from './albumApi';
|
||||||
|
|
||||||
|
// 导出BackgroundTask API
|
||||||
|
export {
|
||||||
|
getUserTasks,
|
||||||
|
getPictureProcessingStatus,
|
||||||
|
} from './backgroundTaskApi';
|
||||||
|
|
||||||
|
// 导出Config API
|
||||||
|
export {
|
||||||
|
getAllConfigs,
|
||||||
|
getConfig,
|
||||||
|
setConfig,
|
||||||
|
deleteConfig,
|
||||||
|
hasRole
|
||||||
|
} from './configApi';
|
||||||
|
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import type { PaginatedResult, PictureResponse, FilteredPicturesRequest, BaseResult } from './types';
|
||||||
|
import { fetchApi, BASE_URL } from './fetchClient';
|
||||||
|
|
||||||
|
// 获取图片列表
|
||||||
|
export async function getPictures(params: FilteredPicturesRequest = {}): Promise<PaginatedResult<PictureResponse>> {
|
||||||
|
// 添加调试日志
|
||||||
|
console.log("Search API 请求参数:", params);
|
||||||
|
|
||||||
|
// 构建查询参数
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
// 添加所有非空参数
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||||
|
if (params.searchQuery) queryParams.append('searchQuery', params.searchQuery);
|
||||||
|
if (params.tags) queryParams.append('tags', params.tags);
|
||||||
|
if (params.startDate) queryParams.append('startDate', params.startDate);
|
||||||
|
if (params.endDate) queryParams.append('endDate', params.endDate);
|
||||||
|
if (params.userId) queryParams.append('userId', params.userId.toString());
|
||||||
|
if (params.sortBy) queryParams.append('sortBy', params.sortBy);
|
||||||
|
if (params.onlyWithGps !== undefined) queryParams.append('onlyWithGps', params.onlyWithGps.toString());
|
||||||
|
if (params.useVectorSearch !== undefined) queryParams.append('useVectorSearch', params.useVectorSearch.toString());
|
||||||
|
if (params.similarityThreshold) queryParams.append('similarityThreshold', params.similarityThreshold.toString());
|
||||||
|
if (params.excludeAlbumId) queryParams.append('excludeAlbumId', params.excludeAlbumId.toString());
|
||||||
|
if (params.albumId) queryParams.append('albumId', params.albumId.toString());
|
||||||
|
if (params.onlyFavorites !== undefined) queryParams.append('onlyFavorites', params.onlyFavorites.toString());
|
||||||
|
if (params.ownerId !== undefined) queryParams.append('ownerId', params.ownerId.toString());
|
||||||
|
if (params.includeAllPublic !== undefined) queryParams.append('includeAllPublic', params.includeAllPublic.toString());
|
||||||
|
|
||||||
|
// 最终URL调试日志
|
||||||
|
const url = `${BASE_URL}/picture/get_pictures?${queryParams.toString()}`;
|
||||||
|
console.log("发送API请求:", url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// 添加结果日志
|
||||||
|
console.log("API 响应结果:", {
|
||||||
|
success: data.success,
|
||||||
|
totalCount: data.totalCount,
|
||||||
|
resultsCount: data.data?.length || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return data as PaginatedResult<PictureResponse>;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取图片列表失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '网络请求失败,请检查您的网络连接',
|
||||||
|
data: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
code: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏图片
|
||||||
|
export async function favoritePicture(pictureId: number): Promise<BaseResult<boolean>> {
|
||||||
|
return fetchApi<boolean>('/picture/favorite', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ pictureId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消收藏图片
|
||||||
|
export async function unfavoritePicture(pictureId: number): Promise<BaseResult<boolean>> {
|
||||||
|
return fetchApi<boolean>('/picture/unfavorite', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ pictureId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户收藏的图片
|
||||||
|
export async function getUserFavorites(page: number = 1, pageSize: number = 8): Promise<PaginatedResult<PictureResponse>> {
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/picture/favorites?page=${page}&pageSize=${pageSize}`;
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data as PaginatedResult<PictureResponse>;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取收藏图片失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '网络请求失败,请检查您的网络连接',
|
||||||
|
data: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
code: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
export async function uploadPicture(
|
||||||
|
file: File,
|
||||||
|
data: {
|
||||||
|
permission?: number;
|
||||||
|
albumId?: number;
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
} = {}
|
||||||
|
): Promise<BaseResult<PictureResponse>> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
if (data.permission !== undefined) {
|
||||||
|
formData.append('permission', data.permission.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.albumId !== undefined) {
|
||||||
|
formData.append('albumId', data.albumId.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// 返回一个Promise
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
xhr.open('POST', `${BASE_URL}/picture/upload_picture`);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (event) => {
|
||||||
|
if (event.lengthComputable && data.onProgress) {
|
||||||
|
const percent = Math.round((event.loaded / event.total) * 100);
|
||||||
|
data.onProgress(percent);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
resolve(response);
|
||||||
|
} else {
|
||||||
|
reject({
|
||||||
|
status: xhr.status,
|
||||||
|
message: xhr.statusText || '上传失败',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => {
|
||||||
|
reject({
|
||||||
|
status: xhr.status,
|
||||||
|
message: '网络错误,上传失败',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传图片失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '上传图片失败',
|
||||||
|
code: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除多张图片
|
||||||
|
export async function deleteMultiplePictures(pictureIds: number[]): Promise<BaseResult<object>> {
|
||||||
|
return fetchApi<object>('/picture/delete_pictures', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ pictureIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { BaseResult, PaginatedResult } from './types';
|
||||||
|
import { fetchApi, BASE_URL } from './fetchClient';
|
||||||
|
|
||||||
|
// 标签响应类型
|
||||||
|
export interface TagResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
pictureCount: number;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选标签请求参数
|
||||||
|
export interface FilteredTagsRequest {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
searchQuery?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortDirection?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建标签请求
|
||||||
|
export interface CreateTagRequest {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新标签请求
|
||||||
|
export interface UpdateTagRequest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有标签
|
||||||
|
export async function getAllTags(): Promise<BaseResult<TagResponse[]>> {
|
||||||
|
return fetchApi<TagResponse[]>('/tag/all', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取筛选后的标签
|
||||||
|
export async function getFilteredTags(params: FilteredTagsRequest = {}): Promise<PaginatedResult<TagResponse>> {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
|
if (params.page) queryParams.append('page', params.page.toString());
|
||||||
|
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString());
|
||||||
|
if (params.searchQuery) queryParams.append('searchQuery', params.searchQuery);
|
||||||
|
if (params.sortBy) queryParams.append('sortBy', params.sortBy);
|
||||||
|
if (params.sortDirection) queryParams.append('sortDirection', params.sortDirection);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${BASE_URL}/tag/get_tags?${queryParams.toString()}`;
|
||||||
|
const response = await fetch(url, { headers });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data as PaginatedResult<TagResponse>;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取标签列表失败:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '网络请求失败,请检查您的网络连接',
|
||||||
|
data: [],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
totalCount: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
code: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取标签详情
|
||||||
|
export async function getTagById(id: number): Promise<BaseResult<TagResponse>> {
|
||||||
|
return fetchApi<TagResponse>(`/tag/${id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建标签
|
||||||
|
export async function createTag(request: CreateTagRequest): Promise<BaseResult<TagResponse>> {
|
||||||
|
return fetchApi<TagResponse>('/tag/create_tag', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新标签
|
||||||
|
export async function updateTag(request: UpdateTagRequest): Promise<BaseResult<TagResponse>> {
|
||||||
|
return fetchApi<TagResponse>('/tag/update_tag', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除标签
|
||||||
|
export async function deleteTag(id: number): Promise<BaseResult<boolean>> {
|
||||||
|
return fetchApi<boolean>('/tag/delete_tag', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(id),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
// API响应的基础结构
|
||||||
|
export interface BaseResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: T;
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页结果通用结构
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T[];
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录请求参数
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册请求参数
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
export interface UserProfile {
|
||||||
|
id: number;
|
||||||
|
userName: string;
|
||||||
|
email: string;
|
||||||
|
roleName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证响应
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string;
|
||||||
|
user: UserProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片请求参数
|
||||||
|
export interface FilteredPicturesRequest {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
searchQuery?: string;
|
||||||
|
tags?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
userId?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
onlyWithGps?: boolean;
|
||||||
|
useVectorSearch?: boolean;
|
||||||
|
similarityThreshold?: number;
|
||||||
|
excludeAlbumId?: number;
|
||||||
|
albumId?: number;
|
||||||
|
onlyFavorites?: boolean;
|
||||||
|
ownerId?: number;
|
||||||
|
includeAllPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片响应数据
|
||||||
|
export interface PictureResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
thumbnailPath: string;
|
||||||
|
description: string;
|
||||||
|
takenAt?: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
exifInfo?: any;
|
||||||
|
tags?: string[];
|
||||||
|
userId: number;
|
||||||
|
username?: string;
|
||||||
|
isFavorited: boolean;
|
||||||
|
favoriteCount: number;
|
||||||
|
permission: number;
|
||||||
|
albumId?: number;
|
||||||
|
albumName?: string;
|
||||||
|
processingStatus: ProcessingStatus;
|
||||||
|
processingError?: string;
|
||||||
|
processingProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收藏请求
|
||||||
|
export interface FavoriteRequest {
|
||||||
|
pictureId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传队列中的文件项
|
||||||
|
export interface UploadFile {
|
||||||
|
id: string; // 本地ID,用于跟踪状态
|
||||||
|
file: File; // 原始文件
|
||||||
|
status: 'pending' | 'uploading' | 'success' | 'error'; // 上传状态
|
||||||
|
percent: number; // 上传进度百分比 0-100
|
||||||
|
error?: string; // 错误信息
|
||||||
|
response?: PictureResponse; // 上传成功后的响应
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传图片请求参数
|
||||||
|
export interface UploadPictureParams {
|
||||||
|
permission?: number; // 权限设置,默认为0(公开)
|
||||||
|
albumId?: number; // 相册ID,可选
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相册响应数据
|
||||||
|
export interface AlbumResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
coverImageUrl?: string;
|
||||||
|
pictureCount: number;
|
||||||
|
userId: number;
|
||||||
|
username?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建相册请求
|
||||||
|
export interface CreateAlbumRequest {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新相册请求
|
||||||
|
export interface UpdateAlbumRequest {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相册图片操作请求
|
||||||
|
export interface AlbumPictureRequest {
|
||||||
|
albumId: number;
|
||||||
|
pictureId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量添加图片到相册请求
|
||||||
|
export interface AlbumPicturesRequest {
|
||||||
|
albumId: number;
|
||||||
|
pictureIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除多张图片请求
|
||||||
|
export interface DeleteMultiplePicturesRequest {
|
||||||
|
pictureIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将类型定义改为枚举,这样既可以作为类型也可以作为值使用
|
||||||
|
export type ProcessingStatus = 'Pending' | 'Processing' | 'Completed' | 'Failed';
|
||||||
|
|
||||||
|
// 添加常量对象提供运行时值
|
||||||
|
export const ProcessingStatus = {
|
||||||
|
Pending: 'Pending' as ProcessingStatus,
|
||||||
|
Processing: 'Processing' as ProcessingStatus,
|
||||||
|
Completed: 'Completed' as ProcessingStatus,
|
||||||
|
Failed: 'Failed' as ProcessingStatus
|
||||||
|
};
|
||||||
|
|
||||||
|
// 图片处理任务
|
||||||
|
export interface PictureProcessingTask {
|
||||||
|
pictureId: number;
|
||||||
|
taskId: string;
|
||||||
|
pictureName: string;
|
||||||
|
status: ProcessingStatus;
|
||||||
|
progress: number; // 0-100
|
||||||
|
error?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
completedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置响应数据
|
||||||
|
export interface ConfigResponse {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetConfigRequest {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserRole = "Administrator" | "User" | "";
|
||||||
|
|
||||||
|
export const UserRole = {
|
||||||
|
Administrator: "Administrator" as UserRole,
|
||||||
|
User: "User" as UserRole,
|
||||||
|
Guest: "" as UserRole
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Progress, Tag, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
ClockCircleOutlined,
|
||||||
|
SyncOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { ProcessingStatus } from '../api/types';
|
||||||
|
|
||||||
|
interface TaskProgressBarProps {
|
||||||
|
status: ProcessingStatus;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
showLabel?: boolean;
|
||||||
|
size?: 'small' | 'default';
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskProgressBar: React.FC<TaskProgressBarProps> = ({
|
||||||
|
status,
|
||||||
|
progress,
|
||||||
|
error,
|
||||||
|
showLabel = true,
|
||||||
|
size = 'default',
|
||||||
|
className,
|
||||||
|
style
|
||||||
|
}) => {
|
||||||
|
let statusColor = '';
|
||||||
|
let icon = null;
|
||||||
|
let statusText = '';
|
||||||
|
let progressStatus: "success" | "exception" | "active" | "normal" | undefined;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case ProcessingStatus.Pending:
|
||||||
|
statusColor = 'orange';
|
||||||
|
progressStatus = 'normal';
|
||||||
|
icon = <ClockCircleOutlined />;
|
||||||
|
statusText = '等待中';
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.Processing:
|
||||||
|
statusColor = 'processing';
|
||||||
|
progressStatus = 'active';
|
||||||
|
icon = <SyncOutlined spin />;
|
||||||
|
statusText = '处理中';
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.Completed:
|
||||||
|
statusColor = 'success';
|
||||||
|
progressStatus = 'success';
|
||||||
|
icon = <CheckCircleOutlined />;
|
||||||
|
statusText = '已完成';
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.Failed:
|
||||||
|
statusColor = 'error';
|
||||||
|
progressStatus = 'exception';
|
||||||
|
icon = <CloseCircleOutlined />;
|
||||||
|
statusText = '失败';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} style={{ ...style }}>
|
||||||
|
{showLabel && (
|
||||||
|
<div style={{ marginBottom: 4, display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Tag color={statusColor} icon={icon} style={{ marginRight: 8 }}>
|
||||||
|
{statusText}
|
||||||
|
</Tag>
|
||||||
|
{status === ProcessingStatus.Failed && error && (
|
||||||
|
<Tooltip title={error}>
|
||||||
|
<span style={{ color: '#ff4d4f', cursor: 'pointer', fontSize: 13 }}>
|
||||||
|
查看错误
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Tooltip title={`${progress}%`}>
|
||||||
|
<Progress
|
||||||
|
percent={progress}
|
||||||
|
size={size}
|
||||||
|
status={progressStatus}
|
||||||
|
showInfo={size !== 'small'}
|
||||||
|
strokeColor={status === ProcessingStatus.Failed ? '#ff4d4f' : undefined}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskProgressBar;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Avatar as AntAvatar, type AvatarProps as AntAvatarProps } from 'antd';
|
||||||
|
import { UserOutlined } from '@ant-design/icons';
|
||||||
|
import md5 from 'md5';
|
||||||
|
|
||||||
|
interface UserAvatarProps extends Omit<AntAvatarProps, 'src'> {
|
||||||
|
email?: string;
|
||||||
|
url?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||||
|
email,
|
||||||
|
url,
|
||||||
|
text,
|
||||||
|
size = 46,
|
||||||
|
style,
|
||||||
|
...restProps
|
||||||
|
}) => {
|
||||||
|
// 确定头像源
|
||||||
|
const getAvatarSrc = () => {
|
||||||
|
if (url) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (email) {
|
||||||
|
const hash = md5(email.toLowerCase().trim());
|
||||||
|
return `https://cn.cravatar.com/avatar/${hash}?s=${typeof size === 'number' ? size * 2 : 96}&d=identicon`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatarSrc = getAvatarSrc();
|
||||||
|
|
||||||
|
// 默认样式
|
||||||
|
const defaultStyle = {
|
||||||
|
backgroundColor: !avatarSrc && !text ? '#18181b' : undefined,
|
||||||
|
cursor: 'pointer',
|
||||||
|
boxShadow: '0 3px 10px rgba(0,0,0,0.1)',
|
||||||
|
...style
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AntAvatar
|
||||||
|
size={size}
|
||||||
|
src={avatarSrc}
|
||||||
|
style={defaultStyle}
|
||||||
|
icon={!avatarSrc && !text ? <UserOutlined /> : null}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{!avatarSrc && text ? text.charAt(0).toUpperCase() : null}
|
||||||
|
</AntAvatar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserAvatar;
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
.image-grid {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
grid-gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 现代化卡片样式 */
|
||||||
|
.custom-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.08);
|
||||||
|
transition: all 0.35s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
height: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
transform: translateY(0);
|
||||||
|
cursor: pointer;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 14px 28px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片占满卡片 */
|
||||||
|
.custom-card-cover {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-thumbnail {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.7s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card:hover .custom-card-thumbnail {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息覆盖层 - 默认隐藏 */
|
||||||
|
.custom-card-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0) 50%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 20px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card:hover .custom-card-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息内容样式 */
|
||||||
|
.custom-card-info {
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card:hover .custom-card-info {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-tags-container {
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-tag {
|
||||||
|
margin-right: 6px;
|
||||||
|
font-size: 11px !important;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ffffff;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 权限和元数据指示器 */
|
||||||
|
.custom-card-indicators {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 12px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card:hover .custom-card-indicators {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-permission {
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-metadata {
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
color: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮容器 */
|
||||||
|
.custom-card-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-action-item {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-action-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右键菜单样式 */
|
||||||
|
.context-menu {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
padding: 8px 0;
|
||||||
|
min-width: 160px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中状态样式 */
|
||||||
|
.custom-card-selected {
|
||||||
|
box-shadow: 0 0 0 3px #1890ff, 0 14px 28px rgba(0,0,0,0.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-selected::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #1890ff;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-card-selected::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
z-index: 6;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-grid-pagination {
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移除旧的加载动画,不再需要 */
|
||||||
|
.image-loading-effect {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-loading-effect::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||||
|
animation: loading 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.image-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
grid-gap: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Typography, Empty, message, Pagination, Modal } from 'antd';
|
||||||
|
import {
|
||||||
|
HeartOutlined, HeartFilled, LockOutlined, GlobalOutlined, TeamOutlined,
|
||||||
|
DeleteOutlined, EditOutlined, DownloadOutlined, ShareAltOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { PictureResponse } from '../../api';
|
||||||
|
import { favoritePicture, unfavoritePicture, getPictures, deleteMultiplePictures } from '../../api';
|
||||||
|
import ImageViewer from './ImageViewer';
|
||||||
|
import ShareImageDialog from './ShareImageDialog';
|
||||||
|
import './ImageGrid.css';
|
||||||
|
import { useAuth } from '../../api/AuthContext';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const permissionTypeMap: Record<number, { label: string; icon: React.ReactNode; color: string }> = {
|
||||||
|
0: { label: '公开', icon: <GlobalOutlined />, color: '#52c41a' },
|
||||||
|
1: { label: '好友可见', icon: <TeamOutlined />, color: '#1890ff' },
|
||||||
|
2: { label: '私人', icon: <LockOutlined />, color: '#ff4d4f' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('日期格式化错误:', error);
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化API参数接口
|
||||||
|
interface PaginationParams {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
albumId?: number;
|
||||||
|
excludeAlbumId?: number;
|
||||||
|
onlyFavorites?: boolean;
|
||||||
|
tags?: string;
|
||||||
|
searchQuery?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
includeAllPublic?: boolean;
|
||||||
|
useVectorSearch?: boolean;
|
||||||
|
similarityThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右键菜单类型接口
|
||||||
|
interface ContextMenuState {
|
||||||
|
visible: boolean;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
imageId?: number;
|
||||||
|
image?: PictureResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简化Props接口,使用默认值
|
||||||
|
interface ImageGridProps {
|
||||||
|
// 核心功能属性
|
||||||
|
onToggleFavorite?: (image: PictureResponse) => void;
|
||||||
|
showFavoriteCount?: boolean;
|
||||||
|
emptyText?: string;
|
||||||
|
showPagination?: boolean;
|
||||||
|
|
||||||
|
// 数据源属性集合
|
||||||
|
dataSource?: PictureResponse[];
|
||||||
|
totalImages?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
|
||||||
|
// 合并查询相关参数
|
||||||
|
queryParams?: {
|
||||||
|
albumId?: number;
|
||||||
|
excludeAlbumId?: number;
|
||||||
|
onlyFavorites?: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
searchQuery?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
includeAllPublic?: boolean;
|
||||||
|
useVectorSearch?: boolean;
|
||||||
|
similarityThreshold?: number;
|
||||||
|
_searchId?: number; // 添加搜索ID属性
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页相关属性
|
||||||
|
pageSize?: number;
|
||||||
|
defaultPage?: number;
|
||||||
|
onPageChange?: (page: number, pageSize: number) => void;
|
||||||
|
onImagesLoaded?: (images: PictureResponse[], totalCount: number) => void;
|
||||||
|
|
||||||
|
// 选择模式相关属性
|
||||||
|
selectedIds?: number[];
|
||||||
|
selectable?: boolean;
|
||||||
|
onSelectionChange?: (selectedIds: number[]) => void;
|
||||||
|
|
||||||
|
// 新增操作回调
|
||||||
|
onDelete?: (image: PictureResponse) => void;
|
||||||
|
onEdit?: (image: PictureResponse) => void;
|
||||||
|
onDownload?: (image: PictureResponse) => void;
|
||||||
|
onShare?: (image: PictureResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const ImageGrid: React.FC<ImageGridProps> = ({
|
||||||
|
// 使用解构赋值时直接设置默认值
|
||||||
|
onToggleFavorite,
|
||||||
|
showFavoriteCount = false,
|
||||||
|
emptyText = "暂无图片",
|
||||||
|
showPagination = true,
|
||||||
|
|
||||||
|
dataSource,
|
||||||
|
totalImages: externalTotalImages,
|
||||||
|
loading: externalLoading,
|
||||||
|
|
||||||
|
queryParams = {},
|
||||||
|
|
||||||
|
pageSize: externalPageSize = 20,
|
||||||
|
defaultPage = 1,
|
||||||
|
onPageChange,
|
||||||
|
onImagesLoaded,
|
||||||
|
|
||||||
|
selectedIds = [],
|
||||||
|
selectable = false,
|
||||||
|
onSelectionChange,
|
||||||
|
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
onDownload,
|
||||||
|
onShare,
|
||||||
|
}) => {
|
||||||
|
// 获取当前登录用户信息
|
||||||
|
const { user, hasRole } = useAuth();
|
||||||
|
|
||||||
|
// 使用更紧凑的状态定义
|
||||||
|
const [images, setImages] = useState<PictureResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentPage, setCurrentPage] = useState(defaultPage);
|
||||||
|
const [pageSize, setPageSize] = useState(externalPageSize);
|
||||||
|
const [totalImages, setTotalImages] = useState(0);
|
||||||
|
const [viewerState, setViewerState] = useState({ visible: false, index: 0 });
|
||||||
|
const [shareDialogState, setShareDialogState] = useState<{
|
||||||
|
visible: boolean;
|
||||||
|
image: PictureResponse | null;
|
||||||
|
}>({
|
||||||
|
visible: false,
|
||||||
|
image: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加右键菜单状态
|
||||||
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 简化标志变量
|
||||||
|
const isUsingExternalData = !!dataSource;
|
||||||
|
const isLoading = isUsingExternalData ? externalLoading : loading;
|
||||||
|
|
||||||
|
// 请求状态追踪
|
||||||
|
const requestState = useRef({
|
||||||
|
inProgress: false,
|
||||||
|
lastParams: '',
|
||||||
|
noResultsFor: '' // 新增:记录哪些查询参数没有返回结果
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优化构建查询参数函数
|
||||||
|
const buildQueryParams = useCallback((): PaginationParams => {
|
||||||
|
const params: PaginationParams = {
|
||||||
|
page: currentPage,
|
||||||
|
pageSize,
|
||||||
|
...queryParams,
|
||||||
|
searchQuery: queryParams.searchQuery,
|
||||||
|
tags: Array.isArray(queryParams.tags) ? queryParams.tags.join(',') : undefined,
|
||||||
|
useVectorSearch: queryParams.useVectorSearch,
|
||||||
|
similarityThreshold: queryParams.similarityThreshold
|
||||||
|
};
|
||||||
|
return params;
|
||||||
|
}, [currentPage, pageSize, queryParams]);
|
||||||
|
|
||||||
|
// 优化加载数据函数,减少依赖项
|
||||||
|
const loadImages = useCallback(async () => {
|
||||||
|
if (isUsingExternalData || requestState.current.inProgress) return;
|
||||||
|
|
||||||
|
const params = buildQueryParams();
|
||||||
|
const paramsString = JSON.stringify(params);
|
||||||
|
|
||||||
|
// 检查是否是已知没有结果的查询
|
||||||
|
if (requestState.current.noResultsFor === paramsString) {
|
||||||
|
// 如果这个查询之前没有结果,且当前还是空结果状态,不再重复请求
|
||||||
|
if (images.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 如果之前没结果但现在有图片显示,重置noResultsFor让新查询可以执行
|
||||||
|
requestState.current.noResultsFor = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestState.current.lastParams === paramsString) {
|
||||||
|
// 如果参数没变且已有数据或无数据状态已确认,跳过请求
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestState.current = {
|
||||||
|
inProgress: true,
|
||||||
|
lastParams: paramsString,
|
||||||
|
noResultsFor: requestState.current.noResultsFor
|
||||||
|
};
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getPictures(params);
|
||||||
|
|
||||||
|
// 无论成功与否,都更新lastParams以避免相同参数的重复请求
|
||||||
|
requestState.current.lastParams = paramsString;
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// 更新图片数据
|
||||||
|
setImages(result.data || []);
|
||||||
|
setTotalImages(result.totalCount || 0);
|
||||||
|
onImagesLoaded?.(result.data || [], result.totalCount || 0);
|
||||||
|
|
||||||
|
// 如果结果为空,记录到noResultsFor
|
||||||
|
if (!result.data || result.data.length === 0) {
|
||||||
|
requestState.current.noResultsFor = paramsString;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '获取图片失败');
|
||||||
|
requestState.current.noResultsFor = paramsString;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('加载图片列表出错');
|
||||||
|
requestState.current.noResultsFor = paramsString;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
requestState.current.inProgress = false;
|
||||||
|
}
|
||||||
|
}, [buildQueryParams, isUsingExternalData, onImagesLoaded]);
|
||||||
|
|
||||||
|
// 简化useEffect
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUsingExternalData) loadImages();
|
||||||
|
}, [loadImages, isUsingExternalData]);
|
||||||
|
|
||||||
|
// 同步外部数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (isUsingExternalData && dataSource) setImages(dataSource);
|
||||||
|
}, [dataSource, isUsingExternalData]);
|
||||||
|
|
||||||
|
// 优化收藏/取消收藏逻辑
|
||||||
|
const handleToggleFavorite = async (image: PictureResponse) => {
|
||||||
|
try {
|
||||||
|
const { id, isFavorited } = image;
|
||||||
|
const api = isFavorited ? unfavoritePicture : favoritePicture;
|
||||||
|
const result = await api(id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message.success(isFavorited ? '已取消收藏' : '已添加到收藏');
|
||||||
|
|
||||||
|
// 更新本地状态
|
||||||
|
setImages(prevImages =>
|
||||||
|
prevImages.map(img =>
|
||||||
|
img.id === id ? {
|
||||||
|
...img,
|
||||||
|
isFavorited: !isFavorited,
|
||||||
|
favoriteCount: isFavorited
|
||||||
|
? Math.max(0, (img.favoriteCount || 0) - 1)
|
||||||
|
: (img.favoriteCount || 0) + 1
|
||||||
|
} : img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
onToggleFavorite?.(image);
|
||||||
|
} else {
|
||||||
|
message.error(result.message || (isFavorited ? '取消收藏失败' : '收藏失败'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('操作失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理分页变化
|
||||||
|
const handlePageChange = (page: number, size: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
if (size !== pageSize) setPageSize(size);
|
||||||
|
onPageChange?.(page, size);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 优化图片点击处理逻辑
|
||||||
|
const handleImageClick = (image: PictureResponse, index: number) => {
|
||||||
|
if (selectable && onSelectionChange) {
|
||||||
|
const isSelected = selectedIds.includes(image.id);
|
||||||
|
const newSelectedIds = isSelected
|
||||||
|
? selectedIds.filter(id => id !== image.id)
|
||||||
|
: [...selectedIds, image.id];
|
||||||
|
onSelectionChange(newSelectedIds);
|
||||||
|
} else {
|
||||||
|
setViewerState({ visible: true, index });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理右键菜单
|
||||||
|
const handleContextMenu = (e: React.MouseEvent, image: PictureResponse) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setContextMenu({
|
||||||
|
visible: true,
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
imageId: image.id,
|
||||||
|
image
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeContextMenu = () => {
|
||||||
|
setContextMenu(prev => ({
|
||||||
|
...prev,
|
||||||
|
visible: false
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleDocumentClick = () => {
|
||||||
|
if (contextMenu.visible) {
|
||||||
|
closeContextMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleDocumentClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleDocumentClick);
|
||||||
|
};
|
||||||
|
}, [contextMenu.visible]);
|
||||||
|
|
||||||
|
// 处理图片分享
|
||||||
|
const handleShareImage = (image: PictureResponse) => {
|
||||||
|
setShareDialogState({
|
||||||
|
visible: true,
|
||||||
|
image
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭分享对话框
|
||||||
|
const handleCloseShareDialog = () => {
|
||||||
|
setShareDialogState({
|
||||||
|
...shareDialogState,
|
||||||
|
visible: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 修改handleMenuAction中的分享处理
|
||||||
|
const handleMenuAction = (action: string) => {
|
||||||
|
if (!contextMenu.image) return;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'favorite':
|
||||||
|
handleToggleFavorite(contextMenu.image);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
handleDeleteImage(contextMenu.image);
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
onEdit?.(contextMenu.image);
|
||||||
|
break;
|
||||||
|
case 'download':
|
||||||
|
onDownload?.(contextMenu.image);
|
||||||
|
break;
|
||||||
|
case 'share':
|
||||||
|
handleShareImage(contextMenu.image);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeContextMenu();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 判断用户是否有权限编辑或删除图片
|
||||||
|
const canEditImage = (image: PictureResponse): boolean => {
|
||||||
|
if (user && hasRole('Administrator')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !!user && !!image.userId && user.id === image.userId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 优化渲染内容函数
|
||||||
|
const renderContent = () => {
|
||||||
|
// 渲染加载状态
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="image-grid">
|
||||||
|
{Array.from({ length: pageSize }).map((_, index) => (
|
||||||
|
<div key={`loading-${index}`} className="custom-card image-loading-effect">
|
||||||
|
<div className="custom-card-cover" style={{ background: '#f5f5f5' }}>
|
||||||
|
{/* 简单的加载状态 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染空状态
|
||||||
|
if (images.length === 0) {
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
description={emptyText}
|
||||||
|
style={{ margin: '80px 0' }}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渲染图片网格
|
||||||
|
return (
|
||||||
|
<div className="image-grid">
|
||||||
|
{images.map((image, index) => {
|
||||||
|
const isOwner = canEditImage(image);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={image.id}
|
||||||
|
className={`custom-card ${selectedIds.includes(image.id) ? 'custom-card-selected' : ''} ${selectable ? 'custom-card-selectable-mode' : ''}`}
|
||||||
|
onClick={() => handleImageClick(image, index)}
|
||||||
|
onContextMenu={(e) => handleContextMenu(e, image)}
|
||||||
|
>
|
||||||
|
<div className="custom-card-cover">
|
||||||
|
<img
|
||||||
|
alt={image.name}
|
||||||
|
src={image.thumbnailPath || image.path}
|
||||||
|
className="custom-card-thumbnail"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!selectable && (
|
||||||
|
<>
|
||||||
|
{/* 顶部指示器 - 悬停时显示 */}
|
||||||
|
<div className="custom-card-indicators">
|
||||||
|
<div className="custom-card-permission" style={{
|
||||||
|
backgroundColor: permissionTypeMap[image.permission]?.color || 'rgba(0, 0, 0, 0.6)'
|
||||||
|
}}>
|
||||||
|
{permissionTypeMap[image.permission]?.icon} {permissionTypeMap[image.permission]?.label || '公开'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="custom-card-metadata">
|
||||||
|
{image.exifInfo && image.exifInfo.width && image.exifInfo.height
|
||||||
|
? `${Math.round(image.exifInfo.width * image.exifInfo.height / 1000000)}MP`
|
||||||
|
: 'N/A'}
|
||||||
|
{' | '}
|
||||||
|
{formatDate(
|
||||||
|
typeof image.takenAt === 'string'
|
||||||
|
? image.takenAt
|
||||||
|
: image.takenAt
|
||||||
|
? image.takenAt.toISOString()
|
||||||
|
: typeof image.createdAt === 'string'
|
||||||
|
? image.createdAt
|
||||||
|
: image.createdAt.toISOString()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 悬停时显示的信息覆盖层 */}
|
||||||
|
<div className="custom-card-overlay">
|
||||||
|
<div className="custom-card-info">
|
||||||
|
<div className="custom-card-title">{image.name}</div>
|
||||||
|
|
||||||
|
{image.tags && (
|
||||||
|
<div className="custom-card-tags-container">
|
||||||
|
{image.tags.map(tag => (
|
||||||
|
<Text key={tag} className="image-tag">#{tag}</Text>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="custom-card-actions">
|
||||||
|
<div
|
||||||
|
className="custom-card-action-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleToggleFavorite(image);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{image.isFavorited ? (
|
||||||
|
<HeartFilled style={{ fontSize: 16, color: '#ff4d4f' }} />
|
||||||
|
) : (
|
||||||
|
<HeartOutlined style={{ fontSize: 16, color: '#ffffff' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div
|
||||||
|
className="custom-card-action-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit && onEdit(image);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditOutlined style={{ fontSize: 16, color: '#ffffff' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="custom-card-action-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onShare?.(image);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ShareAltOutlined style={{ fontSize: 16, color: '#ffffff' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="custom-card-action-item"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDownload?.(image);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DownloadOutlined style={{ fontSize: 16, color: '#ffffff' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染右键菜单
|
||||||
|
const renderContextMenu = () => {
|
||||||
|
if (!contextMenu.visible) return null;
|
||||||
|
|
||||||
|
const menuStyle = {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
top: contextMenu.y,
|
||||||
|
left: contextMenu.x,
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentImage = contextMenu.image;
|
||||||
|
if (!currentImage) return null;
|
||||||
|
|
||||||
|
const isFavorited = currentImage.isFavorited;
|
||||||
|
const isOwner = canEditImage(currentImage);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="context-menu" style={menuStyle}>
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleMenuAction('favorite')}
|
||||||
|
>
|
||||||
|
{isFavorited ? (
|
||||||
|
<><HeartFilled style={{ color: '#ff4d4f' }} /> 取消收藏</>
|
||||||
|
) : (
|
||||||
|
<><HeartOutlined /> 收藏</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleMenuAction('download')}
|
||||||
|
>
|
||||||
|
<DownloadOutlined /> 下载
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleMenuAction('edit')}
|
||||||
|
>
|
||||||
|
<EditOutlined /> 编辑
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
onClick={() => handleMenuAction('share')}
|
||||||
|
>
|
||||||
|
<ShareAltOutlined /> 分享
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div
|
||||||
|
className="context-menu-item"
|
||||||
|
style={{ color: '#ff4d4f' }}
|
||||||
|
onClick={() => handleMenuAction('delete')}
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> 删除
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理删除图片
|
||||||
|
const handleDeleteImage = async (image: PictureResponse) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除图片 "${image.name}" 吗?此操作不可恢复。`,
|
||||||
|
okText: '删除',
|
||||||
|
okType: 'danger',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const result = await deleteMultiplePictures( [image.id] );
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message.success('图片已成功删除');
|
||||||
|
|
||||||
|
// 更新本地图片列表,移除被删除的图片
|
||||||
|
setImages(prevImages =>
|
||||||
|
prevImages.filter(img => img.id !== image.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
onDelete?.(image);
|
||||||
|
|
||||||
|
if (images.length === 1 && currentPage > 1) {
|
||||||
|
setCurrentPage(currentPage - 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '删除图片失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('删除图片失败,请重试');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化组件返回结构
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderContent()}
|
||||||
|
{renderContextMenu()}
|
||||||
|
|
||||||
|
{showPagination && images.length > 0 && (
|
||||||
|
<div className="image-grid-pagination">
|
||||||
|
<Pagination
|
||||||
|
current={currentPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={isUsingExternalData ? (externalTotalImages || 0) : totalImages}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
showSizeChanger
|
||||||
|
showQuickJumper
|
||||||
|
locale={{
|
||||||
|
// 添加完整的中文本地化配置
|
||||||
|
items_per_page: '条/页',
|
||||||
|
jump_to: '跳至',
|
||||||
|
jump_to_confirm: '确定',
|
||||||
|
page: '页',
|
||||||
|
prev_page: '上一页',
|
||||||
|
next_page: '下一页',
|
||||||
|
prev_5: '向前 5 页',
|
||||||
|
next_5: '向后 5 页',
|
||||||
|
prev_3: '向前 3 页',
|
||||||
|
next_3: '向后 3 页'
|
||||||
|
}}
|
||||||
|
pageSizeOptions={['8', '16', '32', '64']}
|
||||||
|
showTotal={(total) => `共 ${total} 张图片`}
|
||||||
|
size="default"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ImageViewer
|
||||||
|
visible={viewerState.visible}
|
||||||
|
onClose={() => setViewerState({ ...viewerState, visible: false })}
|
||||||
|
images={images}
|
||||||
|
initialIndex={viewerState.index}
|
||||||
|
onFavorite={onToggleFavorite || handleToggleFavorite}
|
||||||
|
showFavoriteCount={showFavoriteCount}
|
||||||
|
onShare={handleShareImage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ShareImageDialog
|
||||||
|
visible={shareDialogState.visible}
|
||||||
|
onClose={handleCloseShareDialog}
|
||||||
|
image={shareDialogState.image}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageGrid;
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Divider } from 'antd';
|
||||||
|
import { CloseOutlined, DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
|
import type { PictureResponse } from '../../api/types';
|
||||||
|
import './ImageViewer.css';
|
||||||
|
|
||||||
|
interface ImageInfoProps {
|
||||||
|
image: PictureResponse;
|
||||||
|
onClose: () => void;
|
||||||
|
visible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageInfo: React.FC<ImageInfoProps> = ({
|
||||||
|
image,
|
||||||
|
onClose,
|
||||||
|
visible
|
||||||
|
}) => {
|
||||||
|
const [expandDescription, setExpandDescription] = useState(false);
|
||||||
|
|
||||||
|
// 切换描述展开/折叠状态
|
||||||
|
const toggleDescription = () => {
|
||||||
|
setExpandDescription(!expandDescription);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化EXIF数据
|
||||||
|
const formatExifInfo = (exifInfo: any) => {
|
||||||
|
if (!exifInfo) return [];
|
||||||
|
|
||||||
|
// 定义EXIF信息分类
|
||||||
|
const categories = {
|
||||||
|
basic: { title: "基本信息", items: [] as any[] },
|
||||||
|
camera: { title: "相机信息", items: [] as any[] },
|
||||||
|
settings: { title: "拍摄参数", items: [] as any[] },
|
||||||
|
time: { title: "时间信息", items: [] as any[] },
|
||||||
|
location: { title: "位置信息", items: [] as any[] }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将EXIF信息映射到对应字段
|
||||||
|
const exifMapping: Record<string, { key: string; category: keyof typeof categories; formatter?: (value: any) => string }> = {
|
||||||
|
// 基本信息
|
||||||
|
width: { key: "width", category: "basic", formatter: (v) => `${v}px` },
|
||||||
|
height: { key: "height", category: "basic", formatter: (v) => `${v}px` },
|
||||||
|
|
||||||
|
// 相机信息
|
||||||
|
cameraMaker: { key: "make", category: "camera" },
|
||||||
|
cameraModel: { key: "model", category: "camera" },
|
||||||
|
software: { key: "software", category: "camera" },
|
||||||
|
|
||||||
|
// 拍摄参数
|
||||||
|
exposureTime: { key: "exposureTime", category: "settings" },
|
||||||
|
aperture: { key: "fNumber", category: "settings", formatter: (v) => `f/${v}` },
|
||||||
|
isoSpeed: { key: "iso", category: "settings", formatter: (v) => `ISO ${v}` },
|
||||||
|
focalLength: { key: "focalLength", category: "settings", formatter: (v) => `${v}mm` },
|
||||||
|
flash: { key: "flash", category: "settings" },
|
||||||
|
meteringMode: { key: "meteringMode", category: "settings" },
|
||||||
|
whiteBalance: { key: "whiteBalance", category: "settings" },
|
||||||
|
dateTimeOriginal: {
|
||||||
|
key: "dateTime",
|
||||||
|
category: "time",
|
||||||
|
formatter: (v) => {
|
||||||
|
if (typeof v === 'string' && v.match(/^\d{4}:\d{2}:\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
||||||
|
const normalized = v.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3');
|
||||||
|
const date = new Date(normalized);
|
||||||
|
if (!isNaN(date.getTime())) {
|
||||||
|
return date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v.toString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 位置信息
|
||||||
|
gpsLatitude: { key: "latitude", category: "location" },
|
||||||
|
gpsLongitude: { key: "longitude", category: "location" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理每个EXIF字段
|
||||||
|
Object.entries(exifInfo).forEach(([key, value]) => {
|
||||||
|
if (value === null || value === undefined || value === '') return;
|
||||||
|
|
||||||
|
const mapping = exifMapping[key];
|
||||||
|
if (mapping) {
|
||||||
|
const formattedValue = mapping.formatter ? mapping.formatter(value) : value.toString();
|
||||||
|
const label = formatExifLabel(mapping.key);
|
||||||
|
|
||||||
|
categories[mapping.category].items.push({
|
||||||
|
key: mapping.key,
|
||||||
|
label,
|
||||||
|
value: formattedValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 返回包含数据的分类
|
||||||
|
return Object.values(categories).filter(category => category.items.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化EXIF标签名称
|
||||||
|
const formatExifLabel = (key: string): string => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
// 基本信息
|
||||||
|
width: "宽度",
|
||||||
|
height: "高度",
|
||||||
|
|
||||||
|
// 相机信息
|
||||||
|
make: "相机品牌",
|
||||||
|
model: "相机型号",
|
||||||
|
software: "软件",
|
||||||
|
|
||||||
|
// 拍摄参数
|
||||||
|
exposureTime: "曝光时间",
|
||||||
|
fNumber: "光圈值",
|
||||||
|
iso: "ISO感光度",
|
||||||
|
focalLength: "焦距",
|
||||||
|
flash: "闪光灯",
|
||||||
|
meteringMode: "测光模式",
|
||||||
|
whiteBalance: "白平衡",
|
||||||
|
|
||||||
|
// 时间信息
|
||||||
|
dateTime: "拍摄时间",
|
||||||
|
|
||||||
|
// 位置信息
|
||||||
|
latitude: "纬度",
|
||||||
|
longitude: "经度"
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[key] || key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染EXIF信息
|
||||||
|
const renderExifInfo = (styles: any) => {
|
||||||
|
if (!image?.exifInfo) return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>无EXIF信息</div>;
|
||||||
|
|
||||||
|
const formattedCategories = formatExifInfo(image.exifInfo);
|
||||||
|
|
||||||
|
if (formattedCategories.length === 0) {
|
||||||
|
return <div style={{ color: 'rgba(255, 255, 255, 0.6)' }}>无EXIF信息</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.exifContainer}>
|
||||||
|
{formattedCategories.map(category => (
|
||||||
|
<div key={category.title} style={styles.exifCategory}>
|
||||||
|
<Divider
|
||||||
|
orientation="left"
|
||||||
|
style={styles.divider}
|
||||||
|
>
|
||||||
|
{category.title}
|
||||||
|
</Divider>
|
||||||
|
<div style={styles.exifTable}>
|
||||||
|
{category.items.map(item => (
|
||||||
|
<div key={item.key} style={{ display: 'flex', borderBottom: '1px solid rgba(255, 255, 255, 0.05)' }}>
|
||||||
|
<div style={styles.exifLabel}>{item.label}</div>
|
||||||
|
<div style={styles.exifValue}>{item.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义内联样式对象
|
||||||
|
const styles = {
|
||||||
|
// 抽屉基础样式
|
||||||
|
drawer: {
|
||||||
|
position: 'fixed' as const,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
width: '350px',
|
||||||
|
height: '100%',
|
||||||
|
zIndex: 1050,
|
||||||
|
backgroundColor: 'rgba(28, 30, 34, 0.5)',
|
||||||
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '-10px 0 30px rgba(0, 0, 0, 0.2)',
|
||||||
|
transition: 'transform 0.3s ease',
|
||||||
|
transform: visible ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
overflowY: 'auto' as const
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
backgroundColor: 'rgba(28, 30, 34, 0.6)',
|
||||||
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
padding: '16px 20px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: 500
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: '24px 20px',
|
||||||
|
color: 'white'
|
||||||
|
},
|
||||||
|
// 标题样式
|
||||||
|
titleContainer: {
|
||||||
|
padding: '0 0 16px 0',
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.08)',
|
||||||
|
marginBottom: '20px'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
margin: '0 0 4px 0',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 500,
|
||||||
|
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||||
|
},
|
||||||
|
date: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
fontSize: '13px'
|
||||||
|
},
|
||||||
|
// 描述区域
|
||||||
|
descSection: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
backdropFilter: 'blur(10px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px) saturate(180%)',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
|
||||||
|
},
|
||||||
|
descText: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
lineHeight: '1.6',
|
||||||
|
display: '-webkit-box',
|
||||||
|
WebkitBoxOrient: 'vertical' as const,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
WebkitLineClamp: expandDescription ? 'unset' : 8
|
||||||
|
},
|
||||||
|
expandButton: {
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '8px 0 0 0',
|
||||||
|
fontSize: '13px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center'
|
||||||
|
},
|
||||||
|
// 标签区域
|
||||||
|
tagsSection: {
|
||||||
|
marginBottom: '20px',
|
||||||
|
padding: '0 4px'
|
||||||
|
},
|
||||||
|
tagTitle: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginBottom: '12px'
|
||||||
|
},
|
||||||
|
tagItem: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.15)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 12px',
|
||||||
|
margin: '0 8px 8px 0',
|
||||||
|
display: 'inline-block',
|
||||||
|
fontSize: '12px'
|
||||||
|
},
|
||||||
|
// 规格信息区
|
||||||
|
specsSection: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
backdropFilter: 'blur(10px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(10px) saturate(180%)',
|
||||||
|
padding: '16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
margin: '16px 0 20px',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
|
||||||
|
},
|
||||||
|
specsContainer: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
textAlign: 'center' as const
|
||||||
|
},
|
||||||
|
specItem: {
|
||||||
|
padding: '0 8px',
|
||||||
|
flex: 1
|
||||||
|
},
|
||||||
|
specValue: {
|
||||||
|
fontSize: '15px',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: 'rgba(255, 255, 255, 0.95)',
|
||||||
|
marginBottom: '4px',
|
||||||
|
textShadow: '0 1px 2px rgba(0, 0, 0, 0.1)'
|
||||||
|
},
|
||||||
|
specLabel: {
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)'
|
||||||
|
},
|
||||||
|
// EXIF信息区
|
||||||
|
exifContainer: {
|
||||||
|
marginTop: '10px'
|
||||||
|
},
|
||||||
|
exifCategory: {
|
||||||
|
marginBottom: '20px'
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
margin: '10px 0 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
fontWeight: 500
|
||||||
|
},
|
||||||
|
exifTable: {
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)'
|
||||||
|
},
|
||||||
|
exifLabel: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.7)',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
width: '100px',
|
||||||
|
fontSize: '13px'
|
||||||
|
},
|
||||||
|
exifValue: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
padding: '8px 12px',
|
||||||
|
fontSize: '13px'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.drawer}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<h3 style={styles.headerTitle}>图片信息</h3>
|
||||||
|
<button style={styles.closeButton} onClick={onClose}>
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={styles.body}>
|
||||||
|
<div style={styles.titleContainer}>
|
||||||
|
<h4 style={styles.title}>{image?.name}</h4>
|
||||||
|
<div style={styles.date}>上传于{new Date(image?.createdAt).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{image?.description && (
|
||||||
|
<div style={styles.descSection}>
|
||||||
|
<div style={styles.descText}>{image.description}</div>
|
||||||
|
{image.description.split('\n').length > 8 || image.description.length > 200 ? (
|
||||||
|
<button style={styles.expandButton} onClick={toggleDescription}>
|
||||||
|
{expandDescription ? (
|
||||||
|
<>收起 <UpOutlined style={{ fontSize: '12px', marginLeft: '4px' }} /></>
|
||||||
|
) : (
|
||||||
|
<>展开 <DownOutlined style={{ fontSize: '12px', marginLeft: '4px' }} /></>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{image?.tags && image.tags.length > 0 && (
|
||||||
|
<div style={styles.tagsSection}>
|
||||||
|
<div style={styles.tagTitle}>标签</div>
|
||||||
|
<div>
|
||||||
|
{image.tags.map(tag => (
|
||||||
|
<span key={tag} style={styles.tagItem}>#{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{image?.exifInfo && (
|
||||||
|
<div style={styles.specsSection}>
|
||||||
|
<div style={styles.specsContainer}>
|
||||||
|
<div style={styles.specItem}>
|
||||||
|
<div style={styles.specValue}>{image.exifInfo.width}×{image.exifInfo.height}</div>
|
||||||
|
<div style={styles.specLabel}>分辨率</div>
|
||||||
|
</div>
|
||||||
|
{image.exifInfo.cameraModel && (
|
||||||
|
<div style={styles.specItem}>
|
||||||
|
<div style={styles.specValue}>{image.exifInfo.cameraModel}</div>
|
||||||
|
<div style={styles.specLabel}>相机</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{image.exifInfo.focalLength && (
|
||||||
|
<div style={styles.specItem}>
|
||||||
|
<div style={styles.specValue}>{image.exifInfo.focalLength}</div>
|
||||||
|
<div style={styles.specLabel}>焦距</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 渲染EXIF信息 */}
|
||||||
|
{renderExifInfo(styles)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageInfo;
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
.image-viewer-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-viewer-container.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1002;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1004;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 80%, rgba(0,0,0,0) 100%);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-counter {
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn {
|
||||||
|
color: white !important;
|
||||||
|
border: none;
|
||||||
|
background: transparent !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 1003;
|
||||||
|
overflow: visible;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transform-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-img.thumbnail {
|
||||||
|
filter: blur(1px);
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1005;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls .ant-btn {
|
||||||
|
color: white !important;
|
||||||
|
border: none;
|
||||||
|
background: transparent !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls .ant-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加原图模式指示样式 */
|
||||||
|
.zoom-controls .ant-btn-primary {
|
||||||
|
background-color: #1890ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-controls .ant-btn-primary:hover {
|
||||||
|
background-color: #40a9ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 1005;
|
||||||
|
background: rgba(0, 0, 0, 0.3) !important;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: white !important;
|
||||||
|
border: none !important;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prev-button {
|
||||||
|
left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.next-button {
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.7) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1004;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 80%, rgba(0,0,0,0) 100%);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-name {
|
||||||
|
color: white;
|
||||||
|
max-width: 70%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn {
|
||||||
|
color: white !important;
|
||||||
|
border: none;
|
||||||
|
background: transparent !important;
|
||||||
|
font-size: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-btn span {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info-drawer {
|
||||||
|
position: absolute !important;
|
||||||
|
z-index: 1050 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info-drawer .ant-drawer-mask {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info-drawer .ant-drawer-header {
|
||||||
|
background-color: rgba(255, 255, 255, 0.75);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
border-bottom: 1px solid rgba(240, 240, 240, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-content {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-title-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-title-section h4 {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-description-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(249, 249, 249, 0.5) !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-tags-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-description {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-description .ant-descriptions-item-label {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-specs-section {
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(247, 249, 250, 0.5) !important;
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.specs-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-item {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-value {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #262626;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spec-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-sections {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-category {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-category .ant-divider {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-description {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-description .ant-descriptions-item-label {
|
||||||
|
width: 90px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info-drawer .ant-drawer-body {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-description .ant-descriptions-view {
|
||||||
|
background-color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exif-description .ant-descriptions-row > th,
|
||||||
|
.exif-description .ant-descriptions-row > td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Button, Space, Dropdown, message } from 'antd';
|
||||||
|
import {
|
||||||
|
ZoomInOutlined,
|
||||||
|
ZoomOutOutlined,
|
||||||
|
ExpandOutlined,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
RotateLeftOutlined,
|
||||||
|
RotateRightOutlined,
|
||||||
|
HeartOutlined,
|
||||||
|
HeartFilled,
|
||||||
|
DownloadOutlined,
|
||||||
|
ShareAltOutlined,
|
||||||
|
FolderAddOutlined,
|
||||||
|
EyeOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch';
|
||||||
|
import type { PictureResponse, AlbumResponse } from '../../api/types';
|
||||||
|
import { getAlbums, addPicturesToAlbum, favoritePicture, unfavoritePicture } from '../../api';
|
||||||
|
import ImageInfo from './ImageInfo';
|
||||||
|
import ShareImageDialog from './ShareImageDialog';
|
||||||
|
import './ImageViewer.css';
|
||||||
|
|
||||||
|
|
||||||
|
interface ImageViewerProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
images: PictureResponse[];
|
||||||
|
initialIndex?: number;
|
||||||
|
onFavorite?: (image: PictureResponse) => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
onPrevious?: () => void;
|
||||||
|
showFavoriteCount?: boolean;
|
||||||
|
onShare?: (image: PictureResponse) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
images,
|
||||||
|
initialIndex = 0,
|
||||||
|
onFavorite,
|
||||||
|
onNext,
|
||||||
|
onPrevious,
|
||||||
|
showFavoriteCount = false, // 默认不显示收藏数量
|
||||||
|
onShare,
|
||||||
|
}) => {
|
||||||
|
const wasVisible = useRef(visible);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
|
const [isInfoDrawerOpen, setIsInfoDrawerOpen] = useState(false);
|
||||||
|
const [rotation, setRotation] = useState(0);
|
||||||
|
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
|
||||||
|
const [loadingAlbums, setLoadingAlbums] = useState(false);
|
||||||
|
const [localImages, setLocalImages] = useState<PictureResponse[]>(images);
|
||||||
|
const [shareDialogVisible, setShareDialogVisible] = useState(false);
|
||||||
|
|
||||||
|
// 保留加载状态跟踪,但不再用于显示缩略图
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [showOriginal, setShowOriginal] = useState(false); // 新增:控制是否显示原图
|
||||||
|
|
||||||
|
// 保留防缓存机制
|
||||||
|
const [cacheKey, setCacheKey] = useState<number>(Date.now());
|
||||||
|
|
||||||
|
// 当前显示的图片
|
||||||
|
const currentImage = localImages[currentIndex];
|
||||||
|
|
||||||
|
// 重置查看器状态,包含图片加载状态
|
||||||
|
const resetViewerState = useCallback(() => {
|
||||||
|
setRotation(0);
|
||||||
|
setIsInfoDrawerOpen(false);
|
||||||
|
setImageLoaded(false); // 重置图片加载状态
|
||||||
|
setShowOriginal(false); // 重置为显示缩略图
|
||||||
|
setCacheKey(Date.now()); // 每次重置时更新缓存键
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImageLoaded(false);
|
||||||
|
setShowOriginal(false); // 切换图片时重置为缩略图模式
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
// 监听visible变化的处理
|
||||||
|
useEffect(() => {
|
||||||
|
// 当查看器从不可见变为可见时
|
||||||
|
if (visible && !wasVisible.current) {
|
||||||
|
if (initialIndex >= 0 && initialIndex < images.length) {
|
||||||
|
setCurrentIndex(initialIndex);
|
||||||
|
}
|
||||||
|
resetViewerState();
|
||||||
|
}
|
||||||
|
// 当查看器从可见变为不可见时
|
||||||
|
else if (!visible && wasVisible.current) {
|
||||||
|
// 关闭后等待一小段时间再更新缓存键,确保下次打开时强制刷新图片
|
||||||
|
setTimeout(() => setCacheKey(Date.now()), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新ref以跟踪visible的变化
|
||||||
|
wasVisible.current = visible;
|
||||||
|
}, [visible, initialIndex, images.length, resetViewerState]);
|
||||||
|
|
||||||
|
// 当currentIndex变化时,重置图片加载状态并更新缓存键
|
||||||
|
useEffect(() => {
|
||||||
|
setImageLoaded(false);
|
||||||
|
setShowOriginal(false); // 重置为缩略图模式
|
||||||
|
setCacheKey(Date.now());
|
||||||
|
}, [currentIndex]);
|
||||||
|
|
||||||
|
// 当外部传入的images发生变化时,更新本地缓存
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalImages(images);
|
||||||
|
}, [images]);
|
||||||
|
|
||||||
|
// 处理键盘事件
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!visible) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
handlePrevious();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
handleNext();
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
onClose();
|
||||||
|
break;
|
||||||
|
case 'i':
|
||||||
|
setIsInfoDrawerOpen(prev => !prev);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [visible, currentIndex, images.length]);
|
||||||
|
|
||||||
|
// 处理上一张图片
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
setCurrentIndex(prevIndex => prevIndex - 1);
|
||||||
|
if (onPrevious) onPrevious();
|
||||||
|
}
|
||||||
|
}, [currentIndex, onPrevious]);
|
||||||
|
|
||||||
|
// 处理下一张图片
|
||||||
|
const handleNext = useCallback(() => {
|
||||||
|
if (currentIndex < images.length - 1) {
|
||||||
|
setCurrentIndex(prevIndex => prevIndex + 1);
|
||||||
|
if (onNext) onNext();
|
||||||
|
}
|
||||||
|
}, [currentIndex, images.length, onNext]);
|
||||||
|
|
||||||
|
// 处理收藏按钮点击
|
||||||
|
const handleFavoriteClick = useCallback(async () => {
|
||||||
|
if (!currentImage) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果提供了onFavorite回调,直接使用它,不再重复发送请求
|
||||||
|
if (onFavorite) {
|
||||||
|
onFavorite(currentImage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在没有提供onFavorite回调时才直接发送网络请求
|
||||||
|
let result;
|
||||||
|
if (currentImage.isFavorited) {
|
||||||
|
result = await unfavoritePicture(currentImage.id);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('已取消收藏');
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '取消收藏失败');
|
||||||
|
return; // 如果请求失败,不更新UI状态
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = await favoritePicture(currentImage.id);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('已添加到收藏');
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '收藏失败');
|
||||||
|
return; // 如果请求失败,不更新UI状态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求成功后,更新本地状态
|
||||||
|
setLocalImages(prevImages =>
|
||||||
|
prevImages.map(img =>
|
||||||
|
img.id === currentImage.id ? {
|
||||||
|
...img,
|
||||||
|
isFavorited: !img.isFavorited,
|
||||||
|
favoriteCount: img.isFavorited
|
||||||
|
? Math.max(0, (img.favoriteCount || 0) - 1)
|
||||||
|
: (img.favoriteCount || 0) + 1
|
||||||
|
} : img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('收藏操作失败:', error);
|
||||||
|
message.error('操作失败,请重试');
|
||||||
|
}
|
||||||
|
}, [currentImage, onFavorite]);
|
||||||
|
|
||||||
|
// 处理旋转
|
||||||
|
const handleRotateLeft = () => {
|
||||||
|
setRotation(prev => prev - 90);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRotateRight = () => {
|
||||||
|
setRotation(prev => prev + 90);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载相册
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
loadAlbums();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const loadAlbums = async () => {
|
||||||
|
setLoadingAlbums(true);
|
||||||
|
try {
|
||||||
|
const result = await getAlbums(1, 100);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setAlbums(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载相册失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingAlbums(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddToAlbum = async (albumId: number) => {
|
||||||
|
if (!currentImage) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用新的批量添加方法,参数为数组
|
||||||
|
const result = await addPicturesToAlbum(albumId, [currentImage.id]);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('已添加到相册');
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '添加到相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加到相册失败:', error);
|
||||||
|
message.error('添加到相册失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const albumItems = albums.map(album => ({
|
||||||
|
key: album.id,
|
||||||
|
label: album.name,
|
||||||
|
onClick: () => handleAddToAlbum(album.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 处理切换到原图
|
||||||
|
const handleToggleOriginal = () => {
|
||||||
|
setShowOriginal(prev => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图片加载完成事件
|
||||||
|
const handleImageLoaded = () => {
|
||||||
|
setImageLoaded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 新增:检测图片是否已经加载
|
||||||
|
useEffect(() => {
|
||||||
|
// 如果当前图片已加载到缓存中
|
||||||
|
if (currentImage && !imageLoaded) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
// 图片已在缓存中,立即设置为已加载
|
||||||
|
setImageLoaded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加缓存键参数强制刷新
|
||||||
|
const cacheBuster = `${currentImage.path}${currentImage.path.includes('?') ? '&' : '?'}_cb=${cacheKey}`;
|
||||||
|
img.src = cacheBuster;
|
||||||
|
}
|
||||||
|
}, [currentImage, imageLoaded, cacheKey]);
|
||||||
|
|
||||||
|
// 处理分享按钮点击
|
||||||
|
const handleShareClick = useCallback(() => {
|
||||||
|
if (!currentImage) return;
|
||||||
|
|
||||||
|
if (onShare) {
|
||||||
|
onShare(currentImage);
|
||||||
|
} else {
|
||||||
|
setShareDialogVisible(true);
|
||||||
|
}
|
||||||
|
}, [currentImage, onShare]);
|
||||||
|
|
||||||
|
// 当没有图片或当前图片不存在时,不渲染任何内容
|
||||||
|
if (images.length === 0 || !currentImage) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`image-viewer-container ${visible ? 'visible' : ''}`}
|
||||||
|
style={{ display: visible ? 'block' : 'none' }}
|
||||||
|
>
|
||||||
|
<div className="viewer-overlay" onClick={onClose}></div>
|
||||||
|
|
||||||
|
<div className="viewer-content">
|
||||||
|
<div className="image-container">
|
||||||
|
<TransformWrapper
|
||||||
|
initialScale={1}
|
||||||
|
initialPositionX={0}
|
||||||
|
initialPositionY={0}
|
||||||
|
centerOnInit={true}
|
||||||
|
minScale={0.1}
|
||||||
|
maxScale={8}
|
||||||
|
wheel={{ step: 0.2 }}
|
||||||
|
doubleClick={{ mode: 'reset' }}
|
||||||
|
panning={{ disabled: false }}
|
||||||
|
alignmentAnimation={{ disabled: true }}
|
||||||
|
velocityAnimation={{ disabled: false }}
|
||||||
|
>
|
||||||
|
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||||
|
<>
|
||||||
|
<TransformComponent
|
||||||
|
wrapperClass="transform-wrapper"
|
||||||
|
contentClass="transform-content"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={showOriginal
|
||||||
|
? `${currentImage.path}${currentImage.path.includes('?') ? '&' : '?'}_cb=${cacheKey}`
|
||||||
|
: `${currentImage.thumbnailPath || currentImage.path}${(currentImage.thumbnailPath || currentImage.path).includes('?') ? '&' : '?'}_cb=${cacheKey}`
|
||||||
|
}
|
||||||
|
alt={currentImage.name}
|
||||||
|
style={{
|
||||||
|
transform: `rotate(${rotation}deg)`,
|
||||||
|
opacity: imageLoaded ? 1 : 0.3,
|
||||||
|
transition: 'opacity 0.3s ease'
|
||||||
|
}}
|
||||||
|
className={`viewer-img ${showOriginal ? '' : 'thumbnail'}`}
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={handleImageLoaded}
|
||||||
|
/>
|
||||||
|
</TransformComponent>
|
||||||
|
|
||||||
|
<div className="zoom-controls">
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ExpandOutlined />} onClick={() => resetTransform()} />
|
||||||
|
<Button icon={<ZoomOutOutlined />} onClick={() => zoomOut(0.5)} />
|
||||||
|
<Button icon={<ZoomInOutlined />} onClick={() => zoomIn(0.5)} />
|
||||||
|
<Button icon={<RotateLeftOutlined />} onClick={handleRotateLeft} />
|
||||||
|
<Button icon={<RotateRightOutlined />} onClick={handleRotateRight} />
|
||||||
|
<Button
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={handleToggleOriginal}
|
||||||
|
type={showOriginal ? "primary" : "default"}
|
||||||
|
title={showOriginal ? "正在查看原图" : "查看原图"}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TransformWrapper>
|
||||||
|
|
||||||
|
{/* 图片导航按钮 */}
|
||||||
|
{currentIndex > 0 && (
|
||||||
|
<Button
|
||||||
|
className="nav-button prev-button"
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
onClick={handlePrevious}
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentIndex < images.length - 1 && (
|
||||||
|
<Button
|
||||||
|
className="nav-button next-button"
|
||||||
|
icon={<RightOutlined />}
|
||||||
|
onClick={handleNext}
|
||||||
|
shape="circle"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 顶部操作栏 */}
|
||||||
|
<div className="viewer-header">
|
||||||
|
<div className="image-counter">
|
||||||
|
{currentIndex + 1} / {images.length}
|
||||||
|
</div>
|
||||||
|
<div className="header-actions">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={isInfoDrawerOpen ? <InfoCircleOutlined style={{ color: '#1890ff' }} /> : <InfoCircleOutlined />}
|
||||||
|
onClick={() => setIsInfoDrawerOpen(prev => !prev)}
|
||||||
|
className="header-btn"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={onClose}
|
||||||
|
className="header-btn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部操作栏 */}
|
||||||
|
<div className="viewer-footer">
|
||||||
|
<div className="image-name">
|
||||||
|
{currentImage.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="footer-actions">
|
||||||
|
{onFavorite && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={currentImage.isFavorited ?
|
||||||
|
<HeartFilled style={{ color: '#ff4d4f' }} /> :
|
||||||
|
<HeartOutlined style={{ color: '#fff' }} />
|
||||||
|
}
|
||||||
|
onClick={handleFavoriteClick}
|
||||||
|
className="footer-btn"
|
||||||
|
>
|
||||||
|
{showFavoriteCount && typeof currentImage.favoriteCount === 'number' && (
|
||||||
|
<span>{currentImage.favoriteCount}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Dropdown menu={{ items: albumItems }} disabled={loadingAlbums || albums.length === 0}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<FolderAddOutlined style={{ color: '#fff' }} />}
|
||||||
|
className="footer-btn"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<DownloadOutlined style={{ color: '#fff' }} />}
|
||||||
|
onClick={() => window.open(currentImage.path, '_blank')}
|
||||||
|
className="footer-btn"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ShareAltOutlined style={{ color: '#fff' }} />}
|
||||||
|
onClick={handleShareClick}
|
||||||
|
className="footer-btn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 图片信息 */}
|
||||||
|
{currentImage && (
|
||||||
|
<ImageInfo
|
||||||
|
image={currentImage}
|
||||||
|
visible={isInfoDrawerOpen}
|
||||||
|
onClose={() => setIsInfoDrawerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 添加分享对话框 */}
|
||||||
|
{!onShare && currentImage && (
|
||||||
|
<ShareImageDialog
|
||||||
|
visible={shareDialogVisible}
|
||||||
|
onClose={() => setShareDialogVisible(false)}
|
||||||
|
image={currentImage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageViewer;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
.share-image-dialog .ant-modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-image-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-image-preview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-preview-img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-image-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-type-switch {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-tabs {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-tab-content {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-input-group {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy-btn {
|
||||||
|
width: 40px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy-btn.copied {
|
||||||
|
background-color: #52c41a;
|
||||||
|
border-color: #52c41a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.share-image-dialog {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { Modal, Tabs, Input, Button, message, Radio, Space, Typography } from 'antd';
|
||||||
|
import { CopyOutlined, CheckOutlined } from '@ant-design/icons';
|
||||||
|
import type { PictureResponse } from '../../api/types';
|
||||||
|
import './ShareImageDialog.css';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface ShareImageDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
image: PictureResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ShareImageDialog: React.FC<ShareImageDialogProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
image
|
||||||
|
}) => {
|
||||||
|
const [imageType, setImageType] = useState<'original' | 'thumbnail'>('original');
|
||||||
|
const [copied, setCopied] = useState<Record<string, boolean>>({});
|
||||||
|
const timerRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||||
|
|
||||||
|
if (!image) return null;
|
||||||
|
|
||||||
|
// 构建图片链接
|
||||||
|
const imagePath = imageType === 'original' ? image.path : (image.thumbnailPath || image.path);
|
||||||
|
const imageUrl = new URL(imagePath, window.location.origin).href;
|
||||||
|
const imageName = image.name || 'image';
|
||||||
|
|
||||||
|
// 构建不同格式的链接 - 移到这里,确保随imageType变化而更新
|
||||||
|
const linkFormats = {
|
||||||
|
direct: imageUrl,
|
||||||
|
markdown: ``,
|
||||||
|
html: `<img src="${imageUrl}" alt="${imageName}" />`
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (text: string, key: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
|
||||||
|
// 设置复制状态
|
||||||
|
setCopied(prev => ({...prev, [key]: true}));
|
||||||
|
|
||||||
|
// 清除现有定时器
|
||||||
|
if (timerRef.current[key]) {
|
||||||
|
clearTimeout(timerRef.current[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新定时器
|
||||||
|
timerRef.current[key] = setTimeout(() => {
|
||||||
|
setCopied(prev => ({...prev, [key]: false}));
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
message.success('已复制到剪贴板');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('复制失败,请手动复制');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 定义标签页内容
|
||||||
|
const tabItems = [
|
||||||
|
{
|
||||||
|
key: 'direct',
|
||||||
|
label: '直接链接',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" className="share-tab-content" style={{ width: '100%' }}>
|
||||||
|
<Text type="secondary">可直接访问的图片链接</Text>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
value={linkFormats.direct}
|
||||||
|
readOnly
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={copied.direct ? <CheckOutlined /> : <CopyOutlined />}
|
||||||
|
onClick={() => handleCopy(linkFormats.direct, 'direct')}
|
||||||
|
className={copied.direct ? 'copied' : ''}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'markdown',
|
||||||
|
label: 'Markdown',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" className="share-tab-content" style={{ width: '100%' }}>
|
||||||
|
<Text type="secondary">适用于Markdown文档的图片引用</Text>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
value={linkFormats.markdown}
|
||||||
|
readOnly
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={copied.markdown ? <CheckOutlined /> : <CopyOutlined />}
|
||||||
|
onClick={() => handleCopy(linkFormats.markdown, 'markdown')}
|
||||||
|
className={copied.markdown ? 'copied' : ''}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'html',
|
||||||
|
label: 'HTML',
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" className="share-tab-content" style={{ width: '100%' }}>
|
||||||
|
<Text type="secondary">适用于网页的HTML图片标签</Text>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
value={linkFormats.html}
|
||||||
|
readOnly
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={copied.html ? <CheckOutlined /> : <CopyOutlined />}
|
||||||
|
onClick={() => handleCopy(linkFormats.html, 'html')}
|
||||||
|
className={copied.html ? 'copied' : ''}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="分享图片"
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
className="share-image-dialog"
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<div className="share-image-content">
|
||||||
|
<div className="share-image-preview">
|
||||||
|
<img
|
||||||
|
src={imagePath}
|
||||||
|
alt={imageName}
|
||||||
|
className="share-preview-img"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="share-image-controls">
|
||||||
|
<Radio.Group
|
||||||
|
value={imageType}
|
||||||
|
onChange={e => setImageType(e.target.value)}
|
||||||
|
buttonStyle="solid"
|
||||||
|
className="share-type-switch"
|
||||||
|
>
|
||||||
|
<Radio.Button value="original">原图</Radio.Button>
|
||||||
|
<Radio.Button value="thumbnail">缩略图</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="direct"
|
||||||
|
className="share-tabs"
|
||||||
|
items={tabItems}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShareImageDialog;
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
.search-dialog .ant-modal-content {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dialog .ant-modal-header {
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dialog-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-dialog .ant-modal-body {
|
||||||
|
padding: 24px;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签页样式 */
|
||||||
|
.search-tabs {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索输入区域 */
|
||||||
|
.search-input-container {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选项分组 */
|
||||||
|
.search-option-group {
|
||||||
|
background-color: #fafafa;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 向量搜索样式 */
|
||||||
|
.vector-switch-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vector-switch {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-slider-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-slider {
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索按钮 */
|
||||||
|
.search-button-container {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 搜索结果区域 */
|
||||||
|
.search-results {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-count {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.vector-options .ant-col {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal, Input, Tabs, Switch, Select, Slider, Space, Button, Typography, Tooltip, message, Divider } from 'antd';
|
||||||
|
import { SearchOutlined, FileImageOutlined, ClearOutlined } from '@ant-design/icons';
|
||||||
|
import ImageGrid from '../image/ImageGrid';
|
||||||
|
import type { PictureResponse } from '../../api';
|
||||||
|
import './SearchDialog.css';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
interface SearchDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialSearchText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchDialog: React.FC<SearchDialogProps> = ({
|
||||||
|
visible,
|
||||||
|
onClose,
|
||||||
|
initialSearchText = ''
|
||||||
|
}) => {
|
||||||
|
// 搜索参数状态
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
|
const [useVectorSearch, setUseVectorSearch] = useState(true);
|
||||||
|
const [similarityThreshold, setSimilarityThreshold] = useState(0.35);
|
||||||
|
const [activeTabKey, setActiveTabKey] = useState('vector');
|
||||||
|
|
||||||
|
// 搜索结果状态
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchPerformed, setSearchPerformed] = useState(false);
|
||||||
|
const [, setSearchResults] = useState<PictureResponse[]>([]);
|
||||||
|
const [totalResults, setTotalResults] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
// 添加搜索标识符,用于强制触发新搜索
|
||||||
|
const [searchId, setSearchId] = useState(1);
|
||||||
|
|
||||||
|
// 使用activeSearchParams存储当前搜索参数
|
||||||
|
const [activeSearchParams, setActiveSearchParams] = useState({
|
||||||
|
searchQuery: '',
|
||||||
|
tags: [] as string[],
|
||||||
|
useVectorSearch: true,
|
||||||
|
similarityThreshold: 0.35,
|
||||||
|
_searchId: 1, // 添加搜索ID用于区分不同搜索请求
|
||||||
|
});
|
||||||
|
|
||||||
|
// 示例标签选项,实际应用中可能需要从API获取
|
||||||
|
const tagOptions = [
|
||||||
|
{ value: 'nature', label: '自然' },
|
||||||
|
{ value: 'city', label: '城市' },
|
||||||
|
{ value: 'people', label: '人物' },
|
||||||
|
{ value: 'animals', label: '动物' },
|
||||||
|
{ value: 'food', label: '美食' },
|
||||||
|
{ value: 'travel', label: '旅行' },
|
||||||
|
{ value: 'architecture', label: '建筑' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 当对话框打开或初始搜索文本变更时,更新搜索框中的文本,但不自动搜索
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && initialSearchText) {
|
||||||
|
setSearchText(initialSearchText);
|
||||||
|
}
|
||||||
|
}, [visible, initialSearchText]);
|
||||||
|
|
||||||
|
// 重置搜索表单但保持向量搜索默认设置
|
||||||
|
const resetSearch = () => {
|
||||||
|
setSearchText('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
setUseVectorSearch(true); // 保持默认使用向量搜索
|
||||||
|
setSimilarityThreshold(0.35); // 保持默认阈值
|
||||||
|
setSearchPerformed(false);
|
||||||
|
setSearchResults([]);
|
||||||
|
setTotalResults(0);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setActiveTabKey('vector'); // 保持默认选择向量搜索标签
|
||||||
|
setActiveSearchParams({
|
||||||
|
searchQuery: '',
|
||||||
|
tags: [],
|
||||||
|
useVectorSearch: true,
|
||||||
|
similarityThreshold: 0.35,
|
||||||
|
_searchId: searchId, // 保持当前searchId或重置为1
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当对话框关闭时重置搜索表单
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) {
|
||||||
|
resetSearch();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// 执行搜索 - 修改为仅在点击搜索按钮时调用
|
||||||
|
const handleSearch = () => {
|
||||||
|
// 搜索前先检查是否有搜索条件,避免空搜索
|
||||||
|
if (!searchText.trim() && selectedTags.length === 0 && !useVectorSearch) {
|
||||||
|
message.info('请输入搜索关键词或选择搜索条件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加搜索ID,确保每次搜索都是唯一的
|
||||||
|
const newSearchId = searchId + 1;
|
||||||
|
setSearchId(newSearchId);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setSearchPerformed(true);
|
||||||
|
|
||||||
|
// 更新活动搜索参数,这将触发ImageGrid组件进行搜索
|
||||||
|
setActiveSearchParams({
|
||||||
|
searchQuery: searchText,
|
||||||
|
tags: selectedTags,
|
||||||
|
useVectorSearch,
|
||||||
|
similarityThreshold: useVectorSearch ? similarityThreshold : 0.35,
|
||||||
|
_searchId: newSearchId, // 添加唯一标识符以强制触发新搜索
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图片加载完成事件
|
||||||
|
const handleImagesLoaded = (images: PictureResponse[], totalCount: number) => {
|
||||||
|
setSearchResults(images);
|
||||||
|
setTotalResults(totalCount);
|
||||||
|
setLoading(false); // 图片加载完成后关闭加载状态
|
||||||
|
|
||||||
|
// 如果搜索结果为空且已执行搜索,显示友好提示
|
||||||
|
if (images.length === 0 && searchPerformed) {
|
||||||
|
message.info('没有找到匹配的图片');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className="search-dialog-title">
|
||||||
|
<SearchOutlined style={{ marginRight: 10 }} />
|
||||||
|
高级图片搜索
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
width={1000}
|
||||||
|
footer={null}
|
||||||
|
className="search-dialog"
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTabKey}
|
||||||
|
onChange={setActiveTabKey}
|
||||||
|
className="search-tabs"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: "text",
|
||||||
|
label: <span><SearchOutlined /> 文本搜索</span>,
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div className="search-input-container">
|
||||||
|
<Input.Search
|
||||||
|
placeholder="输入关键词搜索图片..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
enterButton
|
||||||
|
allowClear
|
||||||
|
size="large"
|
||||||
|
autoFocus={activeTabKey === 'text'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider orientation="left" plain>筛选选项</Divider>
|
||||||
|
|
||||||
|
<div className="search-option-group">
|
||||||
|
<Text strong className="option-label">标签筛选:</Text>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
style={{ width: '100%', marginTop: 8 }}
|
||||||
|
placeholder="选择标签进行筛选"
|
||||||
|
value={selectedTags}
|
||||||
|
onChange={setSelectedTags}
|
||||||
|
options={tagOptions}
|
||||||
|
maxTagCount={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-button-container">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{loading ? '搜索中...' : '开始搜索'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vector",
|
||||||
|
label: <span><FileImageOutlined /> 向量搜索</span>,
|
||||||
|
children: (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<div className="search-input-container">
|
||||||
|
<Input.Search
|
||||||
|
placeholder="输入关键词辅助搜索..."
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => setSearchText(e.target.value)}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
enterButton
|
||||||
|
allowClear
|
||||||
|
size="large"
|
||||||
|
autoFocus={activeTabKey === 'vector'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider orientation="left" plain>高级选项</Divider>
|
||||||
|
|
||||||
|
<div className="search-option-group vector-options">
|
||||||
|
<div className="vector-switch-container">
|
||||||
|
<Switch
|
||||||
|
checked={useVectorSearch}
|
||||||
|
onChange={setUseVectorSearch}
|
||||||
|
/>
|
||||||
|
<Text strong style={{ marginLeft: 8 }}>启用向量相似度搜索</Text>
|
||||||
|
<Tooltip title="向量搜索可以查找视觉上相似的图片,而不仅仅是匹配标签或文本">
|
||||||
|
<Text type="secondary" style={{ marginLeft: 8 }}>
|
||||||
|
(基于图像内容的相似度搜索)
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{useVectorSearch && (
|
||||||
|
<div className="threshold-slider-container">
|
||||||
|
<Text>相似度阈值:{similarityThreshold.toFixed(2)}</Text>
|
||||||
|
<Slider
|
||||||
|
min={0.1}
|
||||||
|
max={1.0}
|
||||||
|
step={0.05}
|
||||||
|
value={similarityThreshold}
|
||||||
|
onChange={setSimilarityThreshold}
|
||||||
|
marks={{
|
||||||
|
0.1: '低',
|
||||||
|
0.5: '中',
|
||||||
|
0.9: '高'
|
||||||
|
}}
|
||||||
|
className="threshold-slider"
|
||||||
|
/>
|
||||||
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||||
|
较高的阈值会返回更相似的结果,但可能减少结果数量
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="search-button-container">
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SearchOutlined />}
|
||||||
|
onClick={handleSearch}
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
disabled={loading}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
{loading ? '搜索中...' : '开始搜索'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{searchPerformed && (
|
||||||
|
<div className="search-results">
|
||||||
|
<div className="search-results-header">
|
||||||
|
<Title level={4}>
|
||||||
|
搜索结果 <span className="result-count">({totalResults})</span>
|
||||||
|
</Title>
|
||||||
|
<Button
|
||||||
|
onClick={resetSearch}
|
||||||
|
disabled={loading}
|
||||||
|
icon={<ClearOutlined />}
|
||||||
|
>
|
||||||
|
清除搜索
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGrid
|
||||||
|
queryParams={activeSearchParams}
|
||||||
|
loading={loading}
|
||||||
|
onImagesLoaded={handleImagesLoaded}
|
||||||
|
defaultPage={currentPage}
|
||||||
|
onPageChange={(page) => setCurrentPage(page)}
|
||||||
|
emptyText="没有找到匹配的图片"
|
||||||
|
showPagination={totalResults > 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchDialog;
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Modal, Upload, Button, Progress, message, Form, Select, Radio, Slider } from 'antd';
|
||||||
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import type { UploadFile, UploadPictureParams, AlbumResponse } from '../../api';
|
||||||
|
import { uploadPicture, getAlbums } from '../../api';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
interface UploadDialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUploadComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageUploadDialog: React.FC<UploadDialogProps> = ({ visible, onClose, onUploadComplete }) => {
|
||||||
|
const [uploadQueue, setUploadQueue] = useState<UploadFile[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
|
||||||
|
const [concurrentUploads, setConcurrentUploads] = useState<number>(3);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
fetchAlbums();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const fetchAlbums = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getAlbums();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setAlbums(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取相册列表失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBeforeUpload = (file: File) => {
|
||||||
|
// 检查是否为图片文件
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error(`${file.name} 不是图片文件`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制文件大小,例如 10MB
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error('图片大小不能超过 10MB!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到上传队列
|
||||||
|
const newFile: UploadFile = {
|
||||||
|
id: uuidv4(),
|
||||||
|
file,
|
||||||
|
status: 'pending',
|
||||||
|
percent: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
setUploadQueue((prev) => [...prev, newFile]);
|
||||||
|
return false; // 阻止自动上传
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadFiles = async () => {
|
||||||
|
if (uploadQueue.length === 0) {
|
||||||
|
message.warning('请先选择需要上传的图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setUploading(true);
|
||||||
|
const values = await form.validateFields();
|
||||||
|
|
||||||
|
const params: UploadPictureParams = {};
|
||||||
|
if (values.permission !== undefined) {
|
||||||
|
params.permission = values.permission;
|
||||||
|
}
|
||||||
|
if (values.albumId) {
|
||||||
|
params.albumId = values.albumId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
// 创建上传队列的副本
|
||||||
|
const queue = [...uploadQueue].filter(item => item.status !== 'success');
|
||||||
|
|
||||||
|
// 上传单个文件的函数
|
||||||
|
const uploadSingleFile = async (item: UploadFile) => {
|
||||||
|
// 更新状态为上传中
|
||||||
|
setUploadQueue((prev) =>
|
||||||
|
prev.map(file => file.id === item.id ? { ...file, status: 'uploading' } : file)
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 上传文件
|
||||||
|
const result = await uploadPicture(item.file, {
|
||||||
|
...params,
|
||||||
|
onProgress: (percent) => {
|
||||||
|
setUploadQueue((prev) =>
|
||||||
|
prev.map(file => file.id === item.id ? { ...file, percent } : file)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
// 更新为上传成功
|
||||||
|
setUploadQueue((prev) =>
|
||||||
|
prev.map(file => file.id === item.id ? {
|
||||||
|
...file,
|
||||||
|
status: 'success',
|
||||||
|
response: result.data,
|
||||||
|
percent: 100
|
||||||
|
} : file)
|
||||||
|
);
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
// 更新为上传失败
|
||||||
|
setUploadQueue((prev) =>
|
||||||
|
prev.map(file => file.id === item.id ? {
|
||||||
|
...file,
|
||||||
|
status: 'error',
|
||||||
|
error: result.message || '上传失败'
|
||||||
|
} : file)
|
||||||
|
);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 更新为上传失败
|
||||||
|
setUploadQueue((prev) =>
|
||||||
|
prev.map(file => file.id === item.id ? {
|
||||||
|
...file,
|
||||||
|
status: 'error',
|
||||||
|
error: error.message || '上传失败'
|
||||||
|
} : file)
|
||||||
|
);
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量上传函数 - 支持并发控制
|
||||||
|
const batchUpload = async () => {
|
||||||
|
// 每次处理的批次大小
|
||||||
|
const batchSize = concurrentUploads;
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
// 取出当前批次的文件
|
||||||
|
const batch = queue.splice(0, batchSize);
|
||||||
|
|
||||||
|
// 并行上传当前批次的所有文件
|
||||||
|
await Promise.all(batch.map(item => uploadSingleFile(item)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行批量上传
|
||||||
|
await batchUpload();
|
||||||
|
|
||||||
|
// 显示上传结果
|
||||||
|
if (successCount > 0) {
|
||||||
|
if (failCount > 0) {
|
||||||
|
message.warning(`上传完成,成功 ${successCount} 张,失败 ${failCount} 张`);
|
||||||
|
} else {
|
||||||
|
message.success(`成功上传 ${successCount} 张图片`);
|
||||||
|
// 如果全部成功,清空队列并关闭对话框
|
||||||
|
setTimeout(() => {
|
||||||
|
setUploadQueue([]);
|
||||||
|
onClose();
|
||||||
|
onUploadComplete();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message.error('上传失败,请重试');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('表单验证或上传过程出错:', error);
|
||||||
|
message.error('上传失败,请检查表单信息');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (id: string) => {
|
||||||
|
setUploadQueue((prev) => prev.filter(file => file.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
// 如果正在上传,提示用户
|
||||||
|
if (uploading) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认取消',
|
||||||
|
content: '上传正在进行中,确定要取消吗?',
|
||||||
|
onOk: () => {
|
||||||
|
setUploading(false);
|
||||||
|
setUploadQueue([]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setUploadQueue([]);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自定义上传列表项
|
||||||
|
const renderUploadItem = (item: UploadFile) => {
|
||||||
|
let statusIcon;
|
||||||
|
let statusColor;
|
||||||
|
|
||||||
|
switch(item.status) {
|
||||||
|
case 'success':
|
||||||
|
statusIcon = '✓';
|
||||||
|
statusColor = '#52c41a';
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
statusIcon = '✗';
|
||||||
|
statusColor = '#ff4d4f';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusIcon = '';
|
||||||
|
statusColor = '#1890ff';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
margin: '8px 0',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#f9f9f9',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
<div style={{ marginRight: '8px', width: '40px', height: '40px' }}>
|
||||||
|
{item.file instanceof File && (
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(item.file)}
|
||||||
|
alt={item.file.name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<div style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
{item.file.name}
|
||||||
|
</div>
|
||||||
|
{item.status === 'uploading' && (
|
||||||
|
<Progress percent={Math.round(item.percent)} size="small" />
|
||||||
|
)}
|
||||||
|
{item.status === 'error' && item.error && (
|
||||||
|
<div style={{ color: '#ff4d4f', fontSize: '12px' }}>{item.error}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: '8px' }}>
|
||||||
|
{item.status !== 'uploading' && (
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger={item.status !== 'success'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleRemove(item.id)}
|
||||||
|
disabled={uploading}
|
||||||
|
>
|
||||||
|
{item.status === 'success' ? '移除' : '删除'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{statusIcon && (
|
||||||
|
<span style={{
|
||||||
|
marginLeft: '8px',
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}>
|
||||||
|
{statusIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="上传图片"
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={[
|
||||||
|
<Button key="back" onClick={handleClose} disabled={uploading}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={uploading}
|
||||||
|
onClick={uploadFiles}
|
||||||
|
>
|
||||||
|
{uploading ? '正在上传...' : '开始上传'}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
permission: 2,
|
||||||
|
concurrentUploads: 3
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="albumId"
|
||||||
|
label="选择相册"
|
||||||
|
>
|
||||||
|
<Select placeholder="选择要上传到的相册" allowClear>
|
||||||
|
{albums.map(album => (
|
||||||
|
<Option key={album.id} value={album.id}>{album.name}</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="permission"
|
||||||
|
label="图片权限"
|
||||||
|
>
|
||||||
|
<Radio.Group>
|
||||||
|
<Radio value={0}>公开</Radio>
|
||||||
|
<Radio value={1}>好友可见</Radio>
|
||||||
|
<Radio value={2}>仅自己</Radio>
|
||||||
|
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="concurrentUploads"
|
||||||
|
label="并发上传数量"
|
||||||
|
>
|
||||||
|
<Slider
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
value={concurrentUploads}
|
||||||
|
onChange={(value) => setConcurrentUploads(value)}
|
||||||
|
marks={{ 1: '1', 5: '5', 10: '10' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Dragger
|
||||||
|
beforeUpload={handleBeforeUpload}
|
||||||
|
multiple
|
||||||
|
showUploadList={false}
|
||||||
|
disabled={uploading}
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">
|
||||||
|
支持单个或批量上传,图片大小不超过10MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '16px' }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
<div>已选择 {uploadQueue.length} 张图片</div>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
border: uploadQueue.length > 0 ? '1px solid #f0f0f0' : 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: uploadQueue.length > 0 ? '8px' : '0'
|
||||||
|
}}>
|
||||||
|
{uploadQueue.map(renderUploadItem)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageUploadDialog;
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
PictureOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
HeartOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
CompassOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import AllImages from '../pages/allImages/Index';
|
||||||
|
import Albums from '../pages/albums/Index';
|
||||||
|
import AlbumDetail from '../pages/albumDetail/Index';
|
||||||
|
import Favorites from '../pages/favorites/Index';
|
||||||
|
import Upload from '../pages/upload/Index';
|
||||||
|
import Settings from '../pages/settings/Index';
|
||||||
|
import BackgroundTasks from '../pages/backgroundTasks/Index';
|
||||||
|
import PixHub from '../pages/pixHub/Index';
|
||||||
|
|
||||||
|
// 路由配置类型定义
|
||||||
|
export interface RouteConfig {
|
||||||
|
path: string;
|
||||||
|
element: React.ReactNode;
|
||||||
|
// 以下属性用于菜单配置
|
||||||
|
key: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
hideInMenu?: boolean;
|
||||||
|
children?: RouteConfig[];
|
||||||
|
groupLabel?: string; // 分组标题
|
||||||
|
divider?: boolean; // 是否显示分隔线
|
||||||
|
// 面包屑相关配置
|
||||||
|
breadcrumb?: {
|
||||||
|
title: string;
|
||||||
|
parent?: string; // 父级路由的key
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的路由和菜单配置
|
||||||
|
const routes: RouteConfig[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
key: 'all-images',
|
||||||
|
icon: <PictureOutlined />,
|
||||||
|
label: '所有图片',
|
||||||
|
element: <AllImages />,
|
||||||
|
breadcrumb: {
|
||||||
|
title: '所有图片'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'albums',
|
||||||
|
key: 'albums',
|
||||||
|
icon: <FolderOutlined />,
|
||||||
|
label: '相册',
|
||||||
|
element: <Albums />,
|
||||||
|
breadcrumb: {
|
||||||
|
title: '相册'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'albums/:id',
|
||||||
|
key: 'album-detail',
|
||||||
|
label: '相册详情',
|
||||||
|
element: <AlbumDetail />,
|
||||||
|
hideInMenu: true,
|
||||||
|
breadcrumb: {
|
||||||
|
title: '相册详情',
|
||||||
|
parent: 'albums'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'favorites',
|
||||||
|
key: 'favorites',
|
||||||
|
icon: <HeartOutlined />,
|
||||||
|
label: '收藏',
|
||||||
|
element: <Favorites />,
|
||||||
|
breadcrumb: {
|
||||||
|
title: '收藏'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'square',
|
||||||
|
key: 'square',
|
||||||
|
icon: <CompassOutlined />,
|
||||||
|
label: '图片广场',
|
||||||
|
element: <PixHub />,
|
||||||
|
groupLabel: '社区发现',
|
||||||
|
breadcrumb: {
|
||||||
|
title: '图片广场'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tasks',
|
||||||
|
key: 'tasks',
|
||||||
|
icon: <CloudUploadOutlined />,
|
||||||
|
label: '任务中心',
|
||||||
|
element: <BackgroundTasks />,
|
||||||
|
groupLabel: '系统功能',
|
||||||
|
breadcrumb: {
|
||||||
|
title: '任务中心'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
key: 'settings',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '设置',
|
||||||
|
element: <Settings />,
|
||||||
|
breadcrumb: {
|
||||||
|
title: '设置'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'upload',
|
||||||
|
key: 'upload',
|
||||||
|
label: '上传',
|
||||||
|
element: <Upload />,
|
||||||
|
hideInMenu: true,
|
||||||
|
breadcrumb: {
|
||||||
|
title: '上传'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default routes;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义hook,用于检测当前设备是否为移动设备
|
||||||
|
* @param breakpoint 断点宽度,默认为768px
|
||||||
|
* @returns boolean 如果是移动设备则返回true,否则返回false
|
||||||
|
*/
|
||||||
|
const useIsMobile = (breakpoint: number = 768): boolean => {
|
||||||
|
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 初始化检测
|
||||||
|
const checkIfMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < breakpoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 首次运行
|
||||||
|
checkIfMobile();
|
||||||
|
|
||||||
|
// 监听窗口大小变化
|
||||||
|
window.addEventListener('resize', checkIfMobile);
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', checkIfMobile);
|
||||||
|
};
|
||||||
|
}, [breakpoint]);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useIsMobile;
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Outlet, useNavigate, useLocation, matchPath } from 'react-router';
|
||||||
|
import { Layout, theme } from 'antd';
|
||||||
|
import { clearAuthData, getStoredUser, isAuthenticated } from '../api';
|
||||||
|
import useIsMobile from '../hooks/useIsMobile';
|
||||||
|
|
||||||
|
import Sidebar from './components/Sidebar';
|
||||||
|
import Header from './components/Header';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
import routes, { type RouteConfig } from '../config/routeConfig';
|
||||||
|
|
||||||
|
const { Content } = Layout;
|
||||||
|
|
||||||
|
function MainLayout() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [collapsed, setCollapsed] = useState(isMobile); // 移动设备默认折叠侧边栏
|
||||||
|
// 重命名避免未使用警告,或用于未来扩展
|
||||||
|
const [, setUser] = useState<any>(null);
|
||||||
|
const [currentRouteData, setCurrentRouteData] = useState<{
|
||||||
|
routeInfo: RouteConfig | undefined;
|
||||||
|
params: Record<string, string>;
|
||||||
|
title?: string;
|
||||||
|
}>({
|
||||||
|
routeInfo: undefined,
|
||||||
|
params: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
token: { colorBgContainer },
|
||||||
|
} = theme.useToken();
|
||||||
|
|
||||||
|
// 查找当前路由信息
|
||||||
|
const findCurrentRoute = () => {
|
||||||
|
const pathname = location.pathname;
|
||||||
|
|
||||||
|
// 测试每个路由是否匹配当前路径
|
||||||
|
for (const route of routes) {
|
||||||
|
const match = matchPath(
|
||||||
|
{ path: `/${route.path}`, end: true },
|
||||||
|
pathname
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
routeInfo: route,
|
||||||
|
// 确保params是Record<string, string>类型
|
||||||
|
params: Object.fromEntries(
|
||||||
|
Object.entries(match.params || {}).filter(
|
||||||
|
([, value]) => value !== undefined
|
||||||
|
)
|
||||||
|
) as Record<string, string>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有完全匹配,尝试找到包含参数的路由
|
||||||
|
for (const route of routes) {
|
||||||
|
const pattern = route.path.includes(':')
|
||||||
|
? `/${route.path.split('/:')[0]}`
|
||||||
|
: `/${route.path}`;
|
||||||
|
|
||||||
|
if (pathname.startsWith(pattern)) {
|
||||||
|
const match = matchPath(
|
||||||
|
{ path: `/${route.path}`, end: false },
|
||||||
|
pathname
|
||||||
|
);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
routeInfo: route,
|
||||||
|
// 确保params是Record<string, string>类型
|
||||||
|
params: Object.fromEntries(
|
||||||
|
Object.entries(match.params || {}).filter(
|
||||||
|
([, value]) => value !== undefined
|
||||||
|
)
|
||||||
|
) as Record<string, string>
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
routeInfo: undefined,
|
||||||
|
params: {}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const storedUser = getStoredUser();
|
||||||
|
if (storedUser) {
|
||||||
|
setUser(storedUser);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
// 监听路由变化,更新当前路由信息
|
||||||
|
useEffect(() => {
|
||||||
|
const routeData = findCurrentRoute();
|
||||||
|
setCurrentRouteData(routeData);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
// 当设备类型改变时,自动调整侧边栏状态
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsed(isMobile);
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
// 退出登录处理
|
||||||
|
const handleLogout = () => {
|
||||||
|
clearAuthData();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
setCollapsed(!collapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{
|
||||||
|
height: '100vh',
|
||||||
|
background: '#fcfcfc',
|
||||||
|
fontWeight: 400
|
||||||
|
}}>
|
||||||
|
{/* 侧边栏组件 - 添加onClose回调 */}
|
||||||
|
<Sidebar
|
||||||
|
collapsed={collapsed}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onClose={toggleCollapsed}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Layout>
|
||||||
|
{/* 顶部导航栏组件 */}
|
||||||
|
<Header
|
||||||
|
collapsed={collapsed}
|
||||||
|
toggleCollapsed={toggleCollapsed}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
currentRouteData={currentRouteData}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 主要内容区 */}
|
||||||
|
<Content style={{
|
||||||
|
margin: isMobile ? '10px' : '20px',
|
||||||
|
background: '#fcfcfc',
|
||||||
|
position: 'relative',
|
||||||
|
borderRadius: isMobile ? 10 : 20,
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: isMobile ? '15px' : '25px',
|
||||||
|
minHeight: '100%',
|
||||||
|
background: colorBgContainer,
|
||||||
|
boxShadow: '0 6px 30px rgba(0,0,0,0.03)',
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
{/* 渲染子路由组件 */}
|
||||||
|
<Outlet context={{
|
||||||
|
updateBreadcrumbTitle: (title: string) => {
|
||||||
|
setCurrentRouteData(prev => ({ ...prev, title }));
|
||||||
|
},
|
||||||
|
isMobile
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
{/* 页脚组件 */}
|
||||||
|
<Footer isMobile={isMobile} />
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainLayout;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layout } from 'antd';
|
||||||
|
import { GithubOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { Footer: AntFooter } = Layout;
|
||||||
|
|
||||||
|
interface FooterProps {
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Footer: React.FC<FooterProps> = ({ isMobile = false }) => {
|
||||||
|
return (
|
||||||
|
<AntFooter style={{
|
||||||
|
background: 'white',
|
||||||
|
padding: isMobile ? '10px' : '10px',
|
||||||
|
fontSize: isMobile ? '12px' : '12px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center'
|
||||||
|
}}>
|
||||||
|
<div>Foxel ©{new Date().getFullYear()}</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/DrizzleTime/Foxel"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: isMobile ? '16px' : '18px', color: '#333' }}
|
||||||
|
>
|
||||||
|
<GithubOutlined />
|
||||||
|
</a>
|
||||||
|
</AntFooter>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import { Layout, Button, Dropdown, Breadcrumb, Input } from 'antd';
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
SettingOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useNavigate, Link } from 'react-router';
|
||||||
|
import routes, { type RouteConfig } from '../../config/routeConfig';
|
||||||
|
import UserAvatar from '../../components/UserAvatar';
|
||||||
|
import { useAuth } from '../../api/AuthContext';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import SearchDialog from '../../components/search/SearchDialog';
|
||||||
|
|
||||||
|
const { Header: AntHeader } = Layout;
|
||||||
|
const { Search } = Input;
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
toggleCollapsed: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
currentRouteData?: {
|
||||||
|
routeInfo: RouteConfig | undefined;
|
||||||
|
params: Record<string, string>;
|
||||||
|
title?: string; // 动态标题,用于显示如"相册名称"等动态数据
|
||||||
|
};
|
||||||
|
isMobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Header = ({
|
||||||
|
collapsed,
|
||||||
|
toggleCollapsed,
|
||||||
|
onLogout,
|
||||||
|
currentRouteData,
|
||||||
|
isMobile = false
|
||||||
|
}: HeaderProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [searchDialogVisible, setSearchDialogVisible] = useState(false);
|
||||||
|
const [searchText, setSearchText] = useState('');
|
||||||
|
|
||||||
|
const userMenuItems = [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
icon: <UserOutlined/>,
|
||||||
|
label: '个人资料',
|
||||||
|
onClick: () => navigate('/settings')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'settings',
|
||||||
|
icon: <SettingOutlined/>,
|
||||||
|
label: '设置',
|
||||||
|
onClick: () => navigate('/settings')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined/>,
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: onLogout
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// 生成面包屑项
|
||||||
|
const generateBreadcrumbItems = () => {
|
||||||
|
const breadcrumbItems = [];
|
||||||
|
|
||||||
|
// 添加首页
|
||||||
|
breadcrumbItems.push({
|
||||||
|
key: 'home',
|
||||||
|
title: <Link to="/">首页</Link>,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 确保routeInfo和breadcrumb都存在
|
||||||
|
if (currentRouteData?.routeInfo && currentRouteData.routeInfo.breadcrumb) {
|
||||||
|
const { routeInfo, title } = currentRouteData;
|
||||||
|
const breadcrumb = routeInfo.breadcrumb;
|
||||||
|
|
||||||
|
// 如果有父级路由,先添加父级路由的面包屑
|
||||||
|
if (breadcrumb && breadcrumb.parent) {
|
||||||
|
const parentRoute = routes.find(r => r.key === breadcrumb.parent);
|
||||||
|
if (parentRoute && parentRoute.breadcrumb) {
|
||||||
|
breadcrumbItems.push({
|
||||||
|
key: parentRoute.key,
|
||||||
|
title: <Link to={`/${parentRoute.path}`}>{parentRoute.breadcrumb.title}</Link>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加当前路由的面包屑
|
||||||
|
breadcrumbItems.push({
|
||||||
|
key: routeInfo.key,
|
||||||
|
title: title || breadcrumb?.title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return breadcrumbItems;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理搜索框输入
|
||||||
|
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearchText(e.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理搜索操作,仅当点击搜索按钮或按回车时执行
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
if (value.trim() || !value) { // 允许空搜索打开高级搜索
|
||||||
|
setSearchDialogVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AntHeader style={{
|
||||||
|
padding: isMobile ? '0 10px' : '0 40px',
|
||||||
|
background: '#ffffff',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: isMobile ? 56 : 64,
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 10,
|
||||||
|
width: '100%',
|
||||||
|
backdropFilter: 'blur(10px)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}
|
||||||
|
onClick={toggleCollapsed}
|
||||||
|
style={{
|
||||||
|
fontSize: 18,
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
borderRadius: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 面包屑导航 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Breadcrumb
|
||||||
|
items={generateBreadcrumbItems()}
|
||||||
|
style={{ marginLeft: 16 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 25 }}>
|
||||||
|
{/* 搜索框 - 修复交互问题 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Search
|
||||||
|
placeholder="搜索图片..."
|
||||||
|
allowClear
|
||||||
|
value={searchText}
|
||||||
|
onChange={handleSearchInputChange}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{
|
||||||
|
width: 300,
|
||||||
|
borderRadius: 100
|
||||||
|
}}
|
||||||
|
size="middle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
|
||||||
|
<UserAvatar
|
||||||
|
size={46}
|
||||||
|
email={user?.email}
|
||||||
|
text={user?.userName}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</AntHeader>
|
||||||
|
|
||||||
|
{/* 搜索对话框 - 传递搜索文本 */}
|
||||||
|
<SearchDialog
|
||||||
|
visible={searchDialogVisible}
|
||||||
|
initialSearchText={searchText}
|
||||||
|
onClose={() => {
|
||||||
|
setSearchDialogVisible(false);
|
||||||
|
// 可选:关闭对话框后清空搜索框
|
||||||
|
// setSearchText('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Layout, Menu, type MenuProps } from 'antd';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
import routes from '../../config/routeConfig';
|
||||||
|
import logo from '/logo.png';
|
||||||
|
|
||||||
|
const { Sider } = Layout;
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
collapsed: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定义菜单项类型
|
||||||
|
type MenuItem = Required<MenuProps>['items'][number];
|
||||||
|
|
||||||
|
const Sidebar: React.FC<SidebarProps> = ({ collapsed, isMobile = false, onClose }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// 菜单项样式
|
||||||
|
const menuItemStyle = { fontSize: 15 };
|
||||||
|
const iconStyle = { fontSize: 18 };
|
||||||
|
|
||||||
|
// 分组标题样式
|
||||||
|
const groupTitleStyle = {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8c8c8c',
|
||||||
|
fontWeight: 500,
|
||||||
|
marginLeft: collapsed ? 0 : 16,
|
||||||
|
padding: collapsed ? '8px 0' : '8px 0'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从路由配置生成菜单项
|
||||||
|
const generateMenuItems = (): MenuItem[] => {
|
||||||
|
const items: MenuItem[] = [];
|
||||||
|
let lastGroup = '';
|
||||||
|
|
||||||
|
routes.forEach(route => {
|
||||||
|
if (route.hideInMenu) return;
|
||||||
|
|
||||||
|
// 如果有分组标签且与上一个不同,添加分组
|
||||||
|
if (route.groupLabel && route.groupLabel !== lastGroup && !collapsed) {
|
||||||
|
items.push({
|
||||||
|
type: 'group',
|
||||||
|
label: <div style={groupTitleStyle}>{route.groupLabel}</div>,
|
||||||
|
children: []
|
||||||
|
} as MenuItem);
|
||||||
|
lastGroup = route.groupLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果需要添加分隔符
|
||||||
|
if (route.divider) {
|
||||||
|
items.push({
|
||||||
|
type: 'divider'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加菜单项
|
||||||
|
items.push({
|
||||||
|
key: route.path,
|
||||||
|
icon: route.icon && React.isValidElement(route.icon)
|
||||||
|
? React.cloneElement(route.icon as React.ReactElement<any>, { style: iconStyle })
|
||||||
|
: route.icon,
|
||||||
|
label: <span style={menuItemStyle}>{route.label}</span>
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前选中的菜单项
|
||||||
|
const getSelectedKey = () => {
|
||||||
|
const pathname = location.pathname;
|
||||||
|
const matchedRoute = routes.find(route => {
|
||||||
|
if (route.path.includes(':')) {
|
||||||
|
const basePath = route.path.split(':')[0].replace(/\/$/, '');
|
||||||
|
return pathname.startsWith('/' + basePath);
|
||||||
|
}
|
||||||
|
if (route.path === '/' && pathname === '/') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return pathname === '/' + route.path;
|
||||||
|
});
|
||||||
|
return matchedRoute ? (matchedRoute.path === '/' ? '/' : matchedRoute.path) : '/';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
navigate(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 遮罩层 - 仅在手机模式且侧边栏展开时显示 */}
|
||||||
|
{isMobile && !collapsed && (
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.45)',
|
||||||
|
zIndex: 999, // 确保在Sider(1000)之下
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Sider
|
||||||
|
trigger={null}
|
||||||
|
collapsible
|
||||||
|
collapsed={collapsed}
|
||||||
|
width={isMobile ? 180 : 250}
|
||||||
|
collapsedWidth={isMobile ? 0 : 80}
|
||||||
|
style={{
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100vh',
|
||||||
|
position: isMobile ? 'absolute' : 'relative',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: isMobile ? 1000 : 1,
|
||||||
|
boxShadow: isMobile && !collapsed ? '0 0 10px rgba(0,0,0,0.2)' : 'none',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo区域 */}
|
||||||
|
<div style={{
|
||||||
|
height: isMobile ? '56px' : '64px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||||
|
padding: collapsed ? '0' : '0 20px',
|
||||||
|
color: '#001529',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '18px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderBottom: '1px solid #f0f0f0'
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
src={logo}
|
||||||
|
alt="Foxel Logo"
|
||||||
|
style={{
|
||||||
|
height: collapsed ? '30px' : '32px',
|
||||||
|
marginRight: collapsed ? '0' : '12px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!collapsed && <span>Foxel</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 侧边栏菜单 */}
|
||||||
|
<Menu
|
||||||
|
theme="light"
|
||||||
|
mode="inline"
|
||||||
|
defaultSelectedKeys={[getSelectedKey()]}
|
||||||
|
items={generateMenuItems()}
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
style={{
|
||||||
|
borderRight: 'none',
|
||||||
|
flex: 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sidebar;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import '@ant-design/v5-patch-for-react-19';
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<App />
|
||||||
|
)
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate, useOutletContext } from 'react-router';
|
||||||
|
import {
|
||||||
|
Typography, Button, Spin, Empty, message,
|
||||||
|
Popconfirm, Modal, Form, Input} from 'antd';
|
||||||
|
import {
|
||||||
|
EditOutlined, DeleteOutlined, PlusOutlined} from '@ant-design/icons';
|
||||||
|
import { getAlbumById, deleteAlbum, favoritePicture, unfavoritePicture, addPicturesToAlbum, updateAlbum } from '../../api';
|
||||||
|
import type { AlbumResponse, PictureResponse } from '../../api';
|
||||||
|
import ImageGrid from '../../components/image/ImageGrid';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
type OutletContextType = {
|
||||||
|
updateBreadcrumbTitle: (title: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AlbumDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { updateBreadcrumbTitle } = useOutletContext<OutletContextType>();
|
||||||
|
|
||||||
|
const [album, setAlbum] = useState<AlbumResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||||
|
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||||
|
const [selectedPictures, setSelectedPictures] = useState<number[]>([]);
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const loadAlbum = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getAlbumById(parseInt(id));
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setAlbum(result.data);
|
||||||
|
// 更新面包屑标题
|
||||||
|
updateBreadcrumbTitle(result.data.name);
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '获取相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载相册出错:', error);
|
||||||
|
message.error('加载相册详情出错');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAlbum();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const handleDeleteAlbum = async () => {
|
||||||
|
if (!album) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await deleteAlbum(album.id);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('相册已删除');
|
||||||
|
navigate('/albums');
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '删除相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除相册出错:', error);
|
||||||
|
message.error('删除相册失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFavorite = async (image: PictureResponse) => {
|
||||||
|
try {
|
||||||
|
if (image.isFavorited) {
|
||||||
|
const result = await unfavoritePicture(image.id);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('已取消收藏');
|
||||||
|
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '取消收藏失败');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = await favoritePicture(image.id);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('已添加到收藏');
|
||||||
|
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '收藏失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('处理收藏操作失败:', error);
|
||||||
|
message.error('操作失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddModal = async () => {
|
||||||
|
setIsAddModalVisible(true);
|
||||||
|
setSelectedPictures([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddPictures = async () => {
|
||||||
|
if (!album || selectedPictures.length === 0) {
|
||||||
|
message.info('请先选择图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsAddModalVisible(false);
|
||||||
|
const result = await addPicturesToAlbum(album.id, selectedPictures);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message.success(`已添加 ${selectedPictures.length} 张图片到相册`);
|
||||||
|
setSelectedPictures([]);
|
||||||
|
loadAlbum();
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '添加图片到相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加图片到相册出错:', error);
|
||||||
|
message.error('添加图片到相册失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFormattedDate = (dateString?: Date) => {
|
||||||
|
if (!dateString) return '';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const imagesLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
const handleAlbumImagesLoaded = useCallback(() => {
|
||||||
|
if (!imagesLoadedRef.current) {
|
||||||
|
imagesLoadedRef.current = true;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
imagesLoadedRef.current = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// 打开编辑对话框
|
||||||
|
const openEditModal = () => {
|
||||||
|
if (album) {
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: album.name,
|
||||||
|
description: album.description || ''
|
||||||
|
});
|
||||||
|
setIsEditModalVisible(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交编辑表单
|
||||||
|
const handleEditAlbum = async () => {
|
||||||
|
if (!album) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const values = await editForm.validateFields();
|
||||||
|
|
||||||
|
const result = await updateAlbum({
|
||||||
|
id: album.id,
|
||||||
|
name: values.name,
|
||||||
|
description: values.description
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
message.success('相册已更新');
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
// 重新加载相册信息
|
||||||
|
loadAlbum();
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '更新相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('编辑相册出错:', error);
|
||||||
|
message.error('表单验证失败或提交时出错');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !album) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!album) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center', padding: '100px 0' }}>
|
||||||
|
<Empty description="相册不存在或已被删除" />
|
||||||
|
<Button type="primary" style={{ marginTop: 20 }} onClick={() => navigate('/albums')}>
|
||||||
|
返回相册列表
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* 移除原来的面包屑代码 */}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 40,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontSize: 32,
|
||||||
|
background: 'linear-gradient(120deg, #000000, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}>{album.name}</Title>
|
||||||
|
<Text type="secondary" style={{
|
||||||
|
fontSize: 16,
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: 8
|
||||||
|
}}>{album.description || "无描述"}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||||
|
创建于 {getFormattedDate(album.createdAt)} · {album?.pictureCount || 0} 张照片
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={openAddModal}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
height: 40,
|
||||||
|
padding: '0 20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加照片
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
height: 40,
|
||||||
|
padding: '0 20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8
|
||||||
|
}}
|
||||||
|
onClick={openEditModal}
|
||||||
|
>
|
||||||
|
编辑相册
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这个相册吗?"
|
||||||
|
description="删除后不可恢复,但相册中的照片不会被删除。"
|
||||||
|
onConfirm={handleDeleteAlbum}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
height: 40,
|
||||||
|
padding: '0 20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除相册
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGrid
|
||||||
|
queryParams={{ albumId: parseInt(id || '0') }} // 直接传入albumId参数获取相册图片
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
showFavoriteCount={true}
|
||||||
|
showPagination={true}
|
||||||
|
emptyText="相册中还没有照片"
|
||||||
|
onImagesLoaded={handleAlbumImagesLoaded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 添加图片到相册的对话框 */}
|
||||||
|
<Modal
|
||||||
|
title="添加图片到相册"
|
||||||
|
open={isAddModalVisible}
|
||||||
|
onCancel={() => setIsAddModalVisible(false)}
|
||||||
|
onOk={handleAddPictures}
|
||||||
|
width={1000}
|
||||||
|
>
|
||||||
|
<div style={{ maxHeight: '60vh', overflowY: 'auto', padding: '20px 0' }}>
|
||||||
|
<ImageGrid
|
||||||
|
queryParams={{ excludeAlbumId: parseInt(id || '0') }}
|
||||||
|
showFavoriteCount={false}
|
||||||
|
emptyText="没有可添加的图片"
|
||||||
|
pageSize={12}
|
||||||
|
selectedIds={selectedPictures}
|
||||||
|
selectable={true} // 新增:启用选择模式
|
||||||
|
onSelectionChange={setSelectedPictures} // 新增:设置选择变化回调
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>{selectedPictures.length} 张图片已选择</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 编辑相册的对话框 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑相册"
|
||||||
|
open={isEditModalVisible}
|
||||||
|
onCancel={() => setIsEditModalVisible(false)}
|
||||||
|
footer={[
|
||||||
|
<Button key="back" onClick={() => setIsEditModalVisible(false)}>
|
||||||
|
取消
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={submitting}
|
||||||
|
onClick={handleEditAlbum}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={editForm}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
name: album.name,
|
||||||
|
description: album.description || ''
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="相册名称"
|
||||||
|
rules={[{ required: true, message: '请输入相册名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入相册名称" maxLength={50} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="相册描述"
|
||||||
|
>
|
||||||
|
<TextArea
|
||||||
|
placeholder="请输入相册描述"
|
||||||
|
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AlbumDetail;
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Typography, Row, Col, Card, Button, Modal, Form, Input, Spin, Empty, message, Popconfirm } from 'antd';
|
||||||
|
import { PlusOutlined, EditOutlined, DeleteOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
|
import { getAlbums, createAlbum, updateAlbum, deleteAlbum } from '../../api';
|
||||||
|
import type { AlbumResponse, CreateAlbumRequest, UpdateAlbumRequest } from '../../api';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Meta } = Card;
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
function Albums() {
|
||||||
|
const [albums, setAlbums] = useState<AlbumResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalAlbums, setTotalAlbums] = useState(0);
|
||||||
|
const [isCreateModalVisible, setIsCreateModalVisible] = useState(false);
|
||||||
|
const [isEditModalVisible, setIsEditModalVisible] = useState(false);
|
||||||
|
const [currentAlbum, setCurrentAlbum] = useState<AlbumResponse | null>(null);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
|
||||||
|
const loadAlbums = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getAlbums();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setAlbums(result.data);
|
||||||
|
setTotalAlbums(result.totalCount);
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '获取相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载相册出错:', error);
|
||||||
|
message.error('加载相册列表出错');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAlbums();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateAlbum = async (values: CreateAlbumRequest) => {
|
||||||
|
try {
|
||||||
|
const result = await createAlbum(values);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
message.success('相册创建成功');
|
||||||
|
setIsCreateModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
loadAlbums();
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '创建相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建相册出错:', error);
|
||||||
|
message.error('创建相册失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditAlbum = async (values: UpdateAlbumRequest) => {
|
||||||
|
if (!currentAlbum) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await updateAlbum({
|
||||||
|
...values,
|
||||||
|
id: currentAlbum.id
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
message.success('相册更新成功');
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
editForm.resetFields();
|
||||||
|
setCurrentAlbum(null);
|
||||||
|
loadAlbums();
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '更新相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新相册出错:', error);
|
||||||
|
message.error('更新相册失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAlbum = async (id: number) => {
|
||||||
|
try {
|
||||||
|
const result = await deleteAlbum(id);
|
||||||
|
if (result.success) {
|
||||||
|
message.success('相册已删除');
|
||||||
|
loadAlbums();
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '删除相册失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除相册出错:', error);
|
||||||
|
message.error('删除相册失败,请重试');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditModal = (album: AlbumResponse) => {
|
||||||
|
setCurrentAlbum(album);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: album.name,
|
||||||
|
description: album.description
|
||||||
|
});
|
||||||
|
setIsEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRandomColor = () => {
|
||||||
|
const colors = ['#f56a00', '#7265e6', '#ffbf00', '#00a2ae', '#87d068', '#108ee9', '#f50', '#13c2c2'];
|
||||||
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFormattedDate = (dateString: Date) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 50,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontSize: 32,
|
||||||
|
background: 'linear-gradient(120deg, #000000, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}>相册</Title>
|
||||||
|
<Text type="secondary" style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#888',
|
||||||
|
letterSpacing: '0.3px'
|
||||||
|
}}>共 {totalAlbums} 个相册,分类管理你的照片</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsCreateModalVisible(true)}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
height: 46,
|
||||||
|
padding: '0 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 15
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
创建相册
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '50px 0' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
) : albums.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="暂无相册"
|
||||||
|
style={{ margin: '80px 0' }}
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
>
|
||||||
|
<Button type="primary" onClick={() => setIsCreateModalVisible(true)}>创建第一个相册</Button>
|
||||||
|
</Empty>
|
||||||
|
) : (
|
||||||
|
<Row gutter={[40, 40]}>
|
||||||
|
{albums.map(album => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} key={album.id}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
style={{
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: 'none',
|
||||||
|
background: '#ffffff',
|
||||||
|
boxShadow: '0 8px 30px rgba(0,0,0,0.05)',
|
||||||
|
transition: 'all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||||
|
transform: 'translateY(0)'
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '20px' }}
|
||||||
|
cover={
|
||||||
|
<Link to={`/albums/${album.id}`}>
|
||||||
|
{album.coverImageUrl ? (
|
||||||
|
<img alt={album.name} src={album.coverImageUrl} style={{
|
||||||
|
height: 180,
|
||||||
|
width: '100%',
|
||||||
|
objectFit: 'cover'
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
height: 180,
|
||||||
|
width: '100%',
|
||||||
|
background: getRandomColor(),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<PictureOutlined style={{ fontSize: 60, color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
actions={[
|
||||||
|
<Link to={`/albums/${album.id}`} key="view">查看</Link>,
|
||||||
|
<EditOutlined key="edit" onClick={() => openEditModal(album)} />,
|
||||||
|
<Popconfirm
|
||||||
|
title="确定要删除这个相册吗?"
|
||||||
|
description="删除后不可恢复,但相册中的照片不会被删除。"
|
||||||
|
onConfirm={() => handleDeleteAlbum(album.id)}
|
||||||
|
okText="确定"
|
||||||
|
cancelText="取消"
|
||||||
|
key="delete"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Popconfirm>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Meta
|
||||||
|
title={<Link to={`/albums/${album.id}`} style={{ color: 'inherit' }}>{album.name}</Link>}
|
||||||
|
description={
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text ellipsis style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
{album.description || "无描述"}
|
||||||
|
</Text>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Text type="secondary">{album.pictureCount || 0} 张照片</Text>
|
||||||
|
<Text type="secondary">{getFormattedDate(album.createdAt)}</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建相册对话框 */}
|
||||||
|
<Modal
|
||||||
|
title="创建新相册"
|
||||||
|
open={isCreateModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsCreateModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleCreateAlbum}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="相册名称"
|
||||||
|
rules={[{ required: true, message: '请输入相册名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="给你的相册起个名字" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="相册描述"
|
||||||
|
>
|
||||||
|
<TextArea placeholder="描述一下这个相册" rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||||
|
<Button style={{ marginRight: 8 }} onClick={() => {
|
||||||
|
setIsCreateModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 编辑相册对话框 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑相册"
|
||||||
|
open={isEditModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
editForm.resetFields();
|
||||||
|
setCurrentAlbum(null);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={editForm}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleEditAlbum}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="name"
|
||||||
|
label="相册名称"
|
||||||
|
rules={[{ required: true, message: '请输入相册名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="相册名称" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="description"
|
||||||
|
label="相册描述"
|
||||||
|
>
|
||||||
|
<TextArea placeholder="相册描述" rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item style={{ marginBottom: 0, textAlign: 'right' }}>
|
||||||
|
<Button style={{ marginRight: 8 }} onClick={() => {
|
||||||
|
setIsEditModalVisible(false);
|
||||||
|
editForm.resetFields();
|
||||||
|
setCurrentAlbum(null);
|
||||||
|
}}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Albums;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useRef, useMemo, useCallback } from 'react';
|
||||||
|
import { Typography, Button, Dropdown, message } from 'antd';
|
||||||
|
import { SortAscendingOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
|
import type { PictureResponse } from '../../api';
|
||||||
|
import ImageUploadDialog from '../../components/upload/ImageUploadDialog';
|
||||||
|
import ImageGrid from '../../components/image/ImageGrid';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
function AllImages() {
|
||||||
|
const [images, setImages] = useState<PictureResponse[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [sortBy, setSortBy] = useState<string>('uploadDate_desc');
|
||||||
|
const [isUploadDialogVisible, setIsUploadDialogVisible] = useState(false);
|
||||||
|
|
||||||
|
// 使用useRef记忆sortBy值,避免重复渲染
|
||||||
|
const sortByRef = useRef(sortBy);
|
||||||
|
|
||||||
|
// 优化handleSortChange,减少不必要的状态更新
|
||||||
|
const handleSortChange = (newSortBy: string) => {
|
||||||
|
if (sortBy !== newSortBy) {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
sortByRef.current = newSortBy;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用useMemo创建稳定的queryParams对象
|
||||||
|
const queryParamsObject = useMemo(() => {
|
||||||
|
return { sortBy };
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
|
const handleToggleFavorite = (image: PictureResponse) => {
|
||||||
|
// 只需处理 viewer 中的图片
|
||||||
|
setImages(prevImages =>
|
||||||
|
prevImages.map(img =>
|
||||||
|
img.id === image.id ? {
|
||||||
|
...img,
|
||||||
|
isFavorited: !img.isFavorited,
|
||||||
|
favoriteCount: img.isFavorited
|
||||||
|
? Math.max(0, img.favoriteCount - 1)
|
||||||
|
: img.favoriteCount + 1
|
||||||
|
} : img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadComplete = () => {
|
||||||
|
message.success('图片上传完成,刷新列表');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当分页变化时,保存当前浏览的页码
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
setPageSize(pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImagesLoaded = useCallback((loadedImages: PictureResponse[]) => {
|
||||||
|
if (images.length === 0) {
|
||||||
|
setImages(loadedImages);
|
||||||
|
}
|
||||||
|
}, [images.length]);
|
||||||
|
|
||||||
|
const sortMenu = {
|
||||||
|
items: [
|
||||||
|
{ key: 'takenAt_desc', label: '最新拍摄' },
|
||||||
|
{ key: 'takenAt_asc', label: '最早拍摄' },
|
||||||
|
{ key: 'uploadDate_desc', label: '最新上传' },
|
||||||
|
{ key: 'uploadDate_asc', label: '最早上传' },
|
||||||
|
{ key: 'name_asc', label: '名称 A-Z' },
|
||||||
|
{ key: 'name_desc', label: '名称 Z-A' },
|
||||||
|
],
|
||||||
|
onClick: ({ key }: { key: string }) => handleSortChange(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 50,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontSize: 32,
|
||||||
|
background: 'linear-gradient(120deg, #000000, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}>所有图片</Title>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
height: 46,
|
||||||
|
padding: '0 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 15
|
||||||
|
}}
|
||||||
|
onClick={() => setIsUploadDialogVisible(true)}
|
||||||
|
>
|
||||||
|
上传图片
|
||||||
|
</Button>
|
||||||
|
<Dropdown menu={sortMenu} placement="bottomRight">
|
||||||
|
<Button style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
height: 46,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
padding: '0 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 15,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.02)',
|
||||||
|
background: '#ffffff'
|
||||||
|
}}>
|
||||||
|
<SortAscendingOutlined />
|
||||||
|
排序方式
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGrid
|
||||||
|
queryParams={queryParamsObject}
|
||||||
|
pageSize={pageSize}
|
||||||
|
defaultPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
showFavoriteCount={true}
|
||||||
|
onImagesLoaded={handleImagesLoaded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImageUploadDialog
|
||||||
|
visible={isUploadDialogVisible}
|
||||||
|
onClose={() => setIsUploadDialogVisible(false)}
|
||||||
|
onUploadComplete={handleUploadComplete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AllImages;
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Upload,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
message,
|
||||||
|
Typography,
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Empty,
|
||||||
|
Layout,
|
||||||
|
Divider,
|
||||||
|
Space
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UploadOutlined,
|
||||||
|
FileImageOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
CloudUploadOutlined
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { RcFile, UploadFile as AntUploadFile } from 'antd/es/upload/interface';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
|
import ShareImageDialog from '../../components/image/ShareImageDialog';
|
||||||
|
import { uploadPicture } from '../../api/pictureApi';
|
||||||
|
import type { PictureResponse } from '../../api/types';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
const { Header, Content } = Layout;
|
||||||
|
|
||||||
|
const AnonymousPage: React.FC = () => {
|
||||||
|
const [fileList, setFileList] = useState<AntUploadFile[]>([]);
|
||||||
|
const [uploading, setUploading] = useState<boolean>(false);
|
||||||
|
const [uploadedImages, setUploadedImages] = useState<PictureResponse[]>([]);
|
||||||
|
const [shareImage, setShareImage] = useState<PictureResponse | null>(null);
|
||||||
|
const [shareDialogVisible, setShareDialogVisible] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleBeforeUpload = (file: RcFile) => {
|
||||||
|
// 检查文件类型
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
message.error(`${file.name} 不是图片文件`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制文件大小
|
||||||
|
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||||
|
if (!isLt10M) {
|
||||||
|
message.error('图片大小不能超过 10MB!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到上传列表
|
||||||
|
const newFile: AntUploadFile = {
|
||||||
|
uid: uuidv4(),
|
||||||
|
name: file.name,
|
||||||
|
status: 'done',
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
originFileObj: file,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFileList((prevList) => [...prevList, newFile]);
|
||||||
|
|
||||||
|
// 阻止默认上传行为
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 执行上传
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (fileList.length === 0) {
|
||||||
|
message.warning('请先选择需要上传的图片');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const uploadedList: PictureResponse[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
|
const file = fileList[i];
|
||||||
|
const originFile = file.originFileObj;
|
||||||
|
|
||||||
|
if (!originFile) continue;
|
||||||
|
|
||||||
|
// 更新状态为上传中
|
||||||
|
setFileList((prevList) =>
|
||||||
|
prevList.map(item => {
|
||||||
|
if (item.uid === file.uid) {
|
||||||
|
return { ...item, status: 'uploading' };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await uploadPicture(originFile, {
|
||||||
|
permission: 0, // 匿名上传默认为公开
|
||||||
|
onProgress: (percent) => {
|
||||||
|
setFileList((prevList) =>
|
||||||
|
prevList.map(item => {
|
||||||
|
if (item.uid === file.uid) {
|
||||||
|
return { ...item, percent };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理上传结果
|
||||||
|
if (result.success && result.data) {
|
||||||
|
// 更新上传完成状态
|
||||||
|
setFileList((prevList) =>
|
||||||
|
prevList.map(item => {
|
||||||
|
if (item.uid === file.uid) {
|
||||||
|
return { ...item, status: 'done', percent: 100 };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 添加到已上传图片列表
|
||||||
|
uploadedList.push(result.data);
|
||||||
|
} else {
|
||||||
|
// 更新上传失败状态
|
||||||
|
setFileList((prevList) =>
|
||||||
|
prevList.map(item => {
|
||||||
|
if (item.uid === file.uid) {
|
||||||
|
return { ...item, status: 'error' };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
message.error(`${file.name} 上传失败: ${result.message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('上传出错:', error);
|
||||||
|
|
||||||
|
// 更新上传失败状态
|
||||||
|
setFileList((prevList) =>
|
||||||
|
prevList.map(item => {
|
||||||
|
if (item.uid === file.uid) {
|
||||||
|
return { ...item, status: 'error' };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
message.error(`${file.name} 上传出错`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新上传完成的图片列表
|
||||||
|
setUploadedImages((prev) => [...prev, ...uploadedList]);
|
||||||
|
setUploading(false);
|
||||||
|
|
||||||
|
// 如果有成功上传的图片,清空上传队列
|
||||||
|
if (uploadedList.length > 0) {
|
||||||
|
message.success(`成功上传 ${uploadedList.length} 张图片`);
|
||||||
|
setFileList([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 移除待上传文件
|
||||||
|
const handleRemove = (file: AntUploadFile) => {
|
||||||
|
setFileList((prevList) => prevList.filter(item => item.uid !== file.uid));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 打开分享对话框
|
||||||
|
const handleShareImage = (image: PictureResponse) => {
|
||||||
|
setShareImage(image);
|
||||||
|
setShareDialogVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭分享对话框
|
||||||
|
const handleCloseShareDialog = () => {
|
||||||
|
setShareDialogVisible(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{ minHeight: '100vh', background: 'linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%)' }}>
|
||||||
|
<Header style={{
|
||||||
|
background: 'rgba(255, 255, 255, 0.9)',
|
||||||
|
padding: '0 24px',
|
||||||
|
boxShadow: '0 2px 10px rgba(0,0,0,0.08)',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', height: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>Foxel 匿名图床</Title>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Space>
|
||||||
|
<Link to="/login">
|
||||||
|
<Button type="primary" ghost>登录账户</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="https://github.com/DrizzleTime/Foxel">
|
||||||
|
<Button type="primary" ghost>Github</Button>
|
||||||
|
</Link>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<Content style={{ padding: '32px', maxWidth: '1200px', margin: '0 auto', width: '100%' }}>
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
marginBottom: '32px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.05)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '32px' }}
|
||||||
|
>
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '24px' }}>
|
||||||
|
<CloudUploadOutlined style={{ fontSize: '48px', color: '#1890ff', marginBottom: '12px' }} />
|
||||||
|
<Title level={3} style={{ margin: 0 }}>快速上传</Title>
|
||||||
|
<Text type="secondary" style={{ marginTop: '8px', display: 'block' }}>
|
||||||
|
无需登录,快速上传分享图片,支持一次性上传多张图片
|
||||||
|
</Text>
|
||||||
|
<Divider style={{ margin: '24px 0' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传区域 */}
|
||||||
|
<Dragger
|
||||||
|
multiple
|
||||||
|
fileList={fileList}
|
||||||
|
beforeUpload={handleBeforeUpload}
|
||||||
|
onRemove={handleRemove}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(240, 244, 248, 0.6)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px dashed #d9d9d9',
|
||||||
|
padding: '20px 0'
|
||||||
|
}}
|
||||||
|
itemRender={(originNode, file) => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: file.status === 'error' ? 'rgba(255,0,0,0.05)' :
|
||||||
|
file.status === 'done' ? 'rgba(82,196,26,0.05)' : 'transparent',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}>
|
||||||
|
{file.status === 'uploading' ? (
|
||||||
|
<div style={{ marginRight: '16px', color: '#1890ff', minWidth: '80px' }}>
|
||||||
|
上传中 {file.percent?.toFixed(0)}%
|
||||||
|
</div>
|
||||||
|
) : file.status === 'error' ? (
|
||||||
|
<div style={{ marginRight: '16px', color: '#ff4d4f', minWidth: '80px' }}>上传失败</div>
|
||||||
|
) : file.status === 'done' ? (
|
||||||
|
<div style={{ marginRight: '16px', color: '#52c41a', minWidth: '80px' }}>上传成功</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginRight: '16px', color: '#8c8c8c', minWidth: '80px' }}>等待上传</div>
|
||||||
|
)}
|
||||||
|
{originNode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<FileImageOutlined style={{ fontSize: '56px', color: '#1890ff' }} />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text" style={{ fontSize: '18px', margin: '16px 0' }}>
|
||||||
|
点击或拖拽图片到此区域上传
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-hint" style={{ fontSize: '14px' }}>
|
||||||
|
支持单个或批量上传,图片大小不超过10MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleUpload}
|
||||||
|
loading={uploading}
|
||||||
|
disabled={fileList.length === 0}
|
||||||
|
icon={<UploadOutlined />}
|
||||||
|
size="large"
|
||||||
|
style={{
|
||||||
|
height: '46px',
|
||||||
|
paddingLeft: '30px',
|
||||||
|
paddingRight: '30px',
|
||||||
|
fontSize: '16px',
|
||||||
|
boxShadow: '0 2px 10px rgba(24,144,255,0.3)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading ? '正在上传...' : '开始上传'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 已上传图片展示 */}
|
||||||
|
<Card
|
||||||
|
style={{
|
||||||
|
borderRadius: '12px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.05)',
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '32px' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '24px' }}>
|
||||||
|
<Title level={4} style={{ margin: 0, flexGrow: 1 }}>已上传图片</Title>
|
||||||
|
{uploadedImages.length > 0 && (
|
||||||
|
<Text type="secondary">共 {uploadedImages.length} 张图片</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadedImages.length > 0 ? (
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
{uploadedImages.map((image) => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={6} key={image.id}>
|
||||||
|
<Card
|
||||||
|
hoverable
|
||||||
|
cover={
|
||||||
|
<div style={{
|
||||||
|
height: '180px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
borderTopLeftRadius: '8px',
|
||||||
|
borderTopRightRadius: '8px',
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
<img
|
||||||
|
alt={image.name || 'uploaded image'}
|
||||||
|
src={image.path}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '180px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
background: 'rgba(0,0,0,0.02)',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
}}
|
||||||
|
bodyStyle={{ padding: '16px' }}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
key="share"
|
||||||
|
onClick={() => handleShareImage(image)}
|
||||||
|
icon={<LinkOutlined />}
|
||||||
|
style={{ width: '80%' }}
|
||||||
|
>
|
||||||
|
获取链接
|
||||||
|
</Button>
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
title={
|
||||||
|
<div style={{
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis'
|
||||||
|
}}>
|
||||||
|
{image.name || '未命名图片'}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<div style={{ fontSize: '12px', color: '#8c8c8c' }}>
|
||||||
|
上传成功 · {new Date().toLocaleString()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" align="center" size="small">
|
||||||
|
<Text style={{ fontSize: '16px' }}>暂无已上传图片</Text>
|
||||||
|
<Text type="secondary">上传图片后将显示在这里</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
margin: '60px 0',
|
||||||
|
padding: '30px',
|
||||||
|
background: 'rgba(0,0,0,0.01)',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', margin: '32px 0 16px', opacity: 0.6 }}>
|
||||||
|
<Text type="secondary">Foxel 图床 · 安全存储 · 便捷分享</Text>
|
||||||
|
</div>
|
||||||
|
</Content>
|
||||||
|
|
||||||
|
{/* 分享对话框 */}
|
||||||
|
<ShareImageDialog
|
||||||
|
visible={shareDialogVisible}
|
||||||
|
onClose={handleCloseShareDialog}
|
||||||
|
image={shareImage}
|
||||||
|
/>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnonymousPage;
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Typography, Table, Card, Tag, Space, Button, Empty, message, Modal } from 'antd';
|
||||||
|
import { SyncOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
|
import { getUserTasks } from '../../api';
|
||||||
|
import { type PictureProcessingTask, ProcessingStatus } from '../../api/types';
|
||||||
|
import TaskProgressBar from '../../components/TaskProgressBar';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
import type { ColumnType } from 'antd/es/table';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const BackgroundTasks: React.FC = () => {
|
||||||
|
const [tasks, setTasks] = useState<PictureProcessingTask[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pollingActive, setPollingActive] = useState(true);
|
||||||
|
const [pollingInterval, setPollingIntervalState] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 加载任务数据
|
||||||
|
const fetchTasks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await getUserTasks();
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTasks(result.data);
|
||||||
|
} else {
|
||||||
|
message.error(result.message || '获取任务列表失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取任务失败:', error);
|
||||||
|
message.error('加载任务列表时出错');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 自动刷新逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTasks();
|
||||||
|
|
||||||
|
// 设置轮询
|
||||||
|
if (pollingActive) {
|
||||||
|
const interval = setInterval(fetchTasks, 3000);
|
||||||
|
setPollingIntervalState(interval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pollingInterval) {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchTasks, pollingActive]);
|
||||||
|
|
||||||
|
// 检查是否有活跃的任务,如果没有则停止轮询
|
||||||
|
useEffect(() => {
|
||||||
|
const hasActiveTasks = tasks.some(
|
||||||
|
task => task.status === ProcessingStatus.Pending || task.status === ProcessingStatus.Processing
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasActiveTasks && pollingActive) {
|
||||||
|
setPollingActive(false);
|
||||||
|
if (pollingInterval) {
|
||||||
|
clearInterval(pollingInterval);
|
||||||
|
setPollingIntervalState(null);
|
||||||
|
}
|
||||||
|
} else if (hasActiveTasks && !pollingActive) {
|
||||||
|
setPollingActive(true);
|
||||||
|
const interval = setInterval(fetchTasks, 3000);
|
||||||
|
setPollingIntervalState(interval);
|
||||||
|
}
|
||||||
|
}, [tasks, pollingActive, pollingInterval, fetchTasks]);
|
||||||
|
|
||||||
|
// 渲染状态标签
|
||||||
|
const renderStatus = (status: ProcessingStatus) => {
|
||||||
|
let color = '';
|
||||||
|
let text = '';
|
||||||
|
let icon = null;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case ProcessingStatus.Pending:
|
||||||
|
color = 'orange';
|
||||||
|
text = '等待中';
|
||||||
|
icon = <SyncOutlined spin />;
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.Processing:
|
||||||
|
color = 'processing';
|
||||||
|
text = '处理中';
|
||||||
|
icon = <SyncOutlined spin />;
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.Completed:
|
||||||
|
color = 'success';
|
||||||
|
text = '已完成';
|
||||||
|
break;
|
||||||
|
case ProcessingStatus.Failed:
|
||||||
|
color = 'error';
|
||||||
|
text = '失败';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tag color={color} icon={icon}>{text}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date: Date | undefined) => {
|
||||||
|
if (!date) return '-';
|
||||||
|
return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渲染错误信息
|
||||||
|
const showErrorMessage = (error: string) => {
|
||||||
|
Modal.error({
|
||||||
|
title: '处理失败',
|
||||||
|
content: error,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns: ColumnType<PictureProcessingTask>[] = [
|
||||||
|
{
|
||||||
|
title: '图片名称',
|
||||||
|
dataIndex: 'pictureName',
|
||||||
|
key: 'pictureName',
|
||||||
|
render: (text: string, record: PictureProcessingTask) => (
|
||||||
|
<Link to={`/pictures/${record.pictureId}`}>{text}</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
render: (status: ProcessingStatus) => renderStatus(status),
|
||||||
|
filters: [
|
||||||
|
{ text: '等待中', value: ProcessingStatus.Pending },
|
||||||
|
{ text: '处理中', value: ProcessingStatus.Processing },
|
||||||
|
{ text: '已完成', value: ProcessingStatus.Completed },
|
||||||
|
{ text: '失败', value: ProcessingStatus.Failed },
|
||||||
|
],
|
||||||
|
onFilter: (value, record: PictureProcessingTask) =>
|
||||||
|
record.status === value.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '进度',
|
||||||
|
dataIndex: 'progress',
|
||||||
|
key: 'progress',
|
||||||
|
render: (progress: number, record: PictureProcessingTask) => (
|
||||||
|
<TaskProgressBar
|
||||||
|
status={record.status}
|
||||||
|
progress={progress}
|
||||||
|
error={record.error}
|
||||||
|
showLabel={false}
|
||||||
|
size="small"
|
||||||
|
style={{ width: '150px' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
render: (date: Date) => formatDate(date),
|
||||||
|
sorter: (a: PictureProcessingTask, b: PictureProcessingTask) =>
|
||||||
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '完成时间',
|
||||||
|
dataIndex: 'completedAt',
|
||||||
|
key: 'completedAt',
|
||||||
|
render: (date: Date) => formatDate(date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
render: (_: any, record: PictureProcessingTask) => (
|
||||||
|
<Space size="middle">
|
||||||
|
<Link to={`/pictures/${record.pictureId}`}>
|
||||||
|
<Button type="link" icon={<EyeOutlined />} size="small">
|
||||||
|
查看
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{record.status === ProcessingStatus.Failed && record.error && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
onClick={() => showErrorMessage(record.error!)}
|
||||||
|
>
|
||||||
|
查看错误
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="background-tasks-container">
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 30,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontSize: 32,
|
||||||
|
background: 'linear-gradient(120deg, #000000, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}>图片处理队列</Title>
|
||||||
|
<Text type="secondary" style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#888',
|
||||||
|
letterSpacing: '0.3px'
|
||||||
|
}}>查看和管理图片后台处理任务</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SyncOutlined />}
|
||||||
|
onClick={fetchTasks}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{tasks.length > 0 ? (
|
||||||
|
<Table
|
||||||
|
dataSource={tasks}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="taskId"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
loading ? "正在加载..." : "暂无处理任务"
|
||||||
|
}
|
||||||
|
style={{ margin: '40px 0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BackgroundTasks;
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { useState, useRef, useMemo, useCallback } from 'react';
|
||||||
|
import { Typography, Button, Dropdown } from 'antd';
|
||||||
|
import { SortAscendingOutlined } from '@ant-design/icons';
|
||||||
|
import type { PictureResponse } from '../../api';
|
||||||
|
import ImageGrid from '../../components/image/ImageGrid';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
function Favorites() {
|
||||||
|
const [images, setImages] = useState<PictureResponse[]>([]);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(8);
|
||||||
|
const [sortBy, setSortBy] = useState<string>('uploadDate_desc');
|
||||||
|
|
||||||
|
// 使用useRef记忆sortBy值,避免重复渲染
|
||||||
|
const sortByRef = useRef(sortBy);
|
||||||
|
|
||||||
|
// 优化handleSortChange,减少不必要的状态更新
|
||||||
|
const handleSortChange = (newSortBy: string) => {
|
||||||
|
if (sortBy !== newSortBy) {
|
||||||
|
setSortBy(newSortBy);
|
||||||
|
sortByRef.current = newSortBy;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用useMemo创建稳定的queryParams对象
|
||||||
|
const queryParamsObject = useMemo(() => {
|
||||||
|
return {
|
||||||
|
sortBy,
|
||||||
|
onlyFavorites: true
|
||||||
|
};
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
|
const handleToggleFavorite = (image: PictureResponse) => {
|
||||||
|
// 处理收藏/取消收藏
|
||||||
|
setImages(prevImages =>
|
||||||
|
prevImages.map(img =>
|
||||||
|
img.id === image.id ? {
|
||||||
|
...img,
|
||||||
|
isFavorited: !img.isFavorited,
|
||||||
|
favoriteCount: img.isFavorited
|
||||||
|
? Math.max(0, img.favoriteCount - 1)
|
||||||
|
: img.favoriteCount + 1
|
||||||
|
} : img
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 当分页变化时,保存当前浏览的页码
|
||||||
|
const handlePageChange = (page: number, pageSize: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
setPageSize(pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImagesLoaded = useCallback((loadedImages: PictureResponse[]) => {
|
||||||
|
if (images.length === 0) {
|
||||||
|
setImages(loadedImages);
|
||||||
|
}
|
||||||
|
}, [images.length]);
|
||||||
|
|
||||||
|
const sortMenu = {
|
||||||
|
items: [
|
||||||
|
{ key: 'takenAt_desc', label: '最新拍摄' },
|
||||||
|
{ key: 'takenAt_asc', label: '最早拍摄' },
|
||||||
|
{ key: 'uploadDate_desc', label: '最新收藏' },
|
||||||
|
{ key: 'uploadDate_asc', label: '最早收藏' },
|
||||||
|
{ key: 'name_asc', label: '名称 A-Z' },
|
||||||
|
{ key: 'name_desc', label: '名称 Z-A' },
|
||||||
|
],
|
||||||
|
onClick: ({ key }: { key: string }) => handleSortChange(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
marginBottom: 50,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontSize: 32,
|
||||||
|
background: 'linear-gradient(120deg, #000000, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}>收藏</Title>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<Dropdown menu={sortMenu} placement="bottomRight">
|
||||||
|
<Button style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
height: 46,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
padding: '0 24px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 15,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.02)',
|
||||||
|
background: '#ffffff'
|
||||||
|
}}>
|
||||||
|
<SortAscendingOutlined />
|
||||||
|
排序方式
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGrid
|
||||||
|
queryParams={queryParamsObject}
|
||||||
|
pageSize={pageSize}
|
||||||
|
defaultPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onToggleFavorite={handleToggleFavorite}
|
||||||
|
showFavoriteCount={true}
|
||||||
|
onImagesLoaded={handleImagesLoaded}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Favorites;
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Form, Input, Button, Checkbox, Typography, Row, Col, Divider, message } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined, GithubOutlined, GoogleOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, Link } from 'react-router';
|
||||||
|
import { login, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl } from '../../api';
|
||||||
|
import useIsMobile from '../../hooks/useIsMobile';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const Login: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (handleOAuthCallback()) {
|
||||||
|
message.success('GitHub登录成功!');
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const onFinish = async (values: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await login({
|
||||||
|
email: values.email,
|
||||||
|
password: values.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 保存认证信息
|
||||||
|
saveAuthData(response.data);
|
||||||
|
|
||||||
|
// 显示成功消息
|
||||||
|
message.success(response.message || '登录成功!');
|
||||||
|
|
||||||
|
// 跳转到首页
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
// 显示错误消息
|
||||||
|
message.error(response.message || '登录失败,请检查账号和密码');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录出错:', error);
|
||||||
|
message.error('登录过程中出现错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitHubLogin = () => {
|
||||||
|
window.location.href = getGitHubLoginUrl();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
{/* 左侧登录表单 */}
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
md={isMobile ? 24 : 12}
|
||||||
|
style={{
|
||||||
|
padding: isMobile ? '20px' : '40px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
maxWidth: isMobile ? '100%' : '650px',
|
||||||
|
margin: '0 auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: isMobile ? '100%' : '400px',
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: isMobile ? '40px' : 0
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '40px', textAlign: 'center' }}>
|
||||||
|
<Title level={2} style={{
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontWeight: 700,
|
||||||
|
background: 'linear-gradient(120deg, #18181b, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent'
|
||||||
|
}}>
|
||||||
|
欢迎回到 Foxel
|
||||||
|
</Title>
|
||||||
|
<Text style={{ fontSize: '16px', color: '#666' }}>
|
||||||
|
请登录您的账户以继续使用
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
name="login_form"
|
||||||
|
initialValues={{ remember: true }}
|
||||||
|
onFinish={onFinish}
|
||||||
|
size="large"
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
rules={[{ required: true, message: '请输入您的邮箱' }]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="邮箱"
|
||||||
|
style={{
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
border: '1px solid #eaeaea'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: '请输入您的密码' }]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="密码"
|
||||||
|
style={{
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
border: '1px solid #eaeaea'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<Checkbox name="remember">记住我</Checkbox>
|
||||||
|
<a href="#forgot" style={{ color: '#18181b' }}>忘记密码?</a>
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '16px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider plain style={{ color: '#999', fontSize: '14px' }}>
|
||||||
|
或使用以下方式登录
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px', margin: '20px 0' }}>
|
||||||
|
<Button
|
||||||
|
icon={<GithubOutlined />}
|
||||||
|
size="large"
|
||||||
|
shape="circle"
|
||||||
|
onClick={handleGitHubLogin}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f6f6f6',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<GoogleOutlined />}
|
||||||
|
size="large"
|
||||||
|
shape="circle"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f6f6f6',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '30px' }}>
|
||||||
|
<Text style={{ color: '#666' }}>
|
||||||
|
还没有账户? <Link to="/register" style={{ color: '#18181b', fontWeight: 500 }}>立即注册</Link>
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: '15px' }}>
|
||||||
|
<Link to="/anonymous" style={{ color: '#666' }}>
|
||||||
|
<Button type="link" style={{ padding: '0', fontWeight: 500 }}>匿名图床</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 右侧视觉区域 - 仅在非移动设备上显示 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Col md={12} style={{
|
||||||
|
background: 'linear-gradient(135deg, #18181b 0%, #444444 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '100vh'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '600px',
|
||||||
|
height: '600px',
|
||||||
|
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 70%)',
|
||||||
|
top: '20%',
|
||||||
|
left: '30%'
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '400px',
|
||||||
|
height: '400px',
|
||||||
|
background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0) 70%)',
|
||||||
|
bottom: '10%',
|
||||||
|
right: '20%'
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'white',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
maxWidth: '500px'
|
||||||
|
}}>
|
||||||
|
<Title level={2} style={{
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '25px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '1px'
|
||||||
|
}}>
|
||||||
|
图片管理新方式
|
||||||
|
</Title>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '30px'
|
||||||
|
}}>
|
||||||
|
Foxel 提供高效、直观的图片管理体验,让您轻松整理、查找和分享珍贵的视觉记忆。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 图片管理界面预览 */}
|
||||||
|
<div style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '300px',
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '20px',
|
||||||
|
boxShadow: '0 20px 40px rgba(0,0,0,0.3)',
|
||||||
|
backdropFilter: 'blur(20px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
left: '12px',
|
||||||
|
right: '12px',
|
||||||
|
height: '30px',
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
paddingLeft: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: 'rgba(255,255,255,0.6)' }} />
|
||||||
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: 'rgba(255,255,255,0.6)' }} />
|
||||||
|
<div style={{ width: '10px', height: '10px', borderRadius: '50%', background: 'rgba(255,255,255,0.6)' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
fontSize: '24px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: 'rgba(255,255,255,0.7)'
|
||||||
|
}}>
|
||||||
|
Foxel 界面预览
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Row,
|
||||||
|
Col,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
|
Space,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
FireOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import ImageGrid from '../../components/image/ImageGrid';
|
||||||
|
import type { PictureResponse } from '../../api/types';
|
||||||
|
import { getFilteredTags } from '../../api/tagApi';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
const { Search } = Input;
|
||||||
|
const { Option } = Select;
|
||||||
|
|
||||||
|
const PixHub: React.FC = () => {
|
||||||
|
const [activeCategory, setActiveCategory] = useState('全部');
|
||||||
|
const [sortBy, setSortBy] = useState('newest');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
const [categories, setCategories] = useState<string[]>(['全部']);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 获取热门标签
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTopTags = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await getFilteredTags({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 8, // 只获取8个最热门的标签
|
||||||
|
sortBy: 'pictureCount',
|
||||||
|
sortDirection: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data.length > 0) {
|
||||||
|
// 始终保持"全部"作为第一个选项,然后添加从API获取的标签
|
||||||
|
const tagNames = response.data.map(tag => tag.name);
|
||||||
|
setCategories(['全部', ...tagNames]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取热门标签失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTopTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 处理分页变化
|
||||||
|
const handlePageChange = (page: number, size: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
setPageSize(size);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理图片加载完成回调
|
||||||
|
const handleImagesLoaded = (_: PictureResponse[], total: number) => {
|
||||||
|
setTotalCount(total);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 构建查询参数
|
||||||
|
const queryParams = {
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
tags: activeCategory !== '全部' ? [activeCategory] : undefined,
|
||||||
|
sortBy: sortBy === 'popular' ? 'favoriteCount_desc' :
|
||||||
|
sortBy === 'newest' ? 'newest' :
|
||||||
|
'oldest',
|
||||||
|
includeAllPublic: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="image-square">
|
||||||
|
<div className="page-header" style={{ marginBottom: 32 }}>
|
||||||
|
<Row gutter={[24, 24]} align="middle">
|
||||||
|
<Col lg={10} md={12} sm={24} xs={24}>
|
||||||
|
<Title level={2} style={{ marginBottom: 8, fontWeight: 600 }}>
|
||||||
|
图片广场
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: 400, marginLeft: 12, color: '#8c8c8c' }}>
|
||||||
|
探索世界各地的精彩瞬间
|
||||||
|
</Text>
|
||||||
|
</Title>
|
||||||
|
<Paragraph style={{ color: '#666666' }}>
|
||||||
|
发现创作者分享的高质量摄影作品,获取灵感,找到你喜爱的风格
|
||||||
|
</Paragraph>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col lg={14} md={12} sm={24} xs={24}>
|
||||||
|
<Row gutter={[16, 16]} justify="end">
|
||||||
|
<Col lg={16} md={16} sm={16} xs={24}>
|
||||||
|
<Search
|
||||||
|
placeholder="搜索图片、标签或创作者"
|
||||||
|
allowClear
|
||||||
|
enterButton={<SearchOutlined />}
|
||||||
|
size="large"
|
||||||
|
onSearch={(value) => setSearchQuery(value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col lg={8} md={8} sm={8} xs={24}>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
size="large"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(value) => setSortBy(value)}
|
||||||
|
suffixIcon={<FilterOutlined />}
|
||||||
|
>
|
||||||
|
<Option value="popular">热门优先</Option>
|
||||||
|
<Option value="newest">最新发布</Option>
|
||||||
|
<Option value="oldest">最早发布</Option>
|
||||||
|
</Select>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="category-nav" style={{ marginBottom: 28, overflowX: 'auto' }}>
|
||||||
|
<Space size={[12, 20]} wrap style={{ justifyContent: 'center' }}>
|
||||||
|
{categories.map(category => (
|
||||||
|
<Button
|
||||||
|
key={category}
|
||||||
|
type={activeCategory === category ? "primary" : "default"}
|
||||||
|
shape="round"
|
||||||
|
size="large"
|
||||||
|
onClick={() => setActiveCategory(category)}
|
||||||
|
style={{
|
||||||
|
fontWeight: 500,
|
||||||
|
minWidth: 80,
|
||||||
|
boxShadow: activeCategory === category ? '0 4px 12px rgba(0,0,0,0.15)' : 'none',
|
||||||
|
}}
|
||||||
|
icon={category === '全部' ? <ThunderboltOutlined /> : null}
|
||||||
|
loading={category === '全部' ? loading : false}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider style={{ margin: '24px 0' }} />
|
||||||
|
|
||||||
|
<div className="results-info" style={{ marginBottom: 24 }}>
|
||||||
|
<Row justify="space-between" align="middle">
|
||||||
|
<Col>
|
||||||
|
<Text style={{ fontSize: 15 }}>
|
||||||
|
找到 <strong>{totalCount}</strong> 张图片
|
||||||
|
{activeCategory !== '全部' && <span> · {activeCategory}分类</span>}
|
||||||
|
{searchQuery && <span> · 搜索"{searchQuery}"</span>}
|
||||||
|
</Text>
|
||||||
|
</Col>
|
||||||
|
<Col>
|
||||||
|
<Space>
|
||||||
|
<FireOutlined style={{ color: '#ff4d4f' }} />
|
||||||
|
<Text type="secondary">热门推荐</Text>
|
||||||
|
</Space>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageGrid
|
||||||
|
queryParams={queryParams}
|
||||||
|
pageSize={pageSize}
|
||||||
|
defaultPage={currentPage}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
showFavoriteCount={true}
|
||||||
|
onImagesLoaded={handleImagesLoaded}
|
||||||
|
emptyText="未找到匹配的图片,请尝试更改搜索条件或选择其他分类"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PixHub;
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// 模拟图片数据
|
||||||
|
export const mockImages = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '湖畔日落',
|
||||||
|
url: 'https://images.unsplash.com/photo-1501785888041-af3ef285b470?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '风景',
|
||||||
|
tags: ['风景', '日落', '自然'],
|
||||||
|
likes: 342,
|
||||||
|
views: 1205,
|
||||||
|
favorited: false,
|
||||||
|
liked: true,
|
||||||
|
createdAt: '2023-09-15T10:30:00Z',
|
||||||
|
description: '湖边美丽的日落景色,金色的阳光洒在水面上,形成了壮观的景象。',
|
||||||
|
author: {
|
||||||
|
id: 101,
|
||||||
|
name: '王摄影',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '都市天际线',
|
||||||
|
url: 'https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '城市',
|
||||||
|
tags: ['城市', '建筑', '天际线'],
|
||||||
|
likes: 278,
|
||||||
|
views: 890,
|
||||||
|
favorited: true,
|
||||||
|
liked: false,
|
||||||
|
createdAt: '2023-10-02T14:45:00Z',
|
||||||
|
description: '现代城市的壮观天际线,高楼大厦在夕阳映衬下显得格外壮观。',
|
||||||
|
author: {
|
||||||
|
id: 102,
|
||||||
|
name: '城市猎影',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '山谷晨雾',
|
||||||
|
url: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '自然',
|
||||||
|
tags: ['山', '晨雾', '自然'],
|
||||||
|
likes: 432,
|
||||||
|
views: 1560,
|
||||||
|
favorited: false,
|
||||||
|
liked: false,
|
||||||
|
createdAt: '2023-08-05T06:15:00Z',
|
||||||
|
description: '清晨的山谷被薄雾笼罩,阳光穿透云层形成光束,构成一幅绝美的画面。',
|
||||||
|
author: {
|
||||||
|
id: 103,
|
||||||
|
name: '山野行者',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/men/76.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '意式咖啡',
|
||||||
|
url: 'https://images.unsplash.com/photo-1513558161293-cdaf765ed2fd?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '美食',
|
||||||
|
tags: ['咖啡', '美食', '生活'],
|
||||||
|
likes: 189,
|
||||||
|
views: 745,
|
||||||
|
favorited: true,
|
||||||
|
liked: true,
|
||||||
|
createdAt: '2023-10-12T09:20:00Z',
|
||||||
|
description: '精心制作的意式浓缩咖啡,香气四溢,是一天完美的开始。',
|
||||||
|
author: {
|
||||||
|
id: 104,
|
||||||
|
name: '美食家',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/women/91.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '森林小径',
|
||||||
|
url: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '自然',
|
||||||
|
tags: ['森林', '自然', '小径'],
|
||||||
|
likes: 256,
|
||||||
|
views: 920,
|
||||||
|
favorited: false,
|
||||||
|
liked: false,
|
||||||
|
createdAt: '2023-09-28T15:30:00Z',
|
||||||
|
description: '穿过茂密森林的小径,阳光透过树叶形成斑驳的光影效果。',
|
||||||
|
author: {
|
||||||
|
id: 105,
|
||||||
|
name: '自然探索者',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/men/55.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: '古典建筑',
|
||||||
|
url: 'https://images.unsplash.com/photo-1496568816309-51d7c20e3b21?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '建筑',
|
||||||
|
tags: ['建筑', '古典', '艺术'],
|
||||||
|
likes: 312,
|
||||||
|
views: 1105,
|
||||||
|
favorited: true,
|
||||||
|
liked: false,
|
||||||
|
createdAt: '2023-07-19T11:40:00Z',
|
||||||
|
description: '充满历史感的欧洲古典建筑,精美的细节和宏伟的设计令人惊叹。',
|
||||||
|
author: {
|
||||||
|
id: 106,
|
||||||
|
name: '建筑师',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/women/22.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: '夏日海滩',
|
||||||
|
url: 'https://images.unsplash.com/photo-1520454974749-611b7248ffdb?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '旅行',
|
||||||
|
tags: ['海滩', '旅行', '夏日'],
|
||||||
|
likes: 385,
|
||||||
|
views: 1320,
|
||||||
|
favorited: false,
|
||||||
|
liked: true,
|
||||||
|
createdAt: '2023-08-22T13:50:00Z',
|
||||||
|
description: '阳光明媚的夏日海滩,湛蓝的海水和金色的沙滩构成完美的度假胜地。',
|
||||||
|
author: {
|
||||||
|
id: 107,
|
||||||
|
name: '旅行摄影师',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/men/29.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: '雨后街景',
|
||||||
|
url: 'https://images.unsplash.com/photo-1520116468816-95b69f847357?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '城市',
|
||||||
|
tags: ['城市', '雨天', '生活'],
|
||||||
|
likes: 197,
|
||||||
|
views: 876,
|
||||||
|
favorited: true,
|
||||||
|
liked: true,
|
||||||
|
createdAt: '2023-10-05T18:25:00Z',
|
||||||
|
description: '雨后的城市街道,湿漉漉的路面反射着霓虹灯光,营造出独特的都市氛围。',
|
||||||
|
author: {
|
||||||
|
id: 108,
|
||||||
|
name: '城市观察者',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/women/67.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: '宁静湖泊',
|
||||||
|
url: 'https://images.unsplash.com/photo-1508739773434-c26b3d09e071?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '风景',
|
||||||
|
tags: ['湖泊', '风景', '宁静'],
|
||||||
|
likes: 274,
|
||||||
|
views: 945,
|
||||||
|
favorited: false,
|
||||||
|
liked: false,
|
||||||
|
createdAt: '2023-08-30T07:10:00Z',
|
||||||
|
description: '清晨宁静的湖泊,平静的湖面如同一面镜子,倒映着周围的山景和天空。',
|
||||||
|
author: {
|
||||||
|
id: 109,
|
||||||
|
name: '风景摄影家',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/men/42.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: '人像写真',
|
||||||
|
url: 'https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '人像',
|
||||||
|
tags: ['人像', '艺术', '写真'],
|
||||||
|
likes: 364,
|
||||||
|
views: 1230,
|
||||||
|
favorited: true,
|
||||||
|
liked: false,
|
||||||
|
createdAt: '2023-09-18T16:40:00Z',
|
||||||
|
description: '艺术风格的人像摄影,通过光影和构图展现主题的个性与魅力。',
|
||||||
|
author: {
|
||||||
|
id: 110,
|
||||||
|
name: '人像摄影师',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/women/33.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
title: '繁星夜空',
|
||||||
|
url: 'https://images.unsplash.com/photo-1489549132488-d00b7eee80f1?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '自然',
|
||||||
|
tags: ['夜空', '星星', '自然'],
|
||||||
|
likes: 412,
|
||||||
|
views: 1480,
|
||||||
|
favorited: false,
|
||||||
|
liked: true,
|
||||||
|
createdAt: '2023-07-28T22:15:00Z',
|
||||||
|
description: '壮丽的夜空星河,数以万计的星星点缀着黑色的天幕,令人叹为观止。',
|
||||||
|
author: {
|
||||||
|
id: 111,
|
||||||
|
name: '星空摄影师',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/men/18.jpg',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
title: '历史建筑',
|
||||||
|
url: 'https://images.unsplash.com/photo-1526378722484-bd91ca387e72?ixlib=rb-1.2.1&auto=format&fit=crop&w=1000&q=80',
|
||||||
|
category: '建筑',
|
||||||
|
tags: ['建筑', '历史', '艺术'],
|
||||||
|
likes: 287,
|
||||||
|
views: 1050,
|
||||||
|
favorited: true,
|
||||||
|
liked: false,
|
||||||
|
createdAt: '2023-08-12T10:05:00Z',
|
||||||
|
description: '充满历史感的古老建筑,每一块砖石都诉说着岁月的故事。',
|
||||||
|
author: {
|
||||||
|
id: 112,
|
||||||
|
name: '历史摄影',
|
||||||
|
avatar: 'https://randomuser.me/api/portraits/women/52.jpg',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Form, Input, Button, Checkbox, Typography, Row, Col, Divider, message } from 'antd';
|
||||||
|
import { UserOutlined, LockOutlined, MailOutlined, GithubOutlined, GoogleOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, Link } from 'react-router';
|
||||||
|
import { register, saveAuthData, isAuthenticated, handleOAuthCallback, getGitHubLoginUrl } from '../../api';
|
||||||
|
import useIsMobile from '../../hooks/useIsMobile';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const Register: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 处理可能的OAuth回调
|
||||||
|
if (handleOAuthCallback()) {
|
||||||
|
message.success('使用GitHub账号注册成功!');
|
||||||
|
navigate('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const onFinish = async (values: any) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await register({
|
||||||
|
username: values.username,
|
||||||
|
email: values.email,
|
||||||
|
password: values.password
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
// 保存认证信息
|
||||||
|
saveAuthData(response.data);
|
||||||
|
|
||||||
|
// 显示成功消息
|
||||||
|
message.success(response.message || '注册成功!');
|
||||||
|
|
||||||
|
// 跳转到首页
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
// 显示错误消息
|
||||||
|
message.error(response.message || '注册失败,请检查填写信息');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('注册出错:', error);
|
||||||
|
message.error('注册过程中出现错误,请稍后重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGitHubLogin = () => {
|
||||||
|
window.location.href = getGitHubLoginUrl();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
{/* 左侧注册表单 */}
|
||||||
|
<Col
|
||||||
|
xs={24}
|
||||||
|
md={isMobile ? 24 : 12}
|
||||||
|
style={{
|
||||||
|
padding: isMobile ? '20px' : '20px 40px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
maxWidth: isMobile ? '100%' : '650px',
|
||||||
|
margin: '0 auto'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: isMobile ? '100%' : '400px',
|
||||||
|
width: '100%',
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: isMobile ? '40px' : 0
|
||||||
|
}}>
|
||||||
|
<div style={{ marginBottom: '30px', textAlign: 'center' }}>
|
||||||
|
<Title level={2} style={{
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontWeight: 700,
|
||||||
|
background: 'linear-gradient(120deg, #18181b, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent'
|
||||||
|
}}>
|
||||||
|
创建 Foxel 账户
|
||||||
|
</Title>
|
||||||
|
<Text style={{ fontSize: '16px', color: '#666' }}>
|
||||||
|
开始您的图像管理之旅
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
name="register_form"
|
||||||
|
initialValues={{ agreement: true }}
|
||||||
|
onFinish={onFinish}
|
||||||
|
size="large"
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="username"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入您的用户名' },
|
||||||
|
{ min: 3, message: '用户名至少3个字符' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<UserOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="用户名"
|
||||||
|
style={{
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
border: '1px solid #eaeaea'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入您的电子邮箱' },
|
||||||
|
{ type: 'email', message: '请输入有效的电子邮箱地址' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
prefix={<MailOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="电子邮箱"
|
||||||
|
style={{
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
border: '1px solid #eaeaea'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="password"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入您的密码' },
|
||||||
|
{ min: 8, message: '密码至少8个字符' }
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="密码"
|
||||||
|
style={{
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
border: '1px solid #eaeaea'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="confirm"
|
||||||
|
dependencies={['password']}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请确认您的密码' },
|
||||||
|
({ getFieldValue }) => ({
|
||||||
|
validator(_, value) {
|
||||||
|
if (!value || getFieldValue('password') === value) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error('两次输入的密码不匹配'));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input.Password
|
||||||
|
prefix={<LockOutlined style={{ color: '#bfbfbf' }} />}
|
||||||
|
placeholder="确认密码"
|
||||||
|
style={{
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
border: '1px solid #eaeaea'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="agreement"
|
||||||
|
valuePropName="checked"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: (_, value) =>
|
||||||
|
value ? Promise.resolve() : Promise.reject(new Error('请阅读并同意服务条款和隐私政策'))
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Checkbox>
|
||||||
|
我已阅读并同意 <a href="#terms">服务条款</a> 和 <a href="#privacy">隐私政策</a>
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
htmlType="submit"
|
||||||
|
loading={loading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '50px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '16px',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Divider plain style={{ color: '#999', fontSize: '14px' }}>
|
||||||
|
或使用以下方式注册
|
||||||
|
</Divider>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '20px', margin: '20px 0' }}>
|
||||||
|
<Button
|
||||||
|
icon={<GithubOutlined />}
|
||||||
|
size="large"
|
||||||
|
shape="circle"
|
||||||
|
onClick={handleGitHubLogin}
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f6f6f6',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<GoogleOutlined />}
|
||||||
|
size="large"
|
||||||
|
shape="circle"
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#f6f6f6',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.06)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center', marginTop: '20px' }}>
|
||||||
|
<Text style={{ color: '#666' }}>
|
||||||
|
已经有账户? <Link to="/login" style={{ color: '#18181b', fontWeight: 500 }}>立即登录</Link>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* 右侧视觉区域 - 仅在非移动设备上显示 */}
|
||||||
|
{!isMobile && (
|
||||||
|
<Col md={12} style={{
|
||||||
|
background: 'linear-gradient(135deg, #18181b 0%, #444444 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: '100vh'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '500px',
|
||||||
|
height: '500px',
|
||||||
|
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 70%)',
|
||||||
|
bottom: '20%',
|
||||||
|
left: '10%'
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '300px',
|
||||||
|
height: '300px',
|
||||||
|
background: 'radial-gradient(circle, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0) 70%)',
|
||||||
|
top: '15%',
|
||||||
|
right: '15%'
|
||||||
|
}}></div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
color: 'white',
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 1,
|
||||||
|
maxWidth: '500px'
|
||||||
|
}}>
|
||||||
|
<Title level={2} style={{
|
||||||
|
color: 'white',
|
||||||
|
marginBottom: '25px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '1px'
|
||||||
|
}}>
|
||||||
|
加入 Foxel 社区
|
||||||
|
</Title>
|
||||||
|
<Text style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
lineHeight: '1.8',
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '30px'
|
||||||
|
}}>
|
||||||
|
注册账户后,您可以享受无缝的图片管理体验,包括云端存储、智能相册分类和高级分享功能。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* 特性列表 */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '20px',
|
||||||
|
marginTop: '30px',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||||
|
padding: '30px',
|
||||||
|
borderRadius: '20px',
|
||||||
|
boxShadow: '0 15px 35px rgba(0,0,0,0.2)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '18px'
|
||||||
|
}}>✓</div>
|
||||||
|
<Text style={{ color: 'white', fontSize: '16px', textAlign: 'left' }}>
|
||||||
|
无限云存储空间
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '18px'
|
||||||
|
}}>✓</div>
|
||||||
|
<Text style={{ color: 'white', fontSize: '16px', textAlign: 'left' }}>
|
||||||
|
AI 智能相册分类
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '40px',
|
||||||
|
height: '40px',
|
||||||
|
borderRadius: '10px',
|
||||||
|
background: 'rgba(255,255,255,0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '18px'
|
||||||
|
}}>✓</div>
|
||||||
|
<Text style={{ color: 'white', fontSize: '16px', textAlign: 'left' }}>
|
||||||
|
跨设备同步与访问
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Register;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Form, Input, Button, Space, Row, Col, Tooltip } from 'antd';
|
||||||
|
import { SaveOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface ConfigGroupProps {
|
||||||
|
groupName: string;
|
||||||
|
configs: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
onSave: (group: string, key: string, value: string) => Promise<void>;
|
||||||
|
descriptions: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConfigGroup: React.FC<ConfigGroupProps> = ({
|
||||||
|
groupName,
|
||||||
|
configs,
|
||||||
|
onSave,
|
||||||
|
descriptions
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// 保存单个配置项
|
||||||
|
const handleSaveSingle = async (key: string) => {
|
||||||
|
try {
|
||||||
|
const value = form.getFieldValue(key);
|
||||||
|
await onSave(groupName, key, value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存配置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存所有配置项
|
||||||
|
const handleSaveAll = async () => {
|
||||||
|
try {
|
||||||
|
const values = form.getFieldsValue();
|
||||||
|
for (const key in values) {
|
||||||
|
await onSave(groupName, key, values[key]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存所有配置失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={configs}
|
||||||
|
>
|
||||||
|
{Object.keys(configs).map(key => (
|
||||||
|
<Row key={key} gutter={16} align="middle">
|
||||||
|
<Col xs={24} lg={16}>
|
||||||
|
<Form.Item
|
||||||
|
name={key}
|
||||||
|
label={
|
||||||
|
<Space>
|
||||||
|
{key}
|
||||||
|
{descriptions[key] && (
|
||||||
|
<Tooltip title={descriptions[key]}>
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{key.toLowerCase().includes('secret') || key.toLowerCase().includes('key') || key.toLowerCase().includes('password') ? (
|
||||||
|
<Input.Password placeholder={`请输入${key}`} />
|
||||||
|
) : (
|
||||||
|
<Input placeholder={`请输入${key}`} />
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={8} style={{ textAlign: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<SaveOutlined />}
|
||||||
|
onClick={() => handleSaveSingle(key)}
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
block
|
||||||
|
>
|
||||||
|
保存所有
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigGroup;
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { Tabs, Layout, Menu } from 'antd';
|
||||||
|
import { useAuth } from '../../api/AuthContext';
|
||||||
|
import { UserRole } from '../../api/types';
|
||||||
|
import { useState, type SetStateAction } from 'react';
|
||||||
|
import SystemConfig from './SystemConfig.tsx';
|
||||||
|
import UserProfile from './UserProfile.tsx';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
BgColorsOutlined,
|
||||||
|
BellOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
const { TabPane } = Tabs;
|
||||||
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
|
function Settings() {
|
||||||
|
const { hasRole } = useAuth();
|
||||||
|
const [activeMenu, setActiveMenu] = useState('profile');
|
||||||
|
const [activeTab, setActiveTab] = useState('basic');
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activeMenu) {
|
||||||
|
case 'profile':
|
||||||
|
return (
|
||||||
|
<div className="settings-content">
|
||||||
|
<UserProfile />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'system':
|
||||||
|
return (
|
||||||
|
<div className="settings-content">
|
||||||
|
<SystemConfig />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'appearance':
|
||||||
|
return (
|
||||||
|
<div className="settings-content">
|
||||||
|
<Tabs activeKey={activeTab} onChange={setActiveTab} className="horizontal-tabs">
|
||||||
|
<TabPane tab="主题" key="theme">
|
||||||
|
<div>主题设置</div>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="布局" key="layout">
|
||||||
|
<div>布局设置</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'notifications':
|
||||||
|
return (
|
||||||
|
<div className="settings-content">
|
||||||
|
<Tabs activeKey={activeTab} onChange={setActiveTab} className="horizontal-tabs">
|
||||||
|
<TabPane tab="通知类型" key="types">
|
||||||
|
<div>通知类型设置</div>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="提醒方式" key="methods">
|
||||||
|
<div>提醒方式设置</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 菜单项配置
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
icon: <UserOutlined />,
|
||||||
|
label: '个人资料',
|
||||||
|
},
|
||||||
|
hasRole(UserRole.Administrator) ? {
|
||||||
|
key: 'system',
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
label: '系统配置',
|
||||||
|
} : null,
|
||||||
|
{
|
||||||
|
key: 'appearance',
|
||||||
|
icon: <BgColorsOutlined />,
|
||||||
|
label: '外观设置',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'notifications',
|
||||||
|
icon: <BellOutlined />,
|
||||||
|
label: '通知设置',
|
||||||
|
},
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const handleMenuChange = (key: SetStateAction<string>) => {
|
||||||
|
setActiveMenu(key);
|
||||||
|
switch (key) {
|
||||||
|
case 'profile':
|
||||||
|
setActiveTab('basic');
|
||||||
|
break;
|
||||||
|
case 'system':
|
||||||
|
setActiveTab('basic');
|
||||||
|
break;
|
||||||
|
case 'appearance':
|
||||||
|
setActiveTab('theme');
|
||||||
|
break;
|
||||||
|
case 'notifications':
|
||||||
|
setActiveTab('types');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout style={{
|
||||||
|
background: '#fff',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<Sider width={200} style={{ background: '#f5f5f5' }}>
|
||||||
|
<Menu
|
||||||
|
mode="vertical"
|
||||||
|
selectedKeys={[activeMenu]}
|
||||||
|
style={{ height: '100%', borderRight: 0 }}
|
||||||
|
items={menuItems}
|
||||||
|
onClick={({ key }) => handleMenuChange(key)}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<Content style={{ minHeight: 500 }}>
|
||||||
|
{renderContent()}
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings;
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Tabs, Card, message, Spin } from 'antd';
|
||||||
|
import { getAllConfigs, setConfig } from '../../api';
|
||||||
|
import ConfigGroup from './ConfigGroup.tsx';
|
||||||
|
|
||||||
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
interface ConfigStructure {
|
||||||
|
[key: string]: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const SystemConfig: React.FC = () => {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [configs, setConfigs] = useState<ConfigStructure>({});
|
||||||
|
const [activeKey, setActiveKey] = useState('AI');
|
||||||
|
|
||||||
|
// 获取所有配置项
|
||||||
|
const fetchConfigs = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await getAllConfigs();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
const configGroups: ConfigStructure = {};
|
||||||
|
response.data.forEach(config => {
|
||||||
|
const [group, key] = config.key.split(':');
|
||||||
|
if (!configGroups[group]) {
|
||||||
|
configGroups[group] = {};
|
||||||
|
}
|
||||||
|
configGroups[group][key] = config.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfigs(configGroups);
|
||||||
|
} else {
|
||||||
|
message.error('获取配置失败: ' + response.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取配置出错');
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存配置项
|
||||||
|
const handleSaveConfig = async (group: string, key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
const configKey = `${group}:${key}`;
|
||||||
|
const response = await setConfig({
|
||||||
|
key: configKey,
|
||||||
|
value: value,
|
||||||
|
description: `${group} ${key} setting`
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
message.success(`保存 ${key} 配置成功`);
|
||||||
|
// 更新本地状态
|
||||||
|
setConfigs(prev => ({
|
||||||
|
...prev,
|
||||||
|
[group]: {
|
||||||
|
...prev[group],
|
||||||
|
[key]: value
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
message.error(`保存失败: ${response.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error('保存配置出错');
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfigs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="系统配置" className="system-config-card">
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
<Spin tip="加载配置中..." />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tabs activeKey={activeKey} onChange={setActiveKey}>
|
||||||
|
<TabPane tab="AI 设置" key="AI">
|
||||||
|
<ConfigGroup
|
||||||
|
groupName="AI"
|
||||||
|
configs={{
|
||||||
|
ApiEndpoint: configs.AI?.ApiEndpoint || '',
|
||||||
|
ApiKey: configs.AI?.ApiKey || '',
|
||||||
|
Model: configs.AI?.Model || '',
|
||||||
|
EmbeddingModel: configs.AI?.EmbeddingModel || ''
|
||||||
|
}}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
descriptions={{
|
||||||
|
ApiEndpoint: 'AI 服务的API端点地址',
|
||||||
|
ApiKey: 'AI 服务的API密钥',
|
||||||
|
Model: 'AI 模型名称',
|
||||||
|
EmbeddingModel: '嵌入向量模型名称'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<TabPane tab="JWT 设置" key="Jwt">
|
||||||
|
<ConfigGroup
|
||||||
|
groupName="Jwt"
|
||||||
|
configs={{
|
||||||
|
SecretKey: configs.Jwt?.SecretKey || '',
|
||||||
|
Issuer: configs.Jwt?.Issuer || '',
|
||||||
|
Audience: configs.Jwt?.Audience || '',
|
||||||
|
}}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
descriptions={{
|
||||||
|
SecretKey: 'JWT 加密密钥',
|
||||||
|
Issuer: 'JWT 签发者',
|
||||||
|
Audience: 'JWT 接收者',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<TabPane tab="应用设置" key="AppSettings">
|
||||||
|
<ConfigGroup
|
||||||
|
groupName="AppSettings"
|
||||||
|
configs={{
|
||||||
|
ServerUrl: configs.AppSettings?.ServerUrl || ''
|
||||||
|
}}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
descriptions={{
|
||||||
|
ServerUrl: '服务器URL'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<TabPane tab="GitHub认证" key="Authentication">
|
||||||
|
<ConfigGroup
|
||||||
|
groupName="Authentication"
|
||||||
|
configs={{
|
||||||
|
"GitHubClientId": configs.Authentication?.["GitHubClientId"] || '',
|
||||||
|
"GitHubClientSecret": configs.Authentication?.["GitHubClientSecret"] || ''
|
||||||
|
}}
|
||||||
|
onSave={(_group, key, value) => handleSaveConfig('Authentication', key, value)}
|
||||||
|
descriptions={{
|
||||||
|
"GitHubClientId": 'GitHub OAuth 应用客户端ID',
|
||||||
|
"GitHubClientSecret": 'GitHub OAuth 应用客户端密钥'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
|
|
||||||
|
<TabPane tab="存储设置" key="Storage">
|
||||||
|
<ConfigGroup
|
||||||
|
groupName="Storage"
|
||||||
|
configs={{
|
||||||
|
"TelegramStorageBotToken": configs.Storage?.["TelegramStorageBotToken"] || '',
|
||||||
|
"TelegramStorageChatId": configs.Storage?.["TelegramStorageChatId"] || ''
|
||||||
|
}}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
descriptions={{
|
||||||
|
"TelegramStorageBotToken": 'Telegram 机器人令牌',
|
||||||
|
"TelegramStorageChatId": 'Telegram 聊天ID'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SystemConfig;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, Form, Input, Button } from 'antd';
|
||||||
|
import { useAuth } from '../../api/AuthContext';
|
||||||
|
import UserAvatar from '../../components/UserAvatar';
|
||||||
|
|
||||||
|
const UserProfile: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="个人资料" style={{ maxWidth: 600 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: 24 }}>
|
||||||
|
<UserAvatar
|
||||||
|
size={100}
|
||||||
|
email={user?.email}
|
||||||
|
text={user?.userName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Form layout="vertical" initialValues={{
|
||||||
|
username: user?.userName || '',
|
||||||
|
email: user?.email || '',
|
||||||
|
}}>
|
||||||
|
<Form.Item name="username" label="用户名">
|
||||||
|
<Input placeholder="用户名" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="email" label="邮箱">
|
||||||
|
<Input placeholder="邮箱" disabled />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="password" label="更新密码">
|
||||||
|
<Input.Password placeholder="留空则不更改" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item name="confirmPassword" label="确认新密码">
|
||||||
|
<Input.Password placeholder="确认新密码" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary">保存更改</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserProfile;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
const { Title } = Typography;
|
||||||
|
|
||||||
|
function Upload() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={2} style={{
|
||||||
|
margin: 0,
|
||||||
|
marginBottom: 20,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: '0.5px',
|
||||||
|
fontSize: 32,
|
||||||
|
background: 'linear-gradient(120deg, #000000, #444444)',
|
||||||
|
WebkitBackgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent',
|
||||||
|
}}>上传图片</Title>
|
||||||
|
{/* 上传表单 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Upload;
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user