feat: videocard mobile action optimize

This commit is contained in:
shinya
2025-08-17 20:34:47 +08:00
parent 863a5fd0b0
commit f170066118
7 changed files with 1599 additions and 683 deletions

View File

@@ -241,34 +241,34 @@ function HomeClient() {
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
))
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotMovies.map((movie, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={movie.title}
poster={movie.poster}
douban_id={Number(movie.id)}
rate={movie.rate}
year={movie.year}
type='movie'
/>
</div>
))}
hotMovies.map((movie, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={movie.title}
poster={movie.poster}
douban_id={Number(movie.id)}
rate={movie.rate}
year={movie.year}
type='movie'
/>
</div>
))}
</ScrollableRow>
</section>
@@ -289,33 +289,33 @@ function HomeClient() {
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
))
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotTvShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
hotTvShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
</ScrollableRow>
</section>
@@ -336,61 +336,61 @@ function HomeClient() {
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 展示当前日期的番剧
(() => {
// 获取当前日期对应的星期
const today = new Date();
const weekdays = [
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
];
const currentWeekday = weekdays[today.getDay()];
// 找到当前星期对应的番剧数据
const todayAnimes =
bangumiCalendarData.find(
(item) => item.weekday.en === currentWeekday
)?.items || [];
return todayAnimes.map((anime, index) => (
<div
key={index}
key={`${anime.id}-${index}`}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
<VideoCard
from='douban'
title={anime.name_cn || anime.name}
poster={
anime.images.large ||
anime.images.common ||
anime.images.medium ||
anime.images.small ||
anime.images.grid
}
douban_id={anime.id}
rate={anime.rating?.score?.toString() || ''}
year={anime.air_date?.split('-')?.[0] || ''}
isBangumi={true}
/>
</div>
))
: // 展示当前日期的番剧
(() => {
// 获取当前日期对应的星期
const today = new Date();
const weekdays = [
'Sun',
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
];
const currentWeekday = weekdays[today.getDay()];
// 找到当前星期对应的番剧数据
const todayAnimes =
bangumiCalendarData.find(
(item) => item.weekday.en === currentWeekday
)?.items || [];
return todayAnimes.map((anime, index) => (
<div
key={`${anime.id}-${index}`}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={anime.name_cn || anime.name}
poster={
anime.images.large ||
anime.images.common ||
anime.images.medium ||
anime.images.small ||
anime.images.grid
}
douban_id={anime.id}
rate={anime.rating?.score?.toString() || ''}
year={anime.air_date?.split('-')?.[0] || ''}
isBangumi={true}
/>
</div>
));
})()}
));
})()}
</ScrollableRow>
</section>
@@ -411,33 +411,33 @@ function HomeClient() {
<ScrollableRow>
{loading
? // 加载状态显示灰色占位数据
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-lg bg-gray-200 animate-pulse dark:bg-gray-800'>
<div className='absolute inset-0 bg-gray-300 dark:bg-gray-700'></div>
</div>
))
<div className='mt-2 h-4 bg-gray-200 rounded animate-pulse dark:bg-gray-800'></div>
</div>
))
: // 显示真实数据
hotVarietyShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
hotVarietyShows.map((show, index) => (
<div
key={index}
className='min-w-[96px] w-24 sm:min-w-[180px] sm:w-44'
>
<VideoCard
from='douban'
title={show.title}
poster={show.poster}
douban_id={Number(show.id)}
rate={show.rate}
year={show.year}
/>
</div>
))}
</ScrollableRow>
</section>
</>
@@ -446,11 +446,41 @@ function HomeClient() {
</div>
{announcement && showAnnouncement && (
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${
showAnnouncement ? '' : 'opacity-0 pointer-events-none'
}`}
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm dark:bg-black/70 p-4 transition-opacity duration-300 ${showAnnouncement ? '' : 'opacity-0 pointer-events-none'
}`}
onTouchStart={(e) => {
// 如果点击的是背景区域,阻止触摸事件冒泡,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
}
}}
onTouchMove={(e) => {
// 如果触摸的是背景区域,阻止触摸移动,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
e.stopPropagation();
}
}}
onTouchEnd={(e) => {
// 如果触摸的是背景区域,阻止触摸结束事件,防止背景滚动
if (e.target === e.currentTarget) {
e.preventDefault();
}
}}
style={{
touchAction: 'none', // 禁用所有触摸操作
}}
>
<div className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'>
<div
className='w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900 transform transition-all duration-300 hover:shadow-2xl'
onTouchMove={(e) => {
// 允许公告内容区域正常滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
touchAction: 'auto', // 允许内容区域的正常触摸操作
}}
>
<div className='flex justify-between items-start mb-4'>
<h3 className='text-2xl font-bold tracking-tight text-gray-800 dark:text-white border-b border-green-500 pb-1'>

View File

@@ -0,0 +1,357 @@
import { X } from 'lucide-react';
import Image from 'next/image';
import React, { useEffect, useState } from 'react';
interface ActionItem {
id: string;
label: string;
icon: React.ReactNode;
onClick: (e?: React.MouseEvent) => void | Promise<void>;
color?: 'default' | 'danger' | 'primary';
disabled?: boolean;
}
interface MobileActionSheetProps {
isOpen: boolean;
onClose: () => void;
title: string;
actions: ActionItem[];
poster?: string;
sources?: string[]; // 播放源信息
isAggregate?: boolean; // 是否为聚合内容
sourceName?: string; // 播放源名称
currentEpisode?: number; // 当前集数
totalEpisodes?: number; // 总集数
}
const MobileActionSheet: React.FC<MobileActionSheetProps> = ({
isOpen,
onClose,
title,
actions,
poster,
sources,
isAggregate,
sourceName,
currentEpisode,
totalEpisodes,
}) => {
const [isVisible, setIsVisible] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
// 控制动画状态
useEffect(() => {
let animationId: number;
let timer: NodeJS.Timeout;
if (isOpen) {
setIsVisible(true);
// 使用双重 requestAnimationFrame 确保DOM完全渲染
animationId = requestAnimationFrame(() => {
animationId = requestAnimationFrame(() => {
setIsAnimating(true);
});
});
} else {
setIsAnimating(false);
// 等待动画完成后隐藏组件
timer = setTimeout(() => {
setIsVisible(false);
}, 200);
}
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (timer) {
clearTimeout(timer);
}
};
}, [isOpen]);
// 阻止背景滚动
useEffect(() => {
if (isVisible) {
// 保存当前滚动位置
const scrollY = window.scrollY;
const scrollX = window.scrollX;
const body = document.body;
const html = document.documentElement;
// 获取滚动条宽度
const scrollBarWidth = window.innerWidth - html.clientWidth;
// 保存原始样式
const originalBodyStyle = {
position: body.style.position,
top: body.style.top,
left: body.style.left,
right: body.style.right,
width: body.style.width,
paddingRight: body.style.paddingRight,
overflow: body.style.overflow,
};
// 设置body样式来阻止滚动但保持原位置
body.style.position = 'fixed';
body.style.top = `-${scrollY}px`;
body.style.left = `-${scrollX}px`;
body.style.right = '0';
body.style.width = '100%';
body.style.overflow = 'hidden';
body.style.paddingRight = `${scrollBarWidth}px`;
return () => {
// 恢复所有原始样式
body.style.position = originalBodyStyle.position;
body.style.top = originalBodyStyle.top;
body.style.left = originalBodyStyle.left;
body.style.right = originalBodyStyle.right;
body.style.width = originalBodyStyle.width;
body.style.paddingRight = originalBodyStyle.paddingRight;
body.style.overflow = originalBodyStyle.overflow;
// 使用 requestAnimationFrame 确保样式恢复后再滚动
requestAnimationFrame(() => {
window.scrollTo(scrollX, scrollY);
});
};
}
}, [isVisible]);
// ESC键关闭
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isVisible) {
document.addEventListener('keydown', handleEsc);
return () => document.removeEventListener('keydown', handleEsc);
}
}, [isVisible, onClose]);
if (!isVisible) return null;
const getActionColor = (color: ActionItem['color']) => {
switch (color) {
case 'danger':
return 'text-red-600 dark:text-red-400';
case 'primary':
return 'text-green-600 dark:text-green-400';
default:
return 'text-gray-700 dark:text-gray-300';
}
};
const getActionHoverColor = (color: ActionItem['color']) => {
switch (color) {
case 'danger':
return 'hover:bg-red-50/50 dark:hover:bg-red-900/10';
case 'primary':
return 'hover:bg-green-50/50 dark:hover:bg-green-900/10';
default:
return 'hover:bg-gray-50/50 dark:hover:bg-gray-800/20';
}
};
return (
<div
className="fixed inset-0 z-[9999] flex items-end justify-center"
onTouchMove={(e) => {
// 阻止最外层容器的触摸移动,防止背景滚动
e.preventDefault();
e.stopPropagation();
}}
style={{
touchAction: 'none', // 禁用所有触摸操作
}}
>
{/* 背景遮罩 */}
<div
className={`absolute inset-0 bg-black/50 transition-opacity duration-200 ease-out ${isAnimating ? 'opacity-100' : 'opacity-0'
}`}
onClick={onClose}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件(包括点击)
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
backdropFilter: 'blur(4px)',
willChange: 'opacity',
touchAction: 'none', // 禁用所有触摸操作
}}
/>
{/* 操作表单 */}
<div
className="relative w-full max-w-lg mx-4 mb-4 bg-white dark:bg-gray-900 rounded-2xl shadow-2xl transition-all duration-200 ease-out"
onTouchMove={(e) => {
// 允许操作表单内部滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
marginBottom: 'calc(1rem + env(safe-area-inset-bottom))',
willChange: 'transform, opacity',
backfaceVisibility: 'hidden', // 避免闪烁
transform: isAnimating
? 'translateY(0) translateZ(0)'
: 'translateY(100%) translateZ(0)', // 组合变换保持滑入效果和硬件加速
opacity: isAnimating ? 1 : 0,
touchAction: 'auto', // 允许操作表单内的正常触摸操作
}}
>
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-800">
<div className="flex items-center gap-3 flex-1 min-w-0">
{poster && (
<div className="relative w-12 h-16 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-800 flex-shrink-0">
<Image
src={poster}
alt={title}
fill
className="object-cover"
loading="lazy"
/>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 truncate">
{title}
</h3>
{sourceName && (
<span className="flex-shrink-0 text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800">
{sourceName}
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-150"
>
<X size={20} className="text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* 操作列表 */}
<div className="px-4 py-2">
{actions.map((action, index) => (
<div key={action.id}>
<button
onClick={() => {
action.onClick();
onClose();
}}
disabled={action.disabled}
className={`
w-full flex items-center gap-4 py-4 px-2 transition-all duration-150 ease-out
${action.disabled
? 'opacity-50 cursor-not-allowed'
: `${getActionHoverColor(action.color)} active:scale-[0.98]`
}
`}
style={{ willChange: 'transform, background-color' }}
>
{/* 图标 - 使用线条风格 */}
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
<span className={`transition-colors duration-150 ${action.disabled
? 'text-gray-400 dark:text-gray-600'
: getActionColor(action.color)
}`}>
{action.icon}
</span>
</div>
{/* 文字 */}
<span className={`
text-left font-medium text-base flex-1
${action.disabled
? 'text-gray-400 dark:text-gray-600'
: 'text-gray-900 dark:text-gray-100'
}
`}>
{action.label}
</span>
{/* 播放进度 - 只在播放按钮且有播放记录时显示 */}
{action.id === 'play' && currentEpisode && totalEpisodes && (
<span className="text-sm text-gray-500 dark:text-gray-400 font-medium">
{currentEpisode}/{totalEpisodes}
</span>
)}
</button>
{/* 分割线 - 最后一项不显示 */}
{index < actions.length - 1 && (
<div className="border-b border-gray-100 dark:border-gray-800 ml-10"></div>
)}
</div>
))}
</div>
{/* 播放源信息展示区域 */}
{isAggregate && sources && sources.length > 0 && (
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800">
{/* 标题区域 */}
<div className="mb-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{sources.length}
</p>
</div>
{/* 播放源列表 */}
<div className="max-h-32 overflow-y-auto">
<div className="grid grid-cols-2 gap-2">
{(() => {
// 优先显示的播放源
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+'];
const sortedSources = sources.sort((a, b) => {
const aIndex = prioritySources.indexOf(a);
const bIndex = prioritySources.indexOf(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
return sortedSources.map((source, index) => (
<div
key={index}
className="flex items-center gap-2 py-2 px-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-800/30"
>
<div className="w-1 h-1 bg-gray-400 dark:bg-gray-500 rounded-full flex-shrink-0" />
<span className="text-xs text-gray-600 dark:text-gray-400 truncate">
{source}
</span>
</div>
));
})()}
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default MobileActionSheet;

View File

@@ -38,6 +38,29 @@ export const UserMenu: React.FC = () => {
const [storageType, setStorageType] = useState<string>('localstorage');
const [mounted, setMounted] = useState(false);
// Body 滚动锁定 - 使用 overflow 方式避免布局问题
useEffect(() => {
if (isSettingsOpen || isChangePasswordOpen) {
const body = document.body;
const html = document.documentElement;
// 保存原始样式
const originalBodyOverflow = body.style.overflow;
const originalHtmlOverflow = html.style.overflow;
// 只设置 overflow 来阻止滚动
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
return () => {
// 恢复所有原始样式
body.style.overflow = originalBodyOverflow;
html.style.overflow = originalHtmlOverflow;
};
}
}, [isSettingsOpen, isChangePasswordOpen]);
// 设置相关状态
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
@@ -566,324 +589,347 @@ export const UserMenu: React.FC = () => {
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseSettings}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 设置面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6 overflow-y-auto'>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<div className='flex items-center gap-3'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<div
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] flex flex-col'
>
{/* 内容容器 - 独立的滚动区域 */}
<div
className='flex-1 p-6 overflow-y-auto'
data-panel-content
style={{
touchAction: 'pan-y', // 只允许垂直滚动
overscrollBehavior: 'contain', // 防止滚动冒泡
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<div className='flex items-center gap-3'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleResetSettings}
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
title='重置为默认设置'
>
</button>
</div>
<button
onClick={handleResetSettings}
className='px-2 py-1 text-xs text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 border border-red-200 hover:border-red-300 dark:border-red-800 dark:hover:border-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors'
title='重置为默认设置'
onClick={handleCloseSettings}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
<button
onClick={handleCloseSettings}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 设置项 */}
<div className='space-y-6'>
{/* 豆瓣数据源选择 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSource
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSource === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanDataSource === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanDataSource) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(getThanksInfo(doubanDataSource)!.url, '_blank')
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanDataSource)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanDataSource === 'custom' && (
{/* 设置项 */}
<div className='space-y-6'>
{/* 豆瓣数据源选择 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</div>
)}
<div className='relative' data-dropdown='douban-datasource'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() => setIsDoubanDropdownOpen(!isDoubanDropdownOpen)}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanDataSourceOptions.find(
(option) => option.value === doubanDataSource
)?.label
}
</button>
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 豆瓣图片代理设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
setIsDoubanImageProxyDropdownOpen(
!isDoubanImageProxyDropdownOpen
)
}
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
{
doubanImageProxyTypeOptions.find(
(option) => option.value === doubanImageProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
{/* 下拉选项列表 */}
{isDoubanDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanDataSourceOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanDataSourceChange(option.value);
setIsDoubanDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanDataSource === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanDataSource === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeChange(option.value);
setIsDoubanImageProxyDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyType === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
{/* 感谢信息 */}
{getThanksInfo(doubanDataSource) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(getThanksInfo(doubanDataSource)!.url, '_blank')
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanDataSource)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanImageProxyType) && (
<div className='mt-3'>
{/* 豆瓣代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanDataSource === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</div>
)}
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div>
{/* 豆瓣图片代理设置 */}
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<div className='relative' data-dropdown='douban-image-proxy'>
{/* 自定义下拉选择框 */}
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(doubanImageProxyType)!.url,
'_blank'
setIsDoubanImageProxyDropdownOpen(
!isDoubanImageProxyDropdownOpen
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
className='w-full px-3 py-2.5 pr-10 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 text-left'
>
<span className='font-medium'>
{getThanksInfo(doubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
{
doubanImageProxyTypeOptions.find(
(option) => option.value === doubanImageProxyType
)?.label
}
</button>
{/* 下拉箭头 */}
<div className='absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none'>
<ChevronDown
className={`w-4 h-4 text-gray-400 dark:text-gray-500 transition-transform duration-200 ${isDoubanDropdownOpen ? 'rotate-180' : ''
}`}
/>
</div>
{/* 下拉选项列表 */}
{isDoubanImageProxyDropdownOpen && (
<div className='absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-auto'>
{doubanImageProxyTypeOptions.map((option) => (
<button
key={option.value}
type='button'
onClick={() => {
handleDoubanImageProxyTypeChange(option.value);
setIsDoubanImageProxyDropdownOpen(false);
}}
className={`w-full px-3 py-2.5 text-left text-sm transition-colors duration-150 flex items-center justify-between hover:bg-gray-100 dark:hover:bg-gray-700 ${doubanImageProxyType === option.value
? 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400'
: 'text-gray-900 dark:text-gray-100'
}`}
>
<span className='truncate'>{option.label}</span>
{doubanImageProxyType === option.value && (
<Check className='w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0 ml-2' />
)}
</button>
))}
</div>
)}
</div>
{/* 感谢信息 */}
{getThanksInfo(doubanImageProxyType) && (
<div className='mt-3'>
<button
type='button'
onClick={() =>
window.open(
getThanksInfo(doubanImageProxyType)!.url,
'_blank'
)
}
className='flex items-center justify-center gap-1.5 w-full px-3 text-xs text-gray-500 dark:text-gray-400 cursor-pointer'
>
<span className='font-medium'>
{getThanksInfo(doubanImageProxyType)!.text}
</span>
<ExternalLink className='w-3.5 opacity-70' />
</button>
</div>
)}
</div>
{/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanImageProxyType === 'custom' && (
<div className='space-y-3'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrl}
onChange={(e) =>
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
</div>
)}
</div>
{/* 豆瓣图片代理地址设置 - 仅在选择自定义代理时显示 */}
{doubanImageProxyType === 'custom' && (
<div className='space-y-3'>
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div>
{/* 默认聚合搜索结果 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<input
type='text'
className='w-full px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-all duration-200 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanImageProxyUrl}
onChange={(e) =>
handleDoubanImageProxyUrlChange(e.target.value)
}
/>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={defaultAggregateSearch}
onChange={(e) => handleAggregateToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
)}
{/* 分割线 */}
<div className='border-t border-gray-200 dark:border-gray-700'></div>
{/* 默认聚合搜索结果 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={defaultAggregateSearch}
onChange={(e) => handleAggregateToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
{/* 优选和测速 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
</label>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableOptimization}
onChange={(e) => handleOptimizationToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
{/* 流式搜索 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
使
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={fluidSearch}
onChange={(e) => handleFluidSearchToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
</div>
{/* 优选和测速 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={enableOptimization}
onChange={(e) => handleOptimizationToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
{/* 底部说明 */}
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
</p>
</div>
{/* 流式搜索 */}
<div className='flex items-center justify-between'>
<div>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
使
</p>
</div>
<label className='flex items-center cursor-pointer'>
<div className='relative'>
<input
type='checkbox'
className='sr-only peer'
checked={fluidSearch}
onChange={(e) => handleFluidSearchToggle(e.target.checked)}
/>
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
</div>
</label>
</div>
</div>
{/* 底部说明 */}
<div className='mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
</p>
</div>
</div>
</>
@@ -896,87 +942,113 @@ export const UserMenu: React.FC = () => {
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={handleCloseChangePassword}
onTouchMove={(e) => {
// 只阻止滚动,允许其他触摸事件
e.preventDefault();
}}
onWheel={(e) => {
// 阻止滚轮滚动
e.preventDefault();
}}
style={{
touchAction: 'none',
}}
/>
{/* 修改密码面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleCloseChangePassword}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 表单 */}
<div className='space-y-4'>
{/* 新密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请输入新密码'
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
/>
<div
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
>
{/* 内容容器 - 独立的滚动区域 */}
<div
className='h-full p-6'
data-panel-content
onTouchMove={(e) => {
// 阻止事件冒泡到遮罩层,但允许内部滚动
e.stopPropagation();
}}
style={{
touchAction: 'auto', // 允许所有触摸操作
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
</h3>
<button
onClick={handleCloseChangePassword}
className='w-8 h-8 p-1 rounded-full flex items-center justify-center text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors'
aria-label='Close'
>
<X className='w-full h-full' />
</button>
</div>
{/* 确认密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请再次输入新密码'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 错误信息 */}
{passwordError && (
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
{passwordError}
{/* 表单 */}
<div className='space-y-4'>
{/* 新密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请输入新密码'
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={handleCloseChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
disabled={passwordLoading}
>
</button>
<button
onClick={handleSubmitChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
disabled={passwordLoading || !newPassword || !confirmPassword}
>
{passwordLoading ? '修改中...' : '确认修改'}
</button>
</div>
{/* 确认密码输入 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400'
placeholder='请再次输入新密码'
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
disabled={passwordLoading}
/>
</div>
{/* 底部说明 */}
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
</p>
{/* 错误信息 */}
{passwordError && (
<div className='text-red-500 text-sm bg-red-50 dark:bg-red-900/20 p-3 rounded-md border border-red-200 dark:border-red-800'>
{passwordError}
</div>
)}
</div>
{/* 操作按钮 */}
<div className='flex gap-3 mt-6 pt-4 border-t border-gray-200 dark:border-gray-700'>
<button
onClick={handleCloseChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors'
disabled={passwordLoading}
>
</button>
<button
onClick={handleSubmitChangePassword}
className='flex-1 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
disabled={passwordLoading || !newPassword || !confirmPassword}
>
{passwordLoading ? '修改中...' : '确认修改'}
</button>
</div>
{/* 底部说明 */}
<div className='mt-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
<p className='text-xs text-gray-500 dark:text-gray-400 text-center'>
</p>
</div>
</div>
</div>
</>

View File

@@ -17,7 +17,7 @@ import { createPortal } from 'react-dom';
import { changelog, ChangelogEntry } from '@/lib/changelog';
import { CURRENT_VERSION } from '@/lib/version';
import { compareVersions,UpdateStatus } from '@/lib/version_check';
import { compareVersions, UpdateStatus } from '@/lib/version_check';
interface VersionPanelProps {
isOpen: boolean;
@@ -268,10 +268,35 @@ export const VersionPanel: React.FC<VersionPanelProps> = ({
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
onClick={onClose}
onTouchStart={(e) => {
// 阻止触摸事件冒泡,防止背景滚动
e.preventDefault();
}}
onTouchMove={(e) => {
// 阻止触摸移动,防止背景滚动
e.preventDefault();
e.stopPropagation();
}}
onTouchEnd={(e) => {
// 阻止触摸结束事件,防止背景滚动
e.preventDefault();
}}
style={{
touchAction: 'none', // 禁用所有触摸操作
}}
/>
{/* 版本面板 */}
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'>
<div
className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl max-h-[90vh] bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] overflow-hidden'
onTouchMove={(e) => {
// 允许版本面板内部滚动,阻止事件冒泡到外层
e.stopPropagation();
}}
style={{
touchAction: 'auto', // 允许面板内的正常触摸操作
}}
>
{/* 标题栏 */}
<div className='flex items-center justify-between p-3 sm:p-6 border-b border-gray-200 dark:border-gray-700'>
<div className='flex items-center gap-2 sm:gap-3'>

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any,react-hooks/exhaustive-deps,@typescript-eslint/no-empty-function */
import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
import Image from 'next/image';
@@ -21,9 +21,12 @@ import {
saveFavorite,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { isMobileDevice, isTouchDevice } from '@/lib/device';
import { processImageUrl } from '@/lib/utils';
import { useLongPress } from '@/hooks/useLongPress';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
import MobileActionSheet from '@/components/MobileActionSheet';
export interface VideoCardProps {
id?: string;
@@ -77,6 +80,10 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
const router = useRouter();
const [favorited, setFavorited] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isTouch, setIsTouch] = useState(false);
const [showMobileActions, setShowMobileActions] = useState(false);
const [searchFavorited, setSearchFavorited] = useState<boolean | null>(null); // 搜索结果的收藏状态
// 可外部修改的可控字段
const [dynamicEpisodes, setDynamicEpisodes] = useState<number | undefined>(
@@ -94,6 +101,12 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
setDynamicSourceNames(source_names);
}, [source_names]);
// 检测设备类型
useEffect(() => {
setIsMobile(isMobileDevice());
setIsTouch(isTouchDevice());
}, []);
useImperativeHandle(ref, () => ({
setEpisodes: (eps?: number) => setDynamicEpisodes(eps),
setSourceNames: (names?: string[]) => setDynamicSourceNames(names),
@@ -144,12 +157,20 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (from === 'douban' || from === 'search' || !actualSource || !actualId) return;
if (from === 'douban' || !actualSource || !actualId) return;
try {
if (favorited) {
// 确定当前收藏状态
const currentFavorited = from === 'search' ? searchFavorited : favorited;
if (currentFavorited) {
// 如果已收藏,删除收藏
await deleteFavorite(actualSource, actualId);
setFavorited(false);
if (from === 'search') {
setSearchFavorited(false);
} else {
setFavorited(false);
}
} else {
// 如果未收藏,添加收藏
await saveFavorite(actualSource, actualId, {
@@ -160,7 +181,11 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
total_episodes: actualEpisodes ?? 1,
save_time: Date.now(),
});
setFavorited(true);
if (from === 'search') {
setSearchFavorited(true);
} else {
setFavorited(true);
}
}
} catch (err) {
throw new Error('切换收藏状态失败');
@@ -176,6 +201,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
actualPoster,
actualEpisodes,
favorited,
searchFavorited,
]
);
@@ -221,6 +247,38 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
actualSearchType,
]);
// 检查搜索结果的收藏状态
const checkSearchFavoriteStatus = useCallback(async () => {
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
try {
const fav = await isFavorited(actualSource, actualId);
setSearchFavorited(fav);
} catch (err) {
setSearchFavorited(false);
}
}
}, [from, isAggregate, actualSource, actualId, searchFavorited]);
// 移动端长按操作
const handleLongPress = useCallback(() => {
if (isMobile && !showMobileActions) { // 防止重复触发
// 立即显示菜单,避免等待数据加载导致动画卡顿
setShowMobileActions(true);
// 异步检查收藏状态,不阻塞菜单显示
if (from === 'search' && !isAggregate && actualSource && actualId && searchFavorited === null) {
checkSearchFavoriteStatus();
}
}
}, [isMobile, showMobileActions, from, isAggregate, actualSource, actualId, searchFavorited, checkSearchFavoriteStatus]);
// 长按手势hook
const longPressProps = useLongPress({
onLongPress: handleLongPress,
onClick: handleClick, // 保持点击播放功能
longPressDelay: 500,
});
const config = useMemo(() => {
const configs = {
playrecord: {
@@ -247,7 +305,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
showSourceName: true,
showProgress: false,
showPlayButton: true,
showHeart: false,
showHeart: true, // 移动端菜单中需要显示收藏选项
showCheckCircle: false,
showDoubanLink: false,
showRating: false,
@@ -267,216 +325,394 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
return configs[from] || configs.search;
}, [from, isAggregate, douban_id, rate]);
// 移动端操作菜单配置
const mobileActions = useMemo(() => {
const actions = [];
// 播放操作
if (config.showPlayButton) {
actions.push({
id: 'play',
label: '播放',
icon: <PlayCircleIcon size={20} />,
onClick: handleClick,
color: 'primary' as const,
});
}
// 聚合源信息 - 直接在菜单中展示,不需要单独的操作项
// 收藏/取消收藏操作
if (config.showHeart && from !== 'douban' && actualSource && actualId) {
const currentFavorited = from === 'search' ? searchFavorited : favorited;
if (from === 'search') {
// 搜索结果:根据加载状态显示不同的选项
if (searchFavorited !== null) {
// 已加载完成,显示实际的收藏状态
actions.push({
id: 'favorite',
label: currentFavorited ? '取消收藏' : '添加收藏',
icon: <Heart size={20} />,
onClick: () => {
const mockEvent = {
preventDefault: () => { },
stopPropagation: () => { },
} as React.MouseEvent;
handleToggleFavorite(mockEvent);
},
color: currentFavorited ? ('danger' as const) : ('default' as const),
});
} else {
// 正在加载中,显示占位项
actions.push({
id: 'favorite-loading',
label: '收藏加载中...',
icon: <Heart size={20} />,
onClick: () => { }, // 加载中时不响应点击
disabled: true,
});
}
} else {
// 非搜索结果:直接显示收藏选项
actions.push({
id: 'favorite',
label: currentFavorited ? '取消收藏' : '添加收藏',
icon: <Heart size={20} />,
onClick: () => {
const mockEvent = {
preventDefault: () => { },
stopPropagation: () => { },
} as React.MouseEvent;
handleToggleFavorite(mockEvent);
},
color: currentFavorited ? ('danger' as const) : ('default' as const),
});
}
}
// 删除播放记录操作
if (config.showCheckCircle && from === 'playrecord' && actualSource && actualId) {
actions.push({
id: 'delete',
label: '删除记录',
icon: <Trash2 size={20} />,
onClick: () => {
const mockEvent = {
preventDefault: () => { },
stopPropagation: () => { },
} as React.MouseEvent;
handleDeleteRecord(mockEvent);
},
color: 'danger' as const,
});
}
// 豆瓣链接操作
if (config.showDoubanLink && actualDoubanId && actualDoubanId !== 0) {
actions.push({
id: 'douban',
label: isBangumi ? 'Bangumi 详情' : '豆瓣详情',
icon: <Link size={20} />,
onClick: () => {
const url = isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`;
window.open(url, '_blank', 'noopener,noreferrer');
},
color: 'default' as const,
});
}
return actions;
}, [
config,
from,
actualSource,
actualId,
favorited,
searchFavorited,
actualDoubanId,
isBangumi,
isAggregate,
dynamicSourceNames,
handleClick,
handleToggleFavorite,
handleDeleteRecord,
]);
return (
<div
className='group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out hover:scale-[1.05] hover:z-[500]'
onClick={handleClick}
>
{/* 海报容器 */}
<div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
{/* 骨架屏 */}
{!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
{/* 图片 */}
<Image
src={processImageUrl(actualPoster)}
alt={actualTitle}
fill
className='object-cover'
referrerPolicy='no-referrer'
loading='lazy'
onLoadingComplete={() => setIsLoading(true)}
onError={(e) => {
// 图片加载失败时的重试机制
const img = e.target as HTMLImageElement;
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
img.src = processImageUrl(actualPoster);
}, 2000);
}
}}
/>
<>
<div
className={`group relative w-full rounded-lg bg-transparent cursor-pointer transition-all duration-300 ease-in-out ${!isTouch ? 'hover:scale-[1.05] hover:z-[500]' : ''
}`}
onClick={isMobile ? undefined : handleClick}
{...(isMobile ? longPressProps : {})}
style={{
// 禁用所有默认的长按和选择效果
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
WebkitTapHighlightColor: 'transparent',
touchAction: 'manipulation',
// 禁用右键菜单和长按菜单
pointerEvents: 'auto',
} as React.CSSProperties}
onContextMenu={(e) => {
// 阻止右键菜单和长按上下文菜单
e.preventDefault();
e.stopPropagation();
return false;
}}
{/* 悬浮遮罩 */}
<div className='absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 transition-opacity duration-300 ease-in-out group-hover:opacity-100' />
onDragStart={(e) => {
// 阻止拖拽
e.preventDefault();
return false;
}}
>
{/* 海报容器 */}
<div className='relative aspect-[2/3] overflow-hidden rounded-lg'>
{/* 骨架屏 */}
{!isLoading && <ImagePlaceholder aspectRatio='aspect-[2/3]' />}
{/* 图片 */}
<Image
src={processImageUrl(actualPoster)}
alt={actualTitle}
fill
className='object-cover'
referrerPolicy='no-referrer'
loading='lazy'
onLoadingComplete={() => setIsLoading(true)}
onError={(e) => {
// 图片加载失败时的重试机制
const img = e.target as HTMLImageElement;
if (!img.dataset.retried) {
img.dataset.retried = 'true';
setTimeout(() => {
img.src = processImageUrl(actualPoster);
}, 2000);
}
}}
style={{
// 禁用图片的默认长按效果
WebkitUserSelect: 'none',
userSelect: 'none',
WebkitTouchCallout: 'none',
pointerEvents: 'none', // 图片不响应任何指针事件
} as React.CSSProperties}
onContextMenu={(e) => {
e.preventDefault();
return false;
}}
onDragStart={(e) => {
e.preventDefault();
return false;
}}
/>
{/* 播放按钮 */}
{config.showPlayButton && (
<div className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'>
<PlayCircleIcon
size={50}
strokeWidth={0.8}
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
{/* 悬浮遮罩 */}
<div className={`absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent transition-opacity duration-300 ease-in-out ${isTouch ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
}`} />
{/* 播放按钮 - Touch设备隐藏非Touch设备显示 */}
{config.showPlayButton && !isTouch && (
<div className='absolute inset-0 flex items-center justify-center opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100 group-hover:scale-100'>
<PlayCircleIcon
size={50}
strokeWidth={0.8}
className='text-white fill-transparent transition-all duration-300 ease-out hover:fill-green-500 hover:scale-[1.1]'
/>
</div>
)}
{/* 操作按钮 - Touch设备隐藏非Touch设备显示 */}
{(config.showHeart || config.showCheckCircle) && !isTouch && (
<div className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out group-hover:opacity-100 group-hover:translate-y-0'>
{config.showCheckCircle && (
<Trash2
onClick={handleDeleteRecord}
size={20}
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
/>
)}
{config.showHeart && (
<Heart
onClick={handleToggleFavorite}
size={20}
className={`transition-all duration-300 ease-out ${favorited
? 'fill-red-600 stroke-red-600'
: 'fill-transparent stroke-white hover:stroke-red-400'
} hover:scale-[1.1]`}
/>
)}
</div>
)}
{/* 年份徽章 */}
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
<div className={`absolute top-2 bg-black/50 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90 ${config.showDoubanLink && actualDoubanId && actualDoubanId !== 0
? 'left-2 group-hover:left-11'
: 'left-2'
}`}>
{actualYear}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{rate}
</div>
)}
{actualEpisodes && actualEpisodes > 1 && (
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{currentEpisode
? `${currentEpisode}/${actualEpisodes}`
: actualEpisodes}
</div>
)}
{/* 豆瓣链接 - Touch设备隐藏非Touch设备显示 */}
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && !isTouch && (
<a
href={
isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0'
>
<div className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<Link size={16} />
</div>
</a>
)}
{/* 聚合播放源指示器 */}
{isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => {
const uniqueSources = Array.from(new Set(dynamicSourceNames));
const sourceCount = uniqueSources.length;
return (
<div className='absolute bottom-2 right-2 opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100'>
<div className='relative group/sources'>
<div className='bg-gray-700 text-white text-xs font-bold w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-gray-600 hover:scale-[1.1] transition-all duration-300 ease-out cursor-pointer'>
{sourceCount}
</div>
{/* 播放源详情悬浮框 */}
{(() => {
// 优先显示的播放源(常见的主流平台)
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+'];
// 按优先级排序播放源
const sortedSources = uniqueSources.sort((a, b) => {
const aIndex = prioritySources.indexOf(a);
const bIndex = prioritySources.indexOf(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
const maxDisplayCount = 6; // 最多显示6个
const displaySources = sortedSources.slice(0, maxDisplayCount);
const hasMore = sortedSources.length > maxDisplayCount;
const remainingCount = sortedSources.length - maxDisplayCount;
return (
<div className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0'>
<div className='bg-gray-800/90 backdrop-blur-sm text-white text-xs sm:text-xs rounded-lg shadow-xl border border-white/10 p-1.5 sm:p-2 min-w-[100px] sm:min-w-[120px] max-w-[140px] sm:max-w-[200px] overflow-hidden'>
{/* 单列布局 */}
<div className='space-y-0.5 sm:space-y-1'>
{displaySources.map((sourceName, index) => (
<div key={index} className='flex items-center gap-1 sm:gap-1.5'>
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div>
<span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}>
{sourceName}
</span>
</div>
))}
</div>
{/* 显示更多提示 */}
{hasMore && (
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'>
<div className='flex items-center justify-center text-gray-400'>
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} </span>
</div>
</div>
)}
{/* 小箭头 */}
<div className='absolute top-full right-2 sm:right-3 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[4px] sm:border-l-[6px] sm:border-r-[6px] sm:border-t-[6px] border-transparent border-t-gray-800/90'></div>
</div>
</div>
);
})()}
</div>
</div>
);
})()}
</div>
{/* 进度条 */}
{config.showProgress && progress !== undefined && (
<div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
<div
className='h-full bg-green-500 transition-all duration-500 ease-out'
style={{ width: `${progress}%` }}
/>
</div>
)}
{/* 操作按钮 */}
{(config.showHeart || config.showCheckCircle) && (
<div className='absolute bottom-3 right-3 flex gap-3 opacity-0 translate-y-2 transition-all duration-300 ease-in-out group-hover:opacity-100 group-hover:translate-y-0'>
{config.showCheckCircle && (
<Trash2
onClick={handleDeleteRecord}
size={20}
className='text-white transition-all duration-300 ease-out hover:stroke-red-500 hover:scale-[1.1]'
/>
)}
{config.showHeart && (
<Heart
onClick={handleToggleFavorite}
size={20}
className={`transition-all duration-300 ease-out ${favorited
? 'fill-red-600 stroke-red-600'
: 'fill-transparent stroke-white hover:stroke-red-400'
} hover:scale-[1.1]`}
/>
)}
</div>
)}
{/* 年份徽章 */}
{config.showYear && actualYear && actualYear !== 'unknown' && actualYear.trim() !== '' && (
<div className={`absolute top-2 bg-black/50 text-white text-xs font-medium px-2 py-1 rounded backdrop-blur-sm shadow-sm transition-all duration-300 ease-out group-hover:opacity-90 ${config.showDoubanLink && actualDoubanId && actualDoubanId !== 0
? 'left-2 group-hover:left-11'
: 'left-2'
}`}>
{actualYear}
</div>
)}
{/* 徽章 */}
{config.showRating && rate && (
<div className='absolute top-2 right-2 bg-pink-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{rate}
</div>
)}
{actualEpisodes && actualEpisodes > 1 && (
<div className='absolute top-2 right-2 bg-green-500 text-white text-xs font-semibold px-2 py-1 rounded-md shadow-md transition-all duration-300 ease-out group-hover:scale-110'>
{currentEpisode
? `${currentEpisode}/${actualEpisodes}`
: actualEpisodes}
</div>
)}
{/* 豆瓣链接 */}
{config.showDoubanLink && actualDoubanId && actualDoubanId !== 0 && (
<a
href={
isBangumi
? `https://bgm.tv/subject/${actualDoubanId.toString()}`
: `https://movie.douban.com/subject/${actualDoubanId.toString()}`
}
target='_blank'
rel='noopener noreferrer'
onClick={(e) => e.stopPropagation()}
className='absolute top-2 left-2 opacity-0 -translate-x-2 transition-all duration-300 ease-in-out delay-100 group-hover:opacity-100 group-hover:translate-x-0'
>
<div className='bg-green-500 text-white text-xs font-bold w-7 h-7 rounded-full flex items-center justify-center shadow-md hover:bg-green-600 hover:scale-[1.1] transition-all duration-300 ease-out'>
<Link size={16} />
{/* 标题与来源 */}
<div className='mt-2 text-center'>
<div className='relative'>
<span className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'>
{actualTitle}
</span>
{/* 自定义 tooltip */}
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none'>
{actualTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
</a>
)}
{/* 聚合播放源指示器 */}
{isAggregate && dynamicSourceNames && dynamicSourceNames.length > 0 && (() => {
const uniqueSources = Array.from(new Set(dynamicSourceNames));
const sourceCount = uniqueSources.length;
return (
<div className='absolute bottom-2 right-2 opacity-0 transition-all duration-300 ease-in-out delay-75 group-hover:opacity-100'>
<div className='relative group/sources'>
<div className='bg-gray-700 text-white text-xs font-bold w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center shadow-md hover:bg-gray-600 hover:scale-[1.1] transition-all duration-300 ease-out cursor-pointer'>
{sourceCount}
</div>
{/* 播放源详情悬浮框 */}
{(() => {
// 优先显示的播放源(常见的主流平台)
const prioritySources = ['爱奇艺', '腾讯视频', '优酷', '芒果TV', '哔哩哔哩', 'Netflix', 'Disney+'];
// 按优先级排序播放源
const sortedSources = uniqueSources.sort((a, b) => {
const aIndex = prioritySources.indexOf(a);
const bIndex = prioritySources.indexOf(b);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return a.localeCompare(b);
});
const maxDisplayCount = 6; // 最多显示6个
const displaySources = sortedSources.slice(0, maxDisplayCount);
const hasMore = sortedSources.length > maxDisplayCount;
const remainingCount = sortedSources.length - maxDisplayCount;
return (
<div className='absolute bottom-full mb-2 opacity-0 invisible group-hover/sources:opacity-100 group-hover/sources:visible transition-all duration-200 ease-out delay-100 pointer-events-none z-50 right-0 sm:right-0 -translate-x-0 sm:translate-x-0'>
<div className='bg-gray-800/90 backdrop-blur-sm text-white text-xs sm:text-xs rounded-lg shadow-xl border border-white/10 p-1.5 sm:p-2 min-w-[100px] sm:min-w-[120px] max-w-[140px] sm:max-w-[200px] overflow-hidden'>
{/* 单列布局 */}
<div className='space-y-0.5 sm:space-y-1'>
{displaySources.map((sourceName, index) => (
<div key={index} className='flex items-center gap-1 sm:gap-1.5'>
<div className='w-0.5 h-0.5 sm:w-1 sm:h-1 bg-blue-400 rounded-full flex-shrink-0'></div>
<span className='truncate text-[10px] sm:text-xs leading-tight' title={sourceName}>
{sourceName}
</span>
</div>
))}
</div>
{/* 显示更多提示 */}
{hasMore && (
<div className='mt-1 sm:mt-2 pt-1 sm:pt-1.5 border-t border-gray-700/50'>
<div className='flex items-center justify-center text-gray-400'>
<span className='text-[10px] sm:text-xs font-medium'>+{remainingCount} </span>
</div>
</div>
)}
{/* 小箭头 */}
<div className='absolute top-full right-2 sm:right-3 w-0 h-0 border-l-[4px] border-r-[4px] border-t-[4px] sm:border-l-[6px] sm:border-r-[6px] sm:border-t-[6px] border-transparent border-t-gray-800/90'></div>
</div>
</div>
);
})()}
</div>
</div>
);
})()}
</div>
{config.showSourceName && source_name && (
<span className='block text-xs text-gray-500 dark:text-gray-400 mt-1'>
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-green-500/60 group-hover:text-green-600 dark:group-hover:text-green-400'>
{source_name}
</span>
</span>
)}
</div>
</div>
{/* 进度条 */}
{config.showProgress && progress !== undefined && (
<div className='mt-1 h-1 w-full bg-gray-200 rounded-full overflow-hidden'>
<div
className='h-full bg-green-500 transition-all duration-500 ease-out'
style={{ width: `${progress}%` }}
/>
</div>
{/* 移动端操作菜单 */}
{isMobile && (
<MobileActionSheet
isOpen={showMobileActions}
onClose={() => setShowMobileActions(false)}
title={actualTitle}
poster={processImageUrl(actualPoster)}
actions={mobileActions}
sources={isAggregate && dynamicSourceNames ? Array.from(new Set(dynamicSourceNames)) : undefined}
isAggregate={isAggregate}
sourceName={source_name}
currentEpisode={currentEpisode}
totalEpisodes={actualEpisodes}
/>
)}
{/* 标题与来源 */}
<div className='mt-2 text-center'>
<div className='relative'>
<span className='block text-sm font-semibold truncate text-gray-900 dark:text-gray-100 transition-colors duration-300 ease-in-out group-hover:text-green-600 dark:group-hover:text-green-400 peer'>
{actualTitle}
</span>
{/* 自定义 tooltip */}
<div className='absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-1 bg-gray-800 text-white text-xs rounded-md shadow-lg opacity-0 invisible peer-hover:opacity-100 peer-hover:visible transition-all duration-200 ease-out delay-100 whitespace-nowrap pointer-events-none'>
{actualTitle}
<div className='absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800'></div>
</div>
</div>
{config.showSourceName && source_name && (
<span className='block text-xs text-gray-500 dark:text-gray-400 mt-1'>
<span className='inline-block border rounded px-2 py-0.5 border-gray-500/60 dark:border-gray-400/60 transition-all duration-300 ease-in-out group-hover:border-green-500/60 group-hover:text-green-600 dark:group-hover:text-green-400'>
{source_name}
</span>
</span>
)}
</div>
</div>
</>
);
}

153
src/hooks/useLongPress.ts Normal file
View File

@@ -0,0 +1,153 @@
import { useCallback, useRef } from 'react';
interface UseLongPressOptions {
onLongPress: () => void;
onClick?: () => void;
longPressDelay?: number;
moveThreshold?: number;
}
interface TouchPosition {
x: number;
y: number;
}
export const useLongPress = ({
onLongPress,
onClick,
longPressDelay = 500,
moveThreshold = 10,
}: UseLongPressOptions) => {
const isLongPress = useRef(false);
const pressTimer = useRef<NodeJS.Timeout | null>(null);
const startPosition = useRef<TouchPosition | null>(null);
const isActive = useRef(false); // 防止重复触发
const clearTimer = useCallback(() => {
if (pressTimer.current) {
clearTimeout(pressTimer.current);
pressTimer.current = null;
}
}, []);
const handleStart = useCallback(
(clientX: number, clientY: number) => {
// 如果已经有活跃的手势,忽略新的开始
if (isActive.current) return;
isActive.current = true;
isLongPress.current = false;
startPosition.current = { x: clientX, y: clientY };
pressTimer.current = setTimeout(() => {
// 再次检查是否仍然活跃
if (!isActive.current) return;
isLongPress.current = true;
// 添加触觉反馈(如果支持)
if (navigator.vibrate) {
navigator.vibrate(50);
}
// 触发长按事件
onLongPress();
}, longPressDelay);
},
[onLongPress, longPressDelay]
);
const handleMove = useCallback(
(clientX: number, clientY: number) => {
if (!startPosition.current || !isActive.current) return;
const distance = Math.sqrt(
Math.pow(clientX - startPosition.current.x, 2) +
Math.pow(clientY - startPosition.current.y, 2)
);
// 如果移动距离超过阈值,取消长按
if (distance > moveThreshold) {
clearTimer();
isActive.current = false;
}
},
[clearTimer, moveThreshold]
);
const handleEnd = useCallback(() => {
clearTimer();
// 如果不是长按且手势仍然活跃,则触发点击事件
if (!isLongPress.current && onClick && isActive.current) {
onClick();
}
// 重置所有状态
isLongPress.current = false;
startPosition.current = null;
isActive.current = false;
}, [clearTimer, onClick]);
// 触摸事件处理器
const onTouchStart = useCallback(
(e: React.TouchEvent) => {
// 阻止默认的长按行为,但不阻止触摸开始事件
const touch = e.touches[0];
handleStart(touch.clientX, touch.clientY);
},
[handleStart]
);
const onTouchMove = useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
handleMove(touch.clientX, touch.clientY);
},
[handleMove]
);
const onTouchEnd = useCallback(
(e: React.TouchEvent) => {
// 始终阻止默认行为,避免任何系统长按菜单
e.preventDefault();
e.stopPropagation();
handleEnd();
},
[handleEnd]
);
// 鼠标事件处理器(用于桌面端测试)
const onMouseDown = useCallback(
(e: React.MouseEvent) => {
handleStart(e.clientX, e.clientY);
},
[handleStart]
);
const onMouseMove = useCallback(
(e: React.MouseEvent) => {
handleMove(e.clientX, e.clientY);
},
[handleMove]
);
const onMouseUp = useCallback(() => {
handleEnd();
}, [handleEnd]);
const onMouseLeave = useCallback(() => {
clearTimer();
isActive.current = false;
}, [clearTimer]);
return {
onTouchStart,
onTouchMove,
onTouchEnd,
onMouseDown,
onMouseMove,
onMouseUp,
onMouseLeave,
};
};

43
src/lib/device.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* 设备检测工具函数
*/
// 检测是否为移动设备
export const isMobileDevice = (): boolean => {
if (typeof window === 'undefined') return false;
// 检测触摸屏支持
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// 检测用户代理
const userAgent = navigator.userAgent.toLowerCase();
const mobileKeywords = ['mobile', 'android', 'iphone', 'ipad', 'tablet'];
const isMobileUA = mobileKeywords.some(keyword => userAgent.includes(keyword));
// 检测屏幕尺寸小于768px认为是移动设备
const isSmallScreen = window.innerWidth < 768;
return hasTouchScreen && (isMobileUA || isSmallScreen);
};
// 检测是否为触摸设备
export const isTouchDevice = (): boolean => {
if (typeof window === 'undefined') return false;
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
};
// 获取设备类型
export const getDeviceType = (): 'mobile' | 'tablet' | 'desktop' => {
if (typeof window === 'undefined') return 'desktop';
const width = window.innerWidth;
const userAgent = navigator.userAgent.toLowerCase();
if (width < 768 || userAgent.includes('mobile') || userAgent.includes('iphone')) {
return 'mobile';
} else if (width < 1024 || userAgent.includes('tablet') || userAgent.includes('ipad')) {
return 'tablet';
} else {
return 'desktop';
}
};