Compare commits
2 Commits
1a058a31c8
...
afc68bf861
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc68bf861 | ||
|
|
194ba376d6 |
60
package-lock.json
generated
60
package-lock.json
generated
@ -85,6 +85,7 @@
|
|||||||
"version": "7.22.6",
|
"version": "7.22.6",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.22.6.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.22.6.tgz",
|
||||||
"integrity": "sha512-HPIyDa6n+HKw5dEuway3vVAhBboYCtREBMp+IWeseZy6TFtzn6MHkCH2KKYUOC/vKKwgSMHQW4htBOrmuRPXfw==",
|
"integrity": "sha512-HPIyDa6n+HKw5dEuway3vVAhBboYCtREBMp+IWeseZy6TFtzn6MHkCH2KKYUOC/vKKwgSMHQW4htBOrmuRPXfw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.22.5",
|
"@babel/code-frame": "^7.22.5",
|
||||||
@ -715,6 +716,7 @@
|
|||||||
"version": "7.22.5",
|
"version": "7.22.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.22.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.22.5.tgz",
|
||||||
"integrity": "sha512-9RdCl0i+q0QExayk2nOS7853w08yLucnnPML6EN9S8fgMPVtdLDCdx/cOQ/i44Lb9UeQX9A35yaqBBOMMZxPxQ==",
|
"integrity": "sha512-9RdCl0i+q0QExayk2nOS7853w08yLucnnPML6EN9S8fgMPVtdLDCdx/cOQ/i44Lb9UeQX9A35yaqBBOMMZxPxQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-plugin-utils": "^7.22.5"
|
"@babel/helper-plugin-utils": "^7.22.5"
|
||||||
},
|
},
|
||||||
@ -1524,6 +1526,7 @@
|
|||||||
"version": "7.22.5",
|
"version": "7.22.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.22.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.22.5.tgz",
|
||||||
"integrity": "sha512-rog5gZaVbUip5iWDMTYbVM15XQq+RkUKhET/IHR6oizR+JEoN6CAfTTuHcK4vwUyzca30qqHqEpzBOnaRMWYMA==",
|
"integrity": "sha512-rog5gZaVbUip5iWDMTYbVM15XQq+RkUKhET/IHR6oizR+JEoN6CAfTTuHcK4vwUyzca30qqHqEpzBOnaRMWYMA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-annotate-as-pure": "^7.22.5",
|
"@babel/helper-annotate-as-pure": "^7.22.5",
|
||||||
"@babel/helper-module-imports": "^7.22.5",
|
"@babel/helper-module-imports": "^7.22.5",
|
||||||
@ -2256,6 +2259,7 @@
|
|||||||
"version": "11.11.1",
|
"version": "11.11.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@emotion/react/-/react-11.11.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@emotion/react/-/react-11.11.1.tgz",
|
||||||
"integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==",
|
"integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.11.0",
|
"@emotion/babel-plugin": "^11.11.0",
|
||||||
@ -2296,6 +2300,7 @@
|
|||||||
"version": "11.11.0",
|
"version": "11.11.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@emotion/styled/-/styled-11.11.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@emotion/styled/-/styled-11.11.0.tgz",
|
||||||
"integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==",
|
"integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.11.0",
|
"@emotion/babel-plugin": "^11.11.0",
|
||||||
@ -3221,6 +3226,7 @@
|
|||||||
"version": "5.13.7",
|
"version": "5.13.7",
|
||||||
"resolved": "https://registry.npmmirror.com/@mui/material/-/material-5.13.7.tgz",
|
"resolved": "https://registry.npmmirror.com/@mui/material/-/material-5.13.7.tgz",
|
||||||
"integrity": "sha512-+n453jDDm88zZM3b5YK29nZ7gXY+s+rryH9ovDbhmfSkOlFtp+KSqbXy5cTaC/UlDqDM7sYYJGq8BmJov3v9Tg==",
|
"integrity": "sha512-+n453jDDm88zZM3b5YK29nZ7gXY+s+rryH9ovDbhmfSkOlFtp+KSqbXy5cTaC/UlDqDM7sYYJGq8BmJov3v9Tg==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.22.5",
|
"@babel/runtime": "^7.22.5",
|
||||||
"@mui/base": "5.0.0-beta.6",
|
"@mui/base": "5.0.0-beta.6",
|
||||||
@ -3778,7 +3784,6 @@
|
|||||||
"version": "9.3.1",
|
"version": "9.3.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-9.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-9.3.1.tgz",
|
||||||
"integrity": "sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==",
|
"integrity": "sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.10.4",
|
"@babel/code-frame": "^7.10.4",
|
||||||
"@babel/runtime": "^7.12.5",
|
"@babel/runtime": "^7.12.5",
|
||||||
@ -3797,7 +3802,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
},
|
},
|
||||||
@ -3809,7 +3813,6 @@
|
|||||||
"version": "5.1.3",
|
"version": "5.1.3",
|
||||||
"resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.1.3.tgz",
|
"resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.1.3.tgz",
|
||||||
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
|
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"deep-equal": "^2.0.5"
|
"deep-equal": "^2.0.5"
|
||||||
}
|
}
|
||||||
@ -3818,7 +3821,6 @@
|
|||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz",
|
||||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.1.0",
|
"ansi-styles": "^4.1.0",
|
||||||
"supports-color": "^7.1.0"
|
"supports-color": "^7.1.0"
|
||||||
@ -3831,7 +3833,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
},
|
},
|
||||||
@ -3842,14 +3843,12 @@
|
|||||||
"node_modules/@testing-library/dom/node_modules/color-name": {
|
"node_modules/@testing-library/dom/node_modules/color-name": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/dom/node_modules/has-flag": {
|
"node_modules/@testing-library/dom/node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@ -3858,7 +3857,6 @@
|
|||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-flag": "^4.0.0"
|
"has-flag": "^4.0.0"
|
||||||
},
|
},
|
||||||
@ -4526,6 +4524,7 @@
|
|||||||
"version": "18.2.14",
|
"version": "18.2.14",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.2.14.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.2.14.tgz",
|
||||||
"integrity": "sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==",
|
"integrity": "sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"@types/scheduler": "*",
|
"@types/scheduler": "*",
|
||||||
@ -4657,6 +4656,7 @@
|
|||||||
"version": "5.61.0",
|
"version": "5.61.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz",
|
||||||
"integrity": "sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==",
|
"integrity": "sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.4.0",
|
"@eslint-community/regexpp": "^4.4.0",
|
||||||
"@typescript-eslint/scope-manager": "5.61.0",
|
"@typescript-eslint/scope-manager": "5.61.0",
|
||||||
@ -4700,6 +4700,7 @@
|
|||||||
"version": "5.61.0",
|
"version": "5.61.0",
|
||||||
"resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-5.61.0.tgz",
|
"resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-5.61.0.tgz",
|
||||||
"integrity": "sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==",
|
"integrity": "sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "5.61.0",
|
"@typescript-eslint/scope-manager": "5.61.0",
|
||||||
"@typescript-eslint/types": "5.61.0",
|
"@typescript-eslint/types": "5.61.0",
|
||||||
@ -4997,6 +4998,7 @@
|
|||||||
"version": "8.9.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.9.0.tgz",
|
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.9.0.tgz",
|
||||||
"integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
|
"integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@ -5083,6 +5085,7 @@
|
|||||||
"version": "6.12.6",
|
"version": "6.12.6",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@ -5813,6 +5816,7 @@
|
|||||||
"version": "4.21.9",
|
"version": "4.21.9",
|
||||||
"resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.21.9.tgz",
|
"resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.21.9.tgz",
|
||||||
"integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==",
|
"integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001503",
|
"caniuse-lite": "^1.0.30001503",
|
||||||
"electron-to-chromium": "^1.4.431",
|
"electron-to-chromium": "^1.4.431",
|
||||||
@ -5909,9 +5913,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001512",
|
"version": "1.0.30001754",
|
||||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001512.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
|
||||||
"integrity": "sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw=="
|
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/browserslist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
"node_modules/case-sensitive-paths-webpack-plugin": {
|
"node_modules/case-sensitive-paths-webpack-plugin": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
@ -6388,6 +6407,7 @@
|
|||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
||||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
@ -7236,6 +7256,7 @@
|
|||||||
"version": "8.44.0",
|
"version": "8.44.0",
|
||||||
"resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.44.0.tgz",
|
"resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.44.0.tgz",
|
||||||
"integrity": "sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==",
|
"integrity": "sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.4.0",
|
"@eslint-community/regexpp": "^4.4.0",
|
||||||
@ -7614,6 +7635,7 @@
|
|||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
||||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
@ -9606,6 +9628,7 @@
|
|||||||
"version": "27.5.1",
|
"version": "27.5.1",
|
||||||
"resolved": "https://registry.npmmirror.com/jest/-/jest-27.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/jest/-/jest-27.5.1.tgz",
|
||||||
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
|
"integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "^27.5.1",
|
"@jest/core": "^27.5.1",
|
||||||
"import-local": "^3.0.2",
|
"import-local": "^3.0.2",
|
||||||
@ -11859,6 +11882,7 @@
|
|||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
||||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
@ -12557,6 +12581,7 @@
|
|||||||
"version": "8.4.24",
|
"version": "8.4.24",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.24.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.24.tgz",
|
||||||
"integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
|
"integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.6",
|
"nanoid": "^3.3.6",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
@ -13525,6 +13550,7 @@
|
|||||||
"version": "6.0.13",
|
"version": "6.0.13",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz",
|
||||||
"integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
|
"integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssesc": "^3.0.0",
|
"cssesc": "^3.0.0",
|
||||||
"util-deprecate": "^1.0.2"
|
"util-deprecate": "^1.0.2"
|
||||||
@ -13835,6 +13861,7 @@
|
|||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react/-/react-18.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react/-/react-18.2.0.tgz",
|
||||||
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
"integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -13970,6 +13997,7 @@
|
|||||||
"version": "18.2.0",
|
"version": "18.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz",
|
||||||
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
"integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.0"
|
"scheduler": "^0.23.0"
|
||||||
@ -14012,6 +14040,7 @@
|
|||||||
"version": "0.11.0",
|
"version": "0.11.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.11.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.11.0.tgz",
|
||||||
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
|
"integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@ -14451,6 +14480,7 @@
|
|||||||
"version": "2.79.1",
|
"version": "2.79.1",
|
||||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-2.79.1.tgz",
|
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-2.79.1.tgz",
|
||||||
"integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==",
|
"integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"rollup": "dist/bin/rollup"
|
"rollup": "dist/bin/rollup"
|
||||||
},
|
},
|
||||||
@ -15734,6 +15764,7 @@
|
|||||||
"version": "0.21.3",
|
"version": "0.21.3",
|
||||||
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz",
|
"resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz",
|
||||||
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
@ -16024,6 +16055,7 @@
|
|||||||
"version": "5.88.1",
|
"version": "5.88.1",
|
||||||
"resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.88.1.tgz",
|
"resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.88.1.tgz",
|
||||||
"integrity": "sha512-FROX3TxQnC/ox4N+3xQoWZzvGXSuscxR32rbzjpXgEzWudJFEJBpdlkkob2ylrv5yzzufD1zph1OoFsLtm6stQ==",
|
"integrity": "sha512-FROX3TxQnC/ox4N+3xQoWZzvGXSuscxR32rbzjpXgEzWudJFEJBpdlkkob2ylrv5yzzufD1zph1OoFsLtm6stQ==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/eslint-scope": "^3.7.3",
|
"@types/eslint-scope": "^3.7.3",
|
||||||
"@types/estree": "^1.0.0",
|
"@types/estree": "^1.0.0",
|
||||||
@ -16084,6 +16116,7 @@
|
|||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
||||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
@ -16125,6 +16158,7 @@
|
|||||||
"version": "4.15.1",
|
"version": "4.15.1",
|
||||||
"resolved": "https://registry.npmmirror.com/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz",
|
"resolved": "https://registry.npmmirror.com/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz",
|
||||||
"integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==",
|
"integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/bonjour": "^3.5.9",
|
"@types/bonjour": "^3.5.9",
|
||||||
"@types/connect-history-api-fallback": "^1.3.5",
|
"@types/connect-history-api-fallback": "^1.3.5",
|
||||||
@ -16179,6 +16213,7 @@
|
|||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
||||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
@ -16499,6 +16534,7 @@
|
|||||||
"version": "8.12.0",
|
"version": "8.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz",
|
||||||
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
"integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"json-schema-traverse": "^1.0.0",
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
|||||||
158
server/cache.go
Normal file
158
server/cache.go
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheItem 缓存项
|
||||||
|
type CacheItem struct {
|
||||||
|
data []byte
|
||||||
|
timestamp time.Time
|
||||||
|
size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThumbnailCache 缩略图缓存
|
||||||
|
type ThumbnailCache struct {
|
||||||
|
items map[string]*CacheItem
|
||||||
|
mu sync.RWMutex
|
||||||
|
maxSize int64 // 最大缓存大小(字节)
|
||||||
|
usedSize int64 // 已使用大小
|
||||||
|
ttl time.Duration // 缓存过期时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewThumbnailCache 创建新的缩略图缓存
|
||||||
|
func NewThumbnailCache(maxSizeMB int, ttlMinutes int) *ThumbnailCache {
|
||||||
|
return &ThumbnailCache{
|
||||||
|
items: make(map[string]*CacheItem),
|
||||||
|
maxSize: int64(maxSizeMB) * 1024 * 1024,
|
||||||
|
ttl: time.Duration(ttlMinutes) * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取缓存项
|
||||||
|
func (c *ThumbnailCache) Get(key string) ([]byte, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
item, exists := c.items[key]
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否过期
|
||||||
|
if time.Since(item.timestamp) > c.ttl {
|
||||||
|
go c.Remove(key)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.data, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set 设置缓存项
|
||||||
|
func (c *ThumbnailCache) Set(key string, data []byte) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
// 如果已存在,先删除旧的
|
||||||
|
if item, exists := c.items[key]; exists {
|
||||||
|
c.usedSize -= int64(item.size)
|
||||||
|
delete(c.items, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要清理空间
|
||||||
|
itemSize := len(data)
|
||||||
|
if int64(itemSize) > c.maxSize {
|
||||||
|
// 单个文件太大,不缓存
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期或最旧的项直到有足够空间
|
||||||
|
c.cleanup(int64(itemSize))
|
||||||
|
|
||||||
|
// 添加新项
|
||||||
|
c.items[key] = &CacheItem{
|
||||||
|
data: data,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
size: itemSize,
|
||||||
|
}
|
||||||
|
c.usedSize += int64(itemSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove 移除缓存项
|
||||||
|
func (c *ThumbnailCache) Remove(key string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if item, exists := c.items[key]; exists {
|
||||||
|
c.usedSize -= int64(item.size)
|
||||||
|
delete(c.items, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear 清空缓存
|
||||||
|
func (c *ThumbnailCache) Clear() {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
c.items = make(map[string]*CacheItem)
|
||||||
|
c.usedSize = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup 清理缓存,确保有足够空间
|
||||||
|
func (c *ThumbnailCache) cleanup(requiredSpace int64) {
|
||||||
|
// 首先清理过期项
|
||||||
|
now := time.Now()
|
||||||
|
for key, item := range c.items {
|
||||||
|
if now.Sub(item.timestamp) > c.ttl {
|
||||||
|
c.usedSize -= int64(item.size)
|
||||||
|
delete(c.items, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果还不够,清理最旧的项
|
||||||
|
for c.usedSize+requiredSpace > c.maxSize && len(c.items) > 0 {
|
||||||
|
var oldestKey string
|
||||||
|
var oldestTime time.Time
|
||||||
|
|
||||||
|
for key, item := range c.items {
|
||||||
|
if oldestKey == "" || item.timestamp.Before(oldestTime) {
|
||||||
|
oldestKey = key
|
||||||
|
oldestTime = item.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldestKey != "" {
|
||||||
|
item := c.items[oldestKey]
|
||||||
|
c.usedSize -= int64(item.size)
|
||||||
|
delete(c.items, oldestKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size 获取缓存大小(字节)
|
||||||
|
func (c *ThumbnailCache) Size() int64 {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.usedSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count 获取缓存项数量
|
||||||
|
func (c *ThumbnailCache) Count() int {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return len(c.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats 获取缓存统计信息
|
||||||
|
func (c *ThumbnailCache) Stats() map[string]interface{} {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"count": len(c.items),
|
||||||
|
"usedSize": c.usedSize,
|
||||||
|
"maxSize": c.maxSize,
|
||||||
|
"usagePercent": float64(c.usedSize) / float64(c.maxSize) * 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,12 +10,19 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
initMovie()
|
initMovie()
|
||||||
|
|
||||||
|
// 初始化缓存系统(512MB内存缓存,TTL 60分钟)
|
||||||
|
InitCache(512, 60)
|
||||||
|
|
||||||
// 设置为发布模式
|
// 设置为发布模式
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
|
||||||
eg := gin.Default()
|
eg := gin.Default()
|
||||||
eg.Use(Cors())
|
eg.Use(Cors())
|
||||||
eg.Static("/res", "movie/")
|
eg.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// 使用支持 Range 请求的视频流处理器(优化大文件加载性能)
|
||||||
|
// 这个处理器会处理所有 /res/ 下的文件,包括视频和缩略图
|
||||||
|
eg.GET("/res/:filename", StreamVideo)
|
||||||
eg.Static("/static", "../build/static")
|
eg.Static("/static", "../build/static")
|
||||||
eg.StaticFile("/manifest.json", "../build/manifest.json")
|
eg.StaticFile("/manifest.json", "../build/manifest.json")
|
||||||
eg.StaticFile("/favicon.ico", "../build/favicon.ico")
|
eg.StaticFile("/favicon.ico", "../build/favicon.ico")
|
||||||
|
|||||||
255
server/video_handler.go
Normal file
255
server/video_handler.go
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 全局缩略图缓存
|
||||||
|
var thumbnailCache *ThumbnailCache
|
||||||
|
|
||||||
|
// StreamVideo 处理视频流式传输,支持 HTTP Range 请求
|
||||||
|
func StreamVideo(c *gin.Context) {
|
||||||
|
filename := c.Param("filename")
|
||||||
|
if filename == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "filename is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理路径,防止目录遍历攻击
|
||||||
|
filename = filepath.Base(filename)
|
||||||
|
filePath := filepath.Join("movie", filename)
|
||||||
|
|
||||||
|
// 检查是否是缩略图(PNG文件)
|
||||||
|
if strings.HasSuffix(strings.ToLower(filename), ".png") {
|
||||||
|
serveThumbnail(c, filePath, filename)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
fileInfo, err := os.Stat(filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to access file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开文件
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to open file: %s, error: %v", filePath, err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// 获取文件大小
|
||||||
|
fileSize := fileInfo.Size()
|
||||||
|
|
||||||
|
// 检查是否支持 Range 请求
|
||||||
|
rangeHeader := c.GetHeader("Range")
|
||||||
|
|
||||||
|
if rangeHeader == "" {
|
||||||
|
// 不支持 Range 请求,发送整个文件
|
||||||
|
c.Header("Content-Type", getContentType(filePath))
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(fileSize, 10))
|
||||||
|
c.Header("Accept-Ranges", "bytes")
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
c.File(filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 Range 头部 (例如: "bytes=0-1023")
|
||||||
|
ranges, err := parseRange(rangeHeader, fileSize)
|
||||||
|
if err != nil {
|
||||||
|
c.Header("Content-Range", fmt.Sprintf("bytes */%d", fileSize))
|
||||||
|
c.JSON(http.StatusRequestedRangeNotSatisfiable, gin.H{"error": "invalid range"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用第一个 Range(简化处理,只支持单个 Range)
|
||||||
|
r := ranges[0]
|
||||||
|
|
||||||
|
// 设置响应头(优化:添加Gzip压缩和CDN友好头)
|
||||||
|
c.Header("Content-Type", getContentType(filePath))
|
||||||
|
c.Header("Content-Range", fmt.Sprintf("bytes %d-%d/%d", r.start, r.end-1, fileSize))
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(r.end-r.start, 10))
|
||||||
|
c.Header("Accept-Ranges", "bytes")
|
||||||
|
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff") // 安全头
|
||||||
|
|
||||||
|
// 定位到指定位置
|
||||||
|
_, err = file.Seek(r.start, 0)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to seek file: %v", err)
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流式传输文件内容
|
||||||
|
c.Status(http.StatusPartialContent)
|
||||||
|
_, err = io.CopyN(c.Writer, file, r.end-r.start)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error streaming file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Range 表示一个字节范围
|
||||||
|
type Range struct {
|
||||||
|
start int64
|
||||||
|
end int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRange 解析 Range 头部
|
||||||
|
func parseRange(rangeHeader string, fileSize int64) ([]Range, error) {
|
||||||
|
// Range 格式: "bytes=start-end"
|
||||||
|
if !strings.HasPrefix(rangeHeader, "bytes=") {
|
||||||
|
return nil, fmt.Errorf("invalid range format")
|
||||||
|
}
|
||||||
|
|
||||||
|
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
|
||||||
|
parts := strings.Split(rangeSpec, "-")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return nil, fmt.Errorf("invalid range format")
|
||||||
|
}
|
||||||
|
|
||||||
|
var start, end int64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// 解析起始位置
|
||||||
|
if parts[0] != "" {
|
||||||
|
start, err = strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid start position")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果没有指定起始位置,则从文件末尾开始计算
|
||||||
|
// 例如: "bytes=-500" 表示最后500字节
|
||||||
|
if parts[1] != "" {
|
||||||
|
suffixLength, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid suffix length")
|
||||||
|
}
|
||||||
|
start = fileSize - suffixLength
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
end = fileSize
|
||||||
|
return []Range{{start: start, end: end}}, nil
|
||||||
|
}
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析结束位置
|
||||||
|
if parts[1] != "" {
|
||||||
|
end, err = strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid end position")
|
||||||
|
}
|
||||||
|
// Range 包含结束位置,所以需要 +1
|
||||||
|
end++
|
||||||
|
} else {
|
||||||
|
// 如果没有指定结束位置,则到文件末尾
|
||||||
|
end = fileSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证范围有效性
|
||||||
|
if start < 0 || end > fileSize || start >= end {
|
||||||
|
return nil, fmt.Errorf("invalid range")
|
||||||
|
}
|
||||||
|
|
||||||
|
return []Range{{start: start, end: end}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getContentType 根据文件扩展名获取 Content-Type
|
||||||
|
func getContentType(filename string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
switch ext {
|
||||||
|
case ".mp4":
|
||||||
|
return "video/mp4"
|
||||||
|
case ".webm":
|
||||||
|
return "video/webm"
|
||||||
|
case ".ogg":
|
||||||
|
return "video/ogg"
|
||||||
|
case ".mov":
|
||||||
|
return "video/quicktime"
|
||||||
|
case ".avi":
|
||||||
|
return "video/x-msvideo"
|
||||||
|
case ".mkv":
|
||||||
|
return "video/x-matroska"
|
||||||
|
case ".m4v":
|
||||||
|
return "video/mp4"
|
||||||
|
default:
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// serveThumbnail 服务缩略图,使用缓存优化
|
||||||
|
func serveThumbnail(c *gin.Context, filePath string, filename string) {
|
||||||
|
// 首先尝试从缓存获取
|
||||||
|
if thumbnailCache != nil {
|
||||||
|
if cachedData, found := thumbnailCache.Get(filename); found {
|
||||||
|
c.Data(http.StatusOK, "image/png", cachedData)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存未命中,从磁盘读取
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "thumbnail not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open thumbnail"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
fileInfo, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get file info"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize := fileInfo.Size()
|
||||||
|
|
||||||
|
// 读取文件内容
|
||||||
|
data := make([]byte, fileSize)
|
||||||
|
_, err = io.ReadFull(file, data)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read thumbnail"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存入缓存
|
||||||
|
if thumbnailCache != nil {
|
||||||
|
thumbnailCache.Set(filename, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
c.Header("Content-Type", "image/png")
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(fileSize, 10))
|
||||||
|
c.Header("Cache-Control", "public, max-age=86400, immutable") // 缩略图缓存1天
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
|
||||||
|
// 发送数据
|
||||||
|
c.Data(http.StatusOK, "image/png", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitCache 初始化缓存系统
|
||||||
|
func InitCache(maxSizeMB int, ttlMinutes int) {
|
||||||
|
thumbnailCache = NewThumbnailCache(maxSizeMB, ttlMinutes)
|
||||||
|
log.Printf("缓存系统已初始化,最大大小: %dMB, TTL: %d分钟", maxSizeMB, ttlMinutes)
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import { createContext } from 'react';
|
|||||||
const ConfigContext = createContext();
|
const ConfigContext = createContext();
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
Host: 'http://192.168.124.2:4444',
|
Host: 'http://192.168.124.8:4444',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConfigContext;
|
export default ConfigContext;
|
||||||
636
src/Main.jsx
636
src/Main.jsx
@ -1,10 +1,9 @@
|
|||||||
// ...existing code...
|
// Main.jsx - 重构后的主组件
|
||||||
import React, { useState, useEffect, useContext, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useContext, useCallback, useRef } from 'react';
|
||||||
import axios from 'axios';
|
// navigation handled via anchor tags for static resource navigation
|
||||||
import Container from '@mui/material/Container';
|
import Container from '@mui/material/Container';
|
||||||
import Grid from '@mui/material/Grid';
|
import Grid from '@mui/material/Grid';
|
||||||
import Pagination from '@mui/material/Pagination';
|
import Pagination from '@mui/material/Pagination';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import TextField from '@mui/material/TextField';
|
import TextField from '@mui/material/TextField';
|
||||||
import InputAdornment from '@mui/material/InputAdornment';
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
@ -17,7 +16,11 @@ import Alert from '@mui/material/Alert';
|
|||||||
import ConfigContext from './Config';
|
import ConfigContext from './Config';
|
||||||
import MovieCard from './components/MovieCard';
|
import MovieCard from './components/MovieCard';
|
||||||
import CategoryNav from './components/CategoryNav';
|
import CategoryNav from './components/CategoryNav';
|
||||||
// ...existing code...
|
|
||||||
|
// 导入自定义hooks
|
||||||
|
import { useMovieList } from './hooks/useMovieList';
|
||||||
|
import { useHistoryManager } from './hooks/useHistoryManager';
|
||||||
|
import { useSearch } from './hooks/useSearch';
|
||||||
|
|
||||||
// 分类配置
|
// 分类配置
|
||||||
const categories = [
|
const categories = [
|
||||||
@ -29,536 +32,299 @@ const categories = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const SEARCH_CATEGORY = 'search';
|
const SEARCH_CATEGORY = 'search';
|
||||||
const LIMIT = 20;
|
|
||||||
const DEFAULT_CATEGORY = categories[0].value;
|
const DEFAULT_CATEGORY = categories[0].value;
|
||||||
|
|
||||||
const usePersistedState = (key, defaultValue) => {
|
|
||||||
const [state, setState] = useState(() => {
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
try {
|
|
||||||
return stored ? JSON.parse(stored) : defaultValue;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Error parsing persisted state for key "${key}":`, e);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(key, JSON.stringify(state));
|
|
||||||
}, [key, state]);
|
|
||||||
|
|
||||||
return [state, setState];
|
|
||||||
};
|
|
||||||
|
|
||||||
const Main = () => {
|
const Main = () => {
|
||||||
const config = useContext(ConfigContext);
|
const config = useContext(ConfigContext);
|
||||||
|
// navigate is no longer used (we use anchors for full navigation to /res/...)
|
||||||
|
|
||||||
|
// 使用自定义hooks
|
||||||
|
const {
|
||||||
|
movies,
|
||||||
|
loading,
|
||||||
|
pagination,
|
||||||
|
error: movieError,
|
||||||
|
fetchMovies,
|
||||||
|
handleMovieOperation,
|
||||||
|
cleanup: cleanupMovieList
|
||||||
|
} = useMovieList();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeCategory,
|
||||||
|
currentPage,
|
||||||
|
activeSearchQuery,
|
||||||
|
isPopStateNav,
|
||||||
|
navigateAndFetch,
|
||||||
|
handlePopState,
|
||||||
|
initializeHistory,
|
||||||
|
setupScrollListener,
|
||||||
|
saveScrollPosition,
|
||||||
|
cleanup: cleanupHistory
|
||||||
|
} = useHistoryManager();
|
||||||
|
|
||||||
|
const {
|
||||||
|
searchInput,
|
||||||
|
isSearching,
|
||||||
|
searchSuggestions,
|
||||||
|
updateSearchInput,
|
||||||
|
executeSearch,
|
||||||
|
clearSearch,
|
||||||
|
handleKeyDown,
|
||||||
|
cleanup: cleanupSearch
|
||||||
|
} = useSearch();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [movies, setMovies] = useState([]);
|
|
||||||
const [pagination, setPagination] = useState({ page: 1, total: 0, pages: 1 });
|
|
||||||
const [searchInput, setSearchInput] = useState('');
|
|
||||||
const [successMessage, setSuccessMessage] = useState('');
|
const [successMessage, setSuccessMessage] = useState('');
|
||||||
|
const scrollListenerRef = useRef(null);
|
||||||
|
|
||||||
const [activeCategory, setActiveCategory] = useState(DEFAULT_CATEGORY);
|
// 初始化历史记录管理
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [activeSearchQuery, setActiveSearchQuery] = useState('');
|
|
||||||
|
|
||||||
const [persistedParams, setPersistedParams] = usePersistedState('mainViewParams', {
|
|
||||||
lastKnownState: {
|
|
||||||
category: DEFAULT_CATEGORY,
|
|
||||||
page: 1,
|
|
||||||
searchQuery: '',
|
|
||||||
scrollPos: 0,
|
|
||||||
},
|
|
||||||
categoryHistory: Object.fromEntries([
|
|
||||||
...categories.map(cat => [cat.value, { lastPage: 1, scrollPos: 0 }]),
|
|
||||||
[SEARCH_CATEGORY, { lastPage: 1, scrollPos: 0, searchQuery: '' }]
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const scrollTimeoutRef = useRef(null);
|
|
||||||
const isPopStateNav = useRef(false);
|
|
||||||
const isMounted = useRef(false);
|
|
||||||
|
|
||||||
const fetchMovies = useCallback(async (category, page, search = '', options = {}) => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const isSearch = category === SEARCH_CATEGORY;
|
|
||||||
let apiUrl = `/movie/list?page=${page}&limit=${LIMIT}`;
|
|
||||||
|
|
||||||
if (isSearch) apiUrl += `&search=${encodeURIComponent(search)}`;
|
|
||||||
else if (category === '最新添加') apiUrl += `&sort=created_time&order=desc`;
|
|
||||||
else apiUrl += `&category=${encodeURIComponent(category)}`;
|
|
||||||
|
|
||||||
const response = await axios.get(apiUrl);
|
|
||||||
const { items, total } = response.data.data;
|
|
||||||
const totalPages = Math.ceil(total / LIMIT);
|
|
||||||
|
|
||||||
if (items.length === 0 && page > 1 && !isSearch && !options.isPopState) {
|
|
||||||
console.log("Empty page, current page has no items:", category, page);
|
|
||||||
setMovies([]);
|
|
||||||
setPagination({ page, total, pages: totalPages });
|
|
||||||
} else {
|
|
||||||
setMovies(items);
|
|
||||||
setPagination({ page, total, pages: totalPages });
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取电影数据失败:', error);
|
|
||||||
setMovies([]);
|
|
||||||
setPagination({ page, total: 0, pages: 1 });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
if (options.restoreScrollPos !== undefined && !options.skipScrollRestore) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setTimeout(() => window.scrollTo(0, options.restoreScrollPos), 50);
|
|
||||||
});
|
|
||||||
} else if (!options.skipScrollRestore && !options.isPopStatePaging) {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
}
|
|
||||||
isPopStateNav.current = false;
|
|
||||||
}
|
|
||||||
// IMPORTANT: Removed duplicated scroll logic block that was here
|
|
||||||
}, [isPopStateNav]);
|
|
||||||
|
|
||||||
const navigateAndFetch = useCallback((newCategory, newPage, newSearchQuery = '', options = {}) => {
|
|
||||||
const { replace = false, preserveScroll = false, isPopStatePaging = false } = options;
|
|
||||||
|
|
||||||
setActiveCategory(newCategory);
|
|
||||||
setCurrentPage(newPage);
|
|
||||||
setActiveSearchQuery(newSearchQuery);
|
|
||||||
|
|
||||||
if (newCategory === SEARCH_CATEGORY) {
|
|
||||||
setSearchInput(newSearchQuery);
|
|
||||||
} else {
|
|
||||||
setSearchInput('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollForHistory = preserveScroll
|
|
||||||
? (window.history.state?.appState?.scrollPos || window.scrollY)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const historyState = {
|
|
||||||
category: newCategory,
|
|
||||||
page: newPage,
|
|
||||||
searchQuery: newSearchQuery,
|
|
||||||
scrollPos: scrollForHistory,
|
|
||||||
};
|
|
||||||
|
|
||||||
const url = window.location.pathname;
|
|
||||||
const browserHistoryState = window.history.state?.appState;
|
|
||||||
const needsPush = !browserHistoryState ||
|
|
||||||
browserHistoryState.category !== newCategory ||
|
|
||||||
browserHistoryState.page !== newPage ||
|
|
||||||
browserHistoryState.searchQuery !== newSearchQuery;
|
|
||||||
|
|
||||||
if (replace) {
|
|
||||||
window.history.replaceState({ appState: historyState }, '', url);
|
|
||||||
} else if (needsPush) {
|
|
||||||
window.history.pushState({ appState: historyState }, '', url);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPersistedParams(prev => {
|
|
||||||
const newCategoryHistory = { ...prev.categoryHistory };
|
|
||||||
const oldCategoryState = prev.lastKnownState.category;
|
|
||||||
|
|
||||||
if (oldCategoryState && oldCategoryState !== newCategory) {
|
|
||||||
newCategoryHistory[oldCategoryState] = {
|
|
||||||
...newCategoryHistory[oldCategoryState],
|
|
||||||
scrollPos: window.scrollY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
newCategoryHistory[newCategory] = {
|
|
||||||
...newCategoryHistory[newCategory],
|
|
||||||
lastPage: newPage,
|
|
||||||
scrollPos: scrollForHistory,
|
|
||||||
...(newCategory === SEARCH_CATEGORY && { searchQuery: newSearchQuery }),
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
lastKnownState: historyState,
|
|
||||||
categoryHistory: newCategoryHistory,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const scrollPosForFetch = preserveScroll ? scrollForHistory : 0;
|
|
||||||
|
|
||||||
fetchMovies(newCategory, newPage, newSearchQuery, {
|
|
||||||
restoreScrollPos: scrollPosForFetch,
|
|
||||||
skipScrollRestore: false,
|
|
||||||
isPopStatePaging: isPopStatePaging,
|
|
||||||
});
|
|
||||||
}, [fetchMovies, setPersistedParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = (event) => {
|
const initResult = initializeHistory(DEFAULT_CATEGORY, 1, '', (category, page, searchQuery, scrollPos, isPopState) => {
|
||||||
if (event.state && event.state.appState) {
|
if (isPopState) {
|
||||||
const { category, page, searchQuery, scrollPos } = event.state.appState;
|
// 处理浏览器前进后退
|
||||||
isPopStateNav.current = true;
|
fetchMovies(category, page, searchQuery);
|
||||||
|
|
||||||
setActiveCategory(category);
|
|
||||||
setCurrentPage(page);
|
|
||||||
setActiveSearchQuery(searchQuery);
|
|
||||||
setSearchInput(searchQuery || '');
|
|
||||||
|
|
||||||
setPersistedParams(prev => ({
|
|
||||||
lastKnownState: event.state.appState,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[category]: {
|
|
||||||
...prev.categoryHistory[category],
|
|
||||||
lastPage: page,
|
|
||||||
scrollPos: scrollPos,
|
|
||||||
...(category === SEARCH_CATEGORY && { searchQuery: searchQuery }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
fetchMovies(category, page, searchQuery, { restoreScrollPos: scrollPos, isPopStatePaging: true });
|
|
||||||
} else {
|
} else {
|
||||||
const lastState = persistedParams.lastKnownState;
|
// 初始加载或重新导航
|
||||||
navigateAndFetch(lastState.category, lastState.page, lastState.searchQuery, { replace: true, preserveScroll: true });
|
fetchMovies(category, page, searchQuery);
|
||||||
|
if (scrollPos > 0) {
|
||||||
|
setTimeout(() => window.scrollTo(0, scrollPos), 50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置popstate事件监听器
|
||||||
|
const handlePopStateEvent = (event) => {
|
||||||
|
const result = handlePopState(event, (category, page, searchQuery, scrollPos, isPopState) => {
|
||||||
|
fetchMovies(category, page, searchQuery);
|
||||||
|
if (scrollPos > 0 && !isPopState) {
|
||||||
|
setTimeout(() => window.scrollTo(0, scrollPos), 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopStateEvent);
|
||||||
|
|
||||||
if (!isMounted.current) {
|
|
||||||
isMounted.current = true;
|
|
||||||
if ('scrollRestoration' in window.history) {
|
|
||||||
window.history.scrollRestoration = 'manual';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.history.state && window.history.state.appState) {
|
|
||||||
const { category, page, searchQuery, scrollPos } = window.history.state.appState;
|
|
||||||
isPopStateNav.current = true;
|
|
||||||
setActiveCategory(category);
|
|
||||||
setCurrentPage(page);
|
|
||||||
setActiveSearchQuery(searchQuery);
|
|
||||||
setSearchInput(searchQuery || '');
|
|
||||||
setPersistedParams(prev => ({
|
|
||||||
lastKnownState: window.history.state.appState,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[category]: { ...prev.categoryHistory[category], lastPage: page, scrollPos: scrollPos, ...(category === SEARCH_CATEGORY && { searchQuery: searchQuery }) }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
fetchMovies(category, page, searchQuery, { restoreScrollPos: scrollPos, isPopStatePaging: true });
|
|
||||||
} else {
|
|
||||||
const { category, page, searchQuery } = persistedParams.lastKnownState;
|
|
||||||
// For initial load from localStorage, use its scrollPos. navigateAndFetch with preserveScroll=true will handle it.
|
|
||||||
navigateAndFetch(category, page, searchQuery, { replace: true, preserveScroll: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('popstate', handlePopState);
|
window.removeEventListener('popstate', handlePopStateEvent);
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [initializeHistory, handlePopState, fetchMovies]);
|
||||||
}, [navigateAndFetch, setPersistedParams]);
|
|
||||||
|
|
||||||
|
// 设置滚动监听器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
|
||||||
scrollTimeoutRef.current = setTimeout(() => {
|
|
||||||
if (window.history.state && window.history.state.appState && !loading && movies.length > 0 && !isPopStateNav.current) {
|
|
||||||
const newScrollPos = window.scrollY;
|
|
||||||
const currentState = window.history.state.appState;
|
|
||||||
|
|
||||||
if (currentState.scrollPos !== newScrollPos) {
|
|
||||||
const updatedState = { ...currentState, scrollPos: newScrollPos };
|
|
||||||
window.history.replaceState({ appState: updatedState }, '', window.location.href);
|
|
||||||
|
|
||||||
setPersistedParams(prev => ({
|
|
||||||
lastKnownState: updatedState,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[currentState.category]: {
|
|
||||||
...prev.categoryHistory[currentState.category],
|
|
||||||
lastPage: currentState.page,
|
|
||||||
scrollPos: newScrollPos,
|
|
||||||
...(currentState.category === SEARCH_CATEGORY && { searchQuery: currentState.searchQuery }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 150);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!loading && movies.length > 0) {
|
if (!loading && movies.length > 0) {
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
scrollListenerRef.current = setupScrollListener((category, scrollPos, searchQuery) => {
|
||||||
|
// 滚动位置已通过saveScrollPosition保存到localStorage
|
||||||
|
console.log('Scroll position saved:', { category, scrollPos });
|
||||||
|
});
|
||||||
|
|
||||||
|
scrollListenerRef.current.addListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', handleScroll);
|
if (scrollListenerRef.current) {
|
||||||
clearTimeout(scrollTimeoutRef.current);
|
scrollListenerRef.current.removeListener();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [loading, movies.length, setPersistedParams, isPopStateNav, scrollTimeoutRef]);
|
}, [loading, movies.length, setupScrollListener]);
|
||||||
|
|
||||||
|
// 组件卸载时的清理工作
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cleanupMovieList();
|
||||||
|
cleanupHistory();
|
||||||
|
cleanupSearch();
|
||||||
|
};
|
||||||
|
}, [cleanupMovieList, cleanupHistory, cleanupSearch]);
|
||||||
|
|
||||||
|
// 分类切换处理
|
||||||
const handleCategoryChange = useCallback((newCategory) => {
|
const handleCategoryChange = useCallback((newCategory) => {
|
||||||
// Save current scroll for the category we are leaving
|
|
||||||
setPersistedParams(prev => {
|
|
||||||
const oldCat = prev.lastKnownState.category;
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[oldCat]: { ...prev.categoryHistory[oldCat], scrollPos: window.scrollY }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
let targetPage = 1;
|
|
||||||
let targetSearchQuery = '';
|
|
||||||
// For category change, restore to its own last known scroll position or top
|
|
||||||
const categorySpecificScroll = persistedParams.categoryHistory[newCategory]?.scrollPos || 0;
|
|
||||||
|
|
||||||
|
|
||||||
if (newCategory === SEARCH_CATEGORY) {
|
if (newCategory === SEARCH_CATEGORY) {
|
||||||
targetSearchQuery = persistedParams.categoryHistory[SEARCH_CATEGORY]?.searchQuery || searchInput.trim() || '';
|
// 切换到搜索分类,保持当前搜索状态
|
||||||
targetPage = persistedParams.categoryHistory[SEARCH_CATEGORY]?.lastPage || 1;
|
navigateAndFetch(SEARCH_CATEGORY, 1, searchInput.trim() || activeSearchQuery);
|
||||||
|
// 立即发起请求以更新列表
|
||||||
|
fetchMovies(SEARCH_CATEGORY, 1, searchInput.trim() || activeSearchQuery);
|
||||||
} else {
|
} else {
|
||||||
targetPage = persistedParams.categoryHistory[newCategory]?.lastPage || 1;
|
// 切换到普通分类
|
||||||
|
navigateAndFetch(newCategory, 1, '');
|
||||||
|
fetchMovies(newCategory, 1, '');
|
||||||
|
clearSearch();
|
||||||
}
|
}
|
||||||
|
}, [navigateAndFetch, searchInput, activeSearchQuery, clearSearch, fetchMovies]);
|
||||||
|
|
||||||
// Update React state first
|
// 分页处理
|
||||||
setActiveCategory(newCategory);
|
|
||||||
setCurrentPage(targetPage);
|
|
||||||
setActiveSearchQuery(targetSearchQuery);
|
|
||||||
if (newCategory === SEARCH_CATEGORY) {
|
|
||||||
setSearchInput(targetSearchQuery);
|
|
||||||
} else {
|
|
||||||
setSearchInput('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then fetch, telling fetchMovies to restore to the category's specific scroll
|
|
||||||
const historyState = { category: newCategory, page: targetPage, searchQuery: targetSearchQuery, scrollPos: categorySpecificScroll };
|
|
||||||
window.history.pushState({ appState: historyState }, '', window.location.pathname);
|
|
||||||
|
|
||||||
setPersistedParams(prev => ({
|
|
||||||
lastKnownState: historyState,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[newCategory]: { ...prev.categoryHistory[newCategory], lastPage: targetPage, scrollPos: categorySpecificScroll, ...(newCategory === SEARCH_CATEGORY && { searchQuery: targetSearchQuery }) }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
fetchMovies(newCategory, targetPage, targetSearchQuery, { restoreScrollPos: categorySpecificScroll });
|
|
||||||
|
|
||||||
}, [searchInput, persistedParams.categoryHistory, setPersistedParams, fetchMovies]);
|
|
||||||
|
|
||||||
|
|
||||||
const handlePageChange = useCallback((_, page) => {
|
const handlePageChange = useCallback((_, page) => {
|
||||||
setPersistedParams(prev => ({
|
navigateAndFetch(activeCategory, page, activeSearchQuery);
|
||||||
...prev,
|
// 立即发起请求以更新列表
|
||||||
categoryHistory: {
|
fetchMovies(activeCategory, page, activeSearchQuery);
|
||||||
...prev.categoryHistory,
|
}, [activeCategory, activeSearchQuery, navigateAndFetch, fetchMovies]);
|
||||||
[activeCategory]: { ...prev.categoryHistory[activeCategory], scrollPos: window.scrollY }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
// For page change, typically scroll to top
|
|
||||||
navigateAndFetch(activeCategory, page, activeSearchQuery, { preserveScroll: false });
|
|
||||||
}, [activeCategory, activeSearchQuery, navigateAndFetch, setPersistedParams]);
|
|
||||||
|
|
||||||
|
// 搜索提交处理
|
||||||
const handleSearchSubmit = useCallback(() => {
|
const handleSearchSubmit = useCallback(() => {
|
||||||
const trimmedSearch = searchInput.trim();
|
executeSearch(searchInput, async (query) => {
|
||||||
if (!trimmedSearch) return;
|
await fetchMovies(SEARCH_CATEGORY, 1, query);
|
||||||
setPersistedParams(prev => {
|
navigateAndFetch(SEARCH_CATEGORY, 1, query);
|
||||||
const oldCat = prev.lastKnownState.category;
|
|
||||||
if (oldCat && oldCat !== SEARCH_CATEGORY) {
|
|
||||||
return { ...prev, categoryHistory: { ...prev.categoryHistory, [oldCat]: { ...prev.categoryHistory[oldCat], scrollPos: window.scrollY } } };
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
});
|
||||||
navigateAndFetch(SEARCH_CATEGORY, 1, trimmedSearch, { preserveScroll: false });
|
}, [searchInput, executeSearch, fetchMovies, navigateAndFetch]);
|
||||||
}, [searchInput, navigateAndFetch, setPersistedParams]);
|
|
||||||
|
|
||||||
|
|
||||||
|
// 清除搜索
|
||||||
const handleClearSearch = useCallback(() => {
|
const handleClearSearch = useCallback(() => {
|
||||||
setSearchInput('');
|
clearSearch();
|
||||||
if (activeCategory === SEARCH_CATEGORY) {
|
navigateAndFetch(DEFAULT_CATEGORY, 1, '');
|
||||||
setPersistedParams(prev => ({
|
}, [clearSearch, navigateAndFetch]);
|
||||||
...prev,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[SEARCH_CATEGORY]: { ...prev.categoryHistory[SEARCH_CATEGORY], scrollPos: window.scrollY, searchQuery: '', lastPage: 1 }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const targetCategory = DEFAULT_CATEGORY;
|
|
||||||
const targetPage = persistedParams.categoryHistory[targetCategory]?.lastPage || 1;
|
|
||||||
const targetScroll = persistedParams.categoryHistory[targetCategory]?.scrollPos || 0;
|
|
||||||
|
|
||||||
// Manually set active states before calling fetch to avoid race with navigateAndFetch
|
// 电影重命名处理
|
||||||
setActiveCategory(targetCategory);
|
|
||||||
setCurrentPage(targetPage);
|
|
||||||
setActiveSearchQuery('');
|
|
||||||
|
|
||||||
const historyState = { category: targetCategory, page: targetPage, searchQuery: '', scrollPos: targetScroll };
|
|
||||||
window.history.pushState({ appState: historyState }, '', window.location.pathname);
|
|
||||||
setPersistedParams(prev => ({
|
|
||||||
lastKnownState: historyState,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[targetCategory]: { ...prev.categoryHistory[targetCategory], lastPage: targetPage, scrollPos: targetScroll }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
fetchMovies(targetCategory, targetPage, '', { restoreScrollPos: targetScroll });
|
|
||||||
|
|
||||||
} else {
|
|
||||||
setActiveSearchQuery('');
|
|
||||||
setPersistedParams(prev => ({ ...prev, lastKnownState: { ...prev.lastKnownState, searchQuery: '' } }));
|
|
||||||
}
|
|
||||||
}, [activeCategory, persistedParams.categoryHistory, setPersistedParams, fetchMovies]);
|
|
||||||
|
|
||||||
|
|
||||||
// handleRename
|
|
||||||
const handleRename = useCallback(async (oldName, newName) => {
|
const handleRename = useCallback(async (oldName, newName) => {
|
||||||
const scrollPosBeforeRename = window.scrollY; // 1. Capture scroll position
|
const scrollPosBeforeRename = window.scrollY;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/movie/rename', { old_name: oldName, new_name: newName });
|
await handleMovieOperation('rename', oldName, newName);
|
||||||
if (response.data.code === 200) {
|
setSuccessMessage(`文件已重命名为:${newName}`);
|
||||||
// 2. await fetchMovies to ensure data is re-fetched and state is updated.
|
|
||||||
// Pass skipScrollRestore: true so fetchMovies doesn't scroll by itself.
|
// 恢复滚动位置
|
||||||
await fetchMovies(activeCategory, currentPage, activeSearchQuery, { skipScrollRestore: true });
|
setTimeout(() => {
|
||||||
|
window.scrollTo(0, scrollPosBeforeRename);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
// 3. After movies are fetched and React has re-rendered, restore the scroll position.
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
window.scrollTo(0, scrollPosBeforeRename);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error(response.data.message || '重命名失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重命名错误:', error);
|
throw error; // 重新抛出供MovieCard处理
|
||||||
throw error; // Re-throw for MovieCard to handle Snackbar
|
|
||||||
}
|
}
|
||||||
}, [fetchMovies, activeCategory, currentPage, activeSearchQuery]);
|
}, [handleMovieOperation]);
|
||||||
|
|
||||||
// handleDelete
|
// 电影删除处理
|
||||||
const handleDelete = useCallback(async (filename) => {
|
const handleDelete = useCallback(async (filename) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/movie/delete', { file_name: filename });
|
await handleMovieOperation('delete', filename);
|
||||||
if (response.data.code === 200) {
|
setSuccessMessage(`已成功删除文件:${filename}`);
|
||||||
// 显示成功消息
|
|
||||||
setSuccessMessage(`已成功删除文件:${filename}`);
|
|
||||||
|
|
||||||
// 重新获取电影列表
|
|
||||||
await fetchMovies(activeCategory, currentPage, activeSearchQuery, { skipScrollRestore: true });
|
|
||||||
} else {
|
|
||||||
throw new Error(response.data.message || '删除失败');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除错误:', error);
|
throw error; // 重新抛出供MovieCard处理
|
||||||
throw error; // Re-throw for MovieCard to handle Snackbar
|
|
||||||
}
|
}
|
||||||
}, [fetchMovies, activeCategory, currentPage, activeSearchQuery]);
|
}, [handleMovieOperation]);
|
||||||
|
|
||||||
|
// 电影卡片点击处理
|
||||||
const handleMovieCardClick = useCallback(() => {
|
const handleMovieCardClick = useCallback(() => {
|
||||||
if (window.history.state && window.history.state.appState) {
|
// 保存当前滚动位置
|
||||||
const currentScrollPos = window.scrollY;
|
saveScrollPosition(activeCategory, window.scrollY, activeSearchQuery);
|
||||||
const currentState = window.history.state.appState;
|
}, [activeCategory, activeSearchQuery, saveScrollPosition]);
|
||||||
if (currentState.scrollPos !== currentScrollPos) { // Only update if changed
|
|
||||||
const updatedState = { ...currentState, scrollPos: currentScrollPos };
|
|
||||||
window.history.replaceState({ appState: updatedState }, '', window.location.href);
|
|
||||||
setPersistedParams(prev => ({
|
|
||||||
...prev,
|
|
||||||
lastKnownState: updatedState,
|
|
||||||
categoryHistory: {
|
|
||||||
...prev.categoryHistory,
|
|
||||||
[updatedState.category]: {
|
|
||||||
...prev.categoryHistory[updatedState.category],
|
|
||||||
lastPage: updatedState.page,
|
|
||||||
scrollPos: currentScrollPos,
|
|
||||||
...(updatedState.category === SEARCH_CATEGORY && { searchQuery: updatedState.searchQuery }),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [setPersistedParams]);
|
|
||||||
|
|
||||||
const handleSearchChange = (e) => setSearchInput(e.target.value);
|
// 搜索输入变化处理
|
||||||
const handleSearchKeyDown = (e) => {
|
const handleSearchChange = useCallback((e) => {
|
||||||
if (e.key === 'Enter') handleSearchSubmit();
|
updateSearchInput(e.target.value);
|
||||||
};
|
}, [updateSearchInput]);
|
||||||
|
|
||||||
const handleSuccessSnackbarClose = () => {
|
// 成功提示关闭处理
|
||||||
|
const handleSuccessSnackbarClose = useCallback(() => {
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const paginationComponent = !loading && movies.length > 0 && pagination.pages > 1 && (
|
// 渲染分页组件
|
||||||
<Pagination
|
const renderPagination = () => {
|
||||||
count={pagination.pages}
|
if (loading || movies.length === 0 || pagination.pages <= 1) {
|
||||||
page={currentPage}
|
return null;
|
||||||
onChange={handlePageChange}
|
}
|
||||||
sx={{ my: 2, display: 'flex', justifyContent: 'center' }}
|
|
||||||
/>
|
return (
|
||||||
);
|
<Pagination
|
||||||
|
count={pagination.pages}
|
||||||
|
page={currentPage}
|
||||||
|
onChange={handlePageChange}
|
||||||
|
sx={{ my: 2, display: 'flex', justifyContent: 'center' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container style={{ marginTop: 20 }}>
|
<Container style={{ marginTop: 20 }}>
|
||||||
|
{/* 搜索输入框 */}
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
placeholder="搜索文件名..."
|
placeholder="搜索文件名..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
onKeyDown={handleSearchKeyDown}
|
onKeyDown={(e) => handleKeyDown(e, handleSearchSubmit)}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment>,
|
startAdornment: <InputAdornment position="start"><SearchIcon /></InputAdornment>,
|
||||||
endAdornment: searchInput && (
|
endAdornment: searchInput && (
|
||||||
<InputAdornment position="end">
|
<InputAdornment position="end">
|
||||||
<IconButton onClick={handleClearSearch} edge="end"><ClearIcon /></IconButton>
|
<IconButton onClick={handleClearSearch} edge="end">
|
||||||
|
<ClearIcon />
|
||||||
|
</IconButton>
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 分类导航 */}
|
||||||
<CategoryNav
|
<CategoryNav
|
||||||
categories={categories}
|
categories={categories}
|
||||||
currentCategory={activeCategory}
|
currentCategory={activeCategory}
|
||||||
onCategoryChange={handleCategoryChange}
|
onCategoryChange={handleCategoryChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 搜索结果提示 */}
|
||||||
{activeCategory === SEARCH_CATEGORY && activeSearchQuery && (
|
{activeCategory === SEARCH_CATEGORY && activeSearchQuery && (
|
||||||
<div style={{ textAlign: 'center', margin: '10px 0' }}>
|
<div style={{ textAlign: 'center', margin: '10px 0' }}>
|
||||||
搜索: "{activeSearchQuery}" ({pagination.total} 个结果)
|
搜索: "{activeSearchQuery}" ({pagination.total} 个结果)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{paginationComponent}
|
{/* 分页组件 */}
|
||||||
|
{renderPagination()}
|
||||||
|
|
||||||
{loading && <CircularProgress sx={{ display: 'block', margin: '20px auto' }} />}
|
{/* 加载状态 */}
|
||||||
|
{loading && (
|
||||||
|
<CircularProgress sx={{ display: 'block', margin: '20px auto' }} />
|
||||||
|
)}
|
||||||
|
|
||||||
{!loading && movies.length === 0 && (
|
{/* 错误状态 */}
|
||||||
<div style={{ textAlign: 'center', margin: '20px 0', color: 'gray' }}>
|
{movieError && (
|
||||||
{activeCategory === SEARCH_CATEGORY && (searchInput || activeSearchQuery) ? `没有找到关于 "${searchInput || activeSearchQuery}" 的结果。` : '没有找到结果。'}
|
<div style={{ textAlign: 'center', margin: '20px 0', color: 'red' }}>
|
||||||
|
错误: {movieError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && movies.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', margin: '20px 0', color: 'gray' }}>
|
||||||
|
{activeCategory === SEARCH_CATEGORY && (searchInput || activeSearchQuery)
|
||||||
|
? `没有找到关于 "${searchInput || activeSearchQuery}" 的结果。`
|
||||||
|
: '没有找到结果。'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 电影列表 */}
|
||||||
{!loading && movies.length > 0 && (
|
{!loading && movies.length > 0 && (
|
||||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||||
{movies.map(item => (
|
{movies.map(item => (
|
||||||
<Grid item xs={6} sm={4} md={3} lg={2} key={item.filename}>
|
<Grid item xs={6} sm={4} md={3} lg={2} key={item.filename}>
|
||||||
<Link
|
{/* Use a real anchor so the browser performs a full navigation to the static resource
|
||||||
to={`/res/${item.filename}`} // Ensure your routing handles this path
|
(this ensures the backend serves /res/<file> directly). We still call
|
||||||
style={{ textDecoration: 'none' }}
|
handleMovieCardClick to persist scroll position before navigating. */}
|
||||||
onClick={handleMovieCardClick}
|
<a
|
||||||
|
href={`/res/${item.filename}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
// allow navigation to proceed, but persist scroll state first
|
||||||
|
try { handleMovieCardClick(); } catch (err) { /* ignore */ }
|
||||||
|
}}
|
||||||
|
style={{ textDecoration: 'none', cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<MovieCard movie={item} config={config} onRename={handleRename} onDelete={handleDelete} />
|
<MovieCard
|
||||||
</Link>
|
movie={item}
|
||||||
|
config={config}
|
||||||
|
onRename={handleRename}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{paginationComponent}
|
{/* 底部分页 */}
|
||||||
|
{renderPagination()}
|
||||||
|
|
||||||
{/* 删除成功提示 */}
|
{/* 成功提示 */}
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={!!successMessage}
|
open={!!successMessage}
|
||||||
autoHideDuration={4000}
|
autoHideDuration={4000}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
// MovieCard.jsx - 优化后的电影卡片组件
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -18,7 +19,8 @@ import EditIcon from '@mui/icons-material/Edit';
|
|||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { styled } from '@mui/system';
|
import { styled } from '@mui/system';
|
||||||
|
|
||||||
const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
const MovieCard = React.memo(({ movie, config, onRename, onDelete }) => {
|
||||||
|
// 组件状态
|
||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
const [openDialog, setOpenDialog] = useState(false);
|
const [openDialog, setOpenDialog] = useState(false);
|
||||||
const [deleteDialog, setDeleteDialog] = useState(false);
|
const [deleteDialog, setDeleteDialog] = useState(false);
|
||||||
@ -27,54 +29,73 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
const [openSnackbar, setOpenSnackbar] = useState(false);
|
const [openSnackbar, setOpenSnackbar] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
const truncateFilename = (filename, maxLength) => {
|
// 工具函数
|
||||||
|
const truncateFilename = useCallback((filename, maxLength) => {
|
||||||
return filename.length > maxLength
|
return filename.length > maxLength
|
||||||
? filename.substring(0, maxLength - 3) + '...'
|
? filename.substring(0, maxLength - 3) + '...'
|
||||||
: filename;
|
: filename;
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleRenameClick = (e) => {
|
const extractNameWithoutExtension = useCallback((filename) => {
|
||||||
|
const lastDotIndex = filename.lastIndexOf('.');
|
||||||
|
return lastDotIndex === -1
|
||||||
|
? filename
|
||||||
|
: filename.substring(0, lastDotIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getFileExtension = useCallback((filename) => {
|
||||||
|
const lastDotIndex = filename.lastIndexOf('.');
|
||||||
|
return lastDotIndex === -1
|
||||||
|
? ''
|
||||||
|
: filename.substring(lastDotIndex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 事件处理函数
|
||||||
|
const handleRenameClick = useCallback((e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const lastDotIndex = movie.filename.lastIndexOf('.');
|
|
||||||
const nameWithoutExt = lastDotIndex === -1
|
const nameWithoutExt = extractNameWithoutExtension(movie.filename);
|
||||||
? movie.filename
|
|
||||||
: movie.filename.substring(0, lastDotIndex);
|
|
||||||
setNewName(nameWithoutExt);
|
setNewName(nameWithoutExt);
|
||||||
setOpenDialog(true);
|
setOpenDialog(true);
|
||||||
};
|
}, [movie.filename, extractNameWithoutExtension]);
|
||||||
|
|
||||||
const handleDeleteClick = (e) => {
|
const handleDeleteClick = useCallback((e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
setDeleteDialog(true);
|
setDeleteDialog(true);
|
||||||
setDeleteConfirmText('');
|
setDeleteConfirmText('');
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleDialogClose = (e) => {
|
const handleDialogClose = useCallback((e) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenDialog(false);
|
setOpenDialog(false);
|
||||||
setDeleteDialog(false);
|
setDeleteDialog(false);
|
||||||
setDeleteConfirmText('');
|
setDeleteConfirmText('');
|
||||||
};
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleRenameSubmit = async (e) => {
|
const handleRenameSubmit = useCallback(async (e) => {
|
||||||
if (e) {
|
if (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!newName.trim()) return;
|
const trimmedName = newName.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setError('文件名不能为空');
|
||||||
|
setOpenSnackbar(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const lastDotIndex = movie.filename.lastIndexOf('.');
|
const extension = getFileExtension(movie.filename);
|
||||||
const extension = lastDotIndex === -1
|
const fullNewName = trimmedName + extension;
|
||||||
? ''
|
|
||||||
: movie.filename.substring(lastDotIndex);
|
|
||||||
const fullNewName = newName.trim() + extension;
|
|
||||||
|
|
||||||
if (fullNewName === movie.filename) {
|
if (fullNewName === movie.filename) {
|
||||||
handleDialogClose();
|
handleDialogClose();
|
||||||
@ -84,17 +105,12 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
await onRename(movie.filename, fullNewName);
|
await onRename(movie.filename, fullNewName);
|
||||||
handleDialogClose();
|
handleDialogClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 统一错误处理,优先显示后端返回的 message,其次显示 error.message,最后显示默认错误
|
setError(error.response?.data?.message || error.message || '重命名失败,请稍后重试');
|
||||||
setError(
|
|
||||||
error.response?.data?.message ||
|
|
||||||
error.message ||
|
|
||||||
'重命名失败,请稍后重试'
|
|
||||||
);
|
|
||||||
setOpenSnackbar(true);
|
setOpenSnackbar(true);
|
||||||
}
|
}
|
||||||
};
|
}, [newName, movie.filename, onRename, handleDialogClose, getFileExtension]);
|
||||||
|
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
if (deleteConfirmText !== '确认删除') {
|
if (deleteConfirmText !== '确认删除') {
|
||||||
setError('请输入"确认删除"来确认操作');
|
setError('请输入"确认删除"来确认操作');
|
||||||
setOpenSnackbar(true);
|
setOpenSnackbar(true);
|
||||||
@ -106,40 +122,38 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
await onDelete(movie.filename);
|
await onDelete(movie.filename);
|
||||||
handleDialogClose();
|
handleDialogClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError(
|
setError(error.response?.data?.message || error.message || '删除失败,请稍后重试');
|
||||||
error.response?.data?.message ||
|
|
||||||
error.message ||
|
|
||||||
'删除失败,请稍后重试'
|
|
||||||
);
|
|
||||||
setOpenSnackbar(true);
|
setOpenSnackbar(true);
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
}
|
}
|
||||||
};
|
}, [deleteConfirmText, movie.filename, onDelete, handleDialogClose]);
|
||||||
|
|
||||||
const handleCloseSnackbar = () => {
|
const handleKeyPress = useCallback((e) => {
|
||||||
setOpenSnackbar(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleRenameSubmit(e);
|
handleRenameSubmit(e);
|
||||||
}
|
}
|
||||||
};
|
}, [handleRenameSubmit]);
|
||||||
|
|
||||||
const handleDeleteKeyPress = (e) => {
|
const handleDeleteKeyPress = useCallback((e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleDeleteConfirm();
|
handleDeleteConfirm();
|
||||||
}
|
}
|
||||||
};
|
}, [handleDeleteConfirm]);
|
||||||
|
|
||||||
const handleDialogContentClick = (e) => {
|
const handleDialogContentClick = useCallback((e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseSnackbar = useCallback(() => {
|
||||||
|
setOpenSnackbar(false);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 样式化组件
|
||||||
const StyledCard = styled(Card)({
|
const StyledCard = styled(Card)({
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
transform: 'scale(1.05)',
|
transform: 'scale(1.05)',
|
||||||
@ -166,7 +180,7 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 5,
|
top: 5,
|
||||||
right: 35, // 调整位置为删除按钮留出空间
|
right: 35,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
}}
|
}}
|
||||||
@ -183,7 +197,7 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
right: 5,
|
right: 5,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
backgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
color: '#f44336', // 红色删除按钮
|
color: '#f44336',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DeleteIcon fontSize="small" />
|
<DeleteIcon fontSize="small" />
|
||||||
@ -193,9 +207,23 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
component="img"
|
component="img"
|
||||||
height="120"
|
height="120"
|
||||||
image={`${window.location.origin}/res/${movie.image}`}
|
image={`${window.location.origin}/res/${movie.image}`}
|
||||||
|
alt={movie.filename}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography>{truncateFilename(movie.filename, 15)}</Typography>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="div"
|
||||||
|
title={movie.filename}
|
||||||
|
sx={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
mb: 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{truncateFilename(movie.filename, 15)}
|
||||||
|
</Typography>
|
||||||
<Typography variant="body2" color="textSecondary">
|
<Typography variant="body2" color="textSecondary">
|
||||||
时长: {movie.duration} min
|
时长: {movie.duration} min
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -254,7 +282,10 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
>
|
>
|
||||||
<DialogTitle>确认删除</DialogTitle>
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
<DialogContent onClick={handleDialogContentClick}>
|
<DialogContent onClick={handleDialogContentClick}>
|
||||||
<Typography variant="body1" sx={{ mb: 2, color: '#f44336' }}>
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{ mb: 2, color: '#f44336' }}
|
||||||
|
>
|
||||||
⚠️ 此操作不可逆转!
|
⚠️ 此操作不可逆转!
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
@ -298,6 +329,7 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
<Snackbar
|
<Snackbar
|
||||||
open={openSnackbar}
|
open={openSnackbar}
|
||||||
autoHideDuration={6000}
|
autoHideDuration={6000}
|
||||||
@ -310,6 +342,8 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => {
|
|||||||
</Snackbar>
|
</Snackbar>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
MovieCard.displayName = 'MovieCard';
|
||||||
|
|
||||||
export default MovieCard;
|
export default MovieCard;
|
||||||
|
|||||||
232
src/hooks/useHistoryManager.js
Normal file
232
src/hooks/useHistoryManager.js
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
// useHistoryManager.js - 浏览器历史管理Hook
|
||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
const usePersistedState = (key, defaultValue) => {
|
||||||
|
const [state, setState] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(key);
|
||||||
|
try {
|
||||||
|
return stored ? JSON.parse(stored) : defaultValue;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Error parsing persisted state for key "${key}":`, e);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(key, JSON.stringify(state));
|
||||||
|
}, [key, state]);
|
||||||
|
|
||||||
|
return [state, setState];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHistoryManager = () => {
|
||||||
|
const [activeCategory, setActiveCategory] = useState('');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [activeSearchQuery, setActiveSearchQuery] = useState('');
|
||||||
|
const [isPopStateNav, setIsPopStateNav] = useState(false);
|
||||||
|
|
||||||
|
const [persistedParams, setPersistedParams] = usePersistedState('mainViewParams', {
|
||||||
|
lastKnownState: {
|
||||||
|
category: '',
|
||||||
|
page: 1,
|
||||||
|
searchQuery: '',
|
||||||
|
scrollPos: 0,
|
||||||
|
},
|
||||||
|
categoryHistory: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const scrollTimeoutRef = useRef(null);
|
||||||
|
const isMounted = useRef(false);
|
||||||
|
|
||||||
|
const saveScrollPosition = useCallback((category, scrollPos, searchQuery = '') => {
|
||||||
|
setPersistedParams(prev => ({
|
||||||
|
...prev,
|
||||||
|
categoryHistory: {
|
||||||
|
...prev.categoryHistory,
|
||||||
|
[category]: {
|
||||||
|
...prev.categoryHistory[category],
|
||||||
|
scrollPos,
|
||||||
|
...(searchQuery && { searchQuery })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}, [setPersistedParams]);
|
||||||
|
|
||||||
|
const navigateAndFetch = useCallback((newCategory, newPage, newSearchQuery = '', options = {}) => {
|
||||||
|
const { replace = false, preserveScroll = false } = options;
|
||||||
|
|
||||||
|
setActiveCategory(newCategory);
|
||||||
|
setCurrentPage(newPage);
|
||||||
|
setActiveSearchQuery(newSearchQuery);
|
||||||
|
|
||||||
|
const scrollForHistory = preserveScroll
|
||||||
|
? (window.history.state?.appState?.scrollPos || window.scrollY)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const historyState = {
|
||||||
|
category: newCategory,
|
||||||
|
page: newPage,
|
||||||
|
searchQuery: newSearchQuery,
|
||||||
|
scrollPos: scrollForHistory,
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = window.location.pathname;
|
||||||
|
const browserHistoryState = window.history.state?.appState;
|
||||||
|
const needsPush = !browserHistoryState ||
|
||||||
|
browserHistoryState.category !== newCategory ||
|
||||||
|
browserHistoryState.page !== newPage ||
|
||||||
|
browserHistoryState.searchQuery !== newSearchQuery;
|
||||||
|
|
||||||
|
if (replace) {
|
||||||
|
window.history.replaceState({ appState: historyState }, '', url);
|
||||||
|
} else if (needsPush) {
|
||||||
|
window.history.pushState({ appState: historyState }, '', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPersistedParams(prev => {
|
||||||
|
const newCategoryHistory = { ...prev.categoryHistory };
|
||||||
|
const oldCategoryState = prev.lastKnownState.category;
|
||||||
|
|
||||||
|
if (oldCategoryState && oldCategoryState !== newCategory) {
|
||||||
|
newCategoryHistory[oldCategoryState] = {
|
||||||
|
...newCategoryHistory[oldCategoryState],
|
||||||
|
scrollPos: window.scrollY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
newCategoryHistory[newCategory] = {
|
||||||
|
...newCategoryHistory[newCategory],
|
||||||
|
lastPage: newPage,
|
||||||
|
scrollPos: scrollForHistory,
|
||||||
|
...(newSearchQuery && { searchQuery: newSearchQuery }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
lastKnownState: historyState,
|
||||||
|
categoryHistory: newCategoryHistory,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { historyState, scrollPosForFetch: preserveScroll ? scrollForHistory : 0 };
|
||||||
|
}, [setPersistedParams]);
|
||||||
|
|
||||||
|
const handlePopState = useCallback((event, onStateChange) => {
|
||||||
|
if (event.state && event.state.appState) {
|
||||||
|
const { category, page, searchQuery, scrollPos } = event.state.appState;
|
||||||
|
setIsPopStateNav(true);
|
||||||
|
|
||||||
|
setActiveCategory(category);
|
||||||
|
setCurrentPage(page);
|
||||||
|
setActiveSearchQuery(searchQuery);
|
||||||
|
|
||||||
|
setPersistedParams(prev => ({
|
||||||
|
lastKnownState: event.state.appState,
|
||||||
|
categoryHistory: {
|
||||||
|
...prev.categoryHistory,
|
||||||
|
[category]: {
|
||||||
|
...prev.categoryHistory[category],
|
||||||
|
lastPage: page,
|
||||||
|
scrollPos,
|
||||||
|
...(searchQuery && { searchQuery }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
onStateChange?.(category, page, searchQuery, scrollPos, true);
|
||||||
|
return { category, page, searchQuery, scrollPos, isPopState: true };
|
||||||
|
} else {
|
||||||
|
// 处理没有状态的回退
|
||||||
|
const lastState = persistedParams.lastKnownState;
|
||||||
|
const result = navigateAndFetch(lastState.category, lastState.page, lastState.searchQuery, {
|
||||||
|
replace: true,
|
||||||
|
preserveScroll: true
|
||||||
|
});
|
||||||
|
onStateChange?.(lastState.category, lastState.page, lastState.searchQuery, 0, false);
|
||||||
|
return { ...lastState, isPopState: false };
|
||||||
|
}
|
||||||
|
}, [persistedParams.lastKnownState, navigateAndFetch, setPersistedParams]);
|
||||||
|
|
||||||
|
const initializeHistory = useCallback((initialCategory, initialPage, initialSearchQuery, onStateChange) => {
|
||||||
|
if (!isMounted.current) {
|
||||||
|
isMounted.current = true;
|
||||||
|
if ('scrollRestoration' in window.history) {
|
||||||
|
window.history.scrollRestoration = 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.history.state && window.history.state.appState) {
|
||||||
|
const { category, page, searchQuery, scrollPos } = window.history.state.appState;
|
||||||
|
setIsPopStateNav(true);
|
||||||
|
setActiveCategory(category);
|
||||||
|
setCurrentPage(page);
|
||||||
|
setActiveSearchQuery(searchQuery);
|
||||||
|
|
||||||
|
setPersistedParams(prev => ({
|
||||||
|
lastKnownState: window.history.state.appState,
|
||||||
|
categoryHistory: {
|
||||||
|
...prev.categoryHistory,
|
||||||
|
[category]: {
|
||||||
|
...prev.categoryHistory[category],
|
||||||
|
lastPage: page,
|
||||||
|
scrollPos,
|
||||||
|
...(searchQuery && { searchQuery })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
onStateChange?.(category, page, searchQuery, scrollPos, true);
|
||||||
|
return { category, page, searchQuery, scrollPos, isPopState: true };
|
||||||
|
} else {
|
||||||
|
const { category, page, searchQuery } = persistedParams.lastKnownState;
|
||||||
|
const result = navigateAndFetch(category, page, searchQuery, { replace: true, preserveScroll: true });
|
||||||
|
onStateChange?.(category, page, searchQuery, 0, false);
|
||||||
|
return { category, page, searchQuery, scrollPos: 0, isPopState: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [persistedParams.lastKnownState, navigateAndFetch, setPersistedParams]);
|
||||||
|
|
||||||
|
const setupScrollListener = useCallback((onScroll) => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
scrollTimeoutRef.current = setTimeout(() => {
|
||||||
|
if (window.history.state && window.history.state.appState && !isPopStateNav) {
|
||||||
|
const newScrollPos = window.scrollY;
|
||||||
|
const currentState = window.history.state.appState;
|
||||||
|
|
||||||
|
if (currentState.scrollPos !== newScrollPos) {
|
||||||
|
const updatedState = { ...currentState, scrollPos: newScrollPos };
|
||||||
|
window.history.replaceState({ appState: updatedState }, '', window.location.href);
|
||||||
|
saveScrollPosition(currentState.category, newScrollPos, currentState.searchQuery);
|
||||||
|
onScroll?.(currentState.category, newScrollPos, currentState.searchQuery);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addListener: () => window.addEventListener('scroll', handleScroll, { passive: true }),
|
||||||
|
removeListener: () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isPopStateNav, saveScrollPosition]);
|
||||||
|
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeCategory,
|
||||||
|
currentPage,
|
||||||
|
activeSearchQuery,
|
||||||
|
isPopStateNav,
|
||||||
|
persistedParams,
|
||||||
|
navigateAndFetch,
|
||||||
|
handlePopState,
|
||||||
|
initializeHistory,
|
||||||
|
setupScrollListener,
|
||||||
|
saveScrollPosition,
|
||||||
|
cleanup
|
||||||
|
};
|
||||||
|
};
|
||||||
124
src/hooks/useMovieList.js
Normal file
124
src/hooks/useMovieList.js
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
// useMovieList.js - 电影列表管理Hook
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
export const useMovieList = () => {
|
||||||
|
const [movies, setMovies] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
page: 1,
|
||||||
|
total: 0,
|
||||||
|
pages: 1
|
||||||
|
});
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const abortControllerRef = useRef(null);
|
||||||
|
|
||||||
|
const fetchMovies = useCallback(async (category, page, search = '') => {
|
||||||
|
// 取消之前的请求
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的取消控制器
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let apiUrl = `/movie/list?page=${page}&limit=${LIMIT}`;
|
||||||
|
|
||||||
|
// 构建URL参数
|
||||||
|
if (search && search.trim()) {
|
||||||
|
apiUrl += `&search=${encodeURIComponent(search.trim())}`;
|
||||||
|
} else if (category === '最新添加') {
|
||||||
|
apiUrl += `&sort=created_time&order=desc`;
|
||||||
|
} else if (category && category !== 'search') {
|
||||||
|
apiUrl += `&category=${encodeURIComponent(category)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get(apiUrl, {
|
||||||
|
signal: abortControllerRef.current.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items, total } = response.data.data;
|
||||||
|
const totalPages = Math.ceil(total / LIMIT);
|
||||||
|
|
||||||
|
// 处理空页面情况
|
||||||
|
if (items.length === 0 && page > 1) {
|
||||||
|
console.log("Empty page, current page has no items:", category, page);
|
||||||
|
setMovies([]);
|
||||||
|
setPagination({ page, total, pages: totalPages });
|
||||||
|
} else {
|
||||||
|
setMovies(items);
|
||||||
|
setPagination({ page, total, pages: totalPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
// 请求被取消,不处理错误
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('获取电影数据失败:', error);
|
||||||
|
setError(error.message || '获取电影列表失败');
|
||||||
|
setMovies([]);
|
||||||
|
setPagination({ page: 1, total: 0, pages: 1 });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMovieOperation = useCallback(async (operation, ...args) => {
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case 'rename':
|
||||||
|
response = await axios.post('/movie/rename', {
|
||||||
|
old_name: args[0],
|
||||||
|
new_name: args[1]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
response = await axios.post('/movie/delete', {
|
||||||
|
file_name: args[0]
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('未知的操作类型');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.code === 200) {
|
||||||
|
// 操作成功后重新获取当前列表
|
||||||
|
await fetchMovies(pagination.category, pagination.page, pagination.search);
|
||||||
|
return { success: true, message: response.data.message };
|
||||||
|
} else {
|
||||||
|
throw new Error(response.data.message || '操作失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('电影操作错误:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [fetchMovies, pagination]);
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
movies,
|
||||||
|
loading,
|
||||||
|
pagination,
|
||||||
|
error,
|
||||||
|
fetchMovies,
|
||||||
|
handleMovieOperation,
|
||||||
|
cleanup
|
||||||
|
};
|
||||||
|
};
|
||||||
163
src/hooks/useSearch.js
Normal file
163
src/hooks/useSearch.js
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
// useSearch.js - 搜索功能管理Hook
|
||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export const useSearch = () => {
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchSuggestions, setSearchSuggestions] = useState([]);
|
||||||
|
const searchTimeoutRef = useRef(null);
|
||||||
|
const abortControllerRef = useRef(null);
|
||||||
|
|
||||||
|
// 生成搜索建议(基于localStorage中保存的历史搜索)
|
||||||
|
const generateSearchSuggestions = useCallback((query) => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setSearchSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从localStorage获取历史搜索记录
|
||||||
|
try {
|
||||||
|
const searchHistory = JSON.parse(localStorage.getItem('searchHistory') || '[]');
|
||||||
|
const recentSearches = searchHistory
|
||||||
|
.filter(item => item.query.toLowerCase().includes(query.toLowerCase()))
|
||||||
|
.slice(0, 5) // 最多显示5个建议
|
||||||
|
.map(item => ({
|
||||||
|
query: item.query,
|
||||||
|
timestamp: item.timestamp
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSearchSuggestions(recentSearches);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse search history:', error);
|
||||||
|
setSearchSuggestions([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 更新搜索输入
|
||||||
|
const updateSearchInput = useCallback((value) => {
|
||||||
|
setSearchInput(value);
|
||||||
|
|
||||||
|
// 清除之前的定时器
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 延迟生成搜索建议
|
||||||
|
searchTimeoutRef.current = setTimeout(() => {
|
||||||
|
generateSearchSuggestions(value);
|
||||||
|
}, 300);
|
||||||
|
}, [generateSearchSuggestions]);
|
||||||
|
|
||||||
|
// 执行搜索
|
||||||
|
const executeSearch = useCallback(async (query, searchFunction) => {
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
if (!trimmedQuery) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 保存搜索历史
|
||||||
|
const searchHistory = JSON.parse(localStorage.getItem('searchHistory') || '[]');
|
||||||
|
const newSearchItem = {
|
||||||
|
query: trimmedQuery,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 去重并保留最近10条记录
|
||||||
|
const updatedHistory = [
|
||||||
|
newSearchItem,
|
||||||
|
...searchHistory.filter(item => item.query !== trimmedQuery)
|
||||||
|
].slice(0, 10);
|
||||||
|
|
||||||
|
localStorage.setItem('searchHistory', JSON.stringify(updatedHistory));
|
||||||
|
|
||||||
|
// 执行实际的搜索
|
||||||
|
await searchFunction(trimmedQuery);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search execution failed:', error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 清除搜索
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchSuggestions([]);
|
||||||
|
setIsSearching(false);
|
||||||
|
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 键盘事件处理
|
||||||
|
const handleKeyDown = useCallback((event, searchFunction) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'Enter':
|
||||||
|
event.preventDefault();
|
||||||
|
executeSearch(searchInput, searchFunction);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault();
|
||||||
|
clearSearch();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [searchInput, executeSearch, clearSearch]);
|
||||||
|
|
||||||
|
// 获取搜索历史统计
|
||||||
|
const getSearchStats = useCallback(() => {
|
||||||
|
try {
|
||||||
|
const searchHistory = JSON.parse(localStorage.getItem('searchHistory') || '[]');
|
||||||
|
return {
|
||||||
|
totalSearches: searchHistory.length,
|
||||||
|
recentSearches: searchHistory.slice(0, 5)
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
totalSearches: 0,
|
||||||
|
recentSearches: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 清除搜索历史
|
||||||
|
const clearSearchHistory = useCallback(() => {
|
||||||
|
localStorage.removeItem('searchHistory');
|
||||||
|
setSearchSuggestions([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 清理函数
|
||||||
|
const cleanup = useCallback(() => {
|
||||||
|
if (searchTimeoutRef.current) {
|
||||||
|
clearTimeout(searchTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchInput,
|
||||||
|
isSearching,
|
||||||
|
searchSuggestions,
|
||||||
|
updateSearchInput,
|
||||||
|
executeSearch,
|
||||||
|
clearSearch,
|
||||||
|
handleKeyDown,
|
||||||
|
getSearchStats,
|
||||||
|
clearSearchHistory,
|
||||||
|
cleanup
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user