feat: add DoubanCustomSelector

This commit is contained in:
shinya
2025-07-31 21:57:07 +08:00
parent d47b72a20b
commit ed96ee0554
6 changed files with 401 additions and 76 deletions

View File

@@ -1 +1 @@
20250731130730
20250731215708

View File

@@ -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<NodeJS.Timeout | null>(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<string>(() => {
@@ -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() {
</p>
</div>
{/* 选择器组件 - custom 模式下不显示 */}
{!custom && (
{/* 选择器组件 */}
{type !== 'custom' ? (
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanSelector
type={type as 'movie' | 'tv' | 'show'}
@@ -325,14 +394,24 @@ function DoubanPageClient() {
onSecondaryChange={handleSecondaryChange}
/>
</div>
) : (
<div className='bg-white/60 dark:bg-gray-800/40 rounded-2xl p-4 sm:p-6 border border-gray-200/30 dark:border-gray-700/30 backdrop-blur-sm'>
<DoubanCustomSelector
customCategories={customCategories}
primarySelection={primarySelection}
secondarySelection={secondarySelection}
onPrimaryChange={handlePrimaryChange}
onSecondaryChange={handleSecondaryChange}
/>
</div>
)}
</div>
{/* 内容展示区域 */}
<div className='max-w-[95%] mx-auto mt-8 overflow-visible'>
{/* 内容网格 */}
<div className='grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fit,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || (!selectorsReady && !custom)
<div className='justify-start grid grid-cols-3 gap-x-2 gap-y-12 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,minmax(160px,1fr))] sm:gap-x-8 sm:gap-y-20'>
{loading || !selectorsReady
? // 显示骨架屏
skeletonData.map((index) => <DoubanCardSkeleton key={index} />)
: // 显示实际数据

View File

@@ -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<DoubanCustomSelectorProps> = ({
customCategories,
primarySelection,
secondarySelection,
onPrimaryChange,
onSecondaryChange,
}) => {
// 为不同的选择器创建独立的refs和状态
const primaryContainerRef = useRef<HTMLDivElement>(null);
const primaryButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [primaryIndicatorStyle, setPrimaryIndicatorStyle] = useState<{
left: number;
width: number;
}>({ left: 0, width: 0 });
const secondaryContainerRef = useRef<HTMLDivElement>(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<HTMLDivElement>,
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 (
<div
ref={containerRef}
className='relative inline-flex bg-gray-200/60 rounded-full p-0.5 sm:p-1 dark:bg-gray-700/60 backdrop-blur-sm'
>
{/* 滑动的白色背景指示器 */}
{indicatorStyle.width > 0 && (
<div
className='absolute top-0.5 bottom-0.5 sm:top-1 sm:bottom-1 bg-white dark:bg-gray-500 rounded-full shadow-sm transition-all duration-300 ease-out'
style={{
left: `${indicatorStyle.left}px`,
width: `${indicatorStyle.width}px`,
}}
/>
)}
{options.map((option, index) => {
const isActive = activeValue === option.value;
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el;
}}
onClick={() => onChange(option.value)}
className={`relative z-10 px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium rounded-full transition-all duration-200 whitespace-nowrap ${
isActive
? 'text-gray-900 dark:text-gray-100 cursor-default'
: 'text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 cursor-pointer'
}`}
>
{option.label}
</button>
);
})}
</div>
);
};
// 如果没有自定义分类,则不渲染任何内容
if (!customCategories || customCategories.length === 0) {
return null;
}
return (
<div className='space-y-4 sm:space-y-6'>
{/* 两级选择器包装 */}
<div className='space-y-3 sm:space-y-4'>
{/* 一级选择器 */}
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
primaryOptions,
primarySelection || primaryOptions[0]?.value,
onPrimaryChange,
true
)}
</div>
</div>
{/* 二级选择器 */}
{secondaryOptions.length > 0 && (
<div className='flex flex-col sm:flex-row sm:items-center gap-2'>
<span className='text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 min-w-[48px]'>
</span>
<div className='overflow-x-auto'>
{renderCapsuleSelector(
secondaryOptions,
secondarySelection || secondaryOptions[0]?.value,
onSecondaryChange,
false
)}
</div>
</div>
)}
</div>
</div>
);
};
export default DoubanCustomSelector;

View File

@@ -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}`))
);
};

View File

@@ -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 (
<Link

View File

@@ -2,7 +2,7 @@
'use client';
const CURRENT_VERSION = '20250731130730';
const CURRENT_VERSION = '20250731215708';
// 版本检查结果枚举
export enum UpdateStatus {