feat: 完整实现成人内容过滤功能的前端集成

- 修改用户菜单设置按钮导航到/settings页面
- 增强搜索页面支持用户认证和内容过滤
- 添加分组结果显示:常规内容和成人内容分标签显示
- 在搜索API调用中包含用户认证信息
- 支持成人内容分组展示和警告提示
- 保持原有聚合搜索功能的兼容性

现在用户可以:
1. 在设置页面控制成人内容过滤开关
2. 在搜索结果中看到内容分组(当存在成人内容时)
3. 获得个性化的搜索体验
This commit is contained in:
katelya
2025-09-04 22:32:31 +08:00
parent 235358c8c2
commit 88e48b8599
2 changed files with 131 additions and 73 deletions
+128 -70
View File
@@ -3,8 +3,9 @@
import { ChevronUp, Search, X } from 'lucide-react'; import { ChevronUp, Search, X } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useMemo, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
import { import {
addSearchHistory, addSearchHistory,
clearSearchHistory, clearSearchHistory,
@@ -30,6 +31,15 @@ function SearchPageClient() {
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]); const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
// 分组结果状态
const [groupedResults, setGroupedResults] = useState<{
regular: SearchResult[];
adult: SearchResult[];
} | null>(null);
// 分组标签页状态
const [activeTab, setActiveTab] = useState<'regular' | 'adult'>('regular');
// 获取默认聚合设置:只读取用户本地设置,默认为 true // 获取默认聚合设置:只读取用户本地设置,默认为 true
const getDefaultAggregate = () => { const getDefaultAggregate = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@@ -45,11 +55,11 @@ function SearchPageClient() {
return getDefaultAggregate() ? 'agg' : 'all'; return getDefaultAggregate() ? 'agg' : 'all';
}); });
// 聚合后的结果(按标题和年份分组) // 聚合函数
const aggregatedResults = useMemo(() => { const aggregateResults = (results: SearchResult[]) => {
const map = new Map<string, SearchResult[]>(); const map = new Map<string, SearchResult[]>();
searchResults.forEach((item) => { results.forEach((item) => {
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown' // 使用 title + year + type 作为键
const key = `${item.title.replaceAll(' ', '')}-${ const key = `${item.title.replaceAll(' ', '')}-${
item.year || 'unknown' item.year || 'unknown'
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`; }-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
@@ -73,23 +83,21 @@ function SearchPageClient() {
if (a[1][0].year === b[1][0].year) { if (a[1][0].year === b[1][0].year) {
return a[0].localeCompare(b[0]); return a[0].localeCompare(b[0]);
} else { } else {
// 处理 unknown 的情况
const aYear = a[1][0].year; const aYear = a[1][0].year;
const bYear = b[1][0].year; const bYear = b[1][0].year;
if (aYear === 'unknown' && bYear === 'unknown') { if (aYear === 'unknown' && bYear === 'unknown') {
return 0; return 0;
} else if (aYear === 'unknown') { } else if (aYear === 'unknown') {
return 1; // a 排在后面 return 1;
} else if (bYear === 'unknown') { } else if (bYear === 'unknown') {
return -1; // b 排在后面 return -1;
} else { } else {
// 都是数字年份,按数字大小排序(大的在前面)
return aYear > bYear ? -1 : 1; return aYear > bYear ? -1 : 1;
} }
} }
}); });
}, [searchResults]); };
useEffect(() => { useEffect(() => {
// 无搜索参数时聚焦搜索框 // 无搜索参数时聚焦搜索框
@@ -161,39 +169,39 @@ function SearchPageClient() {
const fetchSearchResults = async (query: string) => { const fetchSearchResults = async (query: string) => {
try { try {
setIsLoading(true); setIsLoading(true);
// 获取用户认证信息
const authInfo = getAuthInfoFromBrowserCookie();
// 构建请求头
const headers: HeadersInit = {};
if (authInfo?.username) {
headers['Authorization'] = `Bearer ${authInfo.username}`;
}
const response = await fetch( const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}` `/api/search?q=${encodeURIComponent(query.trim())}`,
{ headers }
); );
const data = await response.json(); const data = await response.json();
setSearchResults(
data.results.sort((a: SearchResult, b: SearchResult) => {
// 优先排序:标题与搜索词完全一致的排在前面
const aExactMatch = a.title === query.trim();
const bExactMatch = b.title === query.trim();
if (aExactMatch && !bExactMatch) return -1; // 如果返回了分组结果,我们需要处理这种格式
if (!aExactMatch && bExactMatch) return 1; if (data.grouped) {
// 处理分组结果
setGroupedResults({
regular: data.regular || [],
adult: data.adult || []
});
setSearchResults([...(data.regular || []), ...(data.adult || [])]);
} else {
// 处理普通结果
setGroupedResults(null);
setSearchResults(data.results || []);
}
// 如果都匹配或都不匹配,则按原来的逻辑排序
if (a.year === b.year) {
return a.title.localeCompare(b.title);
} else {
// 处理 unknown 的情况
if (a.year === 'unknown' && b.year === 'unknown') {
return 0;
} else if (a.year === 'unknown') {
return 1; // a 排在后面
} else if (b.year === 'unknown') {
return -1; // b 排在后面
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return parseInt(a.year) > parseInt(b.year) ? -1 : 1;
}
}
})
);
setShowResults(true); setShowResults(true);
} catch (error) { } catch (error) {
setGroupedResults(null);
setSearchResults([]); setSearchResults([]);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -284,50 +292,100 @@ function SearchPageClient() {
</div> </div>
</label> </label>
</div> </div>
{/* 如果有分组结果且有成人内容,显示分组标签 */}
{groupedResults && groupedResults.adult.length > 0 && (
<div className="mb-6">
<div className="flex items-center justify-center mb-4">
<div className="inline-flex p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
<button
onClick={() => setActiveTab('regular')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'regular'
? 'bg-white dark:bg-gray-700 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
({groupedResults.regular.length})
</button>
<button
onClick={() => setActiveTab('adult')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
activeTab === 'adult'
? 'bg-white dark:bg-gray-700 text-red-600 dark:text-red-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
({groupedResults.adult.length})
</button>
</div>
</div>
{activeTab === 'adult' && (
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<p className="text-sm text-red-600 dark:text-red-400 text-center">
18
</p>
</div>
)}
</div>
)}
<div <div
key={`search-results-${viewMode}`} key={`search-results-${viewMode}-${activeTab}`}
className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8' className='justify-start grid grid-cols-3 gap-x-2 gap-y-14 sm:gap-y-20 px-0 sm:px-2 sm:grid-cols-[repeat(auto-fill,_minmax(11rem,_1fr))] sm:gap-x-8'
> >
{viewMode === 'agg' {(() => {
? aggregatedResults.map(([mapKey, group]) => { // 确定要显示的结果
return ( let displayResults = searchResults;
<div key={`agg-${mapKey}`} className='w-full'> if (groupedResults && groupedResults.adult.length > 0) {
<VideoCard displayResults = activeTab === 'adult'
from='search' ? groupedResults.adult
items={group} : groupedResults.regular;
query={ }
searchQuery.trim() !== group[0].title
? searchQuery.trim() // 聚合显示模式
: '' if (viewMode === 'agg') {
} const aggregated = aggregateResults(displayResults);
/> return aggregated.map(([mapKey, group]: [string, SearchResult[]]) => (
</div> <div key={`agg-${mapKey}`} className='w-full'>
);
})
: searchResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
>
<VideoCard <VideoCard
id={item.id} from='search'
title={item.title} items={group}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id?.toString()}
query={ query={
searchQuery.trim() !== item.title searchQuery.trim() !== group[0].title
? searchQuery.trim() ? searchQuery.trim()
: '' : ''
} }
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/> />
</div> </div>
))} ));
}
// 列表显示模式
return displayResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
>
<VideoCard
id={item.id}
title={item.title}
poster={item.poster}
episodes={item.episodes.length}
source={item.source}
source_name={item.source_name}
douban_id={item.douban_id?.toString()}
query={
searchQuery.trim() !== item.title
? searchQuery.trim()
: ''
}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</div>
));
})()}
{searchResults.length === 0 && ( {searchResults.length === 0 && (
<div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'> <div className='col-span-full text-center text-gray-500 py-8 dark:text-gray-400'>
+1 -1
View File
@@ -210,7 +210,7 @@ export const UserMenu: React.FC = () => {
const handleSettings = () => { const handleSettings = () => {
setIsOpen(false); setIsOpen(false);
setIsSettingsOpen(true); router.push('/settings');
}; };
const handleCloseSettings = () => { const handleCloseSettings = () => {