feat: add local settings

This commit is contained in:
shinya
2025-07-15 00:35:28 +08:00
parent 76eacd97f9
commit 61cd291574
21 changed files with 741 additions and 126 deletions

View File

@@ -50,7 +50,6 @@ interface SiteConfig {
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean;
}
// 视频源数据类型
@@ -948,7 +947,6 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
Announcement: '',
SearchDownstreamMaxPage: 1,
SiteInterfaceCacheTime: 7200,
SearchResultDefaultAggregate: false,
});
// 保存状态
const [saving, setSaving] = useState(false);
@@ -1094,45 +1092,6 @@ const SiteConfigComponent = ({ config }: { config: AdminConfig | null }) => {
/>
</div>
{/* 默认按标题和年份聚合 */}
<div className='flex items-center justify-between'>
<label
className={`text-gray-700 dark:text-gray-300 ${
isD1Storage ? 'opacity-50' : ''
}`}
>
{isD1Storage && (
<span className='ml-2 text-xs text-gray-500 dark:text-gray-400'>
(D1 )
</span>
)}
</label>
<button
onClick={() =>
!isD1Storage &&
setSiteSettings((prev) => ({
...prev,
SearchResultDefaultAggregate: !prev.SearchResultDefaultAggregate,
}))
}
disabled={isD1Storage}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
siteSettings.SearchResultDefaultAggregate
? 'bg-green-600'
: 'bg-gray-200 dark:bg-gray-700'
} ${isD1Storage ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
siteSettings.SearchResultDefaultAggregate
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
{/* 操作按钮 */}
<div className='flex justify-end'>
<button

View File

@@ -33,13 +33,11 @@ export async function POST(request: NextRequest) {
Announcement,
SearchDownstreamMaxPage,
SiteInterfaceCacheTime,
SearchResultDefaultAggregate,
} = body as {
SiteName: string;
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean;
};
// 参数校验
@@ -47,8 +45,7 @@ export async function POST(request: NextRequest) {
typeof SiteName !== 'string' ||
typeof Announcement !== 'string' ||
typeof SearchDownstreamMaxPage !== 'number' ||
typeof SiteInterfaceCacheTime !== 'number' ||
typeof SearchResultDefaultAggregate !== 'boolean'
typeof SiteInterfaceCacheTime !== 'number'
) {
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
}
@@ -73,7 +70,6 @@ export async function POST(request: NextRequest) {
Announcement,
SearchDownstreamMaxPage,
SiteInterfaceCacheTime,
SearchResultDefaultAggregate,
};
// 写入数据库

View File

@@ -43,7 +43,7 @@ export async function GET(request: Request) {
}
// 设置缓存头(可选)
headers.set('Cache-Control', 'public, max-age=86400'); // 缓存24小时
headers.set('Cache-Control', 'public, max-age=15720000'); // 缓存半年
// 直接返回图片流
return new Response(imageResponse.body, {

View File

@@ -4,7 +4,8 @@ import { useSearchParams } from 'next/navigation';
import { Suspense } from 'react';
import { useEffect, useRef, useState } from 'react';
import { DoubanItem, DoubanResult } from '@/lib/types';
import { getDoubanData } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import DoubanCardSkeleton from '@/components/DoubanCardSkeleton';
import PageLayout from '@/components/PageLayout';
@@ -45,15 +46,12 @@ function DoubanPageClient() {
const loadInitialData = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=0`
);
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
const data: DoubanResult = await response.json();
const data = await getDoubanData({
type: type as 'tv' | 'movie',
tag,
pageSize: 25,
pageStart: 0,
});
if (data.code === 200) {
setDoubanData(data.list);
@@ -78,17 +76,12 @@ function DoubanPageClient() {
try {
setIsLoadingMore(true);
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=25&pageStart=${
currentPage * 25
}`
);
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
const data: DoubanResult = await response.json();
const data = await getDoubanData({
type: type as 'tv' | 'movie',
tag,
pageSize: 25,
pageStart: currentPage * 25,
});
if (data.code === 200) {
setDoubanData((prev) => [...prev, ...data.list]);

View File

@@ -40,21 +40,17 @@ export default async function RootLayout({
process.env.ANNOUNCEMENT ||
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
let enableRegister = process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
let aggregateSearchResult =
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false';
if (process.env.NEXT_PUBLIC_STORAGE_TYPE !== 'd1') {
const config = await getConfig();
siteName = config.SiteConfig.SiteName;
announcement = config.SiteConfig.Announcement;
enableRegister = config.UserConfig.AllowRegister;
aggregateSearchResult = config.SiteConfig.SearchResultDefaultAggregate;
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
const runtimeConfig = {
STORAGE_TYPE: process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage',
ENABLE_REGISTER: enableRegister,
AGGREGATE_SEARCH_RESULT: aggregateSearchResult,
};
return (

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, no-console */
'use client';
@@ -13,7 +13,8 @@ import {
getAllPlayRecords,
subscribeToDataUpdates,
} from '@/lib/db.client';
import { DoubanItem, DoubanResult } from '@/lib/types';
import { getDoubanData } from '@/lib/douban.client';
import { DoubanItem } from '@/lib/types';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import ContinueWatching from '@/components/ContinueWatching';
@@ -63,20 +64,20 @@ function HomeClient() {
setLoading(true);
// 并行获取热门电影和热门剧集
const [moviesResponse, tvShowsResponse] = await Promise.all([
fetch('/api/douban?type=movie&tag=热门'),
fetch('/api/douban?type=tv&tag=热门'),
const [moviesData, tvShowsData] = await Promise.all([
getDoubanData({ type: 'movie', tag: '热门' }),
getDoubanData({ type: 'tv', tag: '热门' }),
]);
if (moviesResponse.ok) {
const moviesData: DoubanResult = await moviesResponse.json();
if (moviesData.code === 200) {
setHotMovies(moviesData.list);
}
if (tvShowsResponse.ok) {
const tvShowsData: DoubanResult = await tvShowsResponse.json();
if (tvShowsData.code === 200) {
setHotTvShows(tvShowsData.list);
}
} catch (error) {
console.error('获取豆瓣数据失败:', error);
} finally {
setLoading(false);
}

View File

@@ -18,7 +18,7 @@ import {
toggleFavorite,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
import EpisodeSelector from '@/components/EpisodeSelector';
import PageLayout from '@/components/PageLayout';
@@ -1611,7 +1611,7 @@ function PlayPageClient() {
<div className='bg-gray-300 dark:bg-gray-700 aspect-[2/3] flex items-center justify-center rounded-xl overflow-hidden'>
{videoCover ? (
<img
src={videoCover}
src={processImageUrl(videoCover)}
alt={videoTitle}
className='w-full h-full object-cover'
/>

View File

@@ -28,14 +28,20 @@ function SearchPageClient() {
const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
// 视图模式:聚合(agg) 或 全部(all),默认值由环境变量 NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT 决定
const defaultAggregate =
typeof window !== 'undefined' &&
Boolean((window as any).RUNTIME_CONFIG?.AGGREGATE_SEARCH_RESULT);
// 获取默认聚合设置:只读取用户本地设置,默认为 true
const getDefaultAggregate = () => {
if (typeof window !== 'undefined') {
const userSetting = localStorage.getItem('defaultAggregateSearch');
if (userSetting !== null) {
return JSON.parse(userSetting);
}
}
return true; // 默认启用聚合
};
const [viewMode, setViewMode] = useState<'agg' | 'all'>(
defaultAggregate ? 'agg' : 'all'
);
const [viewMode, setViewMode] = useState<'agg' | 'all'>(() => {
return getDefaultAggregate() ? 'agg' : 'all';
});
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {

View File

@@ -10,7 +10,7 @@ import React, {
} from 'react';
import { SearchResult } from '@/lib/types';
import { getVideoResolutionFromM3u8 } from '@/lib/utils';
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
// 定义视频信息类型
interface VideoInfo {
@@ -448,7 +448,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
<div className='flex-shrink-0 w-12 h-20 bg-gray-300 dark:bg-gray-600 rounded overflow-hidden'>
{source.episodes && source.episodes.length > 0 && (
<img
src={source.poster}
src={processImageUrl(source.poster)}
alt={source.title}
className='w-full h-full object-cover'
onError={(e) => {

View File

@@ -4,6 +4,7 @@ import Link from 'next/link';
import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton';
import { SettingsButton } from './SettingsButton';
import { useSite } from './SiteProvider';
import { ThemeToggle } from './ThemeToggle';
@@ -15,15 +16,22 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
const { siteName } = useSite();
return (
<header className='md:hidden relative w-full bg-white/70 backdrop-blur-xl border-b border-gray-200/50 shadow-sm dark:bg-gray-900/70 dark:border-gray-700/50'>
{/* 返回按钮 */}
{showBackButton && (
<div className='absolute top-1/2 left-4 -translate-y-1/2'>
<BackButton />
<div className='h-12 flex items-center justify-between px-4'>
{/* 左侧:返回按钮和设置按钮 */}
<div className='flex items-center gap-2'>
{showBackButton && <BackButton />}
<SettingsButton />
</div>
)}
{/* 站点名称 */}
<div className='h-12 flex items-center justify-center'>
{/* 右侧按钮 */}
<div className='flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</div>
{/* 中间Logo绝对居中 */}
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2'>
<Link
href='/'
className='text-2xl font-bold text-green-600 tracking-tight hover:opacity-80 transition-opacity'
@@ -31,12 +39,6 @@ const MobileHeader = ({ showBackButton = false }: MobileHeaderProps) => {
{siteName}
</Link>
</div>
{/* 右侧按钮 */}
<div className='absolute top-1/2 right-4 -translate-y-1/2 flex items-center gap-2'>
<LogoutButton />
<ThemeToggle />
</div>
</header>
);
};

View File

@@ -2,6 +2,7 @@ import { BackButton } from './BackButton';
import { LogoutButton } from './LogoutButton';
import MobileBottomNav from './MobileBottomNav';
import MobileHeader from './MobileHeader';
import { SettingsButton } from './SettingsButton';
import Sidebar from './Sidebar';
import { ThemeToggle } from './ThemeToggle';
@@ -34,6 +35,7 @@ const PageLayout = ({ children, activePath = '/' }: PageLayoutProps) => {
{/* 桌面端顶部按钮 */}
<div className='absolute top-2 right-4 z-20 hidden md:flex items-center gap-2'>
<SettingsButton />
<LogoutButton />
<ThemeToggle />
</div>

View File

@@ -0,0 +1,185 @@
'use client';
import { Settings, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
export const SettingsButton: React.FC = () => {
const [isOpen, setIsOpen] = useState(false);
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
const [imageProxyUrl, setImageProxyUrl] = useState('');
const [mounted, setMounted] = useState(false);
// 确保组件已挂载
useEffect(() => {
setMounted(true);
}, []);
// 从 localStorage 读取设置
useEffect(() => {
if (typeof window !== 'undefined') {
const savedAggregateSearch = localStorage.getItem(
'defaultAggregateSearch'
);
if (savedAggregateSearch !== null) {
setDefaultAggregateSearch(JSON.parse(savedAggregateSearch));
}
const savedDoubanProxyUrl = localStorage.getItem('doubanProxyUrl');
if (savedDoubanProxyUrl !== null) {
setDoubanProxyUrl(savedDoubanProxyUrl);
}
const savedImageProxyUrl = localStorage.getItem('imageProxyUrl');
if (savedImageProxyUrl !== null) {
setImageProxyUrl(savedImageProxyUrl);
}
}
}, []);
// 保存设置到 localStorage
const handleAggregateToggle = (value: boolean) => {
setDefaultAggregateSearch(value);
if (typeof window !== 'undefined') {
localStorage.setItem('defaultAggregateSearch', JSON.stringify(value));
}
};
const handleDoubanProxyUrlChange = (value: string) => {
setDoubanProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('doubanProxyUrl', value);
}
};
const handleImageProxyUrlChange = (value: string) => {
setImageProxyUrl(value);
if (typeof window !== 'undefined') {
localStorage.setItem('imageProxyUrl', value);
}
};
const handleSettingsClick = () => {
setIsOpen(!isOpen);
};
const handleClosePanel = () => {
setIsOpen(false);
};
// 设置面板内容
const settingsPanel = (
<>
{/* 背景遮罩 */}
<div
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-40'
onClick={handleClosePanel}
/>
{/* 设置面板 */}
<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-50 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={handleClosePanel}
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='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>
</label>
</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'>
URL以绕过豆瓣访问限制使API
</p>
</div>
<input
type='text'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
placeholder='例如: https://proxy.example.com/fetch?url='
value={doubanProxyUrl}
onChange={(e) => handleDoubanProxyUrlChange(e.target.value)}
/>
</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'>
URL以加速图片加载
</p>
</div>
<input
type='text'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'
placeholder='例如: https://imageproxy.example.com/?url='
value={imageProxyUrl}
onChange={(e) => handleImageProxyUrlChange(e.target.value)}
/>
</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>
</>
);
return (
<>
<button
onClick={handleSettingsClick}
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
aria-label='Settings'
>
<Settings className='w-full h-full' />
</button>
{/* 使用 Portal 将设置面板渲染到 document.body */}
{isOpen && mounted && createPortal(settingsPanel, document.body)}
</>
);
};

View File

@@ -13,6 +13,7 @@ import {
toggleFavorite,
} from '@/lib/db.client';
import { SearchResult } from '@/lib/types';
import { processImageUrl } from '@/lib/utils';
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
@@ -265,7 +266,7 @@ export default function VideoCard({
{/* 图片加载动画 - 改进淡入和锐化效果 */}
<Image
src={actualPoster}
src={processImageUrl(actualPoster)}
alt={actualTitle}
fill
className={`object-cover transition-all duration-700 ease-out ${

View File

@@ -4,7 +4,6 @@ export interface AdminConfig {
Announcement: string;
SearchDownstreamMaxPage: number;
SiteInterfaceCacheTime: number;
SearchResultDefaultAggregate: boolean;
};
UserConfig: {
AllowRegister: boolean;

View File

@@ -160,8 +160,6 @@ async function initConfig() {
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate:
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
@@ -199,8 +197,6 @@ async function initConfig() {
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate:
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
@@ -238,8 +234,6 @@ export async function getConfig(): Promise<AdminConfig> {
'本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。';
adminConfig.UserConfig.AllowRegister =
process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true';
adminConfig.SiteConfig.SearchResultDefaultAggregate =
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false';
cachedConfig = adminConfig;
} else {
// DB 无配置,执行一次初始化
@@ -283,8 +277,6 @@ export async function resetConfig() {
SearchDownstreamMaxPage:
Number(process.env.NEXT_PUBLIC_SEARCH_MAX_PAGE) || 5,
SiteInterfaceCacheTime: fileConfig.cache_time || 7200,
SearchResultDefaultAggregate:
process.env.NEXT_PUBLIC_AGGREGATE_SEARCH_RESULT !== 'false',
},
UserConfig: {
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',

206
src/lib/douban.client.ts Normal file
View File

@@ -0,0 +1,206 @@
import { DoubanItem, DoubanResult } from './types';
interface DoubanApiResponse {
subjects: Array<{
id: string;
title: string;
cover: string;
rate: string;
}>;
}
interface DoubanClientParams {
type: 'tv' | 'movie';
tag: string;
pageSize?: number;
pageStart?: number;
}
/**
* 浏览器端豆瓣数据获取函数
*/
export async function fetchDoubanDataClient(
params: DoubanClientParams
): Promise<DoubanResult> {
const { type, tag, pageSize = 16, pageStart = 0 } = params;
// 验证参数
if (!['tv', 'movie'].includes(type)) {
throw new Error('type 参数必须是 tv 或 movie');
}
if (pageSize < 1 || pageSize > 100) {
throw new Error('pageSize 必须在 1-100 之间');
}
if (pageStart < 0) {
throw new Error('pageStart 不能小于 0');
}
// 处理 top250 特殊情况
if (tag === 'top250') {
return handleTop250Client(pageStart);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
const response = await fetchWithTimeout(target);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const doubanData: DoubanApiResponse = await response.json();
// 转换数据格式
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
id: item.id,
title: item.title,
poster: item.cover,
rate: item.rate,
}));
return {
code: 200,
message: '获取成功',
list: list,
};
} catch (error) {
throw new Error(`获取豆瓣数据失败: ${(error as Error).message}`);
}
}
/**
* 处理豆瓣 Top250 数据获取
*/
async function handleTop250Client(pageStart: number): Promise<DoubanResult> {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
try {
const response = await fetchWithTimeout(target, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
// 获取 HTML 内容
const html = await response.text();
// 通过正则同时捕获影片 id、标题、封面以及评分
const moviePattern =
/<div class="item">[\s\S]*?<a[^>]+href="https?:\/\/movie\.douban\.com\/subject\/(\d+)\/"[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]*)<\/span>[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const id = match[1];
const title = match[2];
const cover = match[3];
const rate = match[4] || '';
// 处理图片 URL确保使用 HTTPS
const processedCover = cover.replace(/^http:/, 'https:');
movies.push({
id: id,
title: title,
poster: processedCover,
rate: rate,
});
}
return {
code: 200,
message: '获取成功',
list: movies,
};
} catch (error) {
throw new Error(`获取豆瓣 Top250 数据失败: ${(error as Error).message}`);
}
}
/**
* 带超时的 fetch 请求
*/
async function fetchWithTimeout(
url: string,
options: RequestInit = {}
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
// 检查是否使用代理
const proxyUrl = getDoubanProxyUrl();
const finalUrl = proxyUrl ? `${proxyUrl}${encodeURIComponent(url)}` : url;
const fetchOptions: RequestInit = {
...options,
signal: controller.signal,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
Referer: 'https://movie.douban.com/',
Accept: 'application/json, text/plain, */*',
...options.headers,
},
};
try {
const response = await fetch(finalUrl, fetchOptions);
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* 获取豆瓣代理 URL 设置
*/
export function getDoubanProxyUrl(): string | null {
if (typeof window === 'undefined') return null;
const doubanProxyUrl = localStorage.getItem('doubanProxyUrl');
return doubanProxyUrl && doubanProxyUrl.trim() ? doubanProxyUrl.trim() : null;
}
/**
* 检查是否应该使用客户端获取豆瓣数据
*/
export function shouldUseDoubanClient(): boolean {
return getDoubanProxyUrl() !== null;
}
/**
* 统一的豆瓣数据获取函数,根据代理设置选择使用服务端 API 或客户端代理获取
*/
export async function getDoubanData(
params: DoubanClientParams
): Promise<DoubanResult> {
if (shouldUseDoubanClient()) {
// 使用客户端代理获取(当设置了代理 URL 时)
return fetchDoubanDataClient(params);
} else {
// 使用服务端 API当没有设置代理 URL 时)
const { type, tag, pageSize = 16, pageStart = 0 } = params;
const response = await fetch(
`/api/douban?type=${type}&tag=${tag}&pageSize=${pageSize}&pageStart=${pageStart}`
);
if (!response.ok) {
throw new Error('获取豆瓣数据失败');
}
return response.json();
}
}

View File

@@ -2,6 +2,31 @@
import Hls from 'hls.js';
/**
* 获取图片代理 URL 设置
*/
export function getImageProxyUrl(): string | null {
if (typeof window === 'undefined') return null;
const imageProxyUrl = localStorage.getItem('imageProxyUrl');
return imageProxyUrl && imageProxyUrl.trim() ? imageProxyUrl.trim() : null;
}
/**
* 处理图片 URL如果设置了图片代理则使用代理
*/
export function processImageUrl(originalUrl: string): string {
if (!originalUrl) return originalUrl;
const proxyUrl = getImageProxyUrl();
if (!proxyUrl) return originalUrl;
// 如果原始 URL 已经是代理 URL则不再处理
if (originalUrl.includes(proxyUrl)) return originalUrl;
return `${proxyUrl}${encodeURIComponent(originalUrl)}`;
}
export function cleanHtmlTags(text: string): string {
if (!text) return '';
return text