feat: user control fluid search
This commit is contained in:
@@ -256,6 +256,7 @@ networks:
|
||||
| NEXT_PUBLIC_DOUBAN_IMAGE_PROXY | 自定义豆瓣图片代理 URL | url prefix | (空) |
|
||||
| direct |
|
||||
| NEXT_PUBLIC_DISABLE_YELLOW_FILTER | 关闭色情内容过滤 | true/false | false |
|
||||
| NEXT_PUBLIC_FLUID_SEARCH | 是否开启搜索接口流式输出 | true/ false | true |
|
||||
|
||||
NEXT_PUBLIC_DOUBAN_PROXY_TYPE 选项解释:
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ interface SiteConfig {
|
||||
DoubanImageProxyType: string;
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
}
|
||||
|
||||
// 视频源数据类型
|
||||
@@ -1284,7 +1285,7 @@ const CategoryConfig = ({
|
||||
};
|
||||
|
||||
// 新增配置文件组件
|
||||
const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminConfig | null; refreshConfig: () => Promise<void>; role: 'owner' | 'admin' | null }) => {
|
||||
const ConfigFileComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
|
||||
const [configContent, setConfigContent] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [subscriptionUrl, setSubscriptionUrl] = useState('');
|
||||
@@ -1292,8 +1293,7 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [lastCheckTime, setLastCheckTime] = useState<string>('');
|
||||
|
||||
// 检查是否为站长
|
||||
const isOwner = role === 'owner';
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (config?.ConfigFile) {
|
||||
@@ -1386,22 +1386,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{/* 非站长用户权限提示 */}
|
||||
{!isOwner && (
|
||||
<div className='bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='w-5 h-5 rounded-full bg-amber-500 flex items-center justify-center'>
|
||||
<span className='text-white text-xs font-bold'>!</span>
|
||||
</div>
|
||||
<p className='text-amber-800 dark:text-amber-300 text-sm font-medium'>
|
||||
配置文件模块仅站长可编辑,您只能查看配置内容
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置订阅区域 */}
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700 shadow-sm ${!isOwner ? 'opacity-60' : ''}`}>
|
||||
<div className='bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700 shadow-sm'>
|
||||
<div className='flex items-center justify-between mb-6'>
|
||||
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
|
||||
配置订阅
|
||||
@@ -1422,8 +1408,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon
|
||||
value={subscriptionUrl}
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
placeholder='https://example.com/config.json'
|
||||
disabled={!isOwner}
|
||||
className={`w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500 ${!isOwner ? 'cursor-not-allowed bg-gray-100 dark:bg-gray-700' : ''}`}
|
||||
disabled={false}
|
||||
className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200 shadow-sm hover:border-gray-400 dark:hover:border-gray-500'
|
||||
/>
|
||||
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
|
||||
输入配置文件的订阅地址,要求 JSON 格式,且使用 Base58 编码
|
||||
@@ -1434,8 +1420,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon
|
||||
<div className='pt-2'>
|
||||
<button
|
||||
onClick={handleFetchConfig}
|
||||
disabled={!isOwner || fetching || !subscriptionUrl.trim()}
|
||||
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${!isOwner || fetching || !subscriptionUrl.trim()
|
||||
disabled={fetching || !subscriptionUrl.trim()}
|
||||
className={`w-full px-6 py-3 rounded-lg font-medium transition-all duration-200 ${fetching || !subscriptionUrl.trim()
|
||||
? 'bg-gray-300 dark:bg-gray-600 cursor-not-allowed text-gray-500 dark:text-gray-400'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white shadow-sm hover:shadow-md transform hover:-translate-y-0.5'
|
||||
}`}
|
||||
@@ -1464,13 +1450,10 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setAutoUpdate(!autoUpdate)}
|
||||
disabled={!isOwner}
|
||||
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 ${!isOwner
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: ''
|
||||
} ${autoUpdate
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
disabled={false}
|
||||
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 ${autoUpdate
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -1492,8 +1475,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon
|
||||
onChange={(e) => setConfigContent(e.target.value)}
|
||||
rows={20}
|
||||
placeholder='请输入配置文件内容(JSON 格式)...'
|
||||
disabled={!isOwner}
|
||||
className={`w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500 ${!isOwner ? 'cursor-not-allowed bg-gray-100 dark:bg-gray-700' : ''}`}
|
||||
disabled={false}
|
||||
className='w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm leading-relaxed resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
style={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo, monospace'
|
||||
}}
|
||||
@@ -1508,8 +1491,8 @@ const ConfigFileComponent = ({ config, refreshConfig, role }: { config: AdminCon
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isOwner || saving}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${!isOwner || saving
|
||||
disabled={saving}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${saving
|
||||
? 'bg-gray-400 cursor-not-allowed text-white'
|
||||
: 'bg-green-600 hover:bg-green-700 text-white'
|
||||
}`}
|
||||
@@ -1534,6 +1517,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
DoubanImageProxyType: 'direct',
|
||||
DoubanImageProxy: '',
|
||||
DisableYellowFilter: false,
|
||||
FluidSearch: true,
|
||||
});
|
||||
// 保存状态
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -1560,7 +1544,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
const doubanImageProxyTypeOptions = [
|
||||
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
|
||||
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
|
||||
{ value: 'img3', label: '豆瓣精品 CDN(阿里云)' },
|
||||
{ value: 'img3', label: '豆瓣官方精品 CDN(阿里云)' },
|
||||
{
|
||||
value: 'cmliussss-cdn-tencent',
|
||||
label: '豆瓣 CDN By CMLiussss(腾讯云)',
|
||||
@@ -1598,6 +1582,7 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
config.SiteConfig.DoubanImageProxyType || 'direct',
|
||||
DoubanImageProxy: config.SiteConfig.DoubanImageProxy || '',
|
||||
DisableYellowFilter: config.SiteConfig.DisableYellowFilter || false,
|
||||
FluidSearch: config.SiteConfig.FluidSearch || true,
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
@@ -2016,6 +2001,41 @@ const SiteConfigComponent = ({ config, refreshConfig }: { config: AdminConfig |
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 流式搜索 */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
启用流式搜索
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
FluidSearch: !prev.FluidSearch,
|
||||
}))
|
||||
}
|
||||
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.FluidSearch
|
||||
? 'bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${siteSettings.FluidSearch
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
启用后搜索结果将实时流式返回,提升用户体验。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
@@ -2159,20 +2179,22 @@ function AdminPageClient() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 配置文件标签 */}
|
||||
<CollapsibleTab
|
||||
title='配置文件'
|
||||
icon={
|
||||
<FileText
|
||||
size={20}
|
||||
className='text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
}
|
||||
isExpanded={expandedTabs.configFile}
|
||||
onToggle={() => toggleTab('configFile')}
|
||||
>
|
||||
<ConfigFileComponent config={config} refreshConfig={fetchConfig} role={role} />
|
||||
</CollapsibleTab>
|
||||
{/* 配置文件标签 - 仅站长可见 */}
|
||||
{role === 'owner' && (
|
||||
<CollapsibleTab
|
||||
title='配置文件'
|
||||
icon={
|
||||
<FileText
|
||||
size={20}
|
||||
className='text-gray-600 dark:text-gray-400'
|
||||
/>
|
||||
}
|
||||
isExpanded={expandedTabs.configFile}
|
||||
onToggle={() => toggleTab('configFile')}
|
||||
>
|
||||
<ConfigFileComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
)}
|
||||
|
||||
{/* 站点配置标签 */}
|
||||
<CollapsibleTab
|
||||
|
||||
@@ -5,7 +5,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { promisify } from 'util';
|
||||
import { gunzip } from 'zlib';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { configSelfCheck, setCachedConfig } from '@/lib/config';
|
||||
import { SimpleCrypto } from '@/lib/crypto';
|
||||
import { db } from '@/lib/db';
|
||||
import { configSelfCheck, setCachedConfig } from '@/lib/config';
|
||||
|
||||
const gunzipAsync = promisify(gunzip);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxyType,
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
@@ -48,6 +49,7 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxyType: string;
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
@@ -60,7 +62,8 @@ export async function POST(request: NextRequest) {
|
||||
typeof DoubanProxy !== 'string' ||
|
||||
typeof DoubanImageProxyType !== 'string' ||
|
||||
typeof DoubanImageProxy !== 'string' ||
|
||||
typeof DisableYellowFilter !== 'boolean'
|
||||
typeof DisableYellowFilter !== 'boolean' ||
|
||||
typeof FluidSearch !== 'boolean'
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -89,6 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
DoubanImageProxyType,
|
||||
DoubanImageProxy,
|
||||
DisableYellowFilter,
|
||||
FluidSearch,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
||||
@@ -5,7 +5,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { db } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { IStorage } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function GET(request: Request) {
|
||||
Promise.race([
|
||||
searchFromApi(site, query),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${site.name} timeout`)), 20000)
|
||||
setTimeout(() => reject(new Error(`${site.name} timeout`)), 10000)
|
||||
),
|
||||
]).catch((err) => {
|
||||
console.warn(`搜索失败 ${site.name}:`, err.message);
|
||||
|
||||
@@ -66,10 +66,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 记录已完成的源数量
|
||||
let completedSources = 0;
|
||||
let allResults: any[] = [];
|
||||
const allResults: any[] = [];
|
||||
|
||||
// 为每个源创建搜索 Promise
|
||||
const searchPromises = apiSites.map(async (site, index) => {
|
||||
const searchPromises = apiSites.map(async (site) => {
|
||||
try {
|
||||
// 添加超时控制
|
||||
const searchPromise = Promise.race([
|
||||
|
||||
@@ -53,6 +53,7 @@ export default async function RootLayout({
|
||||
let doubanImageProxy = process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '';
|
||||
let disableYellowFilter =
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true';
|
||||
let fluidSearch = process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false';
|
||||
let customCategories = [] as {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
@@ -75,6 +76,7 @@ export default async function RootLayout({
|
||||
type: category.type,
|
||||
query: category.query,
|
||||
}));
|
||||
fluidSearch = config.SiteConfig.FluidSearch;
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
@@ -87,6 +89,7 @@ export default async function RootLayout({
|
||||
DOUBAN_IMAGE_PROXY: doubanImageProxy,
|
||||
DISABLE_YELLOW_FILTER: disableYellowFilter,
|
||||
CUSTOM_CATEGORIES: customCategories,
|
||||
FLUID_SEARCH: fluidSearch,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -328,10 +328,8 @@ function PlayPageClient() {
|
||||
console.log('播放源评分排序结果:');
|
||||
resultsWithScore.forEach((result, index) => {
|
||||
console.log(
|
||||
`${index + 1}. ${
|
||||
result.source.source_name
|
||||
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${
|
||||
result.testResult.loadSpeed
|
||||
`${index + 1}. ${result.source.source_name
|
||||
} - 评分: ${result.score.toFixed(2)} (${result.testResult.quality}, ${result.testResult.loadSpeed
|
||||
}, ${result.testResult.pingTime}ms)`
|
||||
);
|
||||
});
|
||||
@@ -690,13 +688,13 @@ function PlayPageClient() {
|
||||
const results = data.results.filter(
|
||||
(result: SearchResult) =>
|
||||
result.title.replaceAll(' ', '').toLowerCase() ===
|
||||
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
|
||||
videoTitleRef.current.replaceAll(' ', '').toLowerCase() &&
|
||||
(videoYearRef.current
|
||||
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
|
||||
: true) &&
|
||||
(searchType
|
||||
? (searchType === 'tv' && result.episodes.length > 1) ||
|
||||
(searchType === 'movie' && result.episodes.length === 1)
|
||||
(searchType === 'movie' && result.episodes.length === 1)
|
||||
: true)
|
||||
);
|
||||
setAvailableSources(results);
|
||||
@@ -1266,9 +1264,8 @@ function PlayPageClient() {
|
||||
// 非WebKit浏览器且播放器已存在,使用switch方法切换
|
||||
if (!isWebkit && artPlayerRef.current) {
|
||||
artPlayerRef.current.switch = videoUrl;
|
||||
artPlayerRef.current.title = `${videoTitle} - 第${
|
||||
currentEpisodeIndex + 1
|
||||
}集`;
|
||||
artPlayerRef.current.title = `${videoTitle} - 第${currentEpisodeIndex + 1
|
||||
}集`;
|
||||
artPlayerRef.current.poster = videoCover;
|
||||
if (artPlayerRef.current?.video) {
|
||||
ensureVideoSource(
|
||||
@@ -1594,7 +1591,7 @@ function PlayPageClient() {
|
||||
skipConfigRef.current.outro_time < 0 &&
|
||||
duration > 0 &&
|
||||
currentTime >
|
||||
artPlayerRef.current.duration + skipConfigRef.current.outro_time
|
||||
artPlayerRef.current.duration + skipConfigRef.current.outro_time
|
||||
) {
|
||||
if (
|
||||
currentEpisodeIndexRef.current <
|
||||
@@ -1708,30 +1705,27 @@ function PlayPageClient() {
|
||||
<div className='mb-6 w-80 mx-auto'>
|
||||
<div className='flex justify-center space-x-2 mb-4'>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'searching' || loadingStage === 'fetching'
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'searching' || loadingStage === 'fetching'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'preferring' ||
|
||||
loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'preferring'
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'preferring'
|
||||
? 'bg-green-500 scale-125'
|
||||
: loadingStage === 'ready'
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
></div>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${
|
||||
loadingStage === 'ready'
|
||||
className={`w-3 h-3 rounded-full transition-all duration-500 ${loadingStage === 'ready'
|
||||
? 'bg-green-500 scale-125'
|
||||
: 'bg-gray-300'
|
||||
}`}
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -1742,11 +1736,11 @@ function PlayPageClient() {
|
||||
style={{
|
||||
width:
|
||||
loadingStage === 'searching' ||
|
||||
loadingStage === 'fetching'
|
||||
loadingStage === 'fetching'
|
||||
? '33%'
|
||||
: loadingStage === 'preferring'
|
||||
? '66%'
|
||||
: '100%',
|
||||
? '66%'
|
||||
: '100%',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
@@ -1860,9 +1854,8 @@ function PlayPageClient() {
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${
|
||||
isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
className={`w-3.5 h-3.5 text-gray-500 dark:text-gray-400 transition-transform duration-200 ${isEpisodeSelectorCollapsed ? 'rotate-180' : 'rotate-0'
|
||||
}`}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
@@ -1880,27 +1873,24 @@ function PlayPageClient() {
|
||||
|
||||
{/* 精致的状态指示点 */}
|
||||
<div
|
||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${
|
||||
isEpisodeSelectorCollapsed
|
||||
className={`absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full transition-all duration-200 ${isEpisodeSelectorCollapsed
|
||||
? 'bg-orange-400 animate-pulse'
|
||||
: 'bg-green-400'
|
||||
}`}
|
||||
}`}
|
||||
></div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${
|
||||
isEpisodeSelectorCollapsed
|
||||
className={`grid gap-4 lg:h-[500px] xl:h-[650px] 2xl:h-[750px] transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-1 md:grid-cols-4'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{/* 播放器 */}
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 ${
|
||||
isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||
}`}
|
||||
className={`h-full transition-all duration-300 ease-in-out rounded-xl border border-white/0 dark:border-white/30 ${isEpisodeSelectorCollapsed ? 'col-span-1' : 'md:col-span-3'
|
||||
}`}
|
||||
>
|
||||
<div className='relative w-full h-[300px] lg:h-full'>
|
||||
<div
|
||||
@@ -1950,11 +1940,10 @@ function PlayPageClient() {
|
||||
|
||||
{/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
|
||||
<div
|
||||
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${
|
||||
isEpisodeSelectorCollapsed
|
||||
className={`h-[300px] lg:h-full md:overflow-hidden transition-all duration-300 ease-in-out ${isEpisodeSelectorCollapsed
|
||||
? 'md:col-span-1 lg:hidden lg:opacity-0 lg:scale-95'
|
||||
: 'md:col-span-1 lg:opacity-100 lg:scale-100'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<EpisodeSelector
|
||||
totalEpisodes={totalEpisodes}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable react-hooks/exhaustive-deps, @typescript-eslint/no-explicit-any,@typescript-eslint/no-non-null-assertion,no-empty */
|
||||
'use client';
|
||||
|
||||
import { ChevronUp, Search, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState, startTransition } from 'react';
|
||||
import React, { startTransition, Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
addSearchHistory,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '@/lib/db.client';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
|
||||
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import SearchResultFilter, { SearchFilterCategory } from '@/components/SearchResultFilter';
|
||||
import SearchSuggestions from '@/components/SearchSuggestions';
|
||||
@@ -39,6 +38,7 @@ function SearchPageClient() {
|
||||
const [completedSources, setCompletedSources] = useState(0);
|
||||
const pendingResultsRef = useRef<SearchResult[]>([]);
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
const [useFluidSearch, setUseFluidSearch] = useState(true);
|
||||
// 聚合卡片 refs 与聚合统计缓存
|
||||
const groupRefs = useRef<Map<string, React.RefObject<VideoCardHandle>>>(new Map());
|
||||
const groupStatsRef = useRef<Map<string, { douban_id?: number; episodes?: number; source_names: string[] }>>(new Map());
|
||||
@@ -323,6 +323,18 @@ function SearchPageClient() {
|
||||
// 初始加载搜索历史
|
||||
getSearchHistory().then(setSearchHistory);
|
||||
|
||||
// 读取流式搜索设置
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
if (savedFluidSearch !== null) {
|
||||
setUseFluidSearch(JSON.parse(savedFluidSearch));
|
||||
} else if (defaultFluidSearch !== undefined) {
|
||||
setUseFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
}
|
||||
|
||||
// 监听搜索历史更新事件
|
||||
const unsubscribe = subscribeToDataUpdates(
|
||||
'searchHistoryUpdated',
|
||||
@@ -392,90 +404,134 @@ function SearchPageClient() {
|
||||
}
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
// 打开新的流式连接
|
||||
|
||||
const trimmed = query.trim();
|
||||
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (!event.data) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
switch (payload.type) {
|
||||
case 'start':
|
||||
setTotalSources(payload.totalSources || 0);
|
||||
setCompletedSources(0);
|
||||
break;
|
||||
case 'source_result': {
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
// 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const incoming: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(payload.results as SearchResult[])
|
||||
: (payload.results as SearchResult[]);
|
||||
pendingResultsRef.current.push(...incoming);
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = window.setTimeout(() => {
|
||||
let toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
// 每次搜索时重新读取设置,确保使用最新的配置
|
||||
let currentFluidSearch = useFluidSearch;
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
if (savedFluidSearch !== null) {
|
||||
currentFluidSearch = JSON.parse(savedFluidSearch);
|
||||
} else {
|
||||
const defaultFluidSearch = (window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
currentFluidSearch = defaultFluidSearch;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果读取的配置与当前状态不同,更新状态
|
||||
if (currentFluidSearch !== useFluidSearch) {
|
||||
setUseFluidSearch(currentFluidSearch);
|
||||
}
|
||||
|
||||
if (currentFluidSearch) {
|
||||
// 流式搜索:打开新的流式连接
|
||||
const es = new EventSource(`/api/search/ws?q=${encodeURIComponent(trimmed)}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (!event.data) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
switch (payload.type) {
|
||||
case 'start':
|
||||
setTotalSources(payload.totalSources || 0);
|
||||
setCompletedSources(0);
|
||||
break;
|
||||
case 'source_result': {
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
if (Array.isArray(payload.results) && payload.results.length > 0) {
|
||||
// 缓冲新增结果,节流刷入,避免频繁重渲染导致闪烁
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const incoming: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(payload.results as SearchResult[])
|
||||
: (payload.results as SearchResult[]);
|
||||
pendingResultsRef.current.push(...incoming);
|
||||
if (!flushTimerRef.current) {
|
||||
flushTimerRef.current = window.setTimeout(() => {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
flushTimerRef.current = null;
|
||||
}, 80);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'source_error':
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
break;
|
||||
case 'complete':
|
||||
setCompletedSources(payload.completedSources || totalSources);
|
||||
// 完成前确保将缓冲写入
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}, 80);
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
setIsLoading(false);
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'source_error':
|
||||
setCompletedSources((prev) => prev + 1);
|
||||
break;
|
||||
case 'complete':
|
||||
setCompletedSources(payload.completedSources || totalSources);
|
||||
// 完成前确保将缓冲写入
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
let toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
setIsLoading(false);
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch { }
|
||||
};
|
||||
} catch { }
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
setIsLoading(false);
|
||||
// 错误时也清空缓冲
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
let toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
es.onerror = () => {
|
||||
setIsLoading(false);
|
||||
// 错误时也清空缓冲
|
||||
if (pendingResultsRef.current.length > 0) {
|
||||
const toAppend = pendingResultsRef.current;
|
||||
pendingResultsRef.current = [];
|
||||
if (flushTimerRef.current) {
|
||||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
});
|
||||
}
|
||||
startTransition(() => {
|
||||
setSearchResults((prev) => prev.concat(toAppend));
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// 传统搜索:使用普通接口
|
||||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
const results: SearchResult[] =
|
||||
activeYearOrder === 'none'
|
||||
? sortBatchForNoOrder(data.results as SearchResult[])
|
||||
: (data.results as SearchResult[]);
|
||||
|
||||
setSearchResults(results);
|
||||
setTotalSources(1);
|
||||
setCompletedSources(1);
|
||||
}
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
try { es.close(); } catch { }
|
||||
if (eventSourceRef.current === es) {
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 保存到搜索历史 (事件监听会自动更新界面)
|
||||
@@ -599,12 +655,12 @@ function SearchPageClient() {
|
||||
<div className='mb-4'>
|
||||
<h2 className='text-xl font-bold text-gray-800 dark:text-gray-200'>
|
||||
搜索结果
|
||||
{searchResults.length > 0 && totalSources > 0 && (
|
||||
{searchResults.length > 0 && totalSources > 0 && useFluidSearch && (
|
||||
<span className='ml-2 text-sm font-normal text-gray-500 dark:text-gray-400'>
|
||||
{completedSources}/{totalSources}
|
||||
</span>
|
||||
)}
|
||||
{searchResults.length > 0 && isLoading && (
|
||||
{searchResults.length > 0 && isLoading && useFluidSearch && (
|
||||
<span className='ml-2 inline-block align-middle'>
|
||||
<span className='inline-block h-3 w-3 border-2 border-gray-300 border-t-green-500 rounded-full animate-spin'></span>
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, ArrowUpDown } from 'lucide-react';
|
||||
import { ArrowDownWideNarrow, ArrowUpDown,ArrowUpNarrowWide } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export default function SearchSuggestions({
|
||||
onClose,
|
||||
}: SearchSuggestionsProps) {
|
||||
const [suggestions, setSuggestions] = useState<SuggestionItem[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 防抖定时器
|
||||
@@ -55,7 +54,6 @@ export default function SearchSuggestions({
|
||||
})
|
||||
);
|
||||
setSuggestions(apiSuggestions);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// 类型保护判断 err 是否是 Error 类型
|
||||
@@ -63,12 +61,10 @@ export default function SearchSuggestions({
|
||||
if (err.name !== 'AbortError') {
|
||||
// 不是取消请求导致的错误才清空
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
} else {
|
||||
// 如果 err 不是 Error 类型,也清空提示
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@@ -84,7 +80,6 @@ export default function SearchSuggestions({
|
||||
fetchSuggestionsFromAPI(searchQuery);
|
||||
} else {
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, 300); //300ms
|
||||
},
|
||||
@@ -94,7 +89,6 @@ export default function SearchSuggestions({
|
||||
useEffect(() => {
|
||||
if (!query.trim() || !isVisible) {
|
||||
setSuggestions([]);
|
||||
setSelectedIndex(-1);
|
||||
return;
|
||||
}
|
||||
debouncedFetchSuggestions(query);
|
||||
@@ -107,43 +101,6 @@ export default function SearchSuggestions({
|
||||
};
|
||||
}, [query, isVisible, debouncedFetchSuggestions]);
|
||||
|
||||
// 键盘导航
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isVisible || suggestions.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev < suggestions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : suggestions.length - 1
|
||||
);
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && selectedIndex < suggestions.length) {
|
||||
onSelect(suggestions[selectedIndex].text);
|
||||
} else {
|
||||
onSelect(query);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isVisible, query, suggestions, selectedIndex, onSelect, onClose]);
|
||||
|
||||
// 点击外部关闭
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -171,14 +128,11 @@ export default function SearchSuggestions({
|
||||
ref={containerRef}
|
||||
className='absolute top-full left-0 right-0 z-50 mt-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 max-h-80 overflow-y-auto'
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={`related-${suggestion.text}`}
|
||||
onClick={() => onSelect(suggestion.text)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3 ${
|
||||
selectedIndex === index ? 'bg-gray-100 dark:bg-gray-700' : ''
|
||||
}`}
|
||||
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 flex items-center gap-3"
|
||||
>
|
||||
<span className='flex-1 text-sm text-gray-700 dark:text-gray-300 truncate'>
|
||||
{suggestion.text}
|
||||
|
||||
@@ -42,6 +42,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [defaultAggregateSearch, setDefaultAggregateSearch] = useState(true);
|
||||
const [doubanProxyUrl, setDoubanProxyUrl] = useState('');
|
||||
const [enableOptimization, setEnableOptimization] = useState(true);
|
||||
const [fluidSearch, setFluidSearch] = useState(true);
|
||||
const [doubanDataSource, setDoubanDataSource] = useState('direct');
|
||||
const [doubanImageProxyType, setDoubanImageProxyType] = useState('direct');
|
||||
const [doubanImageProxyUrl, setDoubanImageProxyUrl] = useState('');
|
||||
@@ -66,7 +67,7 @@ export const UserMenu: React.FC = () => {
|
||||
const doubanImageProxyTypeOptions = [
|
||||
{ value: 'direct', label: '直连(浏览器直接请求豆瓣)' },
|
||||
{ value: 'server', label: '服务器代理(由服务器代理请求豆瓣)' },
|
||||
{ value: 'img3', label: '豆瓣精品 CDN(阿里云)' },
|
||||
{ value: 'img3', label: '豆瓣官方精品 CDN(阿里云)' },
|
||||
{
|
||||
value: 'cmliussss-cdn-tencent',
|
||||
label: '豆瓣 CDN By CMLiussss(腾讯云)',
|
||||
@@ -157,6 +158,15 @@ export const UserMenu: React.FC = () => {
|
||||
if (savedEnableOptimization !== null) {
|
||||
setEnableOptimization(JSON.parse(savedEnableOptimization));
|
||||
}
|
||||
|
||||
const savedFluidSearch = localStorage.getItem('fluidSearch');
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
if (savedFluidSearch !== null) {
|
||||
setFluidSearch(JSON.parse(savedFluidSearch));
|
||||
} else if (defaultFluidSearch !== undefined) {
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -325,6 +335,13 @@ export const UserMenu: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFluidSearchToggle = (value: boolean) => {
|
||||
setFluidSearch(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('fluidSearch', JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDoubanDataSourceChange = (value: string) => {
|
||||
setDoubanDataSource(value);
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -374,9 +391,12 @@ export const UserMenu: React.FC = () => {
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY_TYPE || 'direct';
|
||||
const defaultDoubanImageProxyUrl =
|
||||
(window as any).RUNTIME_CONFIG?.DOUBAN_IMAGE_PROXY || '';
|
||||
const defaultFluidSearch =
|
||||
(window as any).RUNTIME_CONFIG?.FLUID_SEARCH !== false;
|
||||
|
||||
setDefaultAggregateSearch(true);
|
||||
setEnableOptimization(true);
|
||||
setFluidSearch(defaultFluidSearch);
|
||||
setDoubanProxyUrl(defaultDoubanProxy);
|
||||
setDoubanDataSource(defaultDoubanProxyType);
|
||||
setDoubanImageProxyType(defaultDoubanImageProxyType);
|
||||
@@ -385,6 +405,7 @@ export const UserMenu: React.FC = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||
localStorage.setItem('enableOptimization', JSON.stringify(true));
|
||||
localStorage.setItem('fluidSearch', JSON.stringify(defaultFluidSearch));
|
||||
localStorage.setItem('doubanProxyUrl', defaultDoubanProxy);
|
||||
localStorage.setItem('doubanDataSource', defaultDoubanProxyType);
|
||||
localStorage.setItem('doubanImageProxyType', defaultDoubanImageProxyType);
|
||||
@@ -807,7 +828,7 @@ export const UserMenu: React.FC = () => {
|
||||
<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'>
|
||||
如出现播放器劫持问题可关闭
|
||||
@@ -826,6 +847,30 @@ export const UserMenu: React.FC = () => {
|
||||
</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>
|
||||
|
||||
{/* 底部说明 */}
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Heart, Link, PlayCircleIcon, Trash2 } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface AdminConfig {
|
||||
DoubanImageProxyType: string;
|
||||
DoubanImageProxy: string;
|
||||
DisableYellowFilter: boolean;
|
||||
FluidSearch: boolean;
|
||||
};
|
||||
UserConfig: {
|
||||
AllowRegister: boolean;
|
||||
|
||||
@@ -168,6 +168,8 @@ async function getInitConfig(configFile: string, subConfig: {
|
||||
DoubanImageProxy: process.env.NEXT_PUBLIC_DOUBAN_IMAGE_PROXY || '',
|
||||
DisableYellowFilter:
|
||||
process.env.NEXT_PUBLIC_DISABLE_YELLOW_FILTER === 'true',
|
||||
FluidSearch:
|
||||
process.env.NEXT_PUBLIC_FLUID_SEARCH !== 'false',
|
||||
},
|
||||
UserConfig: {
|
||||
AllowRegister: process.env.NEXT_PUBLIC_ENABLE_REGISTER === 'true',
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { API_CONFIG, ApiSite, getConfig } from '@/lib/config';
|
||||
import { getCachedSearchPage, setCachedSearchPage } from '@/lib/search-cache';
|
||||
import { SearchResult } from '@/lib/types';
|
||||
import { cleanHtmlTags } from '@/lib/utils';
|
||||
import { getCachedSearchPage, setCachedSearchPage } from '@/lib/search-cache';
|
||||
|
||||
interface ApiSearchItem {
|
||||
vod_id: string;
|
||||
@@ -24,17 +26,14 @@ async function searchWithCache(
|
||||
query: string,
|
||||
page: number,
|
||||
url: string,
|
||||
timeoutMs: number = 5000
|
||||
timeoutMs = 5000
|
||||
): Promise<{ results: SearchResult[]; pageCount?: number }> {
|
||||
// 先查缓存
|
||||
const cached = getCachedSearchPage(apiSite.key, query, page);
|
||||
if (cached) {
|
||||
if (cached.status === 'ok') {
|
||||
console.log(`🎯 缓存命中 [${apiSite.key}] query="${query}" page=${page} status=ok results=${cached.data.length}`);
|
||||
return { results: cached.data, pageCount: cached.pageCount };
|
||||
} else {
|
||||
console.log(`🚫 缓存命中 [${apiSite.key}] query="${query}" page=${page} status=${cached.status} - 返回空结果`);
|
||||
// timeout / forbidden 命中缓存,直接返回空
|
||||
return { results: [] };
|
||||
}
|
||||
}
|
||||
@@ -144,7 +143,7 @@ export async function searchFromApi(
|
||||
|
||||
// 使用新的缓存搜索函数处理第一页
|
||||
const firstPageResult = await searchWithCache(apiSite, query, 1, apiUrl, 5000);
|
||||
let results = firstPageResult.results;
|
||||
const results = firstPageResult.results;
|
||||
const pageCountFromFirst = firstPageResult.pageCount;
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
@@ -66,10 +66,7 @@ export function setCachedSearchPage(
|
||||
// 惰性清理:每次写入时检查是否需要清理
|
||||
const now = Date.now();
|
||||
if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) {
|
||||
const stats = performCacheCleanup();
|
||||
if (stats.expired > 0 || stats.sizeLimited > 0) {
|
||||
console.log(`🧹 惰性缓存清理: 删除过期${stats.expired}项,删除超限${stats.sizeLimited}项,剩余${stats.total}项`);
|
||||
}
|
||||
performCacheCleanup();
|
||||
}
|
||||
|
||||
const key = makeSearchCacheKey(sourceKey, query, page);
|
||||
@@ -87,7 +84,6 @@ export function setCachedSearchPage(
|
||||
function ensureAutoCleanupStarted(): void {
|
||||
if (!cleanupTimer) {
|
||||
startAutoCleanup();
|
||||
console.log(`🚀 启动自动缓存清理,间隔${CACHE_CLEANUP_INTERVAL_MS / 1000}秒,最大缓存${MAX_CACHE_SIZE}项`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +134,7 @@ function startAutoCleanup(): void {
|
||||
if (cleanupTimer) return; // 避免重复启动
|
||||
|
||||
cleanupTimer = setInterval(() => {
|
||||
const stats = performCacheCleanup();
|
||||
if (stats.expired > 0 || stats.sizeLimited > 0) {
|
||||
console.log(`🧹 自动缓存清理: 删除过期${stats.expired}项,删除超限${stats.sizeLimited}项,剩余${stats.total}项`);
|
||||
}
|
||||
performCacheCleanup();
|
||||
}, CACHE_CLEANUP_INTERVAL_MS);
|
||||
|
||||
// 在 Node.js 环境中避免阻止程序退出
|
||||
|
||||
Reference in New Issue
Block a user