feat: 完整实现成人内容过滤功能的前端集成
- 修改用户菜单设置按钮导航到/settings页面 - 增强搜索页面支持用户认证和内容过滤 - 添加分组结果显示:常规内容和成人内容分标签显示 - 在搜索API调用中包含用户认证信息 - 支持成人内容分组展示和警告提示 - 保持原有聚合搜索功能的兼容性 现在用户可以: 1. 在设置页面控制成人内容过滤开关 2. 在搜索结果中看到内容分组(当存在成人内容时) 3. 获得个性化的搜索体验
This commit is contained in:
+128
-70
@@ -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'>
|
||||||
未找到相关结果
|
未找到相关结果
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user