feat: videocard mobile action optimize
This commit is contained in:
292
src/app/page.tsx
292
src/app/page.tsx
@@ -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'>
|
||||
提示
|
||||
|
||||
357
src/components/MobileActionSheet.tsx
Normal file
357
src/components/MobileActionSheet.tsx
Normal 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;
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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
153
src/hooks/useLongPress.ts
Normal 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
43
src/lib/device.ts
Normal 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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user