// ...existing code... import React, { useState, useEffect, useContext, useCallback, useRef } from 'react'; import axios from 'axios'; 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'; import IconButton from '@mui/material/IconButton'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; import ConfigContext from './Config'; import MovieCard from './components/MovieCard'; import CategoryNav from './components/CategoryNav'; // ...existing code... // 分类配置 const categories = [ { label: '15min', value: '15min' }, { label: '30min', value: '30min' }, { label: '60min', value: '60min' }, { label: '大于60min', value: '大于60min' }, { label: '最新添加', value: '最新添加' }, ]; 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); const [loading, setLoading] = useState(true); const [movies, setMovies] = useState([]); const [pagination, setPagination] = useState({ page: 1, total: 0, pages: 1 }); const [searchInput, setSearchInput] = useState(''); 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 }); } else { const lastState = persistedParams.lastKnownState; navigateAndFetch(lastState.category, lastState.page, lastState.searchQuery, { replace: true, preserveScroll: true }); } }; 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 }); } } return () => { window.removeEventListener('popstate', handlePopState); clearTimeout(scrollTimeoutRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [navigateAndFetch, setPersistedParams]); 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 }); } return () => { window.removeEventListener('scroll', handleScroll); clearTimeout(scrollTimeoutRef.current); }; }, [loading, movies.length, setPersistedParams, isPopStateNav, scrollTimeoutRef]); 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; } else { targetPage = persistedParams.categoryHistory[newCategory]?.lastPage || 1; } // 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]); 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; }); navigateAndFetch(SEARCH_CATEGORY, 1, trimmedSearch, { preserveScroll: false }); }, [searchInput, navigateAndFetch, setPersistedParams]); 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; // 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]); // MODIFIED handleRename const handleRename = useCallback(async (oldName, newName) => { const scrollPosBeforeRename = window.scrollY; // 1. Capture scroll position 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 }); // 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 } }, [fetchMovies, activeCategory, currentPage, activeSearchQuery]); // Added fetchMovies dependency 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]); const handleSearchChange = (e) => setSearchInput(e.target.value); const handleSearchKeyDown = (e) => { if (e.key === 'Enter') handleSearchSubmit(); }; const paginationComponent = !loading && movies.length > 0 && pagination.pages > 1 && ( ); return ( , endAdornment: searchInput && ( ) }} sx={{ mb: 2 }} /> {activeCategory === SEARCH_CATEGORY && activeSearchQuery && (
搜索: "{activeSearchQuery}" ({pagination.total} 个结果)
)} {paginationComponent} {loading && } {!loading && movies.length === 0 && (
{activeCategory === SEARCH_CATEGORY && (searchInput || activeSearchQuery) ? `没有找到关于 "${searchInput || activeSearchQuery}" 的结果。` : '没有找到结果。'}
)} {!loading && movies.length > 0 && ( {movies.map(item => ( ))} )} {paginationComponent}
); }; export default Main;