- {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
+ };
+};