实现了AI助手

This commit is contained in:
2025-06-24 14:09:12 +08:00
parent 7d139b22db
commit 8a4ccd5ef2
31 changed files with 2883 additions and 252 deletions
+75 -67
View File
@@ -51,15 +51,29 @@ function AdminComponentsPage() {
specifications: ''
})
// Helper function to get authenticated headers
const getAuthHeaders = (): HeadersInit => {
const token = localStorage.getItem('token')
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const headers = getAuthHeaders()
const [componentsRes, typesRes] = await Promise.all([
fetch('/api/components?limit=100'),
fetch('/api/component-types')
fetch('/api/admin/components?limit=100', { headers }),
fetch('/api/component-types', { headers })
])
if (componentsRes.ok) {
@@ -77,22 +91,19 @@ function AdminComponentsPage() {
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const url = editingComponent
const url = editingComponent
? `/api/components/${editingComponent.id}`
: '/api/components'
const method = editingComponent ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
headers: getAuthHeaders(),
body: JSON.stringify(formData),
})
@@ -109,13 +120,13 @@ function AdminComponentsPage() {
alert('操作失败')
}
}
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个配件吗?')) return
try {
const response = await fetch(`/api/components/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
})
if (response.ok) {
@@ -170,10 +181,10 @@ function AdminComponentsPage() {
}
const parseFile = async (file: File) => {
const text = await file.text()
try {
let data: any[] = []
if (file.name.endsWith('.json')) {
const parsed = JSON.parse(text)
data = Array.isArray(parsed) ? parsed : [parsed]
@@ -183,7 +194,7 @@ function AdminComponentsPage() {
alert('CSV文件格式错误')
return
}
const headers = lines[0].split(',').map(h => h.trim())
data = lines.slice(1)
.filter(line => line.trim())
@@ -196,7 +207,7 @@ function AdminComponentsPage() {
return obj
})
}
setBatchData(data)
validateBatchData(data)
} catch (error) {
@@ -212,7 +223,7 @@ function AdminComponentsPage() {
data.forEach((item, index) => {
const itemErrors: string[] = []
// 验证必需字段
if (!item.name || typeof item.name !== 'string' || item.name.trim() === '') {
itemErrors.push('缺少商品名称')
@@ -223,26 +234,26 @@ function AdminComponentsPage() {
if (!item.model || typeof item.model !== 'string' || item.model.trim() === '') {
itemErrors.push('缺少型号')
}
// 验证价格
const price = typeof item.price === 'string' ? parseFloat(item.price) : item.price
if (isNaN(price) || price < 0) {
itemErrors.push('价格必须是有效的正数')
}
// 验证库存
const stock = typeof item.stock === 'string' ? parseInt(item.stock) : item.stock
if (isNaN(stock) || stock < 0) {
itemErrors.push('库存必须是有效的非负整数')
}
// 验证配件类型
const hasTypeId = item.componentTypeId && item.componentTypeId.trim() !== ''
const hasTypeName = (item.typeName || item.type) && (item.typeName || item.type).trim() !== ''
if (!hasTypeId && !hasTypeName) {
itemErrors.push('缺少配件类型信息 (componentTypeId, typeName 或 type)')
}
if (itemErrors.length > 0) {
invalid.push({ ...item, _errors: itemErrors, _index: index + 1 })
errors.push(`第${index + 1}行: ${itemErrors.join(', ')}`)
@@ -268,31 +279,29 @@ function AdminComponentsPage() {
}
setBatchImportLoading(true)
try {
const response = await fetch('/api/components/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: getAuthHeaders(),
body: JSON.stringify({
components: validData
}),
})
const result = await response.json()
if (response.ok && result.success) {
const { summary, results } = result
// 显示详细的导入结果
const failedItems = results.filter((r: any) => !r.success)
let message = `导入完成!\n成功: ${summary.successful}\n失败: ${summary.failed}`
if (summary.newTypesCreated > 0) {
message += `\n新创建配件类型: ${summary.newTypesCreated}`
}
if (failedItems.length > 0 && failedItems.length <= 5) {
message += '\n\n失败项目:'
failedItems.forEach((item: any, index: number) => {
@@ -302,9 +311,9 @@ function AdminComponentsPage() {
message += `\n\n失败项目过多,请检查数据格式或查看控制台详情`
console.log('批量导入失败项目:', failedItems)
}
alert(message)
if (summary.successful > 0) {
await loadData()
setShowBatchModal(false)
@@ -318,7 +327,7 @@ function AdminComponentsPage() {
alert(`批量导入失败: ${result.message || '未知错误'}`)
console.error('Batch import error:', result)
}
} catch (error) {
alert('批量导入失败:网络或服务器错误')
console.error('Batch import error:', error)
@@ -348,7 +357,7 @@ function AdminComponentsPage() {
headers.join(','),
...sampleData.map(item => headers.map(header => `"${item[header as keyof typeof item] || ''}"`).join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -383,27 +392,27 @@ function AdminComponentsPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">配件管理</h1>
<div className="flex gap-3">
<button
onClick={() => setShowBatchModal(true)}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Upload className="h-5 w-5" />
批量导入
</button>
<button
onClick={() => {
resetForm()
setShowModal(true)
}}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 flex items-center gap-2"
>
<Plus className="h-5 w-5" />
添加配件
</button>
</div>
<h1 className="text-3xl font-bold text-gray-900">配件管理</h1>
<div className="flex gap-3">
<button
onClick={() => setShowBatchModal(true)}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Upload className="h-5 w-5" />
批量导入
</button>
<button
onClick={() => {
resetForm()
setShowModal(true)
}}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 flex items-center gap-2"
>
<Plus className="h-5 w-5" />
添加配件
</button>
</div>
</div>
{/* Search */}
<div className="mb-6">
@@ -444,7 +453,7 @@ function AdminComponentsPage() {
<tbody className="bg-white divide-y divide-gray-200">
{filteredComponents.map((component) => (
<tr key={component.id}>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-6 py-4 max-w-md">
<div className="flex items-center">
<div className="h-10 w-10 flex-shrink-0">
{component.imageUrl ? (
@@ -459,11 +468,11 @@ function AdminComponentsPage() {
</div>
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
<div className="ml-4 min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">
{component.name}
</div>
<div className="text-sm text-gray-500">
<div className="text-sm text-gray-500 truncate">
{component.brand} {component.model}
</div>
</div>
@@ -476,11 +485,10 @@ function AdminComponentsPage() {
¥{component.price.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
component.stock > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${component.stock > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{component.stock}
</span>
</td>
@@ -506,12 +514,12 @@ function AdminComponentsPage() {
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}>
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{editingComponent ? '编辑配件' : '添加配件'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">配件类型</label>
@@ -628,10 +636,10 @@ function AdminComponentsPage() {
{/* Batch Import Modal */}
{showBatchModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}>
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">批量导入配件</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">选择文件</label>
<input
@@ -650,7 +658,7 @@ function AdminComponentsPage() {
</div>
)}
<div className="mb-4">
<div className="mb-8 flex gap-2">
<button
onClick={() => downloadTemplate('csv')}
className="mr-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 flex items-center gap-2"
+1 -1
View File
@@ -203,7 +203,7 @@ function AdminOrdersPage() {
{/* Order Details Modal */}
{showModal && selectedOrder && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="fixed inset-0 flex items-center justify-center z-50" style={{backgroundColor: 'rgba(0, 0, 0, 0.5)'}}>
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">订单详情</h2>
+394
View File
@@ -0,0 +1,394 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import MarkdownItCjs from "markdown-it/dist/markdown-it.js";
import { ComponentCard } from '@/components/ComponentCard';
import { Component } from '@prisma/client';
import { nextTick } from 'process';
const md = MarkdownItCjs()
interface Conversation {
id: string
title: string | null
createdAt: string
updatedAt: string
}
type Message = {
role: 'user' | 'assistant'
content: string
id?: string // 添加唯一标识符
} | {
role: 'card'
components: Component[]
id?: string // 添加唯一标识符
}
export default function AIAssistantPage() {
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [conversations, setConversations] = useState<Conversation[]>([])
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null)
const [isLoadingConversations, setIsLoadingConversations] = useState(true)
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
useEffect(() => {
scrollToBottom()
}, [messages])
useEffect(() => {
loadConversations()
}, [])
const loadConversations = async () => {
try {
setIsLoadingConversations(true)
const token = localStorage.getItem('token')
const response = await fetch('/api/ai/conversations', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const data = await response.json()
setConversations(data.conversations)
}
} catch (error) {
console.error('加载对话历史失败:', error)
} finally {
setIsLoadingConversations(false)
}
}
const loadConversation = async (conversationId: string) => {
try {
setIsLoading(true)
const token = localStorage.getItem('token')
const response = await fetch(`/api/ai/conversations/${conversationId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const conversation = await response.json()
setCurrentConversationId(conversationId)
setMessages(conversation)
}
} catch (error) {
console.error('加载对话失败:', error)
} finally {
setIsLoading(false)
}
}
const startNewConversation = () => {
setMessages([])
setCurrentConversationId(null)
}
const deleteConversation = async (conversationId: string) => {
if (!confirm('确定要删除这个对话吗?')) return
try {
const token = localStorage.getItem('token')
const response = await fetch(`/api/ai/conversations/${conversationId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setConversations(prev => prev.filter(c => c.id !== conversationId))
if (currentConversationId === conversationId) {
startNewConversation()
}
}
} catch (error) {
console.error('删除对话失败:', error)
}
}
const sendMessage = async () => {
if (!input.trim() || isLoading) return
const userInput = input.trim() // 保存并清理输入内容
const userMessage: Message = {
role: 'user',
content: userInput,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
}
setMessages(prev => [...prev, userMessage])
setInput('')
setIsLoading(true)
try {
const token = localStorage.getItem('token')
const response = await fetch('/api/ai', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
prompt: userInput, // 使用保存的输入内容
conversationId: currentConversationId
})
})
if (!response.ok) {
throw new Error('AI服务请求失败')
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) {
throw new Error('无法读取响应流')
}
setMessages(prev => [...prev, {
role: 'assistant',
content: '',
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
}])
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
const lines = chunk.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') {
// 重新加载对话列表以显示新对话
loadConversations()
return
}
try {
const parsed = JSON.parse(data)
if (parsed.conversationId) {
// 设置新对话ID
setCurrentConversationId(parsed.conversationId)
} else if (parsed.content) {
console.log(parsed.content); if (parsed.content == 'next_block') {
console.log("next_block");
setMessages(prev => [...prev, {
role: 'assistant',
content: '',
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
}])
continue
} if (parsed.content.startsWith("show_card")) {
console.log("show_card");
let comps = JSON.parse(parsed.content.split("show_card:")[1].trim())
setMessages(prev => [...prev, {
role: 'card',
components: comps,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9)
}])
continue
}
// 使用函数式更新,确保状态更新的原子性
setMessages(prev => {
const newMessages = [...prev]
const lastMessageIndex = newMessages.length - 1
const lastMessage = newMessages[lastMessageIndex]
if (lastMessage && lastMessage.role === 'assistant') {
// 创建新的消息对象而不是直接修改
newMessages[lastMessageIndex] = {
...lastMessage,
content: lastMessage.content + parsed.content
}
} else {
console.warn('No assistant message to update')
}
return newMessages
})
} else if (parsed.error) {
throw new Error(parsed.error)
}
} catch (e) {
console.error(e);
}
}
}
}
} catch (error) {
console.error('发送消息失败:', error)
setMessages(prev => [
...prev,
{
role: 'assistant',
content: '抱歉,发生了错误,请稍后再试。',
}
])
} finally {
setIsLoading(false)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
return (
<div className=" bg-gray-50">
<div className="container mx-auto px-4 py-8">
<div className="flex gap-6 h-[calc(100vh-140px)]">
{/* 对话历史侧边栏 */}
<div className=" bg-white rounded-lg shadow-sm border p-4 w-80 flex flex-col">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-800">对话历史</h2>
<button
onClick={startNewConversation}
className="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm"
>
新对话
</button>
</div>
<div className="space-y-2 overflow-y-auto">
{isLoadingConversations ? (
<div className="text-center py-4 text-gray-500">加载中...</div>
) : conversations.length === 0 ? (
<div className="text-center py-4 text-gray-500">暂无对话历史</div>
) : (
conversations.map((conversation) => (
<div
key={conversation.id}
className={`p-2 rounded cursor-pointer hover:bg-gray-50 ${currentConversationId === conversation.id ? 'bg-blue-50 border-blue-200' : ''
}`}
>
<div
onClick={() => loadConversation(conversation.id)}
className="flex-1"
>
<div className="text-sm font-medium text-gray-800 truncate">
{conversation.title || '新对话'}
</div>
<div className="text-xs text-gray-500">
{new Date(conversation.updatedAt).toLocaleDateString()}
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation()
deleteConversation(conversation.id)
}}
className="mt-1 text-xs text-red-500 hover:text-red-700"
>
删除
</button>
</div>
))
)}
</div>
</div>
{/* 主聊天区域 */}
<div className=" bg-white grow rounded-lg shadow-sm border flex flex-col">
{/* 聊天标题 */}
<div className="p-4 border-b">
<h1 className="text-xl font-semibold text-gray-800">PC DIY 智能助手</h1>
<p className="text-gray-600 text-sm">我可以帮助您选择和了解PC配件</p>
</div>
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<div className="text-center py-8">
<div className="text-gray-500 mb-4">
👋 您好!我是PC DIY商店的智能助手
</div>
<div className="text-sm text-gray-400">
您可以询问关于PC配件的任何问题,比如:
<ul className="mt-2 space-y-1">
<li>• "推荐一些价格在1000-2000元的显卡"</li>
<li>• "查找华硕品牌的主板"</li>
<li>• "什么是最新的CPU配件"</li>
</ul>
</div>
</div>
) : (messages.map((message, index) => (
<div
key={message.id || index}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
{message.role == "card"
? <div className='max-w-[80%] grid grid-cols-2 gap-2'>
{message.components.map((component) => (
<ComponentCard key={component.id} component={component} />
))
}
</div>
: <div
className={`max-w-[70%] p-3 rounded-lg ${message.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-800'}`}>
<div className="markdown-content"
dangerouslySetInnerHTML={{ __html: md.render(message.content) }}
/>
<div className={`text-xs mt-1 ${message.role === 'user' ? 'text-blue-200' : 'text-gray-500'}`}>
</div>
</div>}
</div>
))
)}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-100 text-gray-800 p-3 rounded-lg">
<div className="flex items-center space-x-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
<span>AI正在思考...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="p-4 border-t">
<div className="flex space-x-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="输入您的问题..."
className="flex-1 p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={2}
disabled={isLoading}
/>
<button
onClick={sendMessage}
disabled={!input.trim() || isLoading}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
发送
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+167
View File
@@ -0,0 +1,167 @@
import { NextRequest, NextResponse } from 'next/server'
import { AIClient } from '@/lib/ai-assistant-openai'
import { getUser } from '@/lib/auth'
import { ComponentService } from '@/lib/services/component-service'
import { Component } from '@prisma/client'
type Message = {
role: 'user' | 'assistant'
content: string
} | {
role: 'card'
components: Component[]
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
// 验证用户身份
const user = await getUser(request)
if (!user) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const client = new AIClient()
const conversation = await client.getConversation((await params).id, user.id)
if (!conversation) {
return NextResponse.json(
{ message: '对话不存在或无权限访问' },
{ status: 404 }
)
}
// 转换为Markdown
const filteredMessages = conversation.messages.filter((m, index) => !(index == 0 || m.role == 'user' && typeof m.content != 'string'))
const res: Message[] = []
let appendMessage: Message | null = null
for (const message of filteredMessages) {
let content: string
if (typeof message.content === "string") {
content = message.content
} else {
const contentParts: string[] = []
for (const part of message.content) {
if (part.type === 'text') {
contentParts.push(part.text)
} else if (part.type === 'tool_use') {
contentParts.push(`**工具调用**: ${part.name}\n\`\`\`\n${JSON.stringify(part.input, null, 2)}\n\`\`\``)
if (part.name === "show-components") {
const ids = (part.input as { component_ids: string[] }).component_ids
const components = await ComponentService.getComponentsByIds(ids)
appendMessage = {
role: 'card',
components: components.filter(c => !!c)
}
}
}
}
content = contentParts.join('\n\n')
}
res.push({
...message,
content
})
if (appendMessage) {
res.push(appendMessage)
appendMessage = null
}
}
return NextResponse.json(res)
} catch (error) {
console.error('获取对话详情错误:', error)
return NextResponse.json(
{ message: '获取对话详情失败' },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// 验证用户身份
const user = await getUser(request)
if (!user) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const client = new AIClient()
const success = await client.deleteConversation(params.id, user.id)
if (!success) {
return NextResponse.json(
{ message: '对话不存在或无权限删除' },
{ status: 404 }
)
}
return NextResponse.json({ message: '对话删除成功' })
} catch (error) {
console.error('删除对话错误:', error)
return NextResponse.json(
{ message: '删除对话失败' },
{ status: 500 }
)
}
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// 验证用户身份
const user = await getUser(request)
if (!user) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const { title } = await request.json()
if (!title || typeof title !== 'string') {
return NextResponse.json(
{ message: '请提供有效的标题' },
{ status: 400 }
)
}
const client = new AIClient()
const success = await client.updateConversationTitle(params.id, user.id, title)
if (!success) {
return NextResponse.json(
{ message: '对话不存在或无权限修改' },
{ status: 404 }
)
}
return NextResponse.json({ message: '标题更新成功' })
} catch (error) {
console.error('更新对话标题错误:', error)
return NextResponse.json(
{ message: '更新对话标题失败' },
{ status: 500 }
)
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server'
import { AIClient } from '@/lib/ai-assistant-openai'
import { getUser } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
// 验证用户身份
const user = await getUser(request)
if (!user) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const client = new AIClient()
const conversations = await client.getUserConversations(user.id, page, limit)
return NextResponse.json(conversations)
} catch (error) {
console.error('获取对话历史错误:', error)
return NextResponse.json(
{ message: '获取对话历史失败' },
{ status: 500 }
)
}
}
+80
View File
@@ -0,0 +1,80 @@
import { NextRequest, NextResponse } from 'next/server'
import { AIClient } from '@/lib/ai-assistant'
import { getUser } from '@/lib/auth'
export async function POST(request: NextRequest) {
try {
// 验证用户身份
const user = await getUser(request)
if (!user) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const body = await request.json()
const { prompt, conversationId } = body
if (!prompt || !prompt.trim()) {
return NextResponse.json(
{ message: '请输入您的问题' },
{ status: 400 }
)
}
let client = new AIClient() // 创建流式响应
const encoder = new TextEncoder()
let newConversationId: string | null = null
const stream = new ReadableStream({
async start(controller) {
try {
for await (const segment of client.processQuery(prompt, user.id, conversationId)) {
// 检查是否是新对话ID
if (segment.startsWith('conversation_id:')) {
newConversationId = segment.substring('conversation_id:'.length)
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ conversationId: newConversationId })}\n\n`))
continue
}
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content: segment })}\n\n`))
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
controller.close()
} catch (error) {
console.error('AI 处理错误:', error)
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
error: error instanceof Error ? error.message : '处理请求时发生错误'
})}\n\n`))
controller.close()
}
}
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
} catch (error) {
console.error('AI 查询错误:', error)
return NextResponse.json(
{ message: 'AI 服务暂时不可用,请稍后再试' },
{ status: 500 }
)
}
}
export async function GET() {
return NextResponse.json({
message: '欢迎使用 PC DIY 商店 AI 助手!',
examples: [
'推荐一些价格在 1000-2000 元的显卡',
'查找华硕品牌的主板',
'什么是最新的 CPU 配件',
'显示所有配件类型'
]
})
}
+3 -8
View File
@@ -1,19 +1,14 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { ComponentService } from '@/lib/services/component-service'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
) { try {
const { id } = await params
const component = await prisma.component.findUnique({
where: { id: id },
include: {
componentType: true
}
})
const component = await ComponentService.getComponentById(id)
if (!component) {
return NextResponse.json(
+13 -54
View File
@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { ComponentService } from '@/lib/services/component-service'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const typeId = searchParams.get('type')
const typeId = searchParams.get('type') // 注意:这里仍然叫type,但实际是typeId
const brand = searchParams.get('brand')
const minPrice = searchParams.get('minPrice')
const maxPrice = searchParams.get('maxPrice')
@@ -12,61 +13,19 @@ export async function GET(request: NextRequest) {
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '12')
const where: any = {}
if (typeId) {
where.componentTypeId = typeId
}
if (brand) {
where.brand = {
contains: brand,
mode: 'insensitive'
}
}
if (minPrice || maxPrice) {
where.price = {}
if (minPrice) where.price.gte = parseFloat(minPrice)
if (maxPrice) where.price.lte = parseFloat(maxPrice)
}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ brand: { contains: search, mode: 'insensitive' } },
{ model: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }
]
}
const [components, total] = await Promise.all([
prisma.component.findMany({
where,
include: {
componentType: true
},
orderBy: {
createdAt: 'desc'
},
skip: (page - 1) * limit,
take: limit
}),
prisma.component.count({ where })
])
const totalPages = Math.ceil(total / limit)
const result = await ComponentService.queryComponents({
typeId: typeId || undefined, // 将null转换为undefined
brand: brand || undefined,
minPrice: minPrice ? parseFloat(minPrice) : undefined,
maxPrice: maxPrice ? parseFloat(maxPrice) : undefined,
search: search || undefined,
page,
limit
})
return NextResponse.json({
components,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
components: result.components,
pagination: result.pagination
})
} catch (error) {
console.error('Components fetch error:', error)
+100 -99
View File
@@ -32,7 +32,8 @@ const componentIcons: { [key: string]: any } = {
'机箱': Box,
}
export default function BuildPage() { const [componentTypes, setComponentTypes] = useState<ComponentType[]>([])
export default function BuildPage() {
const [componentTypes, setComponentTypes] = useState<ComponentType[]>([])
const [availableComponents, setAvailableComponents] = useState<{ [key: string]: Component[] }>({})
const [buildConfig, setBuildConfig] = useState<BuildConfiguration>({})
const [isLoading, setIsLoading] = useState(true)
@@ -121,7 +122,7 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
})
})
}
window.dispatchEvent(new Event('cart-updated'))
alert('配置已添加到购物车!')
} catch (error) {
@@ -177,11 +178,11 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
}
const getFilteredComponents = (typeName: string) => {
console.log(availableComponents,typeName);
console.log(availableComponents, typeName);
const components = availableComponents[typeName] || []
if (!searchTerm) return components
return components.filter(component =>
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.brand.toLowerCase().includes(searchTerm.toLowerCase())
@@ -206,7 +207,7 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">PC装机配置</h1>
<p className="text-xl text-gray-600 mb-8">选择每种配件,组装您的专属电脑</p>
{/* Progress */}
<div className="max-w-md mx-auto">
<div className="flex justify-between text-sm text-gray-600 mb-2">
@@ -214,7 +215,7 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
<span>{getCompletionRate()}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${getCompletionRate()}%` }}
></div>
@@ -229,7 +230,7 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
{componentTypes.map((type) => {
const Icon = componentIcons[type.name] || Package
const selectedComponent = buildConfig[type.name]
return (
<div key={type.id} className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
@@ -301,7 +302,7 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-sm p-6 sticky top-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">配置总览</h2>
<div className="space-y-3 mb-6">
{componentTypes.map((type) => {
const component = buildConfig[type.name]
@@ -314,7 +315,7 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
</div>
)
})}
<div className="border-t border-gray-200 pt-3">
<div className="flex justify-between font-semibold">
<span>总价:</span>
@@ -332,7 +333,7 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
<ShoppingCart className="h-5 w-5 inline mr-2" />
加入购物车
</button>
<button
onClick={createOrder}
disabled={getCompletionRate() < 100}
@@ -351,104 +352,104 @@ export default function BuildPage() { const [componentTypes, setComponentTypes]
{/* Component Selection Modal */}
{selectedType && (
<div className="fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="bg-white rounded-xl max-w-5xl w-full max-h-[85vh] overflow-hidden shadow-2xl">
<div className="p-6 border-b border-gray-200 bg-gray-50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold text-gray-900">选择 {selectedType}</h3>
<button
onClick={() => {
setSelectedType(null)
setSearchTerm('')
}}
className="text-gray-400 hover:text-gray-600 p-2 hover:bg-gray-200 rounded-full transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="fixed inset-0 bg-opacity-30 flex items-center justify-center p-4 z-50" style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}> <div className="bg-white rounded-xl max-w-5xl w-full max-h-[85vh] overflow-hidden shadow-2xl">
<div className="p-6 border-b border-gray-200 bg-gray-50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold text-gray-900">选择 {selectedType}</h3>
<button
onClick={() => {
setSelectedType(null)
setSearchTerm('')
}}
className="text-gray-400 hover:text-gray-600 p-2 hover:bg-gray-200 rounded-full transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Search Box */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
{/* Search Box */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder={`搜索${selectedType}配件...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
<input
type="text"
placeholder={`搜索${selectedType}配件...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{/* Results count */}
<div className="mt-3 text-sm text-gray-600">
找到 {getFilteredComponents(selectedType).length} 个配件
{searchTerm && ` (搜索: "${searchTerm}")`}
</div>
</div>
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(85vh - 200px)' }}>
{getFilteredComponents(selectedType).length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{getFilteredComponents(selectedType).map((component) => (
<div
key={component.id}
className="border border-gray-200 rounded-lg p-5 hover:shadow-lg hover:border-blue-300 cursor-pointer transition-all duration-200 bg-white"
onClick={() => selectComponent(selectedType, component)}
>
<div className="w-full h-36 bg-gray-50 rounded-lg mb-4 flex items-center justify-center overflow-hidden">
{component.imageUrl ? (
<img loading='lazy'
src={component.imageUrl}
alt={component.name}
className="max-w-full max-h-full object-contain"
/>
) : (
<Package className="h-12 w-12 text-gray-400" />
)}
</div>
<h4 className="font-medium text-gray-900 mb-2 line-clamp-2 text-sm">
{component.name}
</h4>
<p className="text-sm text-gray-600 mb-3">{component.brand}</p>
<div className="flex items-center justify-between">
<p className="text-lg font-bold text-red-600">¥{component.price}</p>
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">
选择
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Package className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchTerm ? '没有找到匹配的配件' : '暂无配件'}
</h3>
<p className="text-gray-500">
{searchTerm ? '尝试调整搜索关键词' : '此类型暂无可用配件'}
</p>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
清除搜索
</button>
)}
</div>
{/* Results count */}
<div className="mt-3 text-sm text-gray-600">
找到 {getFilteredComponents(selectedType).length} 个配件
{searchTerm && ` (搜索: "${searchTerm}")`}
</div>
</div>
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(85vh - 200px)' }}>
{getFilteredComponents(selectedType).length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{getFilteredComponents(selectedType).map((component) => (
<div
key={component.id}
className="border border-gray-200 rounded-lg p-5 hover:shadow-lg hover:border-blue-300 cursor-pointer transition-all duration-200 bg-white"
onClick={() => selectComponent(selectedType, component)}
>
<div className="w-full h-36 bg-gray-50 rounded-lg mb-4 flex items-center justify-center overflow-hidden">
{component.imageUrl ? (
<img loading='lazy'
src={component.imageUrl}
alt={component.name}
className="max-w-full max-h-full object-contain"
/>
) : (
<Package className="h-12 w-12 text-gray-400" />
)}
</div>
<h4 className="font-medium text-gray-900 mb-2 line-clamp-2 text-sm">
{component.name}
</h4>
<p className="text-sm text-gray-600 mb-3">{component.brand}</p>
<div className="flex items-center justify-between">
<p className="text-lg font-bold text-red-600">¥{component.price}</p>
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">
选择
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Package className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchTerm ? '没有找到匹配的配件' : '暂无配件'}
</h3>
<p className="text-gray-500">
{searchTerm ? '尝试调整搜索关键词' : '此类型暂无可用配件'}
</p>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
>
清除搜索
</button>
)}
</div>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
+160
View File
@@ -24,3 +24,163 @@ body {
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
/* Markdown 渲染样式 - 专门用于聊天消息 */
.markdown-content {
line-height: 1.5;
font-size: 14px;
}
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
.markdown-content h6 {
margin: 0.5em 0 0.3em 0;
font-weight: 600;
line-height: 1.3;
}
.markdown-content h1 { font-size: 1.5em; }
.markdown-content h2 { font-size: 1.3em; }
.markdown-content h3 { font-size: 1.2em; }
.markdown-content h4 { font-size: 1.1em; }
.markdown-content h5 { font-size: 1em; }
.markdown-content h6 { font-size: 0.9em; }
.markdown-content p {
margin: 0.4em 0;
line-height: 1.5;
}
.markdown-content ul,
.markdown-content ol {
margin: 0.4em 0;
padding-left: 1.2em;
list-style: revert;
}
.markdown-content li {
margin: 0.2em 0;
line-height: 1.4;
}
.markdown-content blockquote {
margin: 0.5em 0;
padding: 0.3em 0.8em;
border-left: 3px solid #e5e7eb;
background-color: #f9fafb;
font-style: italic;
}
.markdown-content code {
padding: 0.1em 0.3em;
background-color: #f3f4f6;
border-radius: 3px;
font-family: 'Courier New', Courier, monospace;
font-size: 0.9em;
}
.markdown-content pre {
margin: 0.5em 0;
padding: 0.8em;
background-color: #f8f9fa;
border-radius: 6px;
overflow-x: auto;
border: 1px solid #e9ecef;
}
.markdown-content pre code {
padding: 0;
background-color: transparent;
border-radius: 0;
font-size: 0.85em;
line-height: 1.4;
}
.markdown-content table {
margin: 0.5em 0;
border-collapse: collapse;
width: 100%;
font-size: 0.9em;
}
.markdown-content th,
.markdown-content td {
padding: 0.3em 0.6em;
border: 1px solid #e5e7eb;
text-align: left;
}
.markdown-content th {
background-color: #f9fafb;
font-weight: 600;
}
.markdown-content hr {
margin: 0.8em 0;
border: none;
border-top: 1px solid #e5e7eb;
}
.markdown-content a {
color: #2563eb;
text-decoration: underline;
}
.markdown-content a:hover {
color: #1d4ed8;
}
.markdown-content strong {
font-weight: 600;
}
.markdown-content em {
font-style: italic;
}
/* 为用户消息的 Markdown 内容调整颜色 */
.bg-blue-600 .markdown-content,
.bg-blue-600 .markdown-content h1,
.bg-blue-600 .markdown-content h2,
.bg-blue-600 .markdown-content h3,
.bg-blue-600 .markdown-content h4,
.bg-blue-600 .markdown-content h5,
.bg-blue-600 .markdown-content h6 {
color: white;
}
.bg-blue-600 .markdown-content code {
background-color: rgba(255, 255, 255, 0.2);
color: white;
}
.bg-blue-600 .markdown-content pre {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
}
.bg-blue-600 .markdown-content blockquote {
border-left-color: rgba(255, 255, 255, 0.3);
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
.bg-blue-600 .markdown-content table th,
.bg-blue-600 .markdown-content table td {
border-color: rgba(255, 255, 255, 0.2);
}
.bg-blue-600 .markdown-content table th {
background-color: rgba(255, 255, 255, 0.1);
}
.bg-blue-600 .markdown-content a {
color: #bfdbfe;
}
.bg-blue-600 .markdown-content a:hover {
color: #dbeafe;
}
+1 -1
View File
@@ -29,7 +29,7 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Navbar />
<main className="min-h-screen">
<main style={{height: "calc(100vh - 64px)", overflowY: "auto"}}>
{children}
</main>
</body>