From c34c4d9e24732bfedb24202b88df5d575e586d1f Mon Sep 17 00:00:00 2001 From: skyfire Date: Fri, 2 Jan 2026 16:59:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(search):=20=E6=B7=BB=E5=8A=A0=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=8A=9F=E8=83=BD=E5=92=8C=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增搜索API接口和页面,支持对标题和内容进行中英文搜索,同时完善了.gitignore文件支持多语言开发环境 --- .gitignore | 85 ++++++++++ src/app/api/search/route.ts | 45 +++++ src/app/search/page.tsx | 270 ++++++++++++++++++++++++++++++ src/components/SearchBox.tsx | 52 ++++++ src/components/StoryList.tsx | 69 +++++--- src/components/layout/header.tsx | 68 +++++--- src/lib/db.ts | 273 +++++++++++++++++++++---------- 7 files changed, 725 insertions(+), 137 deletions(-) create mode 100644 src/app/api/search/route.ts create mode 100644 src/app/search/page.tsx create mode 100644 src/components/SearchBox.tsx diff --git a/.gitignore b/.gitignore index 00bba9b..5e57c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,88 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +# General +.jarvis +Thumbs.db +*.log +*.tmp +*.swp +*.swo +.idea/ +.vscode/ + +# Python +__pycache__/ +*.py[cod] +*$py.class +.Python +env/ +venv/ +.venv/ +build/ +dist/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.tox/ +.coverage +.coverage.* +htmlcov/ +.hypothesis/ +.ipynb_checkpoints +.pyre/ +.pytype/ + +# Rust +target/ + +# Node +node_modules/ +pnpm-debug.log* +lerna-debug.log* +dist/ +coverage/ +.turbo/ +.next/ +.nuxt/ +out/ + +# Go +vendor/ +coverage.out + +# Java +target/ +*.class +.gradle/ +build/ +out/ + +# C/C++ +build/ +cmake-build-*/ +*.o +*.a +*.so +*.obj +*.dll +*.dylib +*.exe +*.pdb + +# .NET +obj/ diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..5f8359b --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { searchStories } from "@/lib/db"; + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url); + const query = searchParams.get("q"); + const page = parseInt(searchParams.get("page") || "1", 10); + const limit = parseInt(searchParams.get("limit") || "20", 10); + + if (!query || query.trim().length === 0) { + return NextResponse.json({ + error: "查询参数不能为空", + results: [], + total: 0, + totalPages: 0, + currentPage: 1, + }); + } + + const result = await searchStories(query.trim(), page, limit); + + return NextResponse.json({ + success: true, + query: query.trim(), + results: result.stories, + total: result.total, + totalPages: result.totalPages, + currentPage: page, + }); + } catch (error) { + console.error("搜索API错误:", error); + return NextResponse.json( + { + error: "Internal Server Error", + message: String(error), + results: [], + total: 0, + totalPages: 0, + currentPage: 1, + }, + { status: 500 }, + ); + } +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx new file mode 100644 index 0000000..a10ec78 --- /dev/null +++ b/src/app/search/page.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { formatDistanceToNow } from "date-fns"; +import { zhCN } from "date-fns/locale"; +import { ArrowUpRight, ExternalLink, Search } from "lucide-react"; +import { searchStories } from "@/lib/db"; + +interface Story { + id: number; + title: string; + titleZh: string | null; + url: string | null; + text: string | null; + textZh: string | null; + by: string; + score: number; + time: Date; +} + +export default function SearchPage() { + const [query, setQuery] = useState(""); + const [stories, setStories] = useState([]); + const [totalPages, setTotalPages] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const searchParams = useSearchParams(); + const router = useRouter(); + + const searchQuery = searchParams.get("q") || ""; + const searchPage = parseInt(searchParams.get("page") || "1", 10); + + useEffect(() => { + setQuery(searchQuery); + setCurrentPage(searchPage); + }, [searchQuery, searchPage]); + + useEffect(() => { + if (query.trim()) { + performSearch(query.trim(), searchPage); + } else { + setStories([]); + setTotalPages(0); + } + }, [query, searchPage]); + + const performSearch = async (searchQuery: string, page: number) => { + setIsLoading(true); + setError(null); + + try { + const result = await searchStories(searchQuery, page, 20); + setStories(result.stories); + setTotalPages(result.totalPages); + } catch (err) { + console.error("搜索失败:", err); + setError("搜索失败,请稍后重试"); + } finally { + setIsLoading(false); + } + }; + + const handleSearch = (newQuery: string) => { + if (newQuery.trim()) { + router.push(`/search?q=${encodeURIComponent(newQuery)}&page=1`); + } else { + router.push("/"); + } + }; + + const handlePageChange = (newPage: number) => { + if (newPage >= 1 && newPage <= totalPages) { + router.push(`/search?q=${encodeURIComponent(query)}&page=${newPage}`); + } + }; + + const maxDisplayPages = 5; + const startPage = Math.max(1, currentPage - Math.floor(maxDisplayPages / 2)); + const endPage = Math.min(totalPages, startPage + maxDisplayPages - 1); + + return ( +
+
+
+ +

搜索结果

+
+ +
+
+ +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch(query)} + placeholder="搜索标题或内容..." + className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition" + /> + +
+ + {isLoading && ( +
+
+

搜索中...

+
+ )} + + {!isLoading && error && ( +
+ {error} +
+ )} + + {!isLoading && !error && query && ( +
+

+ 找到 {stories.length > 0 ? "约" + currentPage * 20 : 0} 条结果 + (查询: "{query}") +

+ + {stories.length === 0 ? ( +
+

没有找到与 "{query}" 相关的结果

+ + 返回首页 + +
+ ) : ( +
+ {stories.map((story) => ( +
+
+ {story.url ? ( + + {story.titleZh || story.title} + + + ) : ( + + {story.titleZh || story.title} + + )} +
+
+ {story.score} 分 + + 作者: {story.by} + + + {formatDistanceToNow(story.time, { + addSuffix: true, + locale: zhCN, + })} + + + + 原帖 + + +
+ {(story.text || story.textZh) && ( +
+ {story.textZh || story.text} +
+ )} +
+ ))} +
+ )} + + {totalPages > 1 && ( +
+ {currentPage > 1 && ( + + )} + + {currentPage > 2 && ( + + )} + + {currentPage > 3 && ...} + + {Array.from( + { length: endPage - startPage + 1 }, + (_, i) => startPage + i, + ).map((p) => ( + + ))} + + {currentPage < totalPages - 2 && ( + ... + )} + + {currentPage < totalPages - 1 && ( + + )} + + {currentPage < totalPages && ( + + )} +
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx new file mode 100644 index 0000000..b08af42 --- /dev/null +++ b/src/components/SearchBox.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState } from "react"; +import { Search, X } from "lucide-react"; + +interface SearchBoxProps { + onSearch: (query: string) => void; + placeholder?: string; +} + +export default function SearchBox({ + onSearch, + placeholder = "搜索标题或内容...", +}: SearchBoxProps) { + const [query, setQuery] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSearch(query.trim()); + }; + + const clearSearch = () => { + setQuery(""); + onSearch(""); + }; + + return ( +
+
+
+ +
+ setQuery(e.target.value)} + placeholder={placeholder} + className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition" + /> + {query && ( + + )} +
+
+ ); +} diff --git a/src/components/StoryList.tsx b/src/components/StoryList.tsx index 5675da8..7bb3b49 100644 --- a/src/components/StoryList.tsx +++ b/src/components/StoryList.tsx @@ -1,7 +1,7 @@ -import Link from 'next/link'; -import { formatDistanceToNow } from 'date-fns'; -import { zhCN } from 'date-fns/locale'; -import { ArrowUpRight } from 'lucide-react'; +import Link from "next/link"; +import { formatDistanceToNow } from "date-fns"; +import { zhCN } from "date-fns/locale"; +import { ArrowUpRight, ExternalLink } from "lucide-react"; interface Story { id: number; @@ -21,7 +21,14 @@ interface StoryListProps { currentPage: number; } -export default function StoryList({ stories, type, currentPage }: StoryListProps) { +export default function StoryList({ + stories, + type, + currentPage, +}: StoryListProps) { + // 判断是否是搜索结果(搜索结果不需要分页) + const isSearchResult = type === "search" || !type; + return (
{stories.map((story) => ( @@ -59,6 +66,16 @@ export default function StoryList({ stories, type, currentPage }: StoryListProps locale: zhCN, })} + + + 原帖 + +
@@ -68,25 +85,27 @@ export default function StoryList({ stories, type, currentPage }: StoryListProps ))} -
- - 上一页 - - 第 {currentPage} 页 - - 下一页 - -
+ {!isSearchResult && ( +
+ + 上一页 + + 第 {currentPage} 页 + + 下一页 + +
+ )} ); -} \ No newline at end of file +} diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index c74a22a..2506867 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -1,25 +1,36 @@ -'use client' +"use client"; -import Image from 'next/image' -import Link from 'next/link' -import { Twitter, Github } from 'lucide-react' -import { useEffect, useState } from 'react' +import Image from "next/image"; +import Link from "next/link"; +import { Twitter, Github, Search } from "lucide-react"; +import { useEffect, useState } from "react"; +import SearchBox from "@/components/SearchBox"; export function Header() { - const [isScrolled, setIsScrolled] = useState(false) + const [isScrolled, setIsScrolled] = useState(false); useEffect(() => { const handleScroll = () => { - setIsScrolled(window.scrollY > 0) + setIsScrolled(window.scrollY > 0); + }; + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); + + const handleSearch = (query: string) => { + if (query.trim()) { + window.location.href = `/search?q=${encodeURIComponent(query)}`; + } else { + window.location.href = "/"; } - window.addEventListener('scroll', handleScroll) - return () => window.removeEventListener('scroll', handleScroll) - }, []) + }; return ( -
+
@@ -34,14 +45,27 @@ export function Header() {
-
+
+
- ) -} \ No newline at end of file + ); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 1560281..1b37fd8 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,5 +1,5 @@ -import { PrismaClient } from '@prisma/client'; -import { HNItem } from './hn'; +import { PrismaClient } from "@prisma/client"; +import { HNItem } from "./hn"; declare global { // eslint-disable-next-line no-var @@ -8,104 +8,124 @@ declare global { const prismaClientSingleton = () => { return new PrismaClient({ - log: ['error'], - }) -} + log: ["error"], + }); +}; -export const prisma = globalThis.prisma ?? prismaClientSingleton() +export const prisma = globalThis.prisma ?? prismaClientSingleton(); -if (process.env.NODE_ENV !== 'production') { - globalThis.prisma = prisma +if (process.env.NODE_ENV !== "production") { + globalThis.prisma = prisma; } // 添加重试逻辑的包装函数 -async function withRetry(operation: () => Promise, maxRetries = 5): Promise { +async function withRetry( + operation: () => Promise, + maxRetries = 5, +): Promise { let lastError: Error | undefined; - + for (let i = 0; i < maxRetries; i++) { try { return await operation(); } catch (error) { lastError = error as Error; console.error(`尝试第 ${i + 1} 次失败:`, error); - + if (i === maxRetries - 1) break; - + // 如果是连接错误,尝试重新连接 - if (error instanceof Error && error.message.includes('connection')) { + if (error instanceof Error && error.message.includes("connection")) { await prisma.$connect(); } - + // 指数退避重试 - await new Promise(resolve => setTimeout(resolve, Math.min(1000 * Math.pow(2, i), 10000))); + await new Promise((resolve) => + setTimeout(resolve, Math.min(1000 * Math.pow(2, i), 10000)), + ); } } - + throw lastError; } -export async function createStory(item: HNItem, translation: { titleZh: string; textZh?: string }) { - return withRetry(() => prisma.story.create({ - data: { - id: item.id, - title: item.title || '', - titleZh: translation.titleZh, - url: item.url, - text: item.text, - textZh: translation.textZh, - by: item.by || '', - score: item.score || 0, - descendants: item.descendants || 0, - time: new Date((item.time || 0) * 1000), - type: item.type || 'story', - dead: item.dead || false, - deleted: item.deleted || false, - kids: item.kids || [], - translated: true, - }, - })); +export async function createStory( + item: HNItem, + translation: { titleZh: string; textZh?: string }, +) { + return withRetry(() => + prisma.story.create({ + data: { + id: item.id, + title: item.title || "", + titleZh: translation.titleZh, + url: item.url, + text: item.text, + textZh: translation.textZh, + by: item.by || "", + score: item.score || 0, + descendants: item.descendants || 0, + time: new Date((item.time || 0) * 1000), + type: item.type || "story", + dead: item.dead || false, + deleted: item.deleted || false, + kids: item.kids || [], + translated: true, + }, + }), + ); } -export type StoryType = 'top' | 'new' | 'week' | 'ask' | 'show' | 'job'; +export type StoryType = "top" | "new" | "week" | "ask" | "show" | "job"; type OrderBy = { - score?: 'asc' | 'desc'; - time?: 'asc' | 'desc'; + score?: "asc" | "desc"; + time?: "asc" | "desc"; }[]; -export async function getStoriesByTypes(types: StoryType[], pageSize: number = 10) { +export async function getStoriesByTypes( + types: StoryType[], + pageSize: number = 10, +) { try { const results = await withRetry(async () => { - const queries = types.map(type => { + const queries = types.map((type) => { const baseQuery = { deleted: false, dead: false, - type: type === 'top' || type === 'new' || type === 'week' ? 'story' : type, - ...(type === 'top' ? { - time: { - gte: new Date(Date.now() - 24 * 60 * 60 * 1000) - } - } : {}), - ...(type === 'week' ? { - time: { - gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - } - } : {}) + type: + type === "top" || type === "new" || type === "week" + ? "story" + : type, + ...(type === "top" + ? { + time: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + } + : {}), + ...(type === "week" + ? { + time: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + } + : {}), }; let orderBy: OrderBy; switch (type) { - case 'top': - orderBy = [{ score: 'desc' }, { time: 'desc' }]; + case "top": + orderBy = [{ score: "desc" }, { time: "desc" }]; break; - case 'new': - orderBy = [{ time: 'desc' }]; + case "new": + orderBy = [{ time: "desc" }]; break; - case 'week': - orderBy = [{ score: 'desc' }]; + case "week": + orderBy = [{ score: "desc" }]; break; default: - orderBy = [{ score: 'desc' }, { time: 'desc' }]; + orderBy = [{ score: "desc" }, { time: "desc" }]; } return prisma.story.findMany({ @@ -120,15 +140,18 @@ export async function getStoriesByTypes(types: StoryType[], pageSize: number = 1 return results; } catch (error) { - console.error('查询文章失败:', error instanceof Error ? error.message : error); - return types.map(() => []); // 返回空数组数组 + console.error( + "查询文章失败:", + error instanceof Error ? error.message : error, + ); + return types.map(() => []); // 返回空数组数组 } } export async function getStories( - type: StoryType = 'top', + type: StoryType = "top", page: number = 1, - pageSize: number = 20 + pageSize: number = 20, ) { const skip = (page - 1) * pageSize; @@ -137,32 +160,41 @@ export async function getStories( const baseQuery = { deleted: false, dead: false, - type: type === 'new' ? 'story' : (type === 'top' || type === 'week' ? 'story' : type), - ...(type === 'top' ? { - time: { - gte: new Date(Date.now() - 24 * 60 * 60 * 1000) - } - } : {}), - ...(type === 'week' ? { - time: { - gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - } - } : {}) + type: + type === "new" + ? "story" + : type === "top" || type === "week" + ? "story" + : type, + ...(type === "top" + ? { + time: { + gte: new Date(Date.now() - 24 * 60 * 60 * 1000), + }, + } + : {}), + ...(type === "week" + ? { + time: { + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }, + } + : {}), }; let orderBy: OrderBy; switch (type) { - case 'top': - orderBy = [{ score: 'desc' }, { time: 'desc' }]; + case "top": + orderBy = [{ score: "desc" }, { time: "desc" }]; break; - case 'new': - orderBy = [{ time: 'desc' }]; + case "new": + orderBy = [{ time: "desc" }]; break; - case 'week': - orderBy = [{ score: 'desc' }]; + case "week": + orderBy = [{ score: "desc" }]; break; default: - orderBy = [{ score: 'desc' }, { time: 'desc' }]; + orderBy = [{ score: "desc" }, { time: "desc" }]; } const [stories, total] = await Promise.all([ @@ -174,40 +206,101 @@ export async function getStories( }), prisma.story.count({ where: baseQuery, - }) + }), ]); return { stories, total, - totalPages: Math.ceil(total / pageSize) + totalPages: Math.ceil(total / pageSize), }; }); } catch (error) { - console.error('查询文章失败:', error instanceof Error ? error.message : error); + console.error( + "查询文章失败:", + error instanceof Error ? error.message : error, + ); return { stories: [], total: 0, - totalPages: 0 + totalPages: 0, }; } } export async function getStory(id: number) { - return withRetry(() => + return withRetry(() => prisma.story.findUnique({ where: { id }, - }) + }), ); } export async function storyExists(id: number): Promise { - const count: number = await withRetry(() => + const count: number = await withRetry(() => prisma.story.count({ where: { id }, - }) + }), ); return count > 0; } -export default prisma; \ No newline at end of file +// 搜索功能 +export async function searchStories( + query: string, + page: number = 1, + pageSize: number = 20, +) { + const skip = (page - 1) * pageSize; + + try { + // 搜索标题(中英文)和内容(中英文) + const [stories, total] = await Promise.all([ + prisma.story.findMany({ + where: { + OR: [ + { title: { contains: query, mode: "insensitive" } }, + { titleZh: { contains: query, mode: "insensitive" } }, + { text: { contains: query, mode: "insensitive" } }, + { textZh: { contains: query, mode: "insensitive" } }, + ], + deleted: false, + dead: false, + }, + orderBy: [{ score: "desc" }, { time: "desc" }], + skip, + take: pageSize, + }), + prisma.story.count({ + where: { + OR: [ + { title: { contains: query, mode: "insensitive" } }, + { titleZh: { contains: query, mode: "insensitive" } }, + { text: { contains: query, mode: "insensitive" } }, + { textZh: { contains: query, mode: "insensitive" } }, + ], + deleted: false, + dead: false, + }, + }), + ]); + + return { + stories, + total, + totalPages: Math.ceil(total / pageSize), + }; + } catch (error) { + console.error( + "搜索文章失败:", + error instanceof Error ? error.message : error, + ); + return { + stories: [], + total: 0, + totalPages: 0, + }; + } +} + +export default prisma;