mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-12 02:20:28 +08:00
initialize project structure with View
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,5 @@ dist-ssr
|
||||
/bin
|
||||
/obj
|
||||
/Uploads
|
||||
/View
|
||||
/appsettings.Development.json
|
||||
/Foxel.sln.DotSettings.user
|
||||
643
View/bun.lock
Normal file
643
View/bun.lock
Normal 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
28
View/eslint.config.js
Normal 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
13
View/index.html
Normal 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
38
View/nginx.conf
Normal 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
36
View/package.json
Normal 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
BIN
View/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
View/public/images/unavailable.gif
Normal file
BIN
View/public/images/unavailable.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
BIN
View/public/logo.png
Normal file
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
4
View/src/App.css
Normal 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
73
View/src/App.tsx
Normal 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;
|
||||
108
View/src/api/AuthContext.tsx
Normal file
108
View/src/api/AuthContext.tsx
Normal 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
117
View/src/api/albumApi.ts
Normal 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
155
View/src/api/authApi.ts
Normal 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`;
|
||||
}
|
||||
17
View/src/api/backgroundTaskApi.ts
Normal file
17
View/src/api/backgroundTaskApi.ts
Normal 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
74
View/src/api/configApi.ts
Normal 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;
|
||||
};
|
||||
31
View/src/api/fetchClient.ts
Normal file
31
View/src/api/fetchClient.ts
Normal 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
55
View/src/api/index.ts
Normal 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
198
View/src/api/pictureApi.ts
Normal 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
111
View/src/api/tagApi.ts
Normal 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
200
View/src/api/types.ts
Normal 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
|
||||
};
|
||||
1
View/src/assets/react.svg
Normal file
1
View/src/assets/react.svg
Normal 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 |
91
View/src/components/TaskProgressBar.tsx
Normal file
91
View/src/components/TaskProgressBar.tsx
Normal 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;
|
||||
57
View/src/components/UserAvatar.tsx
Normal file
57
View/src/components/UserAvatar.tsx
Normal 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;
|
||||
270
View/src/components/image/ImageGrid.css
Normal file
270
View/src/components/image/ImageGrid.css
Normal 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;
|
||||
}
|
||||
}
|
||||
693
View/src/components/image/ImageGrid.tsx
Normal file
693
View/src/components/image/ImageGrid.tsx
Normal 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;
|
||||
420
View/src/components/image/ImageInfo.tsx
Normal file
420
View/src/components/image/ImageInfo.tsx
Normal 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;
|
||||
353
View/src/components/image/ImageViewer.css
Normal file
353
View/src/components/image/ImageViewer.css
Normal 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;
|
||||
}
|
||||
473
View/src/components/image/ImageViewer.tsx
Normal file
473
View/src/components/image/ImageViewer.tsx
Normal 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;
|
||||
72
View/src/components/image/ShareImageDialog.css
Normal file
72
View/src/components/image/ShareImageDialog.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
168
View/src/components/image/ShareImageDialog.tsx
Normal file
168
View/src/components/image/ShareImageDialog.tsx
Normal 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: ``,
|
||||
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;
|
||||
100
View/src/components/search/SearchDialog.css
Normal file
100
View/src/components/search/SearchDialog.css
Normal 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;
|
||||
}
|
||||
}
|
||||
304
View/src/components/search/SearchDialog.tsx
Normal file
304
View/src/components/search/SearchDialog.tsx
Normal 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;
|
||||
396
View/src/components/upload/ImageUploadDialog.tsx
Normal file
396
View/src/components/upload/ImageUploadDialog.tsx
Normal 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;
|
||||
126
View/src/config/routeConfig.tsx
Normal file
126
View/src/config/routeConfig.tsx
Normal 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;
|
||||
32
View/src/hooks/useIsMobile.ts
Normal file
32
View/src/hooks/useIsMobile.ts
Normal 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;
|
||||
179
View/src/layouts/MainLayout.tsx
Normal file
179
View/src/layouts/MainLayout.tsx
Normal 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;
|
||||
34
View/src/layouts/components/Footer.tsx
Normal file
34
View/src/layouts/components/Footer.tsx
Normal 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;
|
||||
193
View/src/layouts/components/Header.tsx
Normal file
193
View/src/layouts/components/Header.tsx
Normal 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;
|
||||
173
View/src/layouts/components/Sidebar.tsx
Normal file
173
View/src/layouts/components/Sidebar.tsx
Normal 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
6
View/src/main.tsx
Normal 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 />
|
||||
)
|
||||
375
View/src/pages/albumDetail/Index.tsx
Normal file
375
View/src/pages/albumDetail/Index.tsx
Normal 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;
|
||||
335
View/src/pages/albums/Index.tsx
Normal file
335
View/src/pages/albums/Index.tsx
Normal 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;
|
||||
154
View/src/pages/allImages/Index.tsx
Normal file
154
View/src/pages/allImages/Index.tsx
Normal 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;
|
||||
424
View/src/pages/anonymous/Index.tsx
Normal file
424
View/src/pages/anonymous/Index.tsx
Normal 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;
|
||||
252
View/src/pages/backgroundTasks/Index.tsx
Normal file
252
View/src/pages/backgroundTasks/Index.tsx
Normal 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;
|
||||
129
View/src/pages/favorites/Index.tsx
Normal file
129
View/src/pages/favorites/Index.tsx
Normal 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;
|
||||
305
View/src/pages/login/Index.tsx
Normal file
305
View/src/pages/login/Index.tsx
Normal 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;
|
||||
187
View/src/pages/pixHub/Index.tsx
Normal file
187
View/src/pages/pixHub/Index.tsx
Normal 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;
|
||||
219
View/src/pages/pixHub/mockData.ts
Normal file
219
View/src/pages/pixHub/mockData.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
];
|
||||
383
View/src/pages/register/Index.tsx
Normal file
383
View/src/pages/register/Index.tsx
Normal 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;
|
||||
102
View/src/pages/settings/ConfigGroup.tsx
Normal file
102
View/src/pages/settings/ConfigGroup.tsx
Normal 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;
|
||||
132
View/src/pages/settings/Index.tsx
Normal file
132
View/src/pages/settings/Index.tsx
Normal 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;
|
||||
171
View/src/pages/settings/SystemConfig.tsx
Normal file
171
View/src/pages/settings/SystemConfig.tsx
Normal 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;
|
||||
47
View/src/pages/settings/UserProfile.tsx
Normal file
47
View/src/pages/settings/UserProfile.tsx
Normal 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;
|
||||
23
View/src/pages/upload/Index.tsx
Normal file
23
View/src/pages/upload/Index.tsx
Normal 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
1
View/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
View/tsconfig.app.json
Normal file
27
View/tsconfig.app.json
Normal 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
7
View/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
25
View/tsconfig.node.json
Normal file
25
View/tsconfig.node.json
Normal 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
6
View/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Reference in New Issue
Block a user