initialize project structure with View

This commit is contained in:
shiyu
2025-05-18 20:49:39 +08:00
parent cde2c7b997
commit b65f1e4bbb
61 changed files with 9346 additions and 1 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,5 @@ dist-ssr
/bin
/obj
/Uploads
/View
/appsettings.Development.json
/Foxel.sln.DotSettings.user

643
View/bun.lock Normal file
View File

@@ -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=="],
}
}

28
View/eslint.config.js Normal file
View File

@@ -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 },
],
},
},
)

13
View/index.html Normal file
View File

@@ -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>

38
View/nginx.conf Normal file
View File

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

36
View/package.json Normal file
View File

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

BIN
View/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

BIN
View/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

4
View/src/App.css Normal file
View File

@@ -0,0 +1,4 @@
@import "https://chinese-fonts-cdn.deno.dev/packages/maple-mono-cn/dist/MapleMono-CN-Regular/result.css";
body{
margin: 0;
}

73
View/src/App.tsx Normal file
View File

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

View File

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

117
View/src/api/albumApi.ts Normal file
View File

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

155
View/src/api/authApi.ts Normal file
View File

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

View File

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

74
View/src/api/configApi.ts Normal file
View File

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

View File

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

55
View/src/api/index.ts Normal file
View File

@@ -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';

198
View/src/api/pictureApi.ts Normal file
View File

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

111
View/src/api/tagApi.ts Normal file
View File

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

200
View/src/api/types.ts Normal file
View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: `![${imageName}](${imageUrl})`,
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
View/src/main.tsx Normal file
View File

@@ -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 />
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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',
}
}
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
View/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

27
View/tsconfig.app.json Normal file
View File

@@ -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"]
}

7
View/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
View/tsconfig.node.json Normal file
View File

@@ -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"]
}

6
View/vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})