diff --git a/package-lock.json b/package-lock.json index 550feb3..4a99d91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "version": "7.22.6", "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.22.6.tgz", "integrity": "sha512-HPIyDa6n+HKw5dEuway3vVAhBboYCtREBMp+IWeseZy6TFtzn6MHkCH2KKYUOC/vKKwgSMHQW4htBOrmuRPXfw==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.5", @@ -715,6 +716,7 @@ "version": "7.22.5", "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.22.5.tgz", "integrity": "sha512-9RdCl0i+q0QExayk2nOS7853w08yLucnnPML6EN9S8fgMPVtdLDCdx/cOQ/i44Lb9UeQX9A35yaqBBOMMZxPxQ==", + "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.22.5" }, @@ -1524,6 +1526,7 @@ "version": "7.22.5", "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.22.5.tgz", "integrity": "sha512-rog5gZaVbUip5iWDMTYbVM15XQq+RkUKhET/IHR6oizR+JEoN6CAfTTuHcK4vwUyzca30qqHqEpzBOnaRMWYMA==", + "peer": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.22.5", "@babel/helper-module-imports": "^7.22.5", @@ -2256,6 +2259,7 @@ "version": "11.11.1", "resolved": "https://registry.npmmirror.com/@emotion/react/-/react-11.11.1.tgz", "integrity": "sha512-5mlW1DquU5HaxjLkfkGN1GA/fvVGdyHURRiX/0FHl2cfIfRxSOfmxEH5YS43edp0OldZrZ+dkBKbngxcNCdZvA==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -2296,6 +2300,7 @@ "version": "11.11.0", "resolved": "https://registry.npmmirror.com/@emotion/styled/-/styled-11.11.0.tgz", "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.11.0", @@ -3221,6 +3226,7 @@ "version": "5.13.7", "resolved": "https://registry.npmmirror.com/@mui/material/-/material-5.13.7.tgz", "integrity": "sha512-+n453jDDm88zZM3b5YK29nZ7gXY+s+rryH9ovDbhmfSkOlFtp+KSqbXy5cTaC/UlDqDM7sYYJGq8BmJov3v9Tg==", + "peer": true, "dependencies": { "@babel/runtime": "^7.22.5", "@mui/base": "5.0.0-beta.6", @@ -3778,7 +3784,6 @@ "version": "9.3.1", "resolved": "https://registry.npmmirror.com/@testing-library/dom/-/dom-9.3.1.tgz", "integrity": "sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -3797,7 +3802,6 @@ "version": "4.3.0", "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3809,7 +3813,6 @@ "version": "5.1.3", "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.1.3.tgz", "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "peer": true, "dependencies": { "deep-equal": "^2.0.5" } @@ -3818,7 +3821,6 @@ "version": "4.1.2", "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3831,7 +3833,6 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3842,14 +3843,12 @@ "node_modules/@testing-library/dom/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "peer": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/@testing-library/dom/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "peer": true, "engines": { "node": ">=8" } @@ -3858,7 +3857,6 @@ "version": "7.2.0", "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -4526,6 +4524,7 @@ "version": "18.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.2.14.tgz", "integrity": "sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==", + "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4657,6 +4656,7 @@ "version": "5.61.0", "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.61.0.tgz", "integrity": "sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.4.0", "@typescript-eslint/scope-manager": "5.61.0", @@ -4700,6 +4700,7 @@ "version": "5.61.0", "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-5.61.0.tgz", "integrity": "sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.61.0", "@typescript-eslint/types": "5.61.0", @@ -4997,6 +4998,7 @@ "version": "8.9.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.9.0.tgz", "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5083,6 +5085,7 @@ "version": "6.12.6", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5813,6 +5816,7 @@ "version": "4.21.9", "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.21.9.tgz", "integrity": "sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001503", "electron-to-chromium": "^1.4.431", @@ -5909,9 +5913,24 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001512", - "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001512.tgz", - "integrity": "sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw==" + "version": "1.0.30001754", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz", + "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": { "version": "2.4.0", @@ -6388,6 +6407,7 @@ "version": "8.12.0", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -7236,6 +7256,7 @@ "version": "8.44.0", "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.44.0.tgz", "integrity": "sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", @@ -7614,6 +7635,7 @@ "version": "8.12.0", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -9606,6 +9628,7 @@ "version": "27.5.1", "resolved": "https://registry.npmmirror.com/jest/-/jest-27.5.1.tgz", "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "peer": true, "dependencies": { "@jest/core": "^27.5.1", "import-local": "^3.0.2", @@ -11859,6 +11882,7 @@ "version": "8.12.0", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -12557,6 +12581,7 @@ "version": "8.4.24", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.24.tgz", "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==", + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13525,6 +13550,7 @@ "version": "6.0.13", "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -13835,6 +13861,7 @@ "version": "18.2.0", "resolved": "https://registry.npmmirror.com/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -13970,6 +13997,7 @@ "version": "18.2.0", "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -14012,6 +14040,7 @@ "version": "0.11.0", "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.11.0.tgz", "integrity": "sha512-F27qZr8uUqwhWZboondsPx8tnC3Ct3SxZA3V5WyEvujRyyNv0VYPhoBg1gZ8/MV5tubQp76Trw8lTv9hzRBa+A==", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14451,6 +14480,7 @@ "version": "2.79.1", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-2.79.1.tgz", "integrity": "sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw==", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -15734,6 +15764,7 @@ "version": "0.21.3", "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "peer": true, "engines": { "node": ">=10" } @@ -16024,6 +16055,7 @@ "version": "5.88.1", "resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.88.1.tgz", "integrity": "sha512-FROX3TxQnC/ox4N+3xQoWZzvGXSuscxR32rbzjpXgEzWudJFEJBpdlkkob2ylrv5yzzufD1zph1OoFsLtm6stQ==", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -16084,6 +16116,7 @@ "version": "8.12.0", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16125,6 +16158,7 @@ "version": "4.15.1", "resolved": "https://registry.npmmirror.com/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -16179,6 +16213,7 @@ "version": "8.12.0", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -16499,6 +16534,7 @@ "version": "8.12.0", "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", diff --git a/src/Main.jsx b/src/Main.jsx index 51cd82a..b48e5e1 100644 --- a/src/Main.jsx +++ b/src/Main.jsx @@ -1,10 +1,9 @@ -// ...existing code... +// Main.jsx - 重构后的主组件 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 Grid from '@mui/material/Grid'; import Pagination from '@mui/material/Pagination'; -import { Link } from 'react-router-dom'; import CircularProgress from '@mui/material/CircularProgress'; import TextField from '@mui/material/TextField'; import InputAdornment from '@mui/material/InputAdornment'; @@ -17,7 +16,11 @@ import Alert from '@mui/material/Alert'; import ConfigContext from './Config'; import MovieCard from './components/MovieCard'; 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 = [ @@ -29,536 +32,299 @@ const categories = [ ]; const SEARCH_CATEGORY = 'search'; -const LIMIT = 20; 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 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 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(() => { - const handlePopState = (event) => { - if (event.state && event.state.appState) { - const { category, page, searchQuery, scrollPos } = event.state.appState; - isPopStateNav.current = true; - - 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 }); + const initResult = initializeHistory(DEFAULT_CATEGORY, 1, '', (category, page, searchQuery, scrollPos, isPopState) => { + if (isPopState) { + // 处理浏览器前进后退 + fetchMovies(category, page, searchQuery); } 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); - - 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 }); - } - } + window.addEventListener('popstate', handlePopStateEvent); return () => { - window.removeEventListener('popstate', handlePopState); - clearTimeout(scrollTimeoutRef.current); + window.removeEventListener('popstate', handlePopStateEvent); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [navigateAndFetch, setPersistedParams]); + }, [initializeHistory, handlePopState, fetchMovies]); + // 设置滚动监听器 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) { - 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 () => { - window.removeEventListener('scroll', handleScroll); - clearTimeout(scrollTimeoutRef.current); + if (scrollListenerRef.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) => { - // 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) { - 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 { - 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) => { - setPersistedParams(prev => ({ - ...prev, - categoryHistory: { - ...prev.categoryHistory, - [activeCategory]: { ...prev.categoryHistory[activeCategory], scrollPos: window.scrollY } - } - })); - // For page change, typically scroll to top - navigateAndFetch(activeCategory, page, activeSearchQuery, { preserveScroll: false }); - }, [activeCategory, activeSearchQuery, navigateAndFetch, setPersistedParams]); + navigateAndFetch(activeCategory, page, activeSearchQuery); + // 立即发起请求以更新列表 + fetchMovies(activeCategory, page, activeSearchQuery); + }, [activeCategory, activeSearchQuery, navigateAndFetch, fetchMovies]); + // 搜索提交处理 const handleSearchSubmit = useCallback(() => { - const trimmedSearch = searchInput.trim(); - if (!trimmedSearch) return; - setPersistedParams(prev => { - const oldCat = prev.lastKnownState.category; - if (oldCat && oldCat !== SEARCH_CATEGORY) { - return { ...prev, categoryHistory: { ...prev.categoryHistory, [oldCat]: { ...prev.categoryHistory[oldCat], scrollPos: window.scrollY } } }; - } - return prev; + executeSearch(searchInput, async (query) => { + await fetchMovies(SEARCH_CATEGORY, 1, query); + navigateAndFetch(SEARCH_CATEGORY, 1, query); }); - navigateAndFetch(SEARCH_CATEGORY, 1, trimmedSearch, { preserveScroll: false }); - }, [searchInput, navigateAndFetch, setPersistedParams]); - + }, [searchInput, executeSearch, fetchMovies, navigateAndFetch]); + // 清除搜索 const handleClearSearch = useCallback(() => { - setSearchInput(''); - if (activeCategory === SEARCH_CATEGORY) { - setPersistedParams(prev => ({ - ...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; + clearSearch(); + navigateAndFetch(DEFAULT_CATEGORY, 1, ''); + }, [clearSearch, navigateAndFetch]); - // 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 scrollPosBeforeRename = window.scrollY; // 1. Capture scroll position + const scrollPosBeforeRename = window.scrollY; try { - const response = await axios.post('/movie/rename', { old_name: oldName, new_name: newName }); - if (response.data.code === 200) { - // 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 }); + await handleMovieOperation('rename', oldName, newName); + setSuccessMessage(`文件已重命名为:${newName}`); + + // 恢复滚动位置 + 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) { - console.error('重命名错误:', error); - throw error; // Re-throw for MovieCard to handle Snackbar + throw error; // 重新抛出供MovieCard处理 } - }, [fetchMovies, activeCategory, currentPage, activeSearchQuery]); + }, [handleMovieOperation]); - // handleDelete + // 电影删除处理 const handleDelete = useCallback(async (filename) => { try { - const response = await axios.post('/movie/delete', { file_name: filename }); - if (response.data.code === 200) { - // 显示成功消息 - setSuccessMessage(`已成功删除文件:${filename}`); - - // 重新获取电影列表 - await fetchMovies(activeCategory, currentPage, activeSearchQuery, { skipScrollRestore: true }); - } else { - throw new Error(response.data.message || '删除失败'); - } + await handleMovieOperation('delete', filename); + setSuccessMessage(`已成功删除文件:${filename}`); } catch (error) { - console.error('删除错误:', error); - throw error; // Re-throw for MovieCard to handle Snackbar + throw error; // 重新抛出供MovieCard处理 } - }, [fetchMovies, activeCategory, currentPage, activeSearchQuery]); + }, [handleMovieOperation]); + // 电影卡片点击处理 const handleMovieCardClick = useCallback(() => { - if (window.history.state && window.history.state.appState) { - const currentScrollPos = window.scrollY; - const currentState = window.history.state.appState; - 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]); + // 保存当前滚动位置 + saveScrollPosition(activeCategory, window.scrollY, activeSearchQuery); + }, [activeCategory, activeSearchQuery, saveScrollPosition]); - const handleSearchChange = (e) => setSearchInput(e.target.value); - const handleSearchKeyDown = (e) => { - if (e.key === 'Enter') handleSearchSubmit(); - }; + // 搜索输入变化处理 + const handleSearchChange = useCallback((e) => { + updateSearchInput(e.target.value); + }, [updateSearchInput]); - const handleSuccessSnackbarClose = () => { + // 成功提示关闭处理 + const handleSuccessSnackbarClose = useCallback(() => { setSuccessMessage(''); - }; + }, []); - const paginationComponent = !loading && movies.length > 0 && pagination.pages > 1 && ( - - ); + // 渲染分页组件 + const renderPagination = () => { + if (loading || movies.length === 0 || pagination.pages <= 1) { + return null; + } + + return ( + + ); + }; return ( + {/* 搜索输入框 */} handleKeyDown(e, handleSearchSubmit)} InputProps={{ startAdornment: , endAdornment: searchInput && ( - + + + ) }} sx={{ mb: 2 }} /> + {/* 分类导航 */} + {/* 搜索结果提示 */} {activeCategory === SEARCH_CATEGORY && activeSearchQuery && (
搜索: "{activeSearchQuery}" ({pagination.total} 个结果)
)} - {paginationComponent} + {/* 分页组件 */} + {renderPagination()} - {loading && } + {/* 加载状态 */} + {loading && ( + + )} - {!loading && movies.length === 0 && ( -
- {activeCategory === SEARCH_CATEGORY && (searchInput || activeSearchQuery) ? `没有找到关于 "${searchInput || activeSearchQuery}" 的结果。` : '没有找到结果。'} + {/* 错误状态 */} + {movieError && ( +
+ 错误: {movieError}
)} + {/* 空状态 */} + {!loading && movies.length === 0 && ( +
+ {activeCategory === SEARCH_CATEGORY && (searchInput || activeSearchQuery) + ? `没有找到关于 "${searchInput || activeSearchQuery}" 的结果。` + : '没有找到结果。' + } +
+ )} + + {/* 电影列表 */} {!loading && movies.length > 0 && ( {movies.map(item => ( - directly). We still call + handleMovieCardClick to persist scroll position before navigating. */} + { + // allow navigation to proceed, but persist scroll state first + try { handleMovieCardClick(); } catch (err) { /* ignore */ } + }} + style={{ textDecoration: 'none', cursor: 'pointer' }} > - - + + ))} )} - {paginationComponent} + {/* 底部分页 */} + {renderPagination()} - {/* 删除成功提示 */} + {/* 成功提示 */} { +const MovieCard = React.memo(({ movie, config, onRename, onDelete }) => { + // 组件状态 const [newName, setNewName] = useState(''); const [openDialog, setOpenDialog] = useState(false); const [deleteDialog, setDeleteDialog] = useState(false); @@ -27,54 +29,73 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { const [openSnackbar, setOpenSnackbar] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const truncateFilename = (filename, maxLength) => { + // 工具函数 + const truncateFilename = useCallback((filename, maxLength) => { return filename.length > maxLength ? filename.substring(0, maxLength - 3) + '...' : 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.preventDefault(); - const lastDotIndex = movie.filename.lastIndexOf('.'); - const nameWithoutExt = lastDotIndex === -1 - ? movie.filename - : movie.filename.substring(0, lastDotIndex); + + const nameWithoutExt = extractNameWithoutExtension(movie.filename); setNewName(nameWithoutExt); setOpenDialog(true); - }; + }, [movie.filename, extractNameWithoutExtension]); - const handleDeleteClick = (e) => { + const handleDeleteClick = useCallback((e) => { e.stopPropagation(); e.preventDefault(); + setDeleteDialog(true); setDeleteConfirmText(''); - }; + }, []); - const handleDialogClose = (e) => { + const handleDialogClose = useCallback((e) => { if (e) { e.stopPropagation(); e.preventDefault(); } + setOpenDialog(false); setDeleteDialog(false); setDeleteConfirmText(''); - }; + setError(null); + }, []); - const handleRenameSubmit = async (e) => { + const handleRenameSubmit = useCallback(async (e) => { if (e) { e.stopPropagation(); e.preventDefault(); } - if (!newName.trim()) return; + const trimmedName = newName.trim(); + if (!trimmedName) { + setError('文件名不能为空'); + setOpenSnackbar(true); + return; + } try { - const lastDotIndex = movie.filename.lastIndexOf('.'); - const extension = lastDotIndex === -1 - ? '' - : movie.filename.substring(lastDotIndex); - const fullNewName = newName.trim() + extension; + const extension = getFileExtension(movie.filename); + const fullNewName = trimmedName + extension; if (fullNewName === movie.filename) { handleDialogClose(); @@ -84,17 +105,12 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { await onRename(movie.filename, fullNewName); handleDialogClose(); } catch (error) { - // 统一错误处理,优先显示后端返回的 message,其次显示 error.message,最后显示默认错误 - setError( - error.response?.data?.message || - error.message || - '重命名失败,请稍后重试' - ); + setError(error.response?.data?.message || error.message || '重命名失败,请稍后重试'); setOpenSnackbar(true); } - }; + }, [newName, movie.filename, onRename, handleDialogClose, getFileExtension]); - const handleDeleteConfirm = async () => { + const handleDeleteConfirm = useCallback(async () => { if (deleteConfirmText !== '确认删除') { setError('请输入"确认删除"来确认操作'); setOpenSnackbar(true); @@ -106,40 +122,38 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { await onDelete(movie.filename); handleDialogClose(); } catch (error) { - setError( - error.response?.data?.message || - error.message || - '删除失败,请稍后重试' - ); + setError(error.response?.data?.message || error.message || '删除失败,请稍后重试'); setOpenSnackbar(true); setIsDeleting(false); } - }; + }, [deleteConfirmText, movie.filename, onDelete, handleDialogClose]); - const handleCloseSnackbar = () => { - setOpenSnackbar(false); - }; - - const handleKeyPress = (e) => { + const handleKeyPress = useCallback((e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleRenameSubmit(e); } - }; + }, [handleRenameSubmit]); - const handleDeleteKeyPress = (e) => { + const handleDeleteKeyPress = useCallback((e) => { if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleDeleteConfirm(); } - }; + }, [handleDeleteConfirm]); - const handleDialogContentClick = (e) => { + const handleDialogContentClick = useCallback((e) => { e.stopPropagation(); - }; + }, []); + const handleCloseSnackbar = useCallback(() => { + setOpenSnackbar(false); + setError(null); + }, []); + + // 样式化组件 const StyledCard = styled(Card)({ '&:hover': { transform: 'scale(1.05)', @@ -166,7 +180,7 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { style={{ position: 'absolute', top: 5, - right: 35, // 调整位置为删除按钮留出空间 + right: 35, backgroundColor: 'rgba(255, 255, 255, 0.7)', zIndex: 2, }} @@ -183,7 +197,7 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { right: 5, backgroundColor: 'rgba(255, 255, 255, 0.7)', zIndex: 2, - color: '#f44336', // 红色删除按钮 + color: '#f44336', }} > @@ -193,9 +207,23 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { component="img" height="120" image={`${window.location.origin}/res/${movie.image}`} + alt={movie.filename} + loading="lazy" /> - {truncateFilename(movie.filename, 15)} + + {truncateFilename(movie.filename, 15)} + 时长: {movie.duration} min @@ -254,7 +282,10 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { > 确认删除 - + ⚠️ 此操作不可逆转! @@ -298,6 +329,7 @@ const MovieCard = ({ movie, config, onRename, onDelete }) => { + {/* 错误提示 */} { ); -}; +}); + +MovieCard.displayName = 'MovieCard'; export default MovieCard; diff --git a/src/hooks/useHistoryManager.js b/src/hooks/useHistoryManager.js new file mode 100644 index 0000000..6640a7e --- /dev/null +++ b/src/hooks/useHistoryManager.js @@ -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 + }; +}; diff --git a/src/hooks/useMovieList.js b/src/hooks/useMovieList.js new file mode 100644 index 0000000..aaf1959 --- /dev/null +++ b/src/hooks/useMovieList.js @@ -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 + }; +}; diff --git a/src/hooks/useSearch.js b/src/hooks/useSearch.js new file mode 100644 index 0000000..c93e1fe --- /dev/null +++ b/src/hooks/useSearch.js @@ -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 + }; +};