mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-24 20:34:49 +08:00
feat: add aggregate search result
This commit is contained in:
@@ -222,9 +222,11 @@ function AggregatePageClient() {
|
||||
return (
|
||||
<a
|
||||
key={src.source}
|
||||
href={`/detail?source=${src.source}&id=${
|
||||
href={`/play?source=${src.source}&id=${
|
||||
src.id
|
||||
}&title=${encodeURIComponent(src.title)}`}
|
||||
}&title=${encodeURIComponent(
|
||||
src.title
|
||||
)}&from=aggregate`}
|
||||
className='relative flex items-center justify-center w-full h-14 bg-gray-500/80 hover:bg-green-500 rounded-lg transition-colors'
|
||||
>
|
||||
{/* 名称 */}
|
||||
@@ -233,7 +235,7 @@ function AggregatePageClient() {
|
||||
</span>
|
||||
{/* 集数徽标 */}
|
||||
{epCount && epCount > 1 ? (
|
||||
<span className='absolute top-1 right-1 text-[10px] font-semibold text-green-900 bg-green-300/90 rounded-full px-1 pointer-events-none'>
|
||||
<span className='absolute top-[2px] right-1 text-[10px] font-semibold text-green-900 bg-green-300/90 rounded-full px-1 pointer-events-none'>
|
||||
{epCount}集
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
@@ -773,9 +773,16 @@ function PlayPageClient() {
|
||||
|
||||
// 处理返回按钮点击
|
||||
const handleBack = () => {
|
||||
window.location.href = `/detail?source=${currentSource}&id=${currentId}&title=${encodeURIComponent(
|
||||
videoTitle
|
||||
)}`;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const fromAggregate = urlParams.get('from') === 'aggregate';
|
||||
|
||||
if (fromAggregate) {
|
||||
window.location.href = `/aggregate?q=${encodeURIComponent(videoTitle)}`;
|
||||
} else {
|
||||
window.location.href = `/detail?source=${currentSource}&id=${currentId}&title=${encodeURIComponent(
|
||||
videoTitle
|
||||
)}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理上一集
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
addSearchHistory,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
getSearchHistory,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
import AggregateCard from '@/components/AggregateCard';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
@@ -37,6 +37,20 @@ function SearchPageClient() {
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 视图模式:聚合(agg) 或 全部(all)
|
||||
const [viewMode, setViewMode] = useState<'agg' | 'all'>('agg');
|
||||
|
||||
// 聚合后的结果(按标题分组)
|
||||
const aggregatedResults = useMemo(() => {
|
||||
const map = new Map<string, SearchResult[]>();
|
||||
searchResults.forEach((item) => {
|
||||
const arr = map.get(item.title) || [];
|
||||
arr.push(item);
|
||||
map.set(item.title, arr);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [searchResults]);
|
||||
|
||||
useEffect(() => {
|
||||
// 自动聚焦搜索框:仅当 URL 中没有搜索参数时
|
||||
if (!searchParams.get('q')) {
|
||||
@@ -127,19 +141,49 @@ function SearchPageClient() {
|
||||
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-green-500'></div>
|
||||
</div>
|
||||
) : showResults ? (
|
||||
// 搜索结果
|
||||
<div 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 sm:px-4'>
|
||||
{searchResults.map((item) => (
|
||||
<div key={item.id} className='w-full'>
|
||||
<VideoCard {...item} from='search' />
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<section className='mb-12'>
|
||||
{/* 标题 + 聚合开关 */}
|
||||
<div className='mb-8 flex items-center justify-between'>
|
||||
<h2 className='text-xl font-bold text-gray-800'>搜索结果</h2>
|
||||
{/* 聚合开关 */}
|
||||
<label className='flex items-center gap-2 cursor-pointer select-none'>
|
||||
<span className='text-sm text-gray-700'>聚合</span>
|
||||
<div className='relative'>
|
||||
<input
|
||||
type='checkbox'
|
||||
className='sr-only peer'
|
||||
checked={viewMode === 'agg'}
|
||||
onChange={() =>
|
||||
setViewMode(viewMode === 'agg' ? 'all' : 'agg')
|
||||
}
|
||||
/>
|
||||
<div className='w-9 h-5 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors'></div>
|
||||
<div className='absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4'></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div 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 sm:px-4'>
|
||||
{viewMode === 'agg'
|
||||
? aggregatedResults.map((group) => {
|
||||
const key = group[0].title;
|
||||
return (
|
||||
<div key={key} className='w-full'>
|
||||
<AggregateCard items={group} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: searchResults.map((item) => (
|
||||
<div key={item.id} className='w-full'>
|
||||
<VideoCard {...item} from='search' />
|
||||
</div>
|
||||
))}
|
||||
{searchResults.length === 0 && (
|
||||
<div className='col-span-full text-center text-gray-500 py-8'>
|
||||
未找到相关结果
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : searchHistory.length > 0 ? (
|
||||
// 搜索历史
|
||||
<section className='mb-12'>
|
||||
|
||||
112
src/components/AggregateCard.tsx
Normal file
112
src/components/AggregateCard.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// 聚合卡需要的基本字段,与搜索接口保持一致
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
source: string;
|
||||
source_name: string;
|
||||
episodes?: number;
|
||||
}
|
||||
|
||||
interface AggregateCardProps {
|
||||
/** 同一标题下的多个搜索结果 */
|
||||
items: SearchResult[];
|
||||
}
|
||||
|
||||
function PlayCircleSolid({
|
||||
className = '',
|
||||
fillColor = 'none',
|
||||
}: {
|
||||
className?: string;
|
||||
fillColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width='44'
|
||||
height='44'
|
||||
viewBox='0 0 44 44'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
className={className}
|
||||
>
|
||||
<circle
|
||||
cx='22'
|
||||
cy='22'
|
||||
r='20'
|
||||
stroke='white'
|
||||
strokeWidth='1.5'
|
||||
fill={fillColor}
|
||||
/>
|
||||
<polygon points='19,15 19,29 29,22' fill='white' />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 `VideoCard` 基本一致,删除了集数徽标、来源标签、收藏等功能
|
||||
* 点击播放按钮 -> 跳到第一个源播放
|
||||
* 点击卡片其他区域 -> 跳到聚合详情页 (/aggregate)
|
||||
*/
|
||||
const AggregateCard: React.FC<AggregateCardProps> = ({ items }) => {
|
||||
// 使用列表中的第一个结果做展示 & 播放
|
||||
const first = items[0];
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Link href={`/aggregate?q=${encodeURIComponent(first.title)}`}>
|
||||
<div className='group relative w-full rounded-lg bg-transparent shadow-none flex flex-col'>
|
||||
{/* 封面图片 2:3 */}
|
||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md'>
|
||||
<Image
|
||||
src={first.poster}
|
||||
alt={first.title}
|
||||
fill
|
||||
className='object-cover'
|
||||
/>
|
||||
|
||||
{/* Hover 层 & 播放按钮 */}
|
||||
<div className='absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center pointer-events-none'>
|
||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||
<div
|
||||
className={`transition-all duration-200 pointer-events-auto ${
|
||||
playHover ? 'scale-110' : ''
|
||||
}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(
|
||||
`/play?source=${first.source}&id=${
|
||||
first.id
|
||||
}&title=${encodeURIComponent(first.title)}&from=aggregate`
|
||||
);
|
||||
}}
|
||||
onMouseEnter={() => setPlayHover(true)}
|
||||
onMouseLeave={() => setPlayHover(false)}
|
||||
>
|
||||
<PlayCircleSolid fillColor={playHover ? '#22c55e' : 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className='absolute top-[calc(100%+0.2rem)] left-0 right-0'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<span className='text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm'>
|
||||
{first.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default AggregateCard;
|
||||
Reference in New Issue
Block a user