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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"common": {
|
||||
"search": "搜索",
|
||||
"aiSearch": "AI 搜索",
|
||||
"searching": "搜索中...",
|
||||
"toggleToAI": "切换到 AI 搜索",
|
||||
"toggleToTraditional": "切换到传统搜索",
|
||||
"aiHint": "试试:\"帮我找能生成视频的 AI 工具\"",
|
||||
"traditionalHint": "输入项目名称或描述",
|
||||
"loading": "加载中...",
|
||||
"noResults": "未找到结果",
|
||||
"noProjects": "暂无项目",
|
||||
|
||||
Reference in New Issue
Block a user