From c91ceb6ae4279cff08e5f1e46509c05f49815ebd Mon Sep 17 00:00:00 2001 From: Stardm0 <2581793700@qq.com> Date: Tue, 12 Aug 2025 09:22:05 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=BB=BA=E8=AE=AE=E6=8E=A5=E5=8F=A3=E5=8F=8A=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/search/suggestions/route.ts | 93 ++++++++++++ src/app/search/page.tsx | 64 +++++++- src/components/SearchSuggestions.tsx | 186 ++++++++++++++++++++++++ 3 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 src/app/api/search/suggestions/route.ts create mode 100644 src/components/SearchSuggestions.tsx diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts new file mode 100644 index 0000000..6e405b6 --- /dev/null +++ b/src/app/api/search/suggestions/route.ts @@ -0,0 +1,93 @@ +/* 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); + + const cacheTime = 300; // 5分钟缓存 + + 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}`, + }, + } + ); + } catch (error) { + console.error('获取搜索建议失败', error); + return NextResponse.json({ error: '获取搜索建议失败' }, { status: 500 }); + } +} + +async function generateSuggestions(query: string): Promise< + Array<{ + text: string; + type: 'related'; + 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) { + // 只取一个数据源的搜索API,传入用户输入的query + const results = await searchFromApi(apiSites[10], query); //10是豆瓣资源 + 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) => ({ + text: word, + type: 'related' as const, + score: 1.5, + })); + + return realSuggestions; +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 36dba7d..a8cfd01 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -13,8 +13,10 @@ import { subscribeToDataUpdates, } from '@/lib/db.client'; 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() { @@ -29,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 = () => { @@ -150,11 +153,13 @@ function SearchPageClient() { if (query) { setSearchQuery(query); fetchSearchResults(query); + setShowSuggestions(false); // 保存到搜索历史 (事件监听会自动更新界面) addSearchHistory(query); } else { setShowResults(false); + setShowSuggestions(false); } }, [searchParams]); @@ -165,8 +170,18 @@ function SearchPageClient() { `/api/search?q=${encodeURIComponent(query.trim())}` ); const data = await response.json(); + let results = data.results; + if ( + typeof window !== 'undefined' && + !(window as any).RUNTIME_CONFIG?.DISABLE_YELLOW_FILTER + ) { + results = results.filter((result: SearchResult) => { + const typeName = result.type_name || ''; + return !yellowWords.some((word: string) => typeName.includes(word)); + }); + } setSearchResults( - data.results.sort((a: SearchResult, b: SearchResult) => { + results.sort((a: SearchResult, b: SearchResult) => { // 优先排序:标题与搜索词完全一致的排在前面 const aExactMatch = a.title === query.trim(); const bExactMatch = b.title === query.trim(); @@ -200,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, ' '); @@ -209,6 +244,7 @@ function SearchPageClient() { setSearchQuery(trimmed); setIsLoading(true); setShowResults(true); + setShowSuggestions(false); router.push(`/search?q=${encodeURIComponent(trimmed)}`); // 直接发请求 @@ -218,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 { @@ -244,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)} + /> @@ -316,7 +374,7 @@ function SearchPageClient() { episodes={item.episodes.length} source={item.source} source_name={item.source_name} - douban_id={item.douban_id?.toString()} + douban_id={item.douban_id} query={ searchQuery.trim() !== item.title ? searchQuery.trim() diff --git a/src/components/SearchSuggestions.tsx b/src/components/SearchSuggestions.tsx new file mode 100644 index 0000000..f15ee5f --- /dev/null +++ b/src/components/SearchSuggestions.tsx @@ -0,0 +1,186 @@ +'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) => ( + + ))} +
+ ); +} From 78f6c03060388e4a5adef765bec5018802bd051a Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Tue, 12 Aug 2025 09:49:27 +0800 Subject: [PATCH 2/7] fix: don't tag when triggered by PR --- .github/workflows/docker-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 438497f..fec2668 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -83,7 +83,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:{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 From e4178c8d27d27aedf37ad45d6bba727ea29beee1 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Tue, 12 Aug 2025 09:49:27 +0800 Subject: [PATCH 3/7] fix: don't tag when triggered by PR --- .github/workflows/docker-image.yml | 42 ++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 438497f..139bfb2 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,8 +82,8 @@ jobs: file: ./Dockerfile platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - tags: ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv:${{ env.latest_tag }} - outputs: type=image,name=ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv,name-canonical=true,push=true + 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=${{ github.event_name != 'pull_request_target' }} - name: Export digest run: | @@ -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 @@ -134,7 +167,6 @@ jobs: 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 +181,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 From 052cedf8990e55b64ab020eaa7f3a69365d05c4c Mon Sep 17 00:00:00 2001 From: Stardm0 <2581793700@qq.com> Date: Tue, 12 Aug 2025 11:37:01 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=BB=BA=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/search/suggestions/route.ts | 60 ++++++++++++++++++++----- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/src/app/api/search/suggestions/route.ts b/src/app/api/search/suggestions/route.ts index 6e405b6..59e9af3 100644 --- a/src/app/api/search/suggestions/route.ts +++ b/src/app/api/search/suggestions/route.ts @@ -37,7 +37,8 @@ export async function GET(request: NextRequest) { // 生成建议 const suggestions = await generateSuggestions(query); - const cacheTime = 300; // 5分钟缓存 + // 从配置中获取缓存时间,如果没有配置则使用默认值300秒(5分钟) + const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300; return NextResponse.json( { suggestions }, @@ -58,7 +59,7 @@ export async function GET(request: NextRequest) { async function generateSuggestions(query: string): Promise< Array<{ text: string; - type: 'related'; + type: 'exact' | 'related' | 'suggestion'; score: number; }> > { @@ -67,9 +68,12 @@ async function generateSuggestions(query: string): Promise< const config = await getConfig(); const apiSites = config.SourceConfig.filter((site: any) => !site.disabled); let realKeywords: string[] = []; + if (apiSites.length > 0) { - // 只取一个数据源的搜索API,传入用户输入的query - const results = await searchFromApi(apiSites[10], query); //10是豆瓣资源 + // 取第一个可用的数据源进行搜索 + const firstSite = apiSites[0]; + const results = await searchFromApi(firstSite, query); + realKeywords = Array.from( new Set( results @@ -83,11 +87,47 @@ async function generateSuggestions(query: string): Promise< ).slice(0, 8); } - const realSuggestions = realKeywords.map((word) => ({ - text: word, - type: 'related' as const, - score: 1.5, - })); + // 根据关键词与查询的匹配程度计算分数,并动态确定类型 + 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 realSuggestions; + return sortedSuggestions; } From 76451c5f935587b6baa97b01979cb0e7bc78f8d4 Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 12 Aug 2025 12:52:50 +0800 Subject: [PATCH 5/7] feat: add netlify-vary header to avoid cache without search param --- src/app/play/page.tsx | 14 ++++++++++++-- src/app/search/page.tsx | 7 ++++++- src/components/SearchSuggestions.tsx | 7 ++++++- src/lib/douban.client.ts | 21 ++++++++++++++++++--- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 1ab4424..cfe8491 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -660,7 +660,12 @@ function PlayPageClient() { ): Promise => { try { const detailResponse = await fetch( - `/api/detail?source=${source}&id=${id}` + `/api/detail?source=${source}&id=${id}`, + { + headers: { + 'Netlify-Vary': 'query', + }, + } ); if (!detailResponse.ok) { throw new Error('获取视频详情失败'); @@ -679,7 +684,12 @@ function PlayPageClient() { // 根据搜索词获取全部源信息 try { const response = await fetch( - `/api/search?q=${encodeURIComponent(query.trim())}` + `/api/search?q=${encodeURIComponent(query.trim())}`, + { + headers: { + 'Netlify-Vary': 'query', + }, + } ); if (!response.ok) { throw new Error('搜索失败'); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index a8cfd01..b13e698 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -167,7 +167,12 @@ function SearchPageClient() { try { setIsLoading(true); const response = await fetch( - `/api/search?q=${encodeURIComponent(query.trim())}` + `/api/search?q=${encodeURIComponent(query.trim())}`, + { + headers: { + 'Netlify-Vary': 'query', + }, + } ); const data = await response.json(); let results = data.results; diff --git a/src/components/SearchSuggestions.tsx b/src/components/SearchSuggestions.tsx index f15ee5f..5804f37 100644 --- a/src/components/SearchSuggestions.tsx +++ b/src/components/SearchSuggestions.tsx @@ -42,7 +42,12 @@ export default function SearchSuggestions({ try { const response = await fetch( `/api/search/suggestions?q=${encodeURIComponent(searchQuery)}`, - { signal: controller.signal } + { + signal: controller.signal, + headers: { + 'Netlify-Vary': 'query', + }, + } ); if (response.ok) { const data = await response.json(); diff --git a/src/lib/douban.client.ts b/src/lib/douban.client.ts index ca0cfd8..e8bf437 100644 --- a/src/lib/douban.client.ts +++ b/src/lib/douban.client.ts @@ -211,7 +211,12 @@ export async function getDoubanCategories( case 'direct': default: const response = await fetch( - `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}` + `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`, + { + headers: { + 'Netlify-Vary': 'query', + }, + } ); return response.json(); @@ -244,7 +249,12 @@ export async function getDoubanList( case 'direct': default: const response = await fetch( - `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}` + `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`, + { + headers: { + 'Netlify-Vary': 'query', + }, + } ); return response.json(); @@ -364,7 +374,12 @@ export async function getDoubanRecommends( case 'direct': default: const response = await fetch( - `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}` + `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`, + { + headers: { + 'Netlify-Vary': 'query', + }, + } ); return response.json(); From ebd5d1e38034e564cc34262d2808e02502e428e9 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Tue, 12 Aug 2025 12:57:02 +0800 Subject: [PATCH 6/7] fix: docker image sha256 not found --- .github/workflows/docker-image.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 139bfb2..63ef45a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -83,7 +83,7 @@ jobs: platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} 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=${{ github.event_name != 'pull_request_target' }} + outputs: type=image,name=ghcr.io/${{ steps.lowercase.outputs.owner }}/moontv,name-canonical=true,push=true - name: Export digest run: | @@ -165,7 +165,6 @@ 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}} - name: Create manifest list and push From aaffcc3646af527ca4416545e1dbfd02c83556b2 Mon Sep 17 00:00:00 2001 From: shinya Date: Tue, 12 Aug 2025 13:16:22 +0800 Subject: [PATCH 7/7] fix: netlify cache vary header in response --- src/app/api/detail/route.ts | 1 + src/app/api/douban/categories/route.ts | 1 + src/app/api/douban/recommends/route.ts | 1 + src/app/api/douban/route.ts | 2 ++ src/app/api/image-proxy/route.ts | 1 + src/app/api/search/one/route.ts | 2 ++ src/app/api/search/resources/route.ts | 1 + src/app/api/search/route.ts | 2 ++ src/app/api/search/suggestions/route.ts | 18 +++++++++++------- src/app/play/page.tsx | 14 ++------------ src/app/search/page.tsx | 7 +------ src/components/SearchSuggestions.tsx | 3 --- src/lib/douban.client.ts | 21 +++------------------ 13 files changed, 28 insertions(+), 46 deletions(-) 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 index 59e9af3..648d0df 100644 --- a/src/app/api/search/suggestions/route.ts +++ b/src/app/api/search/suggestions/route.ts @@ -47,6 +47,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', }, } ); @@ -68,12 +69,12 @@ async function generateSuggestions(query: string): Promise< 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 @@ -91,17 +92,20 @@ async function generateSuggestions(query: string): Promise< 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)) { + } else if ( + wordLower.startsWith(queryLower) || + wordLower.endsWith(queryLower) + ) { score = 1.8; // 前缀或后缀匹配 - } else if (queryWords.some(qw => wordLower.includes(qw))) { + } else if (queryWords.some((qw) => wordLower.includes(qw))) { score = 1.5; // 包含查询词 } - + // 根据匹配程度确定类型 let type: 'exact' | 'related' | 'suggestion' = 'related'; if (score >= 2.0) { @@ -111,7 +115,7 @@ async function generateSuggestions(query: string): Promise< } else { type = 'suggestion'; } - + return { text: word, type, diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index cfe8491..1ab4424 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -660,12 +660,7 @@ function PlayPageClient() { ): Promise => { try { const detailResponse = await fetch( - `/api/detail?source=${source}&id=${id}`, - { - headers: { - 'Netlify-Vary': 'query', - }, - } + `/api/detail?source=${source}&id=${id}` ); if (!detailResponse.ok) { throw new Error('获取视频详情失败'); @@ -684,12 +679,7 @@ function PlayPageClient() { // 根据搜索词获取全部源信息 try { const response = await fetch( - `/api/search?q=${encodeURIComponent(query.trim())}`, - { - headers: { - 'Netlify-Vary': 'query', - }, - } + `/api/search?q=${encodeURIComponent(query.trim())}` ); if (!response.ok) { throw new Error('搜索失败'); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index b13e698..a8cfd01 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -167,12 +167,7 @@ function SearchPageClient() { try { setIsLoading(true); const response = await fetch( - `/api/search?q=${encodeURIComponent(query.trim())}`, - { - headers: { - 'Netlify-Vary': 'query', - }, - } + `/api/search?q=${encodeURIComponent(query.trim())}` ); const data = await response.json(); let results = data.results; diff --git a/src/components/SearchSuggestions.tsx b/src/components/SearchSuggestions.tsx index 5804f37..a4fd7a1 100644 --- a/src/components/SearchSuggestions.tsx +++ b/src/components/SearchSuggestions.tsx @@ -44,9 +44,6 @@ export default function SearchSuggestions({ `/api/search/suggestions?q=${encodeURIComponent(searchQuery)}`, { signal: controller.signal, - headers: { - 'Netlify-Vary': 'query', - }, } ); if (response.ok) { diff --git a/src/lib/douban.client.ts b/src/lib/douban.client.ts index e8bf437..ca0cfd8 100644 --- a/src/lib/douban.client.ts +++ b/src/lib/douban.client.ts @@ -211,12 +211,7 @@ export async function getDoubanCategories( case 'direct': default: const response = await fetch( - `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}`, - { - headers: { - 'Netlify-Vary': 'query', - }, - } + `/api/douban/categories?kind=${kind}&category=${category}&type=${type}&limit=${pageLimit}&start=${pageStart}` ); return response.json(); @@ -249,12 +244,7 @@ export async function getDoubanList( case 'direct': default: const response = await fetch( - `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}`, - { - headers: { - 'Netlify-Vary': 'query', - }, - } + `/api/douban?tag=${tag}&type=${type}&pageSize=${pageLimit}&pageStart=${pageStart}` ); return response.json(); @@ -374,12 +364,7 @@ export async function getDoubanRecommends( case 'direct': default: const response = await fetch( - `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}`, - { - headers: { - 'Netlify-Vary': 'query', - }, - } + `/api/douban/recommends?kind=${kind}&limit=${pageLimit}&start=${pageStart}&category=${category}&format=${format}®ion=${region}&year=${year}&platform=${platform}&sort=${sort}&label=${label}` ); return response.json();