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(() => {