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 (