实现了AI助手
This commit is contained in:
parent
7d139b22db
commit
8a4ccd5ef2
1
.dev-tools/data/8.json
Normal file
1
.dev-tools/data/8.json
Normal file
@ -0,0 +1 @@
|
||||
[{"name":"技嘉Z390M GAMING主板1151支持8代9代 I9 9900K 技嘉Z390M GAMING","brand":"技嘉","model":"Z390M GAMING","price":427,"description":"技嘉Z390M GAMING主板,支持Intel 8代和9代处理器,兼容I9 9900K。","imageUrl":"https://img10.360buyimg.com/n7/jfs/t20260424/280862/19/21900/86848/68099c8cF661274d4/61c1bab2541e2ed1.png","stock":14,"typeName":"主板","specifications":"{\"chipset\":\"Intel Z390\",\"socket\":\"LGA1151\",\"compatibleCPUs\":\"Intel 8th/9th Gen (e.g., i9 9900K)\"}"},{"name":"研域A610M1迷你ITX工控主板12/13/14代LGA1700针双网口6串口工业台式机CPU套装电脑mini小主板PCIE x16 H610芯片 双网6串I210AT网卡A610M1","brand":"研域","model":"A610M1","price":849,"description":"研域A610M1迷你ITX工控主板,H610芯片组,支持Intel 12/13/14代LGA1700处理器,双网口6串口,PCIE x16。","imageUrl":"https://img12.360buyimg.com/n7/jfs/t1/272903/10/18528/145710/67f73f3eF7963dbb7/9c3944277e3a3884.jpg","stock":10,"typeName":"主板","specifications":"{\"chipset\":\"Intel H610\",\"socket\":\"LGA1700\",\"formFactor\":\"Mini-ITX\",\"compatibleCPUs\":\"Intel 12th/13th/14th Gen\",\"features\":\"Dual LAN (I210AT), 6 COM ports, PCIe x16\"}"},{"name":"微星微星MSI充新微星B450M PRO-VDH MORTAR 主板AM4替A3... 微星B450M MORTAR MAX迫击","brand":"微星","model":"B450M MORTAR MAX","price":478.62,"description":"微星B450M MORTAR MAX迫击炮主板,AM4接口,可替代B450M PRO-VDH MORTAR。","imageUrl":"https://img11.360buyimg.com/n7/jfs/t1/120656/3/46019/183868/66f84b30Fdd3d3cd6/a3eee3f6fa7659db.jpg","stock":14,"typeName":"主板","specifications":"{\"chipset\":\"AMD B450\",\"socket\":\"AM4\",\"formFactor\":\"Micro-ATX\"}"},{"name":"升技B760ITX D4电脑主板支持12/13/14代处理器,装甲散热,支持三屏输出 M.2 B760ITX D4 雪山白","brand":"升技","model":"B760ITX D4 雪山白","price":479,"description":"升技B760ITX D4雪山白电脑主板,支持Intel 12/13/14代处理器,具备装甲散热和三屏输出,含M.2接口。","imageUrl":"https://img10.360buyimg.com/n7/jfs/t1/6856/38/28446/102266/6539ce43Fe8362ecf/370d8875f2fc8fc8.jpg","stock":10,"typeName":"主板","specifications":"{\"chipset\":\"Intel B760\",\"socket\":\"LGA1700\",\"formFactor\":\"Mini-ITX\",\"memoryType\":\"DDR4\",\"compatibleCPUs\":\"Intel 12th/13th/14th Gen\",\"features\":\"Armor散热, Three-screen output, M.2\"}"},{"name":"GIGABYTE技嘉B560M AORUS ELITE/PRO拆机主板 支持处理器10-11代CPU 电脑 技嘉B560M AORUS PRO AX","brand":"技嘉","model":"B560M AORUS PRO AX","price":469,"description":"技嘉B560M AORUS PRO AX拆机主板,支持Intel 10-11代CPU。","imageUrl":"https://img14.360buyimg.com/n7/jfs/t1/246338/22/15148/129034/66dea4a5F979c55d3/0043e2e6afd87f25.png","stock":30,"typeName":"主板","specifications":"{\"chipset\":\"Intel B560\",\"socket\":\"LGA1200\",\"compatibleCPUs\":\"Intel 10th/11th Gen\"}"}]
|
||||
@ -1,34 +1,39 @@
|
||||
let result = [];
|
||||
let richResult = []
|
||||
$0.querySelectorAll("li").forEach((ele, index) => {
|
||||
Array.from($0.querySelectorAll("li"))
|
||||
.filter(ele =>
|
||||
!(ele.querySelector(".p-promo-flag") && ele.querySelector(".p-promo-flag").textContent.includes("广告")))
|
||||
.forEach((ele, index) => {
|
||||
let price = parseFloat(ele.querySelector("strong").textContent.trim().slice(1));
|
||||
let title = ele.querySelector(".p-name").textContent.trim();
|
||||
let img = ele.querySelector("img").src;
|
||||
result.push([index, title, price]);
|
||||
richResult.push({
|
||||
index,
|
||||
title: title,
|
||||
price: price,
|
||||
img: img
|
||||
index,
|
||||
title: title,
|
||||
price: price,
|
||||
img: img
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
|
||||
let data = []
|
||||
|
||||
let res = data.map(item => {
|
||||
return {
|
||||
name: item.name,
|
||||
brand: item.brand,
|
||||
model: item.model,
|
||||
price: item.price,
|
||||
description: item.description,
|
||||
imageUrl: richResult.find(item_ => item_.index == item.index).img,
|
||||
stock: Math.random() * 50 | 0 + 10,
|
||||
typeName: item.typeName,
|
||||
specifications: item.specifications
|
||||
};
|
||||
return {
|
||||
name: item.name,
|
||||
brand: item.brand,
|
||||
model: item.model,
|
||||
price: item.price,
|
||||
description: item.description,
|
||||
imageUrl: richResult.find(item_ => item_.index == item.index).img,
|
||||
stock: Math.random() * 50 | 0 + 10,
|
||||
typeName: item.typeName,
|
||||
specifications: typeof item.specifications == "string" ? item.specifications : JSON.stringify(item.specifications)
|
||||
};
|
||||
}).filter(item => item.imageUrl).filter(item => item.typeName == "主板")
|
||||
|
||||
console.log(JSON.stringify(res));
|
||||
|
||||
162
AI_ASSISTANT_README.md
Normal file
162
AI_ASSISTANT_README.md
Normal file
@ -0,0 +1,162 @@
|
||||
# PC DIY 商店 AI 助手使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
AI 助手集成了 Anthropic 的 Claude AI,可以帮助用户:
|
||||
|
||||
1. **查询配件信息** - 按类型、品牌、价格范围搜索配件
|
||||
2. **获取配件类型** - 查看所有可用的配件类型
|
||||
3. **智能推荐** - 基于用户需求提供配件推荐
|
||||
4. **价格对比** - 帮助用户比较不同配件的价格和性能
|
||||
|
||||
## 环境配置
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
确保已安装 Anthropic SDK:
|
||||
|
||||
```bash
|
||||
bun add @anthropic-ai/sdk
|
||||
```
|
||||
|
||||
### 2. 环境变量
|
||||
|
||||
在 `.env.local` 文件中添加:
|
||||
|
||||
```env
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 3. 获取 Anthropic API Key
|
||||
|
||||
1. 访问 [Anthropic Console](https://console.anthropic.com/)
|
||||
2. 注册/登录账户
|
||||
3. 创建 API Key
|
||||
4. 将 API Key 添加到环境变量中
|
||||
|
||||
## API 使用
|
||||
|
||||
### 发送查询请求
|
||||
|
||||
```javascript
|
||||
const response = await fetch('/api/ai', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: '推荐一些价格在1000-2000元的显卡'
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data.response);
|
||||
```
|
||||
|
||||
### 支持的工具调用
|
||||
|
||||
#### 1. query-components
|
||||
查询配件信息,支持以下参数:
|
||||
|
||||
- `type`: 配件类型ID
|
||||
- `brand`: 品牌名称
|
||||
- `minPrice`: 最低价格
|
||||
- `maxPrice`: 最高价格
|
||||
- `search`: 搜索关键词
|
||||
- `page`: 页码(默认1)
|
||||
- `limit`: 每页数量(默认12)
|
||||
|
||||
#### 2. get-component-types
|
||||
获取所有配件类型列表,无需参数。
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例查询
|
||||
|
||||
1. **查询配件类型**
|
||||
```
|
||||
"显示所有配件类型"
|
||||
"有哪些配件类型可以选择?"
|
||||
```
|
||||
|
||||
2. **搜索特定配件**
|
||||
```
|
||||
"推荐一些价格在1000-2000元的显卡"
|
||||
"查找华硕品牌的主板"
|
||||
"有什么高性能的CPU推荐"
|
||||
```
|
||||
|
||||
3. **价格筛选**
|
||||
```
|
||||
"500元以下的内存条有哪些"
|
||||
"3000元左右的显卡推荐"
|
||||
```
|
||||
|
||||
4. **品牌筛选**
|
||||
```
|
||||
"华硕的所有配件"
|
||||
"英特尔的CPU产品"
|
||||
```
|
||||
|
||||
### 页面访问
|
||||
|
||||
访问 `/ai-assistant` 页面可以使用图形界面与 AI 助手交互。
|
||||
|
||||
## 技术架构
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
lib/
|
||||
ai-assistant.ts # AI 助手核心逻辑
|
||||
app/
|
||||
api/ai/route.ts # AI API 端点
|
||||
ai-assistant/page.tsx # AI 助手前端页面
|
||||
components/
|
||||
Navbar.tsx # 导航栏(已添加AI助手链接)
|
||||
```
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **MCPClient 类** - 封装 Anthropic API 调用
|
||||
2. **工具调用处理** - 处理 AI 对数据库的查询需求
|
||||
3. **消息处理** - 管理对话上下文和工具结果
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API Key 安全** - 确保 API Key 不要提交到版本控制系统
|
||||
2. **错误处理** - AI 助手包含完整的错误处理机制
|
||||
3. **费用控制** - Anthropic API 按 token 计费,建议设置使用限制
|
||||
4. **网络依赖** - 需要稳定的网络连接到 Anthropic 服务
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
1. **API Key 错误**
|
||||
- 检查环境变量是否正确设置
|
||||
- 确认 API Key 是否有效
|
||||
|
||||
2. **网络连接问题**
|
||||
- 检查网络连接
|
||||
- 确认 Anthropic 服务是否可访问
|
||||
|
||||
3. **数据库连接问题**
|
||||
- 确保数据库正常运行
|
||||
- 检查 Prisma 配置
|
||||
|
||||
### 调试方法
|
||||
|
||||
1. 查看浏览器控制台错误
|
||||
2. 检查服务器端日志
|
||||
3. 验证 API 响应格式
|
||||
|
||||
## 后续扩展
|
||||
|
||||
可以考虑添加:
|
||||
|
||||
1. **用户偏好记忆** - 记住用户的配件偏好
|
||||
2. **配置推荐** - 根据预算推荐完整的装机配置
|
||||
3. **性能对比** - 添加配件性能参数对比功能
|
||||
4. **库存提醒** - 监控配件库存状态
|
||||
@ -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,7 +91,6 @@ function AdminComponentsPage() {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@ -90,9 +103,7 @@ function AdminComponentsPage() {
|
||||
|
||||
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) {
|
||||
@ -272,9 +283,7 @@ function AdminComponentsPage() {
|
||||
try {
|
||||
const response = await fetch('/api/components/batch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
components: validData
|
||||
}),
|
||||
@ -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,7 +514,7 @@ 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 ? '编辑配件' : '添加配件'}
|
||||
@ -628,7 +636,7 @@ 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
394
app/ai-assistant/page.tsx
Normal file
394
app/ai-assistant/page.tsx
Normal 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
app/api/ai/conversations/[id]/route.ts
Normal file
167
app/api/ai/conversations/[id]/route.ts
Normal 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
app/api/ai/conversations/route.ts
Normal file
32
app/api/ai/conversations/route.ts
Normal 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
app/api/ai/route.ts
Normal file
80
app/api/ai/route.ts
Normal 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 配件',
|
||||
'显示所有配件类型'
|
||||
]
|
||||
})
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
@ -177,7 +178,7 @@ 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
|
||||
@ -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>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
{/* 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"
|
||||
/>
|
||||
{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
app/globals.css
160
app/globals.css
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
201
bun.lock
201
bun.lock
@ -4,15 +4,22 @@
|
||||
"": {
|
||||
"name": "pc-diy-store",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.54.0",
|
||||
"@modelcontextprotocol/sdk": "^1.13.0",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.5.0",
|
||||
"dedent": "^1.6.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.522.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"openai": "^5.6.0",
|
||||
"prisma": "^6.10.1",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
@ -34,6 +41,8 @@
|
||||
|
||||
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.54.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-xyoCtHJnt/qg5GG6IgK+UJEndz8h8ljzt/caKXmq3LfBF81nC/BW6E4x2rOWCZcvsLyVW+e8U5mtIr6UCE/kJw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
@ -94,6 +103,8 @@
|
||||
|
||||
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.13.0", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-P5FZsXU0kY881F6Hbk9GhsYx02/KgWK1DYf7/tyE/1lcFKhDYPQR9iYjhQXJn+Sg6hQleMo3DB7h7+p4wgp2Lw=="],
|
||||
|
||||
"@next/env": ["@next/env@15.3.4", "", {}, "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg=="],
|
||||
@ -184,6 +195,12 @@
|
||||
|
||||
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
|
||||
|
||||
"@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="],
|
||||
|
||||
"@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="],
|
||||
|
||||
"@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@20.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-jJD50LtlD2dodAEO653i3YF04NWak6jN3ky+Ri3Em3mGR39/glWiboM/IePaRbgwSfqM1TpGXfAg8ohn/4dTgA=="],
|
||||
@ -192,12 +209,26 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"bcryptjs": ["bcryptjs@3.0.2", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001724", "", {}, "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA=="],
|
||||
|
||||
"chart.js": ["chart.js@4.5.0", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ=="],
|
||||
@ -216,8 +247,18 @@
|
||||
|
||||
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
@ -242,32 +283,102 @@
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"dedent": ["dedent@1.6.0", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.18.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.2", "", {}, "sha512-6RxOBZ/cYgd8usLwsEl+EC09Au/9BcmCKYF2/xbml6DNczf7nv0MQb+7BA2F+li6//I+28VNlQR37XfQtcAJuA=="],
|
||||
|
||||
"express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.2.2", "", {}, "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.0", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.4.2", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A=="],
|
||||
|
||||
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.2", "", { "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ=="],
|
||||
|
||||
"jwa": ["jwa@1.4.2", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw=="],
|
||||
@ -296,6 +407,8 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="],
|
||||
|
||||
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
|
||||
|
||||
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
@ -320,6 +433,20 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.0", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
|
||||
|
||||
"minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
|
||||
|
||||
"minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="],
|
||||
@ -330,6 +457,8 @@
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["next@15.3.4", "", { "dependencies": { "@next/env": "15.3.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.4", "@next/swc-darwin-x64": "15.3.4", "@next/swc-linux-arm64-gnu": "15.3.4", "@next/swc-linux-arm64-musl": "15.3.4", "@next/swc-linux-x64-gnu": "15.3.4", "@next/swc-linux-x64-musl": "15.3.4", "@next/swc-win32-arm64-msvc": "15.3.4", "@next/swc-win32-x64-msvc": "15.3.4", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA=="],
|
||||
|
||||
"next-auth": ["next-auth@4.24.11", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.2", "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw=="],
|
||||
@ -340,12 +469,28 @@
|
||||
|
||||
"object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oidc-token-hash": ["oidc-token-hash@5.1.0", "", {}, "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"openai": ["openai@5.6.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-jNH5z+hYAdOMZXyEt0yZ7246s+UZjg2AwFQqkAhZIPPjxNtHHO5mykOefau6FkOqj16aC94MOdJl/rZBcKj/cQ=="],
|
||||
|
||||
"openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.2.0", "", {}, "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"preact": ["preact@10.26.9", "", {}, "sha512-SSjF9vcnF27mJK1XyFMNJzFd5u3pQiATFqoaDy03XuN00u4ziveVVEGt5RKJrDR8MHE/wJo9Nnad56RLzS2RMA=="],
|
||||
@ -358,6 +503,18 @@
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
|
||||
|
||||
"qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="],
|
||||
|
||||
"react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="],
|
||||
|
||||
"react-chartjs-2": ["react-chartjs-2@5.3.0", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw=="],
|
||||
@ -374,18 +531,42 @@
|
||||
|
||||
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="],
|
||||
|
||||
"semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
|
||||
|
||||
"send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.0", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.34.2", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.4", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.2", "@img/sharp-darwin-x64": "0.34.2", "@img/sharp-libvips-darwin-arm64": "1.1.0", "@img/sharp-libvips-darwin-x64": "1.1.0", "@img/sharp-libvips-linux-arm": "1.1.0", "@img/sharp-libvips-linux-arm64": "1.1.0", "@img/sharp-libvips-linux-ppc64": "1.1.0", "@img/sharp-libvips-linux-s390x": "1.1.0", "@img/sharp-libvips-linux-x64": "1.1.0", "@img/sharp-libvips-linuxmusl-arm64": "1.1.0", "@img/sharp-libvips-linuxmusl-x64": "1.1.0", "@img/sharp-linux-arm": "0.34.2", "@img/sharp-linux-arm64": "0.34.2", "@img/sharp-linux-s390x": "0.34.2", "@img/sharp-linux-x64": "0.34.2", "@img/sharp-linuxmusl-arm64": "0.34.2", "@img/sharp-linuxmusl-x64": "0.34.2", "@img/sharp-wasm32": "0.34.2", "@img/sharp-win32-arm64": "0.34.2", "@img/sharp-win32-ia32": "0.34.2", "@img/sharp-win32-x64": "0.34.2" } }, "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
@ -398,18 +579,38 @@
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"zod": ["zod@3.25.67", "", {}, "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.4.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ=="],
|
||||
|
||||
@ -98,6 +98,9 @@ export function Navbar() {
|
||||
<Link href="/build" className={getLinkStyle('/build')}>
|
||||
装机配置
|
||||
</Link>
|
||||
<Link href="/ai-assistant" className={getLinkStyle('/ai-assistant')}>
|
||||
AI助手
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* User Actions */}
|
||||
@ -177,14 +180,20 @@ export function Navbar() {
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
配件商城
|
||||
</Link>
|
||||
<Link
|
||||
</Link> <Link
|
||||
href="/build"
|
||||
className={`block px-3 py-2 ${getLinkStyle('/build')}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
装机配置
|
||||
</Link>
|
||||
<Link
|
||||
href="/ai-assistant"
|
||||
className={`block px-3 py-2 ${getLinkStyle('/ai-assistant')}`}
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
AI助手
|
||||
</Link>
|
||||
{user ? (
|
||||
<>
|
||||
<Link
|
||||
|
||||
359
lib/ai-assistant-openai.ts
Normal file
359
lib/ai-assistant-openai.ts
Normal file
@ -0,0 +1,359 @@
|
||||
import OpenAI from 'openai';
|
||||
import { ComponentService } from '@/lib/services/component-service';
|
||||
import { ConversationService } from '@/lib/services/conversation-service';
|
||||
import { Component } from "@prisma/client";
|
||||
|
||||
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
|
||||
const OPENAI_BASE_URL = process.env.OPENAI_BASE_URL;
|
||||
|
||||
if (!OPENAI_API_KEY) {
|
||||
throw new Error("OPENAI_API_KEY is not set");
|
||||
}
|
||||
|
||||
// 定义消息类型(兼容OpenAI格式)
|
||||
type MessageParam = OpenAI.Chat.Completions.ChatCompletionMessageParam;
|
||||
type FunctionCall = OpenAI.Chat.Completions.ChatCompletionMessageToolCall;
|
||||
type Message = OpenAI.Chat.Completions.ChatCompletionMessage;
|
||||
|
||||
|
||||
|
||||
export class AIClient {
|
||||
private openai: OpenAI;
|
||||
private tools: OpenAI.Chat.Completions.ChatCompletionTool[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "query-components",
|
||||
description: "查询PC配件信息,支持按类型、品牌、价格范围、关键词等条件搜索。单次最多返回5个配件。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["CPU", "内存", "硬盘", "主板", "显卡", "机箱"],
|
||||
description: "配件类型"
|
||||
},
|
||||
brand: {
|
||||
type: "string",
|
||||
description: "品牌名称"
|
||||
},
|
||||
minPrice: {
|
||||
type: "number",
|
||||
description: "最低价格"
|
||||
},
|
||||
maxPrice: {
|
||||
type: "number",
|
||||
description: "最高价格"
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
description: "搜索关键词,会在名称、品牌、型号、描述中搜索"
|
||||
},
|
||||
page: {
|
||||
type: "integer",
|
||||
description: "页码,默认为1"
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "show-components",
|
||||
description: "根据提供的配件ID列表,向用户以卡片形式展示一个或多个具体型号的配件。如果你提到了某些特定配件,请用此工具更好地向用户展示。",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
component_ids: {
|
||||
type: "array",
|
||||
description: "一个包含一个或多个要展示的配件ID的数组。这些ID来自`query-components`工具的查询结果。",
|
||||
items: {
|
||||
type: "string",
|
||||
description: "单个配件的唯一ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["component_ids"]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.openai = new OpenAI({
|
||||
apiKey: OPENAI_API_KEY,
|
||||
baseURL: OPENAI_BASE_URL || "https://api.openai.com/v1"
|
||||
});
|
||||
}
|
||||
|
||||
// 查询配件
|
||||
private async queryComponents(args: any): Promise<string> {
|
||||
try {
|
||||
const result = await ComponentService.queryComponents({
|
||||
type: args.type,
|
||||
brand: args.brand,
|
||||
minPrice: args.minPrice,
|
||||
maxPrice: args.maxPrice,
|
||||
search: args.search,
|
||||
page: args.page || 1,
|
||||
limit: 5
|
||||
})
|
||||
|
||||
if (result.components.length === 0) {
|
||||
return "未找到符合条件的配件"
|
||||
}
|
||||
|
||||
// 返回格式化的结果
|
||||
return JSON.stringify(result.components.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
brand: c.brand,
|
||||
model: c.model,
|
||||
price: c.price,
|
||||
description: c.description,
|
||||
stock: c.stock,
|
||||
specifications: c.specifications,
|
||||
})))
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询配件失败:', error)
|
||||
return `查询配件时发生错误: ${error instanceof Error ? error.message : '未知错误'}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async *processQuery(
|
||||
query: string,
|
||||
userId: string,
|
||||
conversationId?: string
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
let messages: MessageParam[];
|
||||
let currentConversationId = conversationId;
|
||||
|
||||
// 如果提供了对话ID,加载现有对话
|
||||
if (conversationId) {
|
||||
const conversation = await ConversationService.getConversation(conversationId, userId);
|
||||
if (conversation) {
|
||||
messages = [...conversation.messages as MessageParam[]];
|
||||
// 添加新的用户消息
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: query
|
||||
});
|
||||
} else {
|
||||
throw new Error('对话不存在或无权限访问');
|
||||
}
|
||||
} else {
|
||||
// 创建新对话
|
||||
currentConversationId = await ConversationService.createConversation(userId, query);
|
||||
|
||||
// 发送新对话ID给前端
|
||||
yield `conversation_id:${currentConversationId}`;
|
||||
|
||||
// 获取新创建的对话消息
|
||||
const conversation = await ConversationService.getConversation(currentConversationId, userId);
|
||||
if (!conversation) {
|
||||
throw new Error('创建对话失败');
|
||||
}
|
||||
messages = conversation.messages as MessageParam[];
|
||||
}
|
||||
|
||||
try {
|
||||
let maxIterations = 10; // 防止无限循环
|
||||
let currentIteration = 0;
|
||||
|
||||
while (currentIteration < maxIterations) {
|
||||
currentIteration++;
|
||||
console.log("Send Req", messages);
|
||||
|
||||
const stream = await this.openai.chat.completions.create({
|
||||
model: ["web-rev-claude-sonnet-4-20250514", "gemini-2.5-flash"][1],
|
||||
max_tokens: 2048,
|
||||
messages,
|
||||
tools: this.tools,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let assistantMessage: MessageParam = {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: []
|
||||
};
|
||||
|
||||
// 处理流式响应
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta;
|
||||
if (!delta) continue;
|
||||
|
||||
// 处理文本内容
|
||||
if (delta.content) {
|
||||
assistantMessage.content += delta.content;
|
||||
yield delta.content;
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
const index = toolCall.index;
|
||||
|
||||
// 初始化工具调用
|
||||
if (!assistantMessage.tool_calls) {
|
||||
assistantMessage.tool_calls = [];
|
||||
}
|
||||
|
||||
if (!assistantMessage.tool_calls[index]) {
|
||||
assistantMessage.tool_calls[index] = {
|
||||
id: toolCall.id || "",
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolCall.function?.name || "",
|
||||
arguments: ""
|
||||
}
|
||||
};
|
||||
yield `\n\n**使用工具**: ${toolCall.function?.name}\n\n\`\`\`\n`;
|
||||
}
|
||||
|
||||
// 累积工具调用参数
|
||||
if (toolCall.function?.arguments) {
|
||||
assistantMessage.tool_calls[index].function.arguments += toolCall.function.arguments;
|
||||
yield toolCall.function.arguments;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有工具调用,结束代码块
|
||||
if (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0) {
|
||||
yield `\n\`\`\``;
|
||||
}
|
||||
|
||||
// 将助手消息添加到对话中
|
||||
messages.push(assistantMessage);
|
||||
|
||||
// 如果没有工具调用,结束循环
|
||||
if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// 处理工具调用
|
||||
let needRerun = false;
|
||||
for (const toolCall of assistantMessage.tool_calls) {
|
||||
if (!toolCall.function.name) continue;
|
||||
|
||||
let toolArgs: any = {};
|
||||
try {
|
||||
toolArgs = JSON.parse(toolCall.function.arguments);
|
||||
} catch (error) {
|
||||
console.error('解析工具参数失败:', error);
|
||||
continue;
|
||||
}
|
||||
|
||||
let toolResult = "";
|
||||
|
||||
switch (toolCall.function.name) {
|
||||
case "query-components":
|
||||
toolResult = await this.queryComponents(toolArgs);
|
||||
needRerun = true;
|
||||
yield 'next_block';
|
||||
break;
|
||||
|
||||
case "show-components":
|
||||
const components = await ComponentService.getComponentsByIds(toolArgs.component_ids);
|
||||
toolResult = `成功向用户展示了 ${components.map(c => c?.id).join(',')}`;
|
||||
needRerun = true;
|
||||
yield `show_card: ${JSON.stringify(components)}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`未知工具调用: ${toolCall.function.name}`);
|
||||
toolResult = `未知工具: ${toolCall.function.name}`;
|
||||
}
|
||||
|
||||
// 添加工具调用结果消息
|
||||
messages.push({
|
||||
role: "tool",
|
||||
content: toolResult,
|
||||
tool_call_id: toolCall.id
|
||||
});
|
||||
}
|
||||
|
||||
if (!needRerun) break;
|
||||
}
|
||||
|
||||
if (currentIteration >= maxIterations) {
|
||||
yield "\n\n⚠️ 达到最大处理轮次,对话结束。";
|
||||
} // 保存更新后的对话到数据库
|
||||
if (currentConversationId) {
|
||||
// 转换消息格式以兼容现有的ConversationService
|
||||
const compatibleMessages = messages.map((msg): any => {
|
||||
if (msg.role === "tool") {
|
||||
// 将工具消息转换为用户消息格式以保持兼容性
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool result: ${msg.content}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: msg.role,
|
||||
content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
|
||||
};
|
||||
});
|
||||
await ConversationService.updateConversationMessages(currentConversationId, userId, compatibleMessages);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('处理查询时发生错误:', error);
|
||||
yield `\n\n❌ 抱歉,处理您的请求时遇到了问题: ${error instanceof Error ? error.message : '未知错误'}`; // 即使出错也要保存对话
|
||||
if (currentConversationId) {
|
||||
try {
|
||||
const compatibleMessages = messages.map((msg): any => {
|
||||
if (msg.role === "tool") {
|
||||
return {
|
||||
role: "user",
|
||||
content: `Tool result: ${msg.content}`
|
||||
};
|
||||
}
|
||||
return {
|
||||
role: msg.role,
|
||||
content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)
|
||||
};
|
||||
});
|
||||
await ConversationService.updateConversationMessages(currentConversationId, userId, compatibleMessages);
|
||||
} catch (saveError) {
|
||||
console.error('保存对话失败:', saveError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的对话历史
|
||||
*/
|
||||
async getUserConversations(userId: string, page = 1, limit = 20) {
|
||||
return await ConversationService.getUserConversations(userId, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定对话的详情
|
||||
*/
|
||||
async getConversation(conversationId: string, userId: string) {
|
||||
return await ConversationService.getConversation(conversationId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*/
|
||||
async deleteConversation(conversationId: string, userId: string) {
|
||||
return await ConversationService.deleteConversation(conversationId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新对话标题
|
||||
*/
|
||||
async updateConversationTitle(conversationId: string, userId: string, title: string) {
|
||||
return await ConversationService.updateConversationTitle(conversationId, userId, title);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
399
lib/ai-assistant.ts
Normal file
399
lib/ai-assistant.ts
Normal file
@ -0,0 +1,399 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk";
|
||||
import {
|
||||
Message,
|
||||
MessageParam,
|
||||
Tool
|
||||
} from "@anthropic-ai/sdk/resources/messages/messages.mjs";
|
||||
import { ComponentService } from '@/lib/services/component-service';
|
||||
import { ConversationService } from '@/lib/services/conversation-service';
|
||||
|
||||
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||||
const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL;
|
||||
if (!ANTHROPIC_API_KEY) {
|
||||
throw new Error("ANTHROPIC_API_KEY is not set");
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class AIClient {
|
||||
private anthropic: Anthropic;
|
||||
private tools: Tool[] = [
|
||||
{
|
||||
name: "query-components",
|
||||
description: "查询PC配件信息,支持按类型、品牌、价格范围、关键词等条件搜索。单次最多返回5个配件。",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["CPU", "内存", "硬盘", "主板", "显卡", "机箱"],
|
||||
description: "配件类型"
|
||||
},
|
||||
brand: {
|
||||
type: "string",
|
||||
description: "品牌名称"
|
||||
},
|
||||
minPrice: {
|
||||
type: "number",
|
||||
description: "最低价格"
|
||||
},
|
||||
maxPrice: {
|
||||
type: "number",
|
||||
description: "最高价格"
|
||||
},
|
||||
search: {
|
||||
type: "string",
|
||||
description: "搜索关键词,会在名称、品牌、型号、描述中搜索"
|
||||
},
|
||||
page: {
|
||||
type: "integer",
|
||||
description: "页码,默认为1"
|
||||
},
|
||||
}
|
||||
}
|
||||
}, {
|
||||
name: "show-components",
|
||||
description: "根据提供的配件ID列表,向用户以卡片形式展示一个或多个具体型号的配件。如果你提到了某些特定配件,请用此工具更好地向用户展示。",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
component_ids: {
|
||||
type: "array",
|
||||
description: "一个包含一个或多个要展示的配件ID的数组。这些ID来自`query-components`工具的查询结果。",
|
||||
items: {
|
||||
type: "string",
|
||||
description: "单个配件的唯一ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["component_ids"]
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
constructor() {
|
||||
this.anthropic = new Anthropic({
|
||||
apiKey: ANTHROPIC_API_KEY,
|
||||
baseURL: ANTHROPIC_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
// 查询配件
|
||||
private async queryComponents(args: any): Promise<string> {
|
||||
try {
|
||||
const result = await ComponentService.queryComponents({
|
||||
type: args.type,
|
||||
brand: args.brand,
|
||||
minPrice: args.minPrice,
|
||||
maxPrice: args.maxPrice,
|
||||
search: args.search,
|
||||
page: args.page || 1,
|
||||
limit: 5
|
||||
})
|
||||
|
||||
if (result.components.length === 0) {
|
||||
return "未找到符合条件的配件"
|
||||
}
|
||||
|
||||
// 返回格式化的结果
|
||||
return JSON.stringify(result.components.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
brand: c.brand,
|
||||
model: c.model,
|
||||
price: c.price,
|
||||
description: c.description,
|
||||
stock: c.stock,
|
||||
specifications: c.specifications,
|
||||
})))
|
||||
|
||||
} catch (error) {
|
||||
console.error('查询配件失败:', error)
|
||||
return `查询配件时发生错误: ${error instanceof Error ? error.message : '未知错误'}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async *processQuery(
|
||||
query: string,
|
||||
userId: string,
|
||||
conversationId?: string
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
let messages: MessageParam[];
|
||||
let currentConversationId = conversationId;
|
||||
|
||||
// 如果提供了对话ID,加载现有对话
|
||||
if (conversationId) {
|
||||
const conversation = await ConversationService.getConversation(conversationId, userId);
|
||||
if (conversation) {
|
||||
messages = [...conversation.messages];
|
||||
// 添加新的用户消息
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: query
|
||||
});
|
||||
} else {
|
||||
throw new Error('对话不存在或无权限访问');
|
||||
}
|
||||
} else {
|
||||
// 创建新对话
|
||||
currentConversationId = await ConversationService.createConversation(userId, query);
|
||||
|
||||
// 发送新对话ID给前端
|
||||
yield `conversation_id:${currentConversationId}`;
|
||||
|
||||
// 获取新创建的对话消息
|
||||
const conversation = await ConversationService.getConversation(currentConversationId, userId);
|
||||
if (!conversation) {
|
||||
throw new Error('创建对话失败');
|
||||
}
|
||||
messages = conversation.messages;
|
||||
}
|
||||
|
||||
try {
|
||||
let maxIterations = 20;
|
||||
let currentIteration = 0;
|
||||
|
||||
while (currentIteration < maxIterations) {
|
||||
currentIteration++;
|
||||
console.log("Send Req", messages);
|
||||
|
||||
const response = await this.anthropic.messages.create({
|
||||
model: ["claude-sonnet-4-20250514", "claude-3-5-haiku-20241022"][1],
|
||||
max_tokens: 2048,
|
||||
messages,
|
||||
tools: this.tools,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let tempMsg: Message | null = null
|
||||
|
||||
// 处理流式响应
|
||||
for await (const chunk of response) {
|
||||
console.log(chunk);
|
||||
|
||||
switch (chunk.type) {
|
||||
case "message_start":
|
||||
tempMsg = {
|
||||
id: chunk.message.id,
|
||||
type: chunk.message.type,
|
||||
role: chunk.message.role,
|
||||
content: [],
|
||||
model: chunk.message.model,
|
||||
usage: chunk.message.usage,
|
||||
stop_reason: chunk.message.stop_reason,
|
||||
stop_sequence: chunk.message.stop_sequence,
|
||||
}
|
||||
break
|
||||
|
||||
case "message_delta":
|
||||
tempMsg = {
|
||||
...tempMsg || {},
|
||||
stop_reason: chunk.delta.stop_reason,
|
||||
stop_sequence: chunk.delta.stop_sequence,
|
||||
// @ts-ignore
|
||||
usage: chunk.usage,
|
||||
}
|
||||
break
|
||||
|
||||
case "message_stop":
|
||||
break
|
||||
|
||||
case "content_block_start":
|
||||
const contentBlock = chunk.content_block;
|
||||
switch (contentBlock.type) {
|
||||
case "text":
|
||||
tempMsg?.content.push({
|
||||
type: contentBlock.type,
|
||||
text: contentBlock.text,
|
||||
citations: contentBlock.citations || [],
|
||||
})
|
||||
yield contentBlock.text
|
||||
break
|
||||
case "tool_use":
|
||||
tempMsg?.content.push({
|
||||
type: contentBlock.type,
|
||||
id: contentBlock.id,
|
||||
name: contentBlock.name,
|
||||
input: "",
|
||||
});
|
||||
yield `\n\n**使用工具**: ${contentBlock.name}\n\n\`\`\`\n`
|
||||
break
|
||||
}
|
||||
break
|
||||
|
||||
case "content_block_delta":
|
||||
const contentBlockDelta = chunk.delta;
|
||||
const targetContent = tempMsg?.content[chunk.index];
|
||||
switch (contentBlockDelta.type) {
|
||||
case "text_delta":
|
||||
if (targetContent && targetContent.type === "text") {
|
||||
targetContent.text += contentBlockDelta.text;
|
||||
yield contentBlockDelta.text;
|
||||
} else console.error("文本块不匹配,无法追加内容", targetContent);
|
||||
break
|
||||
case "input_json_delta":
|
||||
if (targetContent && targetContent.type === "tool_use") {
|
||||
targetContent.input += contentBlockDelta.partial_json || "";
|
||||
yield contentBlockDelta.partial_json || "";
|
||||
} else console.error("工具调用块不匹配,无法追加输入", targetContent);
|
||||
break
|
||||
}
|
||||
break
|
||||
|
||||
case "content_block_stop":
|
||||
let type = tempMsg?.content[chunk.index].type;
|
||||
if (type === "tool_use") {
|
||||
yield `\n\`\`\``;
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
if (!tempMsg) {
|
||||
console.error("没有接收到有效的消息内容");
|
||||
yield "\n\n❌ 抱歉,处理您的请求时没有返回有效的消息内容。";
|
||||
break;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
role: tempMsg.role,
|
||||
content: tempMsg.content.map(c => {
|
||||
if (c.type === "text") {
|
||||
return {
|
||||
type: c.type,
|
||||
text: c.text,
|
||||
};
|
||||
} else if (c.type === "tool_use") {
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
name: c.name,
|
||||
input: JSON.parse(c.input as string)
|
||||
};
|
||||
} else return c;
|
||||
})
|
||||
});
|
||||
|
||||
if (!tempMsg.content.find(c => c.type === "tool_use")) {
|
||||
break
|
||||
}
|
||||
|
||||
let tempUserMsg: MessageParam = {
|
||||
role: "user",
|
||||
content: []
|
||||
}
|
||||
|
||||
let needRerun = false;
|
||||
for (const toolUse of tempMsg.content.filter(c => c.type === "tool_use")) {
|
||||
|
||||
let toolArgs = JSON.parse(toolUse.input as string || "{}");
|
||||
console.log(toolArgs);
|
||||
|
||||
switch (toolUse.name) {
|
||||
case "query-components":
|
||||
// @ts-ignore
|
||||
tempUserMsg.content.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUse.id,
|
||||
content: await this.queryComponents(toolArgs)
|
||||
});
|
||||
needRerun = true;
|
||||
yield 'next_block'
|
||||
break
|
||||
case "show-components":
|
||||
let components = await ComponentService.getComponentsByIds(toolArgs.component_ids)
|
||||
|
||||
// @ts-ignore
|
||||
tempUserMsg.content.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: toolUse.id,
|
||||
content: `成功向用户展示了 ${JSON.stringify(
|
||||
(await ComponentService.getComponentsByIds(toolArgs.component_ids)).map((c, index) => (c && {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
brand: c.brand,
|
||||
model: c.model,
|
||||
price: c.price,
|
||||
description: c.description,
|
||||
stock: c.stock,
|
||||
specifications: c.specifications,
|
||||
} || { id: toolArgs.component_ids[index].id, name: "未知配件" })
|
||||
)
|
||||
)}`
|
||||
});
|
||||
|
||||
needRerun = true;
|
||||
yield `show_card: ${JSON.stringify(components)}`;
|
||||
yield 'next_block'
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`未知工具调用: ${toolUse.name}`);
|
||||
|
||||
}
|
||||
|
||||
// 继续处理下一个工具调用
|
||||
}
|
||||
|
||||
// 将工具调用结果添加到消息列表中
|
||||
messages.push(tempUserMsg);
|
||||
if (!needRerun) break
|
||||
|
||||
} if (currentIteration >= maxIterations) {
|
||||
yield "\n\n⚠️ 达到最大处理轮次,对话结束。";
|
||||
}
|
||||
|
||||
// 保存更新后的对话到数据库
|
||||
if (currentConversationId) {
|
||||
await ConversationService.updateConversationMessages(currentConversationId, userId, messages);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('处理查询时发生错误:', error);
|
||||
yield `\n\n❌ 抱歉,处理您的请求时遇到了问题: ${error instanceof Error ? error.message : '未知错误'}`;
|
||||
|
||||
// 即使出错也要保存对话
|
||||
if (currentConversationId) {
|
||||
try {
|
||||
await ConversationService.updateConversationMessages(currentConversationId, userId, messages);
|
||||
} catch (saveError) {
|
||||
console.error('保存对话失败:', saveError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的对话历史
|
||||
*/
|
||||
async getUserConversations(userId: string, page = 1, limit = 20) {
|
||||
return await ConversationService.getUserConversations(userId, page, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取特定对话的详情
|
||||
*/
|
||||
async getConversation(conversationId: string, userId: string) {
|
||||
return await ConversationService.getConversation(conversationId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*/
|
||||
async deleteConversation(conversationId: string, userId: string) {
|
||||
return await ConversationService.deleteConversation(conversationId, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新对话标题
|
||||
*/
|
||||
async updateConversationTitle(conversationId: string, userId: string, title: string) {
|
||||
return await ConversationService.updateConversationTitle(conversationId, userId, title);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
37
lib/auth.ts
37
lib/auth.ts
@ -1,5 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-jwt-secret-here'
|
||||
|
||||
@ -22,3 +24,38 @@ export function verifyToken(token: string): any {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUser(request: NextRequest) {
|
||||
try {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
const token = authHeader?.replace('Bearer ', '')
|
||||
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
const decoded = verifyToken(token)
|
||||
if (!decoded || !decoded.userId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
address: true,
|
||||
isAdmin: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
8
lib/markdown-it.ts
Normal file
8
lib/markdown-it.ts
Normal file
File diff suppressed because one or more lines are too long
159
lib/services/component-service.ts
Normal file
159
lib/services/component-service.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export interface ComponentQueryParams {
|
||||
type?: string
|
||||
typeId?: string // 直接使用typeId
|
||||
brand?: string
|
||||
minPrice?: number
|
||||
maxPrice?: number
|
||||
search?: string
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ComponentQueryResult {
|
||||
components: Array<{
|
||||
id: string
|
||||
name: string
|
||||
brand: string
|
||||
model: string
|
||||
price: number
|
||||
description: string | null
|
||||
stock: number
|
||||
type: string
|
||||
specifications: string | null
|
||||
imageUrl: string | null
|
||||
}>
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentService {
|
||||
/**
|
||||
* 查询组件列表
|
||||
*/
|
||||
static async queryComponents(params: ComponentQueryParams): Promise<ComponentQueryResult> {
|
||||
const {
|
||||
type,
|
||||
typeId,
|
||||
brand,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
search,
|
||||
page = 1,
|
||||
limit = 12
|
||||
} = params
|
||||
|
||||
const where: any = {}
|
||||
|
||||
// 优先使用typeId,如果没有但有type,则查找typeId
|
||||
if (typeId) {
|
||||
where.componentTypeId = typeId
|
||||
} else if (type) {
|
||||
const componentType = await prisma.componentType.findFirst({
|
||||
where: {
|
||||
name: {
|
||||
contains: type,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
})
|
||||
if (componentType) {
|
||||
where.componentTypeId = componentType.id
|
||||
}
|
||||
}
|
||||
|
||||
if (brand) {
|
||||
where.brand = {
|
||||
contains: brand,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
|
||||
if (minPrice || maxPrice) {
|
||||
where.price = {}
|
||||
if (minPrice) where.price.gte = minPrice
|
||||
if (maxPrice) where.price.lte = 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)
|
||||
|
||||
return {
|
||||
components: components.map(comp => ({
|
||||
id: comp.id,
|
||||
name: comp.name,
|
||||
brand: comp.brand,
|
||||
model: comp.model,
|
||||
price: comp.price,
|
||||
description: comp.description,
|
||||
stock: comp.stock,
|
||||
type: comp.componentType.name,
|
||||
specifications: comp.specifications,
|
||||
imageUrl: comp.imageUrl
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取单个组件
|
||||
*/
|
||||
static async getComponentById(id: string) {
|
||||
return await prisma.component.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
componentType: true
|
||||
}
|
||||
})
|
||||
}
|
||||
static async getComponentsByIds(componentIds: string[]) {
|
||||
return await Promise.all(componentIds.map(id => ComponentService.getComponentById(id)))
|
||||
}
|
||||
/**
|
||||
* 获取所有组件类型
|
||||
*/
|
||||
static async getComponentTypes() {
|
||||
return await prisma.componentType.findMany({
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
163
lib/services/conversation-service.ts
Normal file
163
lib/services/conversation-service.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { MessageParam } from '@anthropic-ai/sdk/resources'
|
||||
|
||||
export interface ConversationData {
|
||||
id: string
|
||||
title: string | null
|
||||
messages: MessageParam[]
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export class ConversationService {
|
||||
/**
|
||||
* 创建新对话
|
||||
*/
|
||||
static async createConversation(userId: string, initialMessage: string): Promise<string> {
|
||||
// 生成对话标题(取用户消息的前30个字符)
|
||||
const title = initialMessage.length > 30
|
||||
? initialMessage.substring(0, 30) + '...'
|
||||
: initialMessage
|
||||
|
||||
const systemMessage: MessageParam = {
|
||||
role: "user",
|
||||
content: `你是PC DIY商店的专业AI助手。你的主要职责是:
|
||||
|
||||
1. 帮助用户查找和推荐PC配件
|
||||
2. 提供配件的价格、性能和兼容性信息
|
||||
3. 根据用户预算和需求推荐最适合的配件
|
||||
4. 解答关于PC装机的技术问题`
|
||||
}
|
||||
|
||||
const assistantGreeting: MessageParam = {
|
||||
role: "assistant",
|
||||
content: "您好!我是PC DIY商店的AI助手,很高兴为您服务。请告诉我您需要什么帮助?"
|
||||
}
|
||||
|
||||
const userMessage: MessageParam = {
|
||||
role: "user",
|
||||
content: initialMessage
|
||||
}
|
||||
|
||||
const initialMessages = [systemMessage, assistantGreeting, userMessage]
|
||||
|
||||
const conversation = await prisma.conversation.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
messages: JSON.stringify(initialMessages)
|
||||
}
|
||||
})
|
||||
|
||||
return conversation.id
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取对话
|
||||
*/
|
||||
static async getConversation(conversationId: string, userId: string): Promise<ConversationData | null> {
|
||||
const conversation = await prisma.conversation.findFirst({
|
||||
where: {
|
||||
id: conversationId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: conversation.id,
|
||||
title: conversation.title,
|
||||
messages: JSON.parse(conversation.messages) as MessageParam[],
|
||||
createdAt: conversation.createdAt,
|
||||
updatedAt: conversation.updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新对话消息
|
||||
*/
|
||||
static async updateConversationMessages(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
messages: MessageParam[]
|
||||
): Promise<void> {
|
||||
await prisma.conversation.updateMany({
|
||||
where: {
|
||||
id: conversationId,
|
||||
userId
|
||||
},
|
||||
data: {
|
||||
messages: JSON.stringify(messages),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的对话列表
|
||||
*/
|
||||
static async getUserConversations(userId: string, page = 1, limit = 20) {
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
const [conversations, total] = await Promise.all([
|
||||
prisma.conversation.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip,
|
||||
take: limit
|
||||
}),
|
||||
prisma.conversation.count({
|
||||
where: { userId }
|
||||
})
|
||||
])
|
||||
|
||||
return {
|
||||
conversations,
|
||||
total,
|
||||
page,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除对话
|
||||
*/
|
||||
static async deleteConversation(conversationId: string, userId: string): Promise<boolean> {
|
||||
const result = await prisma.conversation.deleteMany({
|
||||
where: {
|
||||
id: conversationId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
return result.count > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新对话标题
|
||||
*/
|
||||
static async updateConversationTitle(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
title: string
|
||||
): Promise<boolean> {
|
||||
const result = await prisma.conversation.updateMany({
|
||||
where: {
|
||||
id: conversationId,
|
||||
userId
|
||||
},
|
||||
data: { title }
|
||||
})
|
||||
|
||||
return result.count > 0
|
||||
}
|
||||
}
|
||||
@ -12,15 +12,22 @@
|
||||
"db:seed": "bun run seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.54.0",
|
||||
"@modelcontextprotocol/sdk": "^1.13.0",
|
||||
"@prisma/client": "^6.10.1",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"chart.js": "^4.5.0",
|
||||
"dedent": "^1.6.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.522.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"next": "15.3.4",
|
||||
"next-auth": "^4.24.11",
|
||||
"openai": "^5.6.0",
|
||||
"prisma": "^6.10.1",
|
||||
"react": "^19.0.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "conversations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT,
|
||||
"messages" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "conversations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@ -22,8 +22,9 @@ model User {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
orders Order[]
|
||||
cartItems CartItem[]
|
||||
orders Order[]
|
||||
cartItems CartItem[]
|
||||
conversations Conversation[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -114,3 +115,16 @@ enum OrderStatus {
|
||||
DELIVERED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model Conversation {
|
||||
id String @id @default(cuid())
|
||||
title String? // 对话标题,可以根据第一个用户消息生成
|
||||
messages String // 存储完整的消息历史的JSON数据
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("conversations")
|
||||
}
|
||||
|
||||
54
requirements.md
Normal file
54
requirements.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 软件开发技术课程设计(II)指导书
|
||||
|
||||
## 一、指导书选用范围
|
||||
所属专业:软件工程
|
||||
领域方向:软件工程与软件开发实践
|
||||
参考学时:2周
|
||||
适用学生:软件工程专业本科生
|
||||
先修课要求:Java Web开发技术、软件工程、数据库
|
||||
|
||||
## 二、课程设计目的
|
||||
通过本课程设计的准备与总结,复习、领会、巩固和运用课堂上所学的软件开发方法和知识,为学生综合应用本专业所学习的多门课程知识创造实践机会,使每个学生了解软件工具与环境对于项目开发的重要性,并且重点深入掌握好几种较新或较流行的软件工具或计算机应用技术,提高学生今后参与开发稍大规模实际软件项目和探索未知领域的能力和自信心。
|
||||
|
||||
## 三、课程设计内容
|
||||
**在线电脑DIY系统**
|
||||
假设某企业需要大学生创建一个在线电脑DIY系统,以实现销售的便利化,提升销售额,系统基本功能有:
|
||||
1. 首页为企业的介绍,电脑配件(CPU、内存、硬盘、主板、显卡、机箱)的详情展示。不需要登录。
|
||||
2. 每种配件都有若干个品牌,价格不同。
|
||||
3. 使用系统的用户包括一个管理员和多个用户,需要登陆才能操作系统,登录后可以修改个人信息。
|
||||
4. 管理员可以管理配件类型、商品信息、浏览用户、查看订单。
|
||||
5. 用户可以注册。登录后可以选购配件,从每种配件中选择1个形成装机单,最终提交订单。用户进行结算(不用真实付款)。
|
||||
6. 用户可以查看自己的订单。
|
||||
7. 管理员统计:按消费金额倒排的前十用户列表,按销售量倒排序的前十配件列表。
|
||||
8. 统计结果以柱状图或其他图形展示。
|
||||
|
||||
## 四、课程设计报告模板
|
||||
第一章 概 述
|
||||
1.1 课程设计目的
|
||||
见指导书
|
||||
1.2 课程设计任务
|
||||
根据自己的题目
|
||||
1.3 使用技术及开发环境
|
||||
说明采用的开发技术、使用框架、开发工具等
|
||||
|
||||
第二章 需求分析
|
||||
2.1 功能分析
|
||||
分析系统功能,画功能结构图,并进行说明
|
||||
2.2 概念模型分析
|
||||
画ER图分析实体及其联系
|
||||
|
||||
第三章 系统设计
|
||||
3.1 数据库设计
|
||||
说明每个表的字段及其类型
|
||||
3.2 模块设计
|
||||
使用流程图分析功能的设计流程,至少有2张图
|
||||
|
||||
第四章 系统实现
|
||||
4.1 项目介绍
|
||||
结合截图说明项目中每个包及文件的作用
|
||||
4.2 系统功能实现
|
||||
系统运行的截图及说明
|
||||
|
||||
第五章 总结
|
||||
个人遇到的困难、解决方法与个人小结
|
||||
|
||||
27
第一章-概述.md
Normal file
27
第一章-概述.md
Normal file
@ -0,0 +1,27 @@
|
||||
# 第一章 概述
|
||||
|
||||
## 1.1 课程设计目的
|
||||
|
||||
通过本次课程设计的准备与总结,深入复习、领会、巩固和运用课堂上所学的软件开发方法和知识,为综合应用本专业所学习的多门课程知识创造实践机会。本课程设计旨在使学生深入了解软件工具与环境对于项目开发的重要性,并且重点深入掌握几种较新或较流行的软件工具或计算机应用技术。
|
||||
|
||||
在实际开发过程中,学生将体验完整的软件开发生命周期,从需求分析、系统设计到编码实现和测试部署,全面提升软件工程实践能力。通过构建真实的Web应用系统,学生能够将理论知识转化为实际操作技能,增强解决复杂工程问题的能力,提高今后参与开发稍大规模实际软件项目和探索未知领域的能力和自信心。
|
||||
|
||||
## 1.2 课程设计任务
|
||||
|
||||
本次课程设计的任务是开发一个在线电脑DIY系统,该系统面向现代消费者对个性化电脑配置的需求,提供一站式的电脑配件选购和装机方案定制服务。系统需要实现从配件展示、用户管理、购物车管理到订单处理的完整电商业务流程。
|
||||
|
||||
系统的核心功能包括企业信息展示和电脑配件详情展示,涵盖CPU、内存、硬盘、主板、显卡、机箱等六大类核心配件。每种配件支持多品牌、多型号的产品管理,满足不同用户的预算和性能需求。系统采用分角色管理模式,包括管理员和普通用户两种用户类型,通过权限控制确保系统安全性和数据完整性。
|
||||
|
||||
用户功能方面,系统支持用户注册、登录、个人信息管理、配件选购、装机方案定制、订单提交和订单查询等完整的购物流程。管理员功能涵盖配件类型管理、商品信息管理、用户管理、订单管理和数据统计分析等后台管理功能。特别是数据统计功能,能够提供按消费金额排序的用户分析和按销售量排序的产品分析,为商业决策提供数据支持。
|
||||
|
||||
## 1.3 使用技术及开发环境
|
||||
|
||||
本项目采用现代化的全栈Web开发技术栈,前端使用React 19.0框架配合Next.js 15.3.4进行开发,利用Next.js的服务端渲染(SSR)和静态生成(SSG)能力,提升应用性能和用户体验。前端UI框架选用TailwindCSS 4.0,通过原子化CSS类实现响应式设计和现代化的用户界面。
|
||||
|
||||
后端采用Next.js的API Routes功能构建RESTful API,实现前后端一体化开发。数据库选用PostgreSQL作为主数据库,通过Prisma 6.10.1作为ORM框架进行数据库操作,提供类型安全的数据库访问和强大的查询能力。身份认证采用JSON Web Token(JWT)机制,确保用户会话安全和API接口的访问控制。
|
||||
|
||||
开发工具方面,使用Visual Studio Code作为主要IDE,配合TypeScript 5.0提供强类型支持和更好的开发体验。包管理器采用Bun作为现代化的JavaScript运行时和包管理工具,提升开发效率和构建性能。版本控制使用Git,配合GitHub进行代码管理和协作开发。
|
||||
|
||||
图表展示功能使用Chart.js 4.5.0和React-Chartjs-2 5.3.0库,以及Recharts 2.15.4库,提供丰富的数据可视化选项。图标系统采用Lucide React 0.522.0,提供一致性的矢量图标库。项目采用模块化架构设计,通过组件化开发提高代码复用性和可维护性。
|
||||
|
||||
开发环境配置包括Node.js 20+、PostgreSQL 15+数据库服务器,以及相应的开发调试工具。项目支持热重载开发模式,提供实时预览和快速迭代能力。生产环境支持静态资源优化、代码分割和性能监控,确保应用的稳定性和高性能表现。
|
||||
43
第三章-系统设计.md
Normal file
43
第三章-系统设计.md
Normal file
@ -0,0 +1,43 @@
|
||||
# 第三章 系统设计
|
||||
|
||||
## 3.1 数据库设计
|
||||
|
||||
系统采用PostgreSQL作为主数据库,通过Prisma ORM进行数据建模和访问。数据库设计遵循第三范式,确保数据的一致性和完整性,同时考虑了系统的性能需求和扩展性要求。
|
||||
|
||||
用户表(users)是系统的核心表之一,存储所有用户的基本信息和权限信息。主键id采用cuid()函数生成的唯一标识符,确保全局唯一性。email字段定义为唯一约束的变长字符串,作为用户登录的主要标识。username字段同样设置唯一约束,提供用户的显示名称。password字段存储经过bcrypt加密的用户密码,确保密码安全性。name、phone、address字段为可选字段,存储用户的详细信息。isAdmin字段为布尔类型,默认值为false,用于区分普通用户和管理员。createdAt和updatedAt字段分别记录用户的创建时间和最后更新时间,支持自动时间戳更新。
|
||||
|
||||
配件类型表(component_types)定义了电脑配件的分类信息。主键id采用cuid()生成,name字段存储配件类型名称并设置唯一约束,确保类型名称的唯一性。description字段为可选字段,提供类型的详细描述信息。该表为配件分类提供基础数据支持。
|
||||
|
||||
配件表(components)是系统最重要的业务表之一,存储所有电脑配件的详细信息。主键id采用cuid()生成,name字段存储配件名称,brand字段存储配件品牌,model字段存储配件型号。price字段采用浮点数类型存储配件价格,支持精确的价格计算。description和imageUrl字段为可选字段,分别存储配件描述和图片URL。stock字段为整型,默认值为0,实时反映配件的库存数量。specifications字段采用字符串类型存储JSON格式的规格参数,提供灵活的参数定义能力。componentTypeId字段为外键,关联配件类型表,建立配件与类型的归属关系。createdAt和updatedAt字段记录配件的创建和更新时间。
|
||||
|
||||
订单表(orders)记录用户的购买订单信息。主键id采用cuid()生成,orderNumber字段存储唯一的订单号,便于订单查询和管理。totalAmount字段采用浮点数存储订单总金额。status字段采用枚举类型,支持PENDING(待确认)、CONFIRMED(已确认)、PROCESSING(处理中)、SHIPPED(已发货)、DELIVERED(已送达)、CANCELLED(已取消)等状态,默认为PENDING状态。userId字段为外键,关联用户表,建立订单与用户的归属关系。createdAt和updatedAt字段记录订单的创建和更新时间,支持订单时间追踪。
|
||||
|
||||
订单项表(order_items)是订单和配件之间的关联表,记录订单中每个配件的详细信息。主键id采用cuid()生成,quantity字段为整型,默认值为1,记录配件的购买数量。price字段记录配件的实际成交价格,避免因价格变动导致的历史订单金额不准确。orderId字段为外键,关联订单表,并设置级联删除约束,确保订单删除时相关订单项同时删除。componentId字段为外键,关联配件表,建立订单项与配件的关系。
|
||||
|
||||
购物车项表(cart_items)实现了基于数据库的购物车功能,支持用户在不同设备间的购物车同步。主键id采用cuid()生成,quantity字段记录配件的购买数量,默认值为1。userId字段为外键,关联用户表,并设置级联删除约束,确保用户删除时购物车数据同时清理。componentId字段为外键,关联配件表,并设置级联删除约束,确保配件删除时相关购物车项同时清理。createdAt和updatedAt字段记录购物车项的创建和更新时间。重要的是,userId和componentId字段组合设置唯一约束,确保一个用户对同一商品只能有一条购物车记录,避免数据冗余。
|
||||
|
||||
数据库设计还包含了完善的索引策略。用户表的email和username字段设置唯一索引,提升登录验证的查询性能。配件表的componentTypeId字段设置普通索引,优化按类型查询配件的性能。订单表的userId字段设置索引,提升用户订单查询的效率。外键约束确保了引用完整性,防止孤立数据的产生。
|
||||
|
||||
## 3.2 模块设计
|
||||
|
||||
系统采用模块化架构设计,将复杂的业务逻辑分解为相互独立且高内聚的功能模块。每个模块负责特定的业务领域,通过明确定义的接口进行交互,确保系统的可维护性和可扩展性。
|
||||
|
||||
用户认证模块是系统安全的核心模块,负责处理用户的注册、登录、权限验证和会话管理。该模块采用JWT令牌机制实现无状态认证,支持跨域访问和分布式部署。用户注册流程首先验证用户输入信息的合法性,包括邮箱格式验证、用户名唯一性检查和密码强度验证。密码采用bcrypt算法进行单向加密,盐值随机生成,确保即使数据库泄露也无法逆向获取原始密码。用户登录流程验证用户凭据的正确性,成功后生成包含用户信息的JWT令牌,设置合理的过期时间以平衡安全性和用户体验。
|
||||
|
||||
**[用户认证流程图占位符]**
|
||||
|
||||
商品管理模块负责电脑配件的展示、检索和管理功能。该模块支持配件的分类浏览、关键词搜索、价格筛选和品牌过滤等多种检索方式。配件详情页面展示配件的完整信息,包括规格参数、库存状态、用户评价等。管理员可以通过该模块进行配件的增删改查操作,支持批量操作和数据导入导出功能。库存管理功能实时监控配件库存,支持库存预警和自动补货提醒。
|
||||
|
||||
购物车模块实现了基于数据库的购物车功能,相比传统的本地存储方案,提供了更好的数据持久性和跨设备同步能力。用户可以在任意页面将感兴趣的配件添加到购物车,系统自动检查库存可用性并更新购物车状态。购物车支持商品数量的实时调整,价格计算自动更新,提供直观的购物体验。结算流程集成了配件兼容性检查功能,确保用户选择的配件能够正常组装使用。
|
||||
|
||||
订单管理模块处理从订单创建到订单完成的全生命周期管理。订单创建流程首先验证购物车数据的有效性,检查库存充足性,计算订单总金额,生成唯一订单号,并将购物车商品转换为订单项。订单状态管理支持多种业务状态,管理员可以根据实际业务进度更新订单状态,用户可以实时查看订单处理进度。订单查询功能支持多维度检索,包括订单号查询、时间范围查询、状态筛选等。
|
||||
|
||||
**[订单处理流程图占位符]**
|
||||
|
||||
装机方案模块是系统的特色功能模块,为用户提供智能化的电脑配置方案定制服务。用户可以从每种配件类型中选择一个产品,系统会实时计算总价格并检查配件间的兼容性。兼容性检查算法考虑了主板与CPU的接口匹配、内存类型与主板的兼容性、显卡尺寸与机箱空间的匹配等关键因素。用户确认配置方案后,可以一键将所有配件添加到购物车或直接提交订单,大大简化了DIY装机的复杂性。
|
||||
|
||||
数据统计模块为系统提供了强大的商业智能分析能力。该模块定期分析用户消费行为、商品销售数据、订单趋势等关键业务指标。用户消费排行统计按照消费金额对用户进行排序,识别高价值客户群体,为精准营销提供数据支持。商品销售排行统计按照销量对配件进行排序,为库存管理和采购决策提供参考依据。统计结果通过图表形式直观展示,支持多种图表类型包括柱状图、饼图、折线图等,满足不同的数据展示需求。
|
||||
|
||||
系统集成模块负责各功能模块间的协调和通信,提供统一的API接口和数据交换格式。该模块采用RESTful API设计规范,支持标准的HTTP方法和状态码,确保接口的规范性和易用性。数据传输采用JSON格式,支持请求参数验证和响应数据格式化。错误处理机制提供统一的错误码和错误信息,便于前端进行错误处理和用户提示。
|
||||
|
||||
安全防护模块贯穿系统的各个层面,提供全方位的安全保障。输入验证功能防止SQL注入、XSS攻击等常见安全威胁。API访问控制基于JWT令牌进行身份验证和权限授权,确保只有合法用户能够访问相应资源。数据加密功能对敏感信息进行加密存储和传输,保护用户隐私和商业机密。访问日志记录用户的关键操作,支持安全审计和异常行为分析。
|
||||
51
第二章-需求分析.md
Normal file
51
第二章-需求分析.md
Normal file
@ -0,0 +1,51 @@
|
||||
# 第二章 需求分析
|
||||
|
||||
## 2.1 功能分析
|
||||
|
||||
在线电脑DIY系统是一个面向现代消费者的综合性电商平台,旨在为用户提供便捷的电脑配件选购和装机方案定制服务。系统的功能设计充分考虑了电脑DIY市场的特点和用户需求,构建了完整的业务流程和用户体验。
|
||||
|
||||
系统的核心功能围绕电脑配件的展示、选购和管理展开。首页作为系统的门户,无需用户登录即可访问,主要展示企业的品牌信息、发展历程和核心价值观,同时提供电脑配件的分类浏览功能。配件展示功能涵盖CPU、内存、硬盘、主板、显卡、机箱等六大类核心配件,每类配件支持多品牌、多型号的产品展示,用户可以通过品牌筛选、价格排序、性能对比等方式快速找到合适的产品。
|
||||
|
||||
用户管理功能实现了完整的用户生命周期管理。新用户可以通过注册功能创建账户,系统支持邮箱验证和用户名唯一性检查,确保账户安全性。登录功能采用JWT令牌机制,支持会话保持和自动登录功能。用户登录后可以访问个人中心,管理个人信息包括姓名、电话、地址等基本信息,同时可以查看账户统计信息如订单数量、消费金额、购物车商品数量等。
|
||||
|
||||
购物车功能是系统的重要组成部分,支持配件的添加、数量调整、删除和清空操作。购物车数据采用数据库存储,确保用户在不同设备间的数据同步。系统还提供了智能的库存检查功能,防止用户购买超出库存的商品,提升购物体验和业务准确性。
|
||||
|
||||
装机方案定制功能是系统的特色功能,用户可以从每种配件类型中选择一个产品,系统会自动检查配件间的兼容性,并计算总价格。用户确认方案后可以一键添加到购物车或直接提交订单,大大简化了DIY装机的复杂性。
|
||||
|
||||
订单管理功能涵盖了从订单创建到订单完成的全流程。用户可以提交订单并进行虚拟结算,无需真实付款。订单支持多种状态管理包括待确认、已确认、处理中、已发货、已送达和已取消,用户可以实时查看订单状态和物流信息。同时,系统提供订单历史查询功能,用户可以查看所有历史订单并支持重新下单操作。
|
||||
|
||||
管理员功能提供了强大的后台管理能力。配件类型管理允许管理员添加、修改和删除配件分类。商品信息管理支持商品的增删改查操作,包括商品名称、品牌、型号、价格、库存、规格参数等详细信息的管理。用户管理功能让管理员可以查看所有注册用户的信息和活动状态。订单管理功能提供订单的查看、状态修改和删除功能,帮助管理员高效处理业务。
|
||||
|
||||
数据统计功能是系统的高级功能,提供了丰富的业务分析能力。系统可以统计按消费金额倒序排列的前十名用户,帮助识别高价值客户。同时统计按销售量倒序排列的前十名配件,为库存管理和采购决策提供数据支持。统计结果通过柱状图、饼图等可视化图表展示,直观清晰地反映业务状况。
|
||||
|
||||
**[功能结构图占位符]**
|
||||
|
||||
系统的功能结构采用层次化设计,分为用户界面层、业务逻辑层和数据访问层。用户界面层负责用户交互和页面展示,业务逻辑层处理核心业务规则和流程控制,数据访问层负责数据库操作和数据持久化。各层之间通过API接口进行通信,确保系统的模块化和可扩展性。
|
||||
|
||||
## 2.2 概念模型分析
|
||||
|
||||
系统的概念模型设计围绕电商业务的核心实体展开,主要包括用户、配件类型、配件、订单、购物车等核心实体,以及它们之间的复杂关系。通过合理的实体关系设计,系统能够准确反映真实业务场景,为后续的数据库设计奠定坚实基础。
|
||||
|
||||
用户实体是系统的核心实体之一,包含用户的基本信息和权限信息。用户实体的关键属性包括用户ID、邮箱、用户名、密码、姓名、电话、地址、管理员标识、创建时间和更新时间。邮箱和用户名作为唯一标识,确保用户账户的唯一性。管理员标识字段区分普通用户和管理员,实现权限控制。
|
||||
|
||||
配件类型实体定义了电脑配件的分类信息,包括类型ID、类型名称和类型描述。系统预设六种配件类型分别是CPU、内存、硬盘、主板、显卡和机箱,每种类型具有唯一的标识和描述信息。配件类型实体为配件分类和检索提供基础支持。
|
||||
|
||||
配件实体是系统的商品实体,包含了详细的商品信息。关键属性包括配件ID、配件名称、品牌、型号、价格、描述、图片URL、库存数量、规格参数、创建时间和更新时间。规格参数采用JSON格式存储,支持灵活的参数定义。库存数量字段支持实时库存管理,防止超卖现象。
|
||||
|
||||
订单实体记录用户的购买行为,包含订单ID、订单号、总金额、订单状态、创建时间和更新时间。订单号采用唯一标识,便于订单查询和管理。订单状态支持多种状态值,反映订单的处理进度。
|
||||
|
||||
订单项实体是订单和配件之间的关联实体,记录订单中每个配件的详细信息。关键属性包括订单项ID、数量、单价、订单ID和配件ID。通过订单项实体,系统可以准确记录每个订单的商品明细。
|
||||
|
||||
购物车实体记录用户的购物车信息,支持配件的临时存储和管理。关键属性包括购物车项ID、数量、创建时间、更新时间、用户ID和配件ID。购物车采用数据库存储,确保数据的持久性和跨设备同步。
|
||||
|
||||
**[ER图占位符]**
|
||||
|
||||
实体间的关系设计体现了业务逻辑的复杂性。用户与订单之间存在一对多关系,一个用户可以创建多个订单,但每个订单只属于一个用户。用户与购物车项之间也存在一对多关系,一个用户可以有多个购物车项,支持多商品的购物车管理。
|
||||
|
||||
配件类型与配件之间存在一对多关系,一个配件类型可以包含多个具体配件,但每个配件只属于一个配件类型。这种关系支持配件的分类管理和检索。
|
||||
|
||||
订单与订单项之间存在一对多关系,一个订单可以包含多个订单项,每个订单项记录一个配件的购买信息。配件与订单项之间存在一对多关系,一个配件可以被多个订单项引用,但每个订单项只对应一个配件。
|
||||
|
||||
配件与购物车项之间存在一对多关系,一个配件可以被多个用户添加到购物车,但每个购物车项只对应一个配件。用户和配件通过购物车项建立多对多关系,实现灵活的购物车管理。
|
||||
|
||||
通过这种实体关系设计,系统能够准确建模电商业务的复杂关系,支持高效的数据操作和业务逻辑实现。实体间的外键约束确保数据一致性,级联删除和更新操作保证数据的完整性。
|
||||
49
第五章-总结.md
Normal file
49
第五章-总结.md
Normal file
@ -0,0 +1,49 @@
|
||||
# 第五章 总结
|
||||
|
||||
## 5.1 开发过程中遇到的困难
|
||||
|
||||
在本次课程设计的开发过程中,遇到了多个层面的技术挑战和业务难题,这些困难不仅考验了理论知识的掌握程度,更检验了解决实际问题的能力和持续学习的态度。
|
||||
|
||||
技术选型阶段面临的第一个挑战是如何在学校课程的技术栈与个人偏好之间做出平衡。学校课程主要教授Java Bean、Spring MVC和Spring Boot等传统Java技术栈,但个人对Java的笨重感和复杂的配置管理并不认同,更倾向于轻量级和高效的开发方式。基于之前掌握的Express + Vue.js技术栈,最终选择了Next.js全栈框架,这样既能保持JavaScript生态的一致性,又能体验现代化的全栈开发模式。然而,从分离式的前后端架构转向Next.js的一体化开发模式需要一个适应期,特别是Next.js 15版本引入的App Router架构与传统的Express路由和Vue.js组件化思维存在显著差异,需要重新理解服务端渲染、客户端组件和服务端组件的概念与使用场景。
|
||||
|
||||
数据库设计阶段遇到的主要困难是如何建立合理的实体关系模型。电商系统的业务逻辑相对复杂,用户、商品、订单、购物车之间存在多种关联关系。特别是购物车功能的设计,初期采用了localStorage方案,但后期发现无法满足跨设备同步的需求,需要重新设计为基于数据库的方案。这个改动涉及到数据模型的重新设计、API接口的重写和前端逻辑的大幅调整,几乎相当于重构了整个购物车模块。
|
||||
|
||||
身份认证和权限管理是另一个技术难点。JWT令牌的生成、验证和刷新机制需要在前后端之间建立一致的处理逻辑。在开发过程中,经常出现令牌过期后用户体验不佳的问题,需要实现自动刷新机制和无感知的重新认证。同时,权限控制需要在多个层面实现,包括API接口层面的权限验证和前端页面层面的访问控制,确保系统的安全性。
|
||||
|
||||
状态管理是前端开发中的一个持续挑战。虽然React提供了useState和useContext等状态管理工具,但在复杂的业务场景中,如何合理地组织状态、避免过度渲染和保持状态的一致性仍然需要仔细考虑。特别是购物车状态和用户登录状态的全局管理,需要在多个组件间保持同步,这要求对React的生命周期和渲染机制有深入的理解。
|
||||
|
||||
性能优化是开发后期面临的重要挑战。随着功能的增加和数据量的增长,页面加载速度和用户交互响应速度开始出现问题。需要学习和应用代码分割、懒加载、图片优化、缓存策略等多种优化技术。特别是数据库查询的优化,需要合理设计索引、避免N+1查询问题和实现有效的分页机制。
|
||||
|
||||
## 5.2 问题解决方法
|
||||
|
||||
面对开发过程中的各种困难,采用了系统性的问题解决方法,通过持续学习、实验验证和迭代改进,逐步克服了技术挑战并完成了系统开发。
|
||||
|
||||
针对技术栈转换和学习适应的问题,采用了对比学习和实践验证的策略。虽然学校课程侧重于Java Spring框架的学习,但个人更倾向于JavaScript生态的简洁性和开发效率。基于之前Express + Vue.js的开发经验,在学习Next.js时采用了类比的方式,将Express的中间件概念对应到Next.js的API中间件,将Vue.js的组件化思维迁移到React组件设计中。然而,Next.js的全栈特性带来了新的挑战,从传统的前后端分离架构转向一体化开发需要重新思考代码组织和数据流设计。通过反复实践和对比不同实现方式的优劣,逐步适应了Next.js的开发模式,并体会到了全栈开发的便利性和高效性。
|
||||
|
||||
数据库设计问题的解决采用了迭代设计的方法。首先建立基础的数据模型,然后在开发过程中根据实际需求不断完善和调整。Prisma ORM的使用大大简化了数据库操作的复杂性,通过schema文件可以清晰地定义数据模型和关系。当需要重构购物车功能时,通过数据库迁移功能平滑地完成了数据结构的变更,确保了数据的完整性和一致性。
|
||||
|
||||
身份认证问题的解决采用了成熟的JWT方案,并结合了最佳实践进行实现。通过研究相关的安全文档和实现案例,建立了完整的认证流程,包括令牌的生成、验证、刷新和注销机制。在前端实现了自动令牌刷新和错误处理逻辑,确保用户体验的流畅性。同时,在API层面实现了统一的权限验证中间件,确保了系统的安全性。
|
||||
|
||||
状态管理问题通过合理的架构设计得到了解决。采用了Context API结合自定义Hook的方式实现全局状态管理,避免了过度复杂的状态管理库。通过事件机制实现了组件间的松耦合通信,确保状态变更能够及时反映到所有相关组件。同时,采用了本地存储结合服务端状态的混合方案,平衡了性能和数据一致性的需求。
|
||||
|
||||
性能优化问题通过多种技术手段得到了改善。在前端层面,采用了Next.js的静态生成和服务端渲染功能,提升了首屏加载速度。通过代码分割和懒加载技术,减少了初始包的大小。在数据库层面,通过合理的索引设计和查询优化,提升了数据访问效率。图片资源采用了CDN分发和格式优化,减少了网络传输时间。
|
||||
|
||||
调试和测试过程中,建立了完善的错误处理机制。在开发阶段使用了详细的日志记录,便于问题定位和分析。通过浏览器开发者工具和React DevTools等调试工具,能够快速识别和解决前端问题。对于API接口,采用了Postman等工具进行测试验证,确保接口的正确性和稳定性。
|
||||
|
||||
## 5.3 个人收获与体会
|
||||
|
||||
通过本次课程设计的完整开发过程,在技术能力、工程思维和综合素质方面都获得了显著的提升,这些收获将对未来的学习和职业发展产生深远的影响。
|
||||
|
||||
技术能力方面,成功实现了从传统Java技术栈向现代JavaScript全栈开发的转型。虽然学校课程主要围绕Java Bean、Spring MVC和Spring Boot展开,但通过本次项目实践,深入掌握了基于JavaScript的现代Web开发完整技术栈。从之前熟悉的Express + Vue.js分离式架构,成功过渡到Next.js的一体化全栈开发模式,体验到了JavaScript生态的统一性和开发效率的显著提升。相比于Java的繁重配置和冗长代码,JavaScript技术栈的简洁性和灵活性让开发过程更加流畅和高效。特别是TypeScript的引入,在保持JavaScript灵活性的同时提供了类型安全,避免了Java的过度抽象和配置复杂性。通过Prisma ORM的使用,体验了现代化数据库操作方式相比传统MyBatis或Hibernate的优势。
|
||||
|
||||
工程思维的培养是本次课程设计的重要收获。学会了如何将复杂的业务需求分解为可管理的开发任务,如何设计可扩展和可维护的系统架构,如何在开发过程中保持代码质量和项目进度的平衡。版本控制和代码管理的实践让我理解了团队协作开发的重要性和规范性。
|
||||
|
||||
问题解决能力得到了显著提升。在面对技术难题时,学会了如何系统性地分析问题、查找资料、验证方案和实施解决方案。这个过程培养了独立思考和持续学习的能力,也增强了面对未知技术领域的信心。每一个问题的解决都是一次知识的积累和能力的提升。
|
||||
|
||||
项目管理和时间管理能力也得到了锻炼。在有限的时间内完成复杂的开发任务,需要合理规划开发计划、设定优先级和控制开发进度。通过这个过程,学会了如何在质量和效率之间找到平衡点,如何在面临技术挑战时调整开发策略。
|
||||
|
||||
用户体验和产品思维的培养是意外的收获。在开发过程中,不仅关注功能的实现,更加注重用户的使用体验和业务流程的合理性。这种从用户角度思考问题的方式,让开发出的系统更加贴近实际需求和使用场景。
|
||||
|
||||
通过本次课程设计,深刻体会到了技术选择的重要性和个人技术偏好的价值。虽然学校课程强调Java技术栈的企业级应用,但通过实际项目开发,验证了JavaScript全栈技术的可行性和优势。相比于Java的笨重配置和复杂抽象,JavaScript技术栈的简洁高效让开发过程更加愉悦和productive。从Express + Vue.js到Next.js的技术演进,不仅是工具的升级,更是开发理念的转变,体现了现代Web开发向一体化、高效化方向的发展趋势。这个过程不仅是技术技能的学习,更是工程能力和综合素质的全面提升,同时也坚定了选择现代化技术栈的信心。面对未来的学习和工作,有了更加清晰的技术方向和更加坚定的发展信心。
|
||||
|
||||
软件开发是一个需要持续学习和不断进步的领域,技术的快速发展要求开发者保持敏锐的学习能力和适应能力。本次课程设计的经验让我认识到,掌握学习方法和培养解决问题的思维方式,比单纯掌握某种技术更加重要。在未来的学习和工作中,将继续保持这种学习态度,不断探索新的技术领域,提升自身的专业能力和综合素质。
|
||||
73
第四章-系统实现.md
Normal file
73
第四章-系统实现.md
Normal file
@ -0,0 +1,73 @@
|
||||
# 第四章 系统实现
|
||||
|
||||
## 4.1 项目介绍
|
||||
|
||||
本项目采用现代化的Web开发架构,基于Next.js 15框架构建,实现了前后端一体化的开发模式。项目结构清晰明确,遵循了React和Next.js的最佳实践,确保代码的可维护性和可扩展性。
|
||||
|
||||
项目根目录包含了配置文件和依赖管理文件。package.json文件定义了项目的依赖关系和脚本命令,包括开发环境启动、生产构建、代码检查和数据库操作等核心命令。next.config.ts文件配置了Next.js的运行参数,包括Turbopack构建优化、静态资源处理和API路由配置。tsconfig.json文件定义了TypeScript的编译选项,确保代码的类型安全和编译一致性。tailwindcss配置文件定义了样式系统的全局配置,包括颜色主题、响应式断点和自定义样式类。
|
||||
|
||||
app目录是Next.js 13+版本的核心目录,采用了新的App Router架构。该目录包含了所有的页面组件、API路由和全局配置文件。layout.tsx文件定义了应用的全局布局,包括HTML结构、全局样式引入和公共组件如导航栏的渲染。page.tsx文件是应用的首页组件,展示企业信息和配件分类导航。globals.css文件包含了全局CSS样式,基于TailwindCSS框架构建了一致的设计系统。
|
||||
|
||||
api目录包含了所有的后端API接口,采用Next.js的API Routes功能实现。auth目录包含用户认证相关的API,包括登录、注册和令牌验证接口。components目录包含配件管理的API,支持配件的查询、创建、更新和删除操作。component-types目录管理配件类型的API接口。orders目录处理订单相关的业务逻辑,包括订单创建、查询、状态更新等功能。cart目录实现了基于数据库的购物车API,支持购物车项的增删改查操作。user目录包含用户信息管理和统计分析的API接口。admin目录提供了管理员专用的API接口,实现了后台管理功能。
|
||||
|
||||
页面目录结构清晰地反映了应用的功能模块。login和register目录分别包含用户登录和注册页面。components目录包含配件浏览和详情页面,支持动态路由参数。orders目录包含订单管理页面,支持订单列表和订单详情的展示。cart目录包含购物车页面,实现了购物车的可视化管理。build目录包含装机方案定制页面,提供交互式的配件选择和兼容性检查功能。profile目录包含用户个人中心页面,支持个人信息管理和账户统计展示。admin目录包含管理员后台页面,提供了完整的后台管理功能。
|
||||
|
||||
components目录包含了可复用的React组件。Navbar.tsx组件实现了响应式导航栏,支持用户状态显示、路由高亮和移动端适配。ComponentCard.tsx组件展示单个配件的卡片信息,包括图片、名称、价格和加入购物车功能。AddToCartButton.tsx组件提供了通用的添加购物车按钮,支持数量选择和库存检查。admin目录包含了管理员专用的组件,如数据表格、表单组件和图表组件等。
|
||||
|
||||
lib目录包含了公共的工具函数和配置文件。prisma.ts文件配置了Prisma客户端实例,提供了类型安全的数据库访问接口。auth.ts文件包含了JWT令牌的生成、验证和解析函数,确保了用户认证的安全性。该目录还可能包含其他工具函数如数据验证、格式化、API客户端等。
|
||||
|
||||
prisma目录包含了数据库相关的配置和文件。schema.prisma文件定义了完整的数据模型,包括所有表结构、字段类型、约束关系和索引配置。migrations目录包含了数据库迁移文件,记录了数据库结构的变更历史,支持版本控制和团队协作。
|
||||
|
||||
public目录存储了静态资源文件,包括图片、图标、字体等资源。这些文件可以直接通过URL访问,Next.js会自动处理静态资源的缓存和优化。
|
||||
|
||||
seed.ts文件是数据库初始化脚本,包含了初始数据的创建逻辑,用于在开发和测试环境中快速搭建基础数据。该脚本通常包含管理员账户、配件类型、示例配件等基础数据的创建。
|
||||
|
||||
**[项目结构截图占位符]**
|
||||
|
||||
## 4.2 系统功能实现
|
||||
|
||||
系统首页作为用户进入应用的第一个页面,承担着品牌展示和功能导航的重要作用。页面采用现代化的响应式设计,顶部展示了企业的品牌标识和核心价值主张。主要内容区域通过精美的视觉设计展示了六大类电脑配件,每个配件类别都配有相应的图标和简要描述。用户可以通过点击不同的配件类别快速跳转到相应的配件浏览页面。页面底部包含了企业的联系信息和版权声明。整体设计简洁大方,符合现代Web应用的设计趋势。
|
||||
|
||||
**[首页截图占位符]**
|
||||
|
||||
用户注册功能提供了完整的账户创建流程。注册表单包含邮箱、用户名、密码和确认密码字段,所有字段都配置了相应的验证规则。邮箱字段要求符合标准邮箱格式,用户名要求3-20位字符且不能与现有用户重复,密码要求至少6位字符包含字母和数字。表单提交后,系统会进行服务端验证,确保数据的合法性和唯一性。注册成功后,用户可以直接跳转到登录页面进行账户验证。页面设计友好,提供了清晰的错误提示和成功反馈。
|
||||
|
||||
**[注册页面截图占位符]**
|
||||
|
||||
用户登录功能采用了安全的身份验证机制。登录表单支持邮箱或用户名两种登录方式,密码字段提供了显示/隐藏功能,提升用户体验。系统采用JWT令牌机制进行会话管理,登录成功后将令牌存储在本地存储中,实现自动登录功能。登录状态在整个应用中保持同步,导航栏会根据用户状态显示相应的菜单选项。安全性方面,系统限制了登录尝试次数,防止暴力破解攻击。
|
||||
|
||||
**[登录页面截图占位符]**
|
||||
|
||||
配件浏览页面是系统的核心功能页面,提供了丰富的配件展示和检索功能。页面顶部包含了配件类型的筛选导航,用户可以快速切换不同的配件类别。主要内容区域采用网格布局展示配件卡片,每个卡片包含配件图片、名称、品牌、价格和库存信息。配件卡片支持鼠标悬停效果,点击可以跳转到配件详情页面。页面右侧提供了高级筛选选项,包括品牌筛选、价格范围和库存状态筛选。搜索功能支持关键词搜索,可以匹配配件名称、品牌和型号。
|
||||
|
||||
**[配件浏览页面截图占位符]**
|
||||
|
||||
配件详情页面展示了单个配件的完整信息。页面左侧是配件的大图展示区域,支持图片放大查看功能。右侧是配件的详细信息,包括名称、品牌、型号、价格、库存状态和详细描述。规格参数表格清晰地列出了配件的技术参数,帮助用户做出购买决策。页面底部包含了添加购物车按钮,支持数量选择和库存检查。用户可以选择不同的购买数量,系统会实时验证库存可用性。
|
||||
|
||||
**[配件详情页面截图占位符]**
|
||||
|
||||
购物车页面实现了完整的购物车管理功能。页面主要内容区域以表格形式展示购物车中的所有商品,包括商品图片、名称、单价、数量和小计金额。每个商品行都提供了数量调整按钮和删除按钮,用户可以方便地修改购物车内容。页面右侧是订单摘要区域,显示商品总数、总金额和结算按钮。购物车支持批量操作,用户可以一键清空购物车或选择性删除商品。结算功能会跳转到订单确认页面,完成购买流程。
|
||||
|
||||
**[购物车页面截图占位符]**
|
||||
|
||||
装机方案定制页面是系统的特色功能,提供了交互式的电脑配置体验。页面分为六个配件类型区域,每个区域展示当前类型的推荐配件。用户可以从每种类型中选择一个配件,系统会实时更新配置清单和总价格。页面右侧是配置摘要区域,显示已选择的配件列表、兼容性检查结果和总价格。兼容性检查功能会自动验证配件间的匹配性,如CPU与主板的接口兼容性。用户确认配置后,可以一键将整套配置添加到购物车或直接提交订单。
|
||||
|
||||
**[装机方案页面截图占位符]**
|
||||
|
||||
订单管理页面为用户提供了完整的订单查询和管理功能。页面以列表形式展示用户的所有历史订单,每个订单条目包含订单号、创建时间、订单状态、总金额和操作按钮。订单状态通过不同颜色的标签进行区分,便于用户快速了解订单进度。页面支持订单状态筛选,用户可以查看特定状态的订单。订单详情功能支持展开查看具体的商品明细和订单流程。重新下单功能允许用户快速重复购买历史订单的商品。
|
||||
|
||||
**[订单管理页面截图占位符]**
|
||||
|
||||
个人中心页面提供了用户信息管理和账户统计功能。页面左侧是用户基本信息编辑区域,用户可以修改姓名、电话、地址等个人信息。右侧是账户统计区域,展示了订单数量、累计消费、待处理订单、已完成订单和购物车商品数量等关键指标。页面还包含密码修改功能,支持安全的密码更新操作。最近订单区域展示了用户的最新订单,方便快速查看订单状态。
|
||||
|
||||
**[个人中心页面截图占位符]**
|
||||
|
||||
管理员后台页面提供了强大的系统管理功能。仪表板页面展示了系统的关键统计信息,包括用户数量、订单数量、销售额趋势等。配件管理页面支持配件的增删改查操作,提供了批量导入和导出功能。用户管理页面展示了所有注册用户的信息,支持用户状态管理和权限设置。订单管理页面允许管理员查看和处理所有订单,支持订单状态的批量更新。
|
||||
|
||||
**[管理员后台截图占位符]**
|
||||
|
||||
数据统计页面是系统的高级功能,提供了丰富的商业分析图表。用户消费排行榜以柱状图形式展示了按消费金额排序的前十名用户,帮助识别高价值客户。商品销售排行榜展示了按销量排序的前十名配件,为库存管理提供数据支持。销售趋势图表展示了订单和销售额的时间变化趋势,帮助分析业务发展状况。图表采用了现代化的可视化库,支持交互式操作和数据导出功能。
|
||||
|
||||
**[数据统计页面截图占位符]**
|
||||
|
||||
系统的响应式设计确保了在不同设备上的良好用户体验。移动端页面针对触摸操作进行了优化,导航菜单采用了折叠式设计,表格数据支持横向滚动查看。页面加载性能经过了优化,采用了懒加载、代码分割和静态资源压缩等技术手段。用户交互体验流畅,表单验证实时反馈,操作响应及时,错误处理友好,为用户提供了专业级的购物体验。
|
||||
Loading…
x
Reference in New Issue
Block a user