diff --git a/VERSION.txt b/VERSION.txt index 5e3eb7a..be8c342 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -20250731130730 \ No newline at end of file +20250731215708 \ No newline at end of file diff --git a/src/app/douban/page.tsx b/src/app/douban/page.tsx index 8e99d6e..65cd3f7 100644 --- a/src/app/douban/page.tsx +++ b/src/app/douban/page.tsx @@ -1,4 +1,4 @@ -/* eslint-disable no-console,react-hooks/exhaustive-deps */ +/* eslint-disable no-console,react-hooks/exhaustive-deps,@typescript-eslint/no-explicit-any */ 'use client'; @@ -10,6 +10,7 @@ import { getDoubanCategories, getDoubanList } from '@/lib/douban.client'; import { DoubanItem, DoubanResult } from '@/lib/types'; import DoubanCardSkeleton from '@/components/DoubanCardSkeleton'; +import DoubanCustomSelector from '@/components/DoubanCustomSelector'; import DoubanSelector from '@/components/DoubanSelector'; import PageLayout from '@/components/PageLayout'; import VideoCard from '@/components/VideoCard'; @@ -27,9 +28,11 @@ function DoubanPageClient() { const debounceTimeoutRef = useRef(null); const type = searchParams.get('type') || 'movie'; - const tag = searchParams.get('tag') || ''; - const custom = searchParams.get('custom') === 'true'; - const name = searchParams.get('name') || ''; + + // 获取 runtimeConfig 中的自定义分类数据 + const [customCategories, setCustomCategories] = useState< + Array<{ name: string; type: 'movie' | 'tv'; query: string }> + >([]); // 选择器状态 - 完全独立,不依赖URL参数 const [primarySelection, setPrimarySelection] = useState(() => { @@ -42,6 +45,14 @@ function DoubanPageClient() { return '全部'; }); + // 获取自定义分类数据 + useEffect(() => { + const runtimeConfig = (window as any).RUNTIME_CONFIG; + if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { + setCustomCategories(runtimeConfig.CUSTOM_CATEGORIES); + } + }, []); + // 初始化时标记选择器为准备好状态 useEffect(() => { // 短暂延迟确保初始状态设置完成 @@ -56,23 +67,42 @@ function DoubanPageClient() { useEffect(() => { setSelectorsReady(false); setLoading(true); // 立即显示loading状态 - }, [type, tag]); + }, [type]); // 当type变化时重置选择器状态 useEffect(() => { - // 批量更新选择器状态 - if (type === 'movie') { - setPrimarySelection('热门'); - setSecondarySelection('全部'); - } else if (type === 'tv') { - setPrimarySelection(''); - setSecondarySelection('tv'); - } else if (type === 'show') { - setPrimarySelection(''); - setSecondarySelection('show'); + if (type === 'custom' && customCategories.length > 0) { + // 自定义分类模式:根据 customCategories 设置初始状态 + const types = Array.from( + new Set(customCategories.map((cat) => cat.type)) + ); + if (types.length > 0) { + const firstType = types[0]; + setPrimarySelection(firstType); + + // 设置第一个分类的 query 作为二级选择 + const firstCategory = customCategories.find( + (cat) => cat.type === firstType + ); + if (firstCategory) { + setSecondarySelection(firstCategory.query); + } + } } else { - setPrimarySelection(''); - setSecondarySelection('全部'); + // 原有逻辑 + if (type === 'movie') { + setPrimarySelection('热门'); + setSecondarySelection('全部'); + } else if (type === 'tv') { + setPrimarySelection(''); + setSecondarySelection('tv'); + } else if (type === 'show') { + setPrimarySelection(''); + setSecondarySelection('show'); + } else { + setPrimarySelection(''); + setSecondarySelection('全部'); + } } // 使用短暂延迟确保状态更新完成后标记选择器准备好 @@ -81,7 +111,7 @@ function DoubanPageClient() { }, 50); return () => clearTimeout(timer); - }, [type, tag, custom]); + }, [type, customCategories]); // 生成骨架屏数据 const skeletonData = Array.from({ length: 25 }, (_, index) => index); @@ -117,13 +147,24 @@ function DoubanPageClient() { try { setLoading(true); let data: DoubanResult; - if (custom) { - data = await getDoubanList({ - tag, - type, - pageLimit: 25, - pageStart: 0, - }); + + if (type === 'custom') { + // 自定义分类模式:根据选中的一级和二级选项获取对应的分类 + const selectedCategory = customCategories.find( + (cat) => + cat.type === primarySelection && cat.query === secondarySelection + ); + + if (selectedCategory) { + data = await getDoubanList({ + tag: selectedCategory.query, + type: selectedCategory.type, + pageLimit: 25, + pageStart: 0, + }); + } else { + throw new Error('没有找到对应的分类'); + } } else { data = await getDoubanCategories(getRequestParams(0)); } @@ -140,17 +181,16 @@ function DoubanPageClient() { } }, [ type, - tag, - custom, primarySelection, secondarySelection, getRequestParams, + customCategories, ]); // 只在选择器准备好后才加载数据 useEffect(() => { // 只有在选择器准备好时才开始加载 - if (!selectorsReady && !custom) { + if (!selectorsReady) { return; } @@ -179,8 +219,6 @@ function DoubanPageClient() { }, [ selectorsReady, type, - tag, - custom, primarySelection, secondarySelection, loadInitialData, @@ -194,13 +232,24 @@ function DoubanPageClient() { setIsLoadingMore(true); let data: DoubanResult; - if (custom) { - data = await getDoubanList({ - tag, - type, - pageLimit: 25, - pageStart: currentPage * 25, - }); + if (type === 'custom') { + // 自定义分类模式:根据选中的一级和二级选项获取对应的分类 + const selectedCategory = customCategories.find( + (cat) => + cat.type === primarySelection && + cat.query === secondarySelection + ); + + if (selectedCategory) { + data = await getDoubanList({ + tag: selectedCategory.query, + type: selectedCategory.type, + pageLimit: 25, + pageStart: currentPage * 25, + }); + } else { + throw new Error('没有找到对应的分类'); + } } else { data = await getDoubanCategories( getRequestParams(currentPage * 25) @@ -222,7 +271,13 @@ function DoubanPageClient() { fetchMoreData(); } - }, [currentPage, type, tag, custom, primarySelection, secondarySelection]); + }, [ + currentPage, + type, + primarySelection, + secondarySelection, + customCategories, + ]); // 设置滚动监听 useEffect(() => { @@ -261,10 +316,25 @@ function DoubanPageClient() { // 只有当值真正改变时才设置loading状态 if (value !== primarySelection) { setLoading(true); - setPrimarySelection(value); + + // 如果是自定义分类模式,同时更新一级和二级选择器 + if (type === 'custom' && customCategories.length > 0) { + const firstCategory = customCategories.find( + (cat) => cat.type === value + ); + if (firstCategory) { + // 批量更新状态,避免多次触发数据加载 + setPrimarySelection(value); + setSecondarySelection(firstCategory.query); + } else { + setPrimarySelection(value); + } + } else { + setPrimarySelection(value); + } } }, - [primarySelection] + [primarySelection, type, customCategories] ); const handleSecondaryChange = useCallback( @@ -280,19 +350,18 @@ function DoubanPageClient() { const getPageTitle = () => { // 根据 type 生成标题 - if (name) { - return name; - } - if (custom) { - return tag; - } - return type === 'movie' ? '电影' : type === 'tv' ? '电视剧' : '综艺'; + return type === 'movie' + ? '电影' + : type === 'tv' + ? '电视剧' + : type === 'show' + ? '综艺' + : '自定义'; }; const getActivePath = () => { const params = new URLSearchParams(); if (type) params.set('type', type); - if (tag) params.set('tag', tag); const queryString = params.toString(); const activePath = `/douban${queryString ? `?${queryString}` : ''}`; @@ -314,8 +383,8 @@ function DoubanPageClient() {

- {/* 选择器组件 - custom 模式下不显示 */} - {!custom && ( + {/* 选择器组件 */} + {type !== 'custom' ? (
+ ) : ( +
+ +
)} {/* 内容展示区域 */}
{/* 内容网格 */} -
- {loading || (!selectorsReady && !custom) +
+ {loading || !selectorsReady ? // 显示骨架屏 skeletonData.map((index) => ) : // 显示实际数据 diff --git a/src/components/DoubanCustomSelector.tsx b/src/components/DoubanCustomSelector.tsx new file mode 100644 index 0000000..9c200aa --- /dev/null +++ b/src/components/DoubanCustomSelector.tsx @@ -0,0 +1,256 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + +'use client'; + +import React, { useEffect, useRef, useState } from 'react'; + +interface CustomCategory { + name: string; + type: 'movie' | 'tv'; + query: string; +} + +interface DoubanCustomSelectorProps { + customCategories: CustomCategory[]; + primarySelection?: string; + secondarySelection?: string; + onPrimaryChange: (value: string) => void; + onSecondaryChange: (value: string) => void; +} + +const DoubanCustomSelector: React.FC = ({ + customCategories, + primarySelection, + secondarySelection, + onPrimaryChange, + onSecondaryChange, +}) => { + // 为不同的选择器创建独立的refs和状态 + const primaryContainerRef = useRef(null); + const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]); + const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{ + left: number; + width: number; + }>({ left: 0, width: 0 }); + + const secondaryContainerRef = useRef(null); + const secondaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]); + const [secondaryIndicatorStyle, setSecondaryIndicatorStyle] = useState<{ + left: number; + width: number; + }>({ left: 0, width: 0 }); + + // 根据 customCategories 生成一级选择器选项(按 type 分组) + const primaryOptions = React.useMemo(() => { + const types = Array.from(new Set(customCategories.map((cat) => cat.type))); + return types.map((type) => ({ + label: type === 'movie' ? '电影' : '剧集', + value: type, + })); + }, [customCategories]); + + // 根据选中的一级选项生成二级选择器选项 + const secondaryOptions = React.useMemo(() => { + if (!primarySelection) return []; + return customCategories + .filter((cat) => cat.type === primarySelection) + .map((cat) => ({ + label: cat.name || cat.query, + value: cat.query, + })); + }, [customCategories, primarySelection]); + + // 更新指示器位置的通用函数 + const updateIndicatorPosition = ( + activeIndex: number, + containerRef: React.RefObject, + buttonRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>, + setIndicatorStyle: React.Dispatch< + React.SetStateAction<{ left: number; width: number }> + > + ) => { + if ( + activeIndex >= 0 && + buttonRefs.current[activeIndex] && + containerRef.current + ) { + const timeoutId = setTimeout(() => { + const button = buttonRefs.current[activeIndex]; + const container = containerRef.current; + if (button && container) { + const buttonRect = button.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + if (buttonRect.width > 0) { + setIndicatorStyle({ + left: buttonRect.left - containerRect.left, + width: buttonRect.width, + }); + } + } + }, 0); + return () => clearTimeout(timeoutId); + } + }; + + // 组件挂载时立即计算初始位置 + useEffect(() => { + // 主选择器初始位置 + if (primaryOptions.length > 0) { + const activeIndex = primaryOptions.findIndex( + (opt) => opt.value === (primarySelection || primaryOptions[0].value) + ); + updateIndicatorPosition( + activeIndex, + primaryContainerRef, + primaryButtonRefs, + setPrimaryIndicatorStyle + ); + } + + // 副选择器初始位置 + if (secondaryOptions.length > 0) { + const activeIndex = secondaryOptions.findIndex( + (opt) => opt.value === (secondarySelection || secondaryOptions[0].value) + ); + updateIndicatorPosition( + activeIndex, + secondaryContainerRef, + secondaryButtonRefs, + setSecondaryIndicatorStyle + ); + } + }, [primaryOptions, secondaryOptions]); // 当选项变化时重新计算 + + // 监听主选择器变化 + useEffect(() => { + if (primaryOptions.length > 0) { + const activeIndex = primaryOptions.findIndex( + (opt) => opt.value === primarySelection + ); + const cleanup = updateIndicatorPosition( + activeIndex, + primaryContainerRef, + primaryButtonRefs, + setPrimaryIndicatorStyle + ); + return cleanup; + } + }, [primarySelection, primaryOptions]); + + // 监听副选择器变化 + useEffect(() => { + if (secondaryOptions.length > 0) { + const activeIndex = secondaryOptions.findIndex( + (opt) => opt.value === secondarySelection + ); + const cleanup = updateIndicatorPosition( + activeIndex, + secondaryContainerRef, + secondaryButtonRefs, + setSecondaryIndicatorStyle + ); + return cleanup; + } + }, [secondarySelection, secondaryOptions]); + + // 渲染胶囊式选择器 + const renderCapsuleSelector = ( + options: { label: string; value: string }[], + activeValue: string | undefined, + onChange: (value: string) => void, + isPrimary = false + ) => { + const containerRef = isPrimary + ? primaryContainerRef + : secondaryContainerRef; + const buttonRefs = isPrimary ? primaryButtonRefs : secondaryButtonRefs; + const indicatorStyle = isPrimary + ? primaryIndicatorStyle + : secondaryIndicatorStyle; + + return ( +
+ {/* 滑动的白色背景指示器 */} + {indicatorStyle.width > 0 && ( +
+ )} + + {options.map((option, index) => { + const isActive = activeValue === option.value; + return ( + + ); + })} +
+ ); + }; + + // 如果没有自定义分类,则不渲染任何内容 + if (!customCategories || customCategories.length === 0) { + return null; + } + + return ( +
+ {/* 两级选择器包装 */} +
+ {/* 一级选择器 */} +
+ + 类型 + +
+ {renderCapsuleSelector( + primaryOptions, + primarySelection || primaryOptions[0]?.value, + onPrimaryChange, + true + )} +
+
+ + {/* 二级选择器 */} + {secondaryOptions.length > 0 && ( +
+ + 片单 + +
+ {renderCapsuleSelector( + secondaryOptions, + secondarySelection || secondaryOptions[0]?.value, + onSecondaryChange, + false + )} +
+
+ )} +
+
+ ); +}; + +export default DoubanCustomSelector; diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 70aae9b..8bb188a 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -42,23 +42,20 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { useEffect(() => { const runtimeConfig = (window as any).RUNTIME_CONFIG; - if (runtimeConfig?.CUSTOM_CATEGORIES) { + if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { setNavItems((prevItems) => [ ...prevItems, - ...runtimeConfig.CUSTOM_CATEGORIES.map((category: any) => ({ + { icon: Star, - label: category.name || category.query, - href: `/douban?type=${category.type}&tag=${category.query}${ - category.name ? `&name=${category.name}` : '' - }&custom=true`, - })), + label: '自定义', + href: '/douban?type=custom', + }, ]); } }, []); const isActive = (href: string) => { const typeMatch = href.match(/type=([^&]+)/)?.[1]; - const tagMatch = href.match(/tag=([^&]+)/)?.[1]; // 解码URL以进行正确的比较 const decodedActive = decodeURIComponent(currentActive); @@ -67,9 +64,7 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => { return ( decodedActive === decodedItemHref || (decodedActive.startsWith('/douban') && - decodedActive.includes(`type=${typeMatch}`) && - tagMatch && - decodedActive.includes(`tag=${tagMatch}`)) + decodedActive.includes(`type=${typeMatch}`)) ); }; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index c3f3947..babd073 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -144,16 +144,14 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { useEffect(() => { const runtimeConfig = (window as any).RUNTIME_CONFIG; - if (runtimeConfig?.CUSTOM_CATEGORIES) { + if (runtimeConfig?.CUSTOM_CATEGORIES?.length > 0) { setMenuItems((prevItems) => [ ...prevItems, - ...runtimeConfig.CUSTOM_CATEGORIES.map((category: any) => ({ + { icon: Star, - label: category.name || category.query, - href: `/douban?type=${category.type}&tag=${category.query}${ - category.name ? `&name=${category.name}` : '' - }&custom=true`, - })), + label: '自定义', + href: '/douban?type=custom', + }, ]); } }, []); @@ -242,7 +240,6 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { {menuItems.map((item) => { // 检查当前路径是否匹配这个菜单项 const typeMatch = item.href.match(/type=([^&]+)/)?.[1]; - const tagMatch = item.href.match(/tag=([^&]+)/)?.[1]; // 解码URL以进行正确的比较 const decodedActive = decodeURIComponent(active); @@ -251,9 +248,7 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => { const isActive = decodedActive === decodedItemHref || (decodedActive.startsWith('/douban') && - decodedActive.includes(`type=${typeMatch}`) && - tagMatch && - decodedActive.includes(`tag=${tagMatch}`)); + decodedActive.includes(`type=${typeMatch}`)); const Icon = item.icon; return (