实现了AI助手
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 配件',
|
||||
'显示所有配件类型'
|
||||
]
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user