feat: 重构 AI 搜索架构并优化用户体验

- 重构 AI 搜索数据流:n8n 只返回项目 ID,Next.js 后端负责组装完整数据
- 首页添加 AI 搜索功能,支持跳转到项目列表页后自动触发搜索
- 新增 HomeSearchBar 组件处理首页搜索逻辑
- 修复英文模式下搜索按钮重叠问题,使用 flex 布局确保间距
- 完善国际化支持,添加切换模式提示和加载状态的翻译
- 优化搜索体验:切换 AI/传统搜索模式时保留输入框内容

Co-Authored-By: Claude (glm-4.7) <noreply@anthropic.com>
This commit is contained in:
2026-01-27 16:40:44 +08:00
co-authored by Claude
parent 11009320f1
commit 660cd5168c
9 changed files with 285 additions and 41 deletions
+9 -2
View File
@@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server'
import { getProjects } from '@/hooks/useProjects'
import { ProjectList } from '@/components/project/ProjectList'
import { SearchBar } from '@/components/search/SearchBar'
import { HomeSearchBar } from '@/components/search/HomeSearchBar'
import { Suspense } from 'react'
interface HomePageProps {
@@ -30,10 +30,17 @@ export default async function HomePage({ params }: HomePageProps) {
{t('heroDescription')}
</p>
<Suspense fallback={<div className="h-16"></div>}>
<SearchBar
<HomeSearchBar
locale={locale}
searchPlaceholder={t('searchPlaceholder')}
searchLabel={tCommon('search')}
aiPlaceholder={t('aiSearchPlaceholder')}
aiLabel={tCommon('aiSearch')}
toggleToAI={tCommon('toggleToAI')}
toggleToTraditional={tCommon('toggleToTraditional')}
searching={tCommon('searching')}
aiHint={tCommon('aiHint')}
traditionalHint={tCommon('traditionalHint')}
/>
</Suspense>
</section>
@@ -1,6 +1,7 @@
'use client'
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { useSearchParams } from 'next/navigation'
import { AISearchBar } from '@/components/search/AISearchBar'
import { AISearchResults } from '@/components/search/AISearchResults'
import type { ProjectWithFlatTags } from '@/hooks/useProjects'
@@ -18,6 +19,11 @@ interface ProjectsPageClientProps {
searchLabel: string
aiPlaceholder: string
aiLabel: string
toggleToAI: string
toggleToTraditional: string
searching: string
aiHint: string
traditionalHint: string
}
export function ProjectsPageClient({
@@ -27,22 +33,31 @@ export function ProjectsPageClient({
searchLabel,
aiPlaceholder,
aiLabel,
toggleToAI,
toggleToTraditional,
searching,
aiHint,
traditionalHint,
}: ProjectsPageClientProps) {
const searchParams = useSearchParams()
const [aiResults, setAiResults] = useState<AISearchResult[]>([])
const [isAIResult, setIsAIResult] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [initialAIHandled, setInitialAIHandled] = useState(false)
const handleAISearch = async (query: string, isAI: boolean) => {
if (!isAI) {
// 传统搜索:刷新页面到 URL 参数
const params = new URLSearchParams()
if (query) params.set('search', query)
window.location.href = `/${locale}/projects?${params.toString()}`
return
// 检查 URL 参数,如果有 ai=1 且有搜索词,则自动触发 AI 搜索
useEffect(() => {
const aiParam = searchParams.get('ai')
const searchParam = searchParams.get('search')
if (aiParam === '1' && searchParam && !initialAIHandled) {
setInitialAIHandled(true)
performAISearch(searchParam)
}
}, [searchParams, initialAIHandled])
// AI 搜索
const performAISearch = async (query: string) => {
setLoading(true)
setError(null)
setIsAIResult(true)
@@ -63,7 +78,31 @@ export function ProjectsPageClient({
}
const data = await response.json()
setAiResults(data.results || [])
// 转换 API 返回格式为组件期望格式
// API 返回: { projects: [{ ...project, similarity }], pagination }
// 组件期望: [{ project, similarity }]
const { similarity, ...projectData } = data.projects || []
const results = (data.projects || []).map((p: any) => ({
project: {
id: p.id,
name: p.name,
nameEn: p.nameEn,
slug: p.slug,
description: p.description,
descriptionEn: p.descriptionEn,
content: p.content,
contentEn: p.contentEn,
status: p.status,
createdAt: p.createdAt,
updatedAt: p.updatedAt,
tags: p.tags || [],
links: p.links || [],
},
similarity: p.similarity,
}))
setAiResults(results)
} catch (err) {
setError(err instanceof Error ? err.message : '未知错误')
setAiResults([])
@@ -72,6 +111,19 @@ export function ProjectsPageClient({
}
}
const handleAISearch = async (query: string, isAI: boolean) => {
if (!isAI) {
// 传统搜索:刷新页面到 URL 参数
const params = new URLSearchParams()
if (query) params.set('search', query)
window.location.href = `/${locale}/projects?${params.toString()}`
return
}
// AI 搜索
await performAISearch(query)
}
return (
<>
{/* AI 搜索栏 */}
@@ -81,6 +133,11 @@ export function ProjectsPageClient({
searchLabel={searchLabel}
aiPlaceholder={aiPlaceholder}
aiLabel={aiLabel}
toggleToAI={toggleToAI}
toggleToTraditional={toggleToTraditional}
searching={searching}
aiHint={aiHint}
traditionalHint={traditionalHint}
onSearch={handleAISearch}
loading={loading}
/>
+5
View File
@@ -40,6 +40,11 @@ export default async function ProjectsPage({
searchLabel={tCommon('search')}
aiPlaceholder={t('aiSearchPlaceholder')}
aiLabel={tCommon('aiSearch')}
toggleToAI={tCommon('toggleToAI')}
toggleToTraditional={tCommon('toggleToTraditional')}
searching={tCommon('searching')}
aiHint={tCommon('aiHint')}
traditionalHint={tCommon('traditionalHint')}
>
{/* Tag Cloud - rendered inside client component for non-AI mode */}
<div className="border-t-2 border-gray-100 dark:border-gray-800 pt-6 mt-8">
+60 -3
View File
@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server'
import { ZodError } from 'zod'
import { ZodError, z } from 'zod'
import { ProjectQuerySchema } from '@/lib/validations'
import { getProjectsByIds, type AISearchResultItem } from '@/hooks/useProjects'
const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK!
@@ -8,6 +9,14 @@ if (!N8N_WEBHOOK_URL) {
throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set')
}
// n8n 返回的简化搜索结果 Schema
const N8NSearchResultSchema = z.array(
z.object({
id: z.string(),
similarity: z.number(),
})
)
export async function POST(request: Request) {
try {
const body = await request.json()
@@ -38,9 +47,57 @@ export async function POST(request: Request) {
throw new Error(`n8n webhook failed: ${n8nResponse.statusText} - ${errorText}`)
}
const results = await n8nResponse.json()
// n8n 返回数据
const n8nData = await n8nResponse.json()
return NextResponse.json(results)
// 兼容多种返回格式
let n8nResults: Array<{ id: string; similarity: number }>
if (Array.isArray(n8nData)) {
// 数组格式: [{ id: "...", similarity: 0.59 }]
n8nResults = N8NSearchResultSchema.parse(n8nData)
} else if (n8nData.id && typeof n8nData.similarity === 'number') {
// 单个对象格式: { id: "...", similarity: 0.59 }
n8nResults = [n8nData as { id: string; similarity: number }]
} else if (n8nData.data && Array.isArray(n8nData.data)) {
// 包装在 data 字段: { data: [{ id: "...", similarity: 0.59 }] }
n8nResults = N8NSearchResultSchema.parse(n8nData.data)
} else {
throw new Error('Invalid n8n response format')
}
if (n8nResults.length === 0) {
return NextResponse.json({ projects: [], pagination: { total: 0, page: 1, limit: validatedQuery.limit || 20 } })
}
// 提取所有项目 ID
const projectIds = n8nResults.map((r) => r.id)
// 从数据库批量获取完整项目数据
const projects = await getProjectsByIds(projectIds)
// 创建 similarity 映射表(ID -> similarity
const similarityMap = new Map(n8nResults.map((r) => [r.id, r.similarity]))
// 将 similarity 合并到项目数据中,并按 n8n 返回的顺序排序
const results: AISearchResultItem[] = n8nResults
.map((n8nItem) => {
const project = projects.find((p) => p.id === n8nItem.id)
if (!project) return null
return {
...project,
similarity: n8nItem.similarity,
}
})
.filter((item): item is AISearchResultItem => item !== null)
return NextResponse.json({
projects: results,
pagination: {
total: results.length,
page: 1,
limit: validatedQuery.limit || 20,
},
})
} catch (error) {
console.error('AI search error:', error)
+38 -26
View File
@@ -9,6 +9,11 @@ interface AISearchBarProps {
searchLabel: string
aiPlaceholder: string
aiLabel: string
toggleToAI: string
toggleToTraditional: string
searching: string
aiHint: string
traditionalHint: string
onSearch: (query: string, isAI: boolean) => void
loading?: boolean
}
@@ -19,6 +24,11 @@ export function AISearchBar({
searchLabel,
aiPlaceholder,
aiLabel,
toggleToAI,
toggleToTraditional,
searching,
aiHint,
traditionalHint,
onSearch,
loading = false,
}: AISearchBarProps) {
@@ -34,7 +44,6 @@ export function AISearchBar({
const toggleAIMode = () => {
setAiMode(!aiMode)
setQuery('')
}
return (
@@ -55,40 +64,43 @@ export function AISearchBar({
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={aiMode ? aiPlaceholder : searchPlaceholder}
className="block w-full pl-12 pr-40 py-4 bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-gray-600 text-text-light dark:text-text-dark placeholder-gray-500 focus:ring-0 focus:border-black dark:focus:border-primary font-display shadow-neo transition-all"
className="block w-full pl-12 pr-48 py-4 bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-gray-600 text-text-light dark:text-text-dark placeholder-gray-500 focus:ring-0 focus:border-black dark:focus:border-primary font-display shadow-neo transition-all"
/>
{/* AI Mode Toggle */}
<button
type="button"
onClick={toggleAIMode}
className={`
absolute inset-y-2 right-24 px-3 py-2 font-display font-bold text-sm border-2 transition-all shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px]
${aiMode
? 'bg-primary text-black border-black hover:bg-yellow-400'
: 'bg-white dark:bg-surface-dark text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-white/10'
}
`}
title={aiMode ? '切换到传统搜索' : '切换到 AI 搜索'}
>
<Sparkles className="w-4 h-4" />
</button>
{/* Buttons Container */}
<div className="absolute inset-y-2 right-2 flex items-center gap-2">
{/* AI Mode Toggle */}
<button
type="button"
onClick={toggleAIMode}
className={`
px-3 py-2 font-display font-bold text-sm border-2 transition-all shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px] whitespace-nowrap min-w-[44px]
${aiMode
? 'bg-primary text-black border-black hover:bg-yellow-400'
: 'bg-white dark:bg-surface-dark text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-white/10'
}
`}
title={aiMode ? toggleToTraditional : toggleToAI}
>
<Sparkles className="w-4 h-4" />
</button>
{/* Search button */}
<button
type="submit"
disabled={loading || !query.trim()}
className="absolute inset-y-2 right-2 px-4 bg-black dark:bg-primary text-white dark:text-black font-bold font-display text-sm border-2 border-black dark:border-primary hover:bg-gray-800 dark:hover:bg-yellow-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
>
{loading ? '搜索中...' : (aiMode ? aiLabel : searchLabel)}
</button>
{/* Search button */}
<button
type="submit"
disabled={loading || !query.trim()}
className="px-4 py-2 bg-black dark:bg-primary text-white dark:text-black font-bold font-display text-sm border-2 border-black dark:border-primary hover:bg-gray-800 dark:hover:bg-yellow-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px] whitespace-nowrap"
>
{loading ? searching : (aiMode ? aiLabel : searchLabel)}
</button>
</div>
</div>
</div>
{/* AI Mode Hint */}
{aiMode && (
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400 font-display">
💡 {aiMode ? '试试:"帮我找能生成视频的 AI 工具"' : '输入项目名称或描述'}
💡 {aiHint}
</div>
)}
</form>
+52
View File
@@ -0,0 +1,52 @@
'use client'
import { AISearchBar } from './AISearchBar'
interface HomeSearchBarProps {
locale: string
searchPlaceholder: string
searchLabel: string
aiPlaceholder: string
aiLabel: string
toggleToAI: string
toggleToTraditional: string
searching: string
aiHint: string
traditionalHint: string
}
export function HomeSearchBar({
locale,
searchPlaceholder,
searchLabel,
aiPlaceholder,
aiLabel,
toggleToAI,
toggleToTraditional,
searching,
aiHint,
traditionalHint,
}: HomeSearchBarProps) {
const handleSearch = (query: string, isAI: boolean) => {
const params = new URLSearchParams()
if (query) params.set('search', query)
if (isAI) params.set('ai', '1')
window.location.href = `/${locale}/projects?${params.toString()}`
}
return (
<AISearchBar
locale={locale}
searchPlaceholder={searchPlaceholder}
searchLabel={searchLabel}
aiPlaceholder={aiPlaceholder}
aiLabel={aiLabel}
toggleToAI={toggleToAI}
toggleToTraditional={toggleToTraditional}
searching={searching}
aiHint={aiHint}
traditionalHint={traditionalHint}
onSearch={handleSearch}
/>
)
}
+44
View File
@@ -181,3 +181,47 @@ export async function getTopTags(limit: number = 10): Promise<TagWithProjectCoun
})
.slice(0, limit)
}
// 定义 AI 搜索结果类型
export type AISearchResultItem = Omit<ProjectWithFlatTags, 'status'> & {
similarity: number
}
// n8n 返回的简化搜索结果类型
export type N8NSearchResult = {
id: string
similarity: number
}
/**
* 根据 ID 列表批量获取项目(用于 AI 搜索结果组装)
* @param ids 项目 ID 列表
* @returns 带有相似度分数的项目列表
*/
export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTags[]> {
if (ids.length === 0) {
return []
}
const projects = await prisma.project.findMany({
where: {
id: {
in: ids,
},
},
include: {
tags: {
include: {
tag: true,
},
},
links: true,
},
})
// Transform tags to flatten the structure
return projects.map((project) => ({
...project,
tags: project.tags.map((pt) => pt.tag),
}))
}
+5
View File
@@ -2,6 +2,11 @@
"common": {
"search": "Search",
"aiSearch": "AI Search",
"searching": "Searching...",
"toggleToAI": "Switch to AI Search",
"toggleToTraditional": "Switch to Traditional Search",
"aiHint": "Try: \"Find AI tools that can generate videos\"",
"traditionalHint": "Enter project name or description",
"loading": "Loading...",
"noResults": "No results found",
"noProjects": "No projects yet",
+5
View File
@@ -2,6 +2,11 @@
"common": {
"search": "搜索",
"aiSearch": "AI 搜索",
"searching": "搜索中...",
"toggleToAI": "切换到 AI 搜索",
"toggleToTraditional": "切换到传统搜索",
"aiHint": "试试:\"帮我找能生成视频的 AI 工具\"",
"traditionalHint": "输入项目名称或描述",
"loading": "加载中...",
"noResults": "未找到结果",
"noProjects": "暂无项目",