diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 438497f..63ef45a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -69,7 +69,6 @@ jobs: tags: | type=ref,event=pr type=raw,value=latest,enable={{is_default_branch}} - type=match,pattern=v(.*),group=1,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Get latest tag run: | @@ -83,7 +82,7 @@ jobs: file: ./Dockerfile platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - tags: ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:${{ env.latest_tag }} + tags: ${{ github.event_name == 'pull_request_target' && format('ghcr.io/{0}/moontv:pr-{1}', steps.lowercase.outputs.owner, github.event.number) || format('ghcr.io/{0}/moontv:{1}', steps.lowercase.outputs.owner, env.latest_tag) }} outputs: type=image,name=ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv,name-canonical=true,push=true - name: Export digest @@ -100,10 +99,44 @@ jobs: if-no-files-found: error retention-days: 1 + merge-pr: + runs-on: ubuntu-latest + needs: + - build + if: github.event_name == 'pull_request_target' + steps: + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set lowercase repository owner + id: lowercase + run: echo "owner=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create -t ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:pr-${{ github.event.number }} \ + $(printf 'ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv@sha256:%s ' *) + merge: runs-on: ubuntu-latest needs: - build + if: github.event_name != 'pull_request_target' steps: - name: Download digests uses: actions/download-artifact@v4 @@ -132,9 +165,7 @@ jobs: with: images: ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv tags: | - type=ref,event=pr,prefix=pr- type=raw,value=latest,enable={{is_default_branch}} - type=match,pattern=v(.*),group=1,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Create manifest list and push working-directory: /tmp/digests @@ -149,7 +180,7 @@ jobs: pr-comment: runs-on: ubuntu-latest needs: - - merge + - merge-pr if: github.event_name == 'pull_request_target' steps: - name: Set lowercase repository owner diff --git a/src/app/api/detail/route.ts b/src/app/api/detail/route.ts index 6d3c71b..915229b 100644 --- a/src/app/api/detail/route.ts +++ b/src/app/api/detail/route.ts @@ -34,6 +34,7 @@ export async function GET(request: Request) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, }); } catch (error) { diff --git a/src/app/api/douban/categories/route.ts b/src/app/api/douban/categories/route.ts index 23c345c..4569ef3 100644 --- a/src/app/api/douban/categories/route.ts +++ b/src/app/api/douban/categories/route.ts @@ -88,6 +88,7 @@ export async function GET(request: Request) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, }); } catch (error) { diff --git a/src/app/api/douban/recommends/route.ts b/src/app/api/douban/recommends/route.ts index 0d3a6c7..d7c8192 100644 --- a/src/app/api/douban/recommends/route.ts +++ b/src/app/api/douban/recommends/route.ts @@ -118,6 +118,7 @@ export async function GET(request: NextRequest) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, }); } catch (error) { diff --git a/src/app/api/douban/route.ts b/src/app/api/douban/route.ts index 3e82039..53b23b4 100644 --- a/src/app/api/douban/route.ts +++ b/src/app/api/douban/route.ts @@ -84,6 +84,7 @@ export async function GET(request: Request) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, }); } catch (error) { @@ -159,6 +160,7 @@ function handleTop250(pageStart: number) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, }); }) diff --git a/src/app/api/image-proxy/route.ts b/src/app/api/image-proxy/route.ts index 493665c..4939a34 100644 --- a/src/app/api/image-proxy/route.ts +++ b/src/app/api/image-proxy/route.ts @@ -46,6 +46,7 @@ export async function GET(request: Request) { headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000'); // 缓存半年 headers.set('CDN-Cache-Control', 'public, s-maxage=15720000'); headers.set('Vercel-CDN-Cache-Control', 'public, s-maxage=15720000'); + headers.set('Netlify-Vary', 'query'); // 直接返回图片流 return new Response(imageResponse.body, { diff --git a/src/app/api/search/one/route.ts b/src/app/api/search/one/route.ts index cfdfea8..7a8ad38 100644 --- a/src/app/api/search/one/route.ts +++ b/src/app/api/search/one/route.ts @@ -21,6 +21,7 @@ export async function GET(request: Request) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, } ); @@ -68,6 +69,7 @@ export async function GET(request: Request) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, } ); diff --git a/src/app/api/search/resources/route.ts b/src/app/api/search/resources/route.ts index fc351fd..01b5980 100644 --- a/src/app/api/search/resources/route.ts +++ b/src/app/api/search/resources/route.ts @@ -15,6 +15,7 @@ export async function GET() { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, }); } catch (error) { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 8298958..5060fe5 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -21,6 +21,7 @@ export async function GET(request: Request) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, } ); @@ -63,6 +64,7 @@ export async function GET(request: Request) { 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', }, } ); diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts new file mode 100644 index 0000000..648d0df --- /dev/null +++ b/src/app/api/search/suggestions/route.ts @@ -0,0 +1,137 @@ +/* eslint-disable @typescript-eslint/no-explicit-any,no-console */ + +import { NextRequest, NextResponse } from 'next/server'; + +import { getAuthInfoFromCookie } from '@/lib/auth'; +import { getConfig } from '@/lib/config'; +import { searchFromApi } from '@/lib/downstream'; + +export const runtime = 'edge'; + +export async function GET(request: NextRequest) { + try { + // 从 cookie 获取用户信息 + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const config = await getConfig(); + if (config.UserConfig.Users) { + // 检查用户是否被封禁 + const user = config.UserConfig.Users.find( + (u) => u.username === authInfo.username + ); + if (user && user.banned) { + return NextResponse.json({ error: '用户已被封禁' }, { status: 401 }); + } + } + + const { searchParams } = new URL(request.url); + const query = searchParams.get('q')?.trim(); + + if (!query) { + return NextResponse.json({ suggestions: [] }); + } + + // 生成建议 + const suggestions = await generateSuggestions(query); + + // 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟) + const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300; + + return NextResponse.json( + { suggestions }, + { + headers: { + 'Cache-Control': `public, max-age=${cacheTime}, s-maxage=${cacheTime}`, + 'CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Vercel-CDN-Cache-Control': `public, s-maxage=${cacheTime}`, + 'Netlify-Vary': 'query', + }, + } + ); + } catch (error) { + console.error('获取搜索建议失败', error); + return NextResponse.json({ error: '获取搜索建议失败' }, { status: 500 }); + } +} + +async function generateSuggestions(query: string): Promise< + Array<{ + text: string; + type: 'exact' | 'related' | 'suggestion'; + score: number; + }> +> { + const queryLower = query.toLowerCase(); + + const config = await getConfig(); + const apiSites = config.SourceConfig.filter((site: any) => !site.disabled); + let realKeywords: string[] = []; + + if (apiSites.length > 0) { + // 取第一个可用的数据源进行搜索 + const firstSite = apiSites[0]; + const results = await searchFromApi(firstSite, query); + + realKeywords = Array.from( + new Set( + results + .map((r: any) => r.title) + .filter(Boolean) + .flatMap((title: string) => title.split(/[ -::·、-]/)) + .filter( + (w: string) => w.length > 1 && w.toLowerCase().includes(queryLower) + ) + ) + ).slice(0, 8); + } + + // 根据关键词与查询的匹配程度计算分数,并动态确定类型 + const realSuggestions = realKeywords.map((word) => { + const wordLower = word.toLowerCase(); + const queryWords = queryLower.split(/[ -::·、-]/); + + // 计算匹配分数:完全匹配得分更高 + let score = 1.0; + if (wordLower === queryLower) { + score = 2.0; // 完全匹配 + } else if ( + wordLower.startsWith(queryLower) || + wordLower.endsWith(queryLower) + ) { + score = 1.8; // 前缀或后缀匹配 + } else if (queryWords.some((qw) => wordLower.includes(qw))) { + score = 1.5; // 包含查询词 + } + + // 根据匹配程度确定类型 + let type: 'exact' | 'related' | 'suggestion' = 'related'; + if (score >= 2.0) { + type = 'exact'; + } else if (score >= 1.5) { + type = 'related'; + } else { + type = 'suggestion'; + } + + return { + text: word, + type, + score, + }; + }); + + // 按分数降序排列,相同分数按类型优先级排列 + const sortedSuggestions = realSuggestions.sort((a, b) => { + if (a.score !== b.score) { + return b.score - a.score; // 分数高的在前 + } + // 分数相同时,按类型优先级:exact > related > suggestion + const typePriority = { exact: 3, related: 2, suggestion: 1 }; + return typePriority[b.type] - typePriority[a.type]; + }); + + return sortedSuggestions; +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 7d2a3cf..a8cfd01 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -16,6 +16,7 @@ import { SearchResult } from '@/lib/types'; import { yellowWords } from '@/lib/yellow'; import PageLayout from '@/components/PageLayout'; +import SearchSuggestions from '@/components/SearchSuggestions'; import VideoCard from '@/components/VideoCard'; function SearchPageClient() { @@ -30,6 +31,7 @@ function SearchPageClient() { const [isLoading, setIsLoading] = useState(false); const [showResults, setShowResults] = useState(false); const [searchResults, setSearchResults] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); // 获取默认聚合设置:只读取用户本地设置,默认为 true const getDefaultAggregate = () => { @@ -151,11 +153,13 @@ function SearchPageClient() { if (query) { setSearchQuery(query); fetchSearchResults(query); + setShowSuggestions(false); // 保存到搜索历史 (事件监听会自动更新界面) addSearchHistory(query); } else { setShowResults(false); + setShowSuggestions(false); } }, [searchParams]); @@ -211,6 +215,26 @@ function SearchPageClient() { } }; + // 输入框内容变化时触发,显示搜索建议 + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setSearchQuery(value); + + if (value.trim()) { + setShowSuggestions(true); + } else { + setShowSuggestions(false); + } + }; + + // 搜索框聚焦时触发,显示搜索建议 + const handleInputFocus = () => { + if (searchQuery.trim()) { + setShowSuggestions(true); + } + }; + + // 搜索表单提交时触发,处理搜索逻辑 const handleSearch = (e: React.FormEvent) => { e.preventDefault(); const trimmed = searchQuery.trim().replace(/\s+/g, ' '); @@ -220,6 +244,7 @@ function SearchPageClient() { setSearchQuery(trimmed); setIsLoading(true); setShowResults(true); + setShowSuggestions(false); router.push(`/search?q=${encodeURIComponent(trimmed)}`); // 直接发请求 @@ -229,6 +254,19 @@ function SearchPageClient() { addSearchHistory(trimmed); }; + const handleSuggestionSelect = (suggestion: string) => { + setSearchQuery(suggestion); + setShowSuggestions(false); + + // 自动执行搜索 + setIsLoading(true); + setShowResults(true); + + router.push(`/search?q=${encodeURIComponent(suggestion)}`); + fetchSearchResults(suggestion); + addSearchHistory(suggestion); + }; + // 返回顶部功能 const scrollToTop = () => { try { @@ -255,10 +293,19 @@ function SearchPageClient() { id='searchInput' type='text' value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} + onChange={handleInputChange} + onFocus={handleInputFocus} placeholder='搜索电影、电视剧...' className='w-full h-12 rounded-lg bg-gray-50/80 py-3 pl-10 pr-4 text-sm text-gray-700 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-400 focus:bg-white border border-gray-200/50 shadow-sm dark:bg-gray-800 dark:text-gray-300 dark:placeholder-gray-500 dark:focus:bg-gray-700 dark:border-gray-700' /> + + {/* 搜索建议 */} + setShowSuggestions(false)} + /> diff --git a/src/components/SearchSuggestions.tsx b/src/components/SearchSuggestions.tsx new file mode 100644 index 0000000..a4fd7a1 --- /dev/null +++ b/src/components/SearchSuggestions.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface SearchSuggestionsProps { + query: string; + isVisible: boolean; + onSelect: (suggestion: string) => void; + onClose: () => void; +} + +interface SuggestionItem { + text: string; + type: 'related'; + icon?: React.ReactNode; +} + +export default function SearchSuggestions({ + query, + isVisible, + onSelect, + onClose, +}: SearchSuggestionsProps) { + const [suggestions, setSuggestions] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + + // 防抖定时器 + const debounceTimer = useRef | null>(null); + + // 用于中止旧请求 + const abortControllerRef = useRef(null); + + const fetchSuggestionsFromAPI = useCallback(async (searchQuery: string) => { + // 每次请求前取消上一次的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + const response = await fetch( + `/api/search/suggestions?q=${encodeURIComponent(searchQuery)}`, + { + signal: controller.signal, + } + ); + if (response.ok) { + const data = await response.json(); + const apiSuggestions = data.suggestions.map( + (item: { text: string }) => ({ + text: item.text, + type: 'related' as const, + }) + ); + setSuggestions(apiSuggestions); + setSelectedIndex(-1); + } + } catch (err: unknown) { + // 类型保护判断 err 是否是 Error 类型 + if (err instanceof Error) { + if (err.name !== 'AbortError') { + // 不是取消请求导致的错误才清空 + setSuggestions([]); + setSelectedIndex(-1); + } + } else { + // 如果 err 不是 Error 类型,也清空提示 + setSuggestions([]); + setSelectedIndex(-1); + } + } + }, []); + + // 防抖触发 + const debouncedFetchSuggestions = useCallback( + (searchQuery: string) => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + if (searchQuery.trim() && isVisible) { + fetchSuggestionsFromAPI(searchQuery); + } else { + setSuggestions([]); + setSelectedIndex(-1); + } + }, 300); //300ms + }, + [isVisible, fetchSuggestionsFromAPI] + ); + + useEffect(() => { + if (!query.trim() || !isVisible) { + setSuggestions([]); + setSelectedIndex(-1); + return; + } + debouncedFetchSuggestions(query); + + // 清理定时器 + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, [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); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isVisible, suggestions, selectedIndex, onSelect, onClose]); + + // 点击外部关闭 + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + + if (isVisible) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isVisible, onClose]); + + if (!isVisible || suggestions.length === 0) { + return null; + } + + return ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ ); +}