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
+130 -72
View File
@@ -3,8 +3,9 @@
import { ChevronUp, Search, X } from 'lucide-react';
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 {
addSearchHistory,
clearSearchHistory,
@@ -29,6 +30,15 @@ function SearchPageClient() {
const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
// 分组结果状态
const [groupedResults, setGroupedResults] = useState<{
regular: SearchResult[];
adult: SearchResult[];
} | null>(null);
// 分组标签页状态
const [activeTab, setActiveTab] = useState<'regular' | 'adult'>('regular');
// 获取默认聚合设置:只读取用户本地设置,默认为 true
const getDefaultAggregate = () => {
@@ -45,11 +55,11 @@ function SearchPageClient() {
return getDefaultAggregate() ? 'agg' : 'all';
});
// 聚合后的结果(按标题和年份分组)
const aggregatedResults = useMemo(() => {
// 聚合函数
const aggregateResults = (results: SearchResult[]) => {
const map = new Map<string, SearchResult[]>();
searchResults.forEach((item) => {
// 使用 title + year + type 作为键,year 必然存在,但依然兜底 'unknown'
results.forEach((item) => {
// 使用 title + year + type 作为键
const key = `${item.title.replaceAll(' ', '')}-${
item.year || 'unknown'
}-${item.episodes.length === 1 ? 'movie' : 'tv'}`;
@@ -73,23 +83,21 @@ function SearchPageClient() {
if (a[1][0].year === b[1][0].year) {
return a[0].localeCompare(b[0]);
} else {
// 处理 unknown 的情况
const aYear = a[1][0].year;
const bYear = b[1][0].year;
if (aYear === 'unknown' && bYear === 'unknown') {
return 0;
} else if (aYear === 'unknown') {
return 1; // a 排在后面
return 1;
} else if (bYear === 'unknown') {
return -1; // b 排在后面
return -1;
} else {
// 都是数字年份,按数字大小排序(大的在前面)
return aYear > bYear ? -1 : 1;
}
}
});
}, [searchResults]);
};
useEffect(() => {
// 无搜索参数时聚焦搜索框
@@ -161,39 +169,39 @@ function SearchPageClient() {
const fetchSearchResults = async (query: string) => {
try {
setIsLoading(true);
// 获取用户认证信息
const authInfo = getAuthInfoFromBrowserCookie();
// 构建请求头
const headers: HeadersInit = {};
if (authInfo?.username) {
headers['Authorization'] = `Bearer ${authInfo.username}`;
}
const response = await fetch(
`/api/search?q=${encodeURIComponent(query.trim())}`
`/api/search?q=${encodeURIComponent(query.trim())}`,
{ headers }
);
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 (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;
}
}
})
);
// 如果返回了分组结果,我们需要处理这种格式
if (data.grouped) {
// 处理分组结果
setGroupedResults({
regular: data.regular || [],
adult: data.adult || []
});
setSearchResults([...(data.regular || []), ...(data.adult || [])]);
} else {
// 处理普通结果
setGroupedResults(null);
setSearchResults(data.results || []);
}
setShowResults(true);
} catch (error) {
setGroupedResults(null);
setSearchResults([]);
} finally {
setIsLoading(false);
@@ -284,50 +292,100 @@ function SearchPageClient() {
</div>
</label>
</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
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'
>
{viewMode === 'agg'
? aggregatedResults.map(([mapKey, group]) => {
return (
<div key={`agg-${mapKey}`} className='w-full'>
<VideoCard
from='search'
items={group}
query={
searchQuery.trim() !== group[0].title
? searchQuery.trim()
: ''
}
/>
</div>
);
})
: searchResults.map((item) => (
<div
key={`all-${item.source}-${item.id}`}
className='w-full'
>
{(() => {
// 确定要显示的结果
let displayResults = searchResults;
if (groupedResults && groupedResults.adult.length > 0) {
displayResults = activeTab === 'adult'
? groupedResults.adult
: groupedResults.regular;
}
// 聚合显示模式
if (viewMode === 'agg') {
const aggregated = aggregateResults(displayResults);
return aggregated.map(([mapKey, group]: [string, SearchResult[]]) => (
<div key={`agg-${mapKey}`} 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()}
from='search'
items={group}
query={
searchQuery.trim() !== item.title
searchQuery.trim() !== group[0].title
? searchQuery.trim()
: ''
}
year={item.year}
from='search'
type={item.episodes.length > 1 ? 'tv' : 'movie'}
/>
</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 && (
<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 = () => {
setIsOpen(false);
setIsSettingsOpen(true);
router.push('/settings');
};
const handleCloseSettings = () => {