Update application code and dependencies

This commit is contained in:
root
2026-05-06 17:22:50 +08:00
parent efc8f4bf78
commit a3ee04379d
60 changed files with 6793 additions and 860 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
.env*.local
npm-debug.log*
/data
*.db
+2
View File
@@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/app/generated/prisma
+34
View File
@@ -0,0 +1,34 @@
FROM node:20-alpine AS base
WORKDIR /app
# 阶段1: 安装所有依赖(包括 devDependencies,因为构建需要)
FROM base AS deps
COPY package*.json ./
RUN npm ci
# 阶段2: 构建应用
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# 阶段3: 生产运行环境
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# 复制 standalone 构建输出
COPY --from=builder /app/.next/standalone ./
# 复制静态文件
COPY --from=builder /app/.next/static ./.next/static
# 复制 prisma schema (用于数据库迁移)
COPY --from=builder /app/prisma ./prisma
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
+231 -20
View File
@@ -1,36 +1,247 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# NextApp - 门户系统使用说明
## Getting Started
基于 Next.js 15 + Prisma 6 + SQLite 构建的最小化门户网站。
First, run the development server:
## 环境要求
- Node.js 18.x 或更高版本
- npm 或 yarn
## 快速开始
### 1. 安装依赖
```bash
npm install
```
### 2. 数据库初始化
项目使用 SQLite 数据库,数据存储在 `dev.db` 文件中。
```bash
# 生成 Prisma 客户端
npx prisma generate
# 应用数据库迁移(创建表结构)
npx prisma migrate dev
# 种子数据(添加初始页面)
curl http://localhost:3000/api/seed
# 或在浏览器访问: http://localhost:3000/api/seed
```
### 3. 启动开发服务器
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
服务器启动后,访问 **http://localhost:3000**
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
### 4. 停止服务器
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
在运行服务器的终端按 `Ctrl + C`,或:
## Learn More
```bash
# 查找进程
ps aux | grep "next"
To learn more about Next.js, take a look at the following resources:
# 结束进程
kill -9 <PID>
# 或
pkill -f "next-server"
```
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## 常用命令
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
| 命令 | 说明 |
|------|------|
| `npm run dev` | 启动开发服务器(支持热重载)|
| `npm run build` | 构建生产版本 |
| `npm run start` | 启动生产服务器 |
| `npm run lint` | 代码检查 |
## Deploy on Vercel
## 数据库操作
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
### Prisma 命令
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
```bash
# 生成 Prisma 客户端
npx prisma generate
# 创建新迁移
npx prisma migrate dev --name <迁移名称>
# 重置数据库(清空所有数据)
npx prisma migrate reset
# 打开 Prisma Studio(数据库可视化工具)
npx prisma studio
```
### 数据库文件
- **位置**: `prisma/dev.db`
- **类型**: SQLite
- **工具**: 可使用 `npx prisma studio` 或 SQLite 客户端(如 DB Browser for SQLite)查看
## 项目结构
```
nextapp/
├── app/ # Next.js App Router
│ ├── api/ # API 路由
│ │ ├── pages/ # 页面相关 API
│ │ │ ├── route.ts # GET/POST 所有页面
│ │ │ └── [slug]/
│ │ │ └── route.ts # GET 单个页面
│ │ └── seed/
│ │ └── route.ts # 种子数据接口
│ ├── components/ # React 组件
│ │ └── TestButton.tsx # 测试按钮组件
│ ├── lib/
│ │ └── prisma.ts # Prisma 客户端实例
│ ├── globals.css # 全局样式
│ ├── layout.tsx # 根布局
│ └── page.tsx # 首页
├── prisma/
│ ├── schema.prisma # 数据模型定义
│ └── migrations/ # 数据库迁移文件
├── dev.db # SQLite 数据库(运行时生成)
├── .env # 环境变量
├── package.json
└── tsconfig.json
```
## API 端点
### 页面相关
| 方法 | 端点 | 说明 |
|------|------|------|
| GET | `/api/pages` | 获取所有页面 |
| POST | `/api/pages` | 创建新页面 |
| GET | `/api/pages/[slug]` | 获取指定页面 |
| GET | `/api/seed` | 初始化种子数据 |
### 示例
```bash
# 获取所有页面
curl http://localhost:3000/api/pages
# 创建新页面
curl -X POST http://localhost:3000/api/pages \
-H "Content-Type: application/json" \
-d '{"slug":"contact","title":"联系我们","content":"<h1>联系我们</h1><p>联系方式...</p>"}'
# 获取指定页面
curl http://localhost:3000/api/pages/home
```
## 数据模型
### Page(页面)
```prisma
model Page {
id Int @id @default(autoincrement())
slug String @unique # 页面标识(如:home, about
title String # 页面标题
content String # 页面内容(HTML)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
### Setting(设置)
```prisma
model Setting {
id Int @id @default(autoincrement())
key String @unique # 设置键名
value String # 设置值
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
## 功能说明
### 首页
- 访问 `/` 显示 slug 为 `home` 的页面内容
- 导航栏包含 Logo 和"测试按钮"
- 点击"测试按钮"弹出 "hello" 提示
### 测试按钮
- 位置:导航栏右侧
- 功能:点击弹出 alert 提示 "hello"
- 实现:`app/components/TestButton.tsx`(客户端组件)
## 生产部署
### 构建
```bash
npm run build
```
### 启动生产服务器
```bash
npm run start
```
生产服务器默认运行在 **http://localhost:3000**
## 注意事项
1. **数据库文件**`dev.db` 包含数据库,不要手动编辑
2. **环境变量**:数据库 URL 配置在 `.env` 文件中
3. **Prisma 客户端**:修改 `schema.prisma` 后需运行 `npx prisma generate`
4. **迁移**:修改数据模型后需创建新的迁移:`npx prisma migrate dev --name <描述>`
5. **热重载**:开发模式下代码修改会自动刷新浏览器
## 技术栈
- **框架**: Next.js 15 (App Router)
- **ORM**: Prisma 6
- **数据库**: SQLite
- **样式**: Tailwind CSS 4
- **语言**: TypeScript
## 故障排查
### 数据库不同步
```bash
npx prisma migrate reset
npx prisma migrate dev
```
### Prisma 客户端未生成
```bash
npx prisma generate
```
### 端口被占用
```bash
# 修改端口启动
npm run dev -- -p 3001
```
### 清除缓存重新构建
```bash
rm -rf .next node_modules/.cache
npm run dev
```
## 许可证
MIT
+196
View File
@@ -0,0 +1,196 @@
'use client'
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function AgentForm({
categories,
agent,
}: {
categories: { id: number; name: string }[]
agent?: {
id?: number
name: string
slug: string
description: string
icon?: string
categoryId?: number
features?: string
status?: string
}
}) {
const router = useRouter()
const [formData, setFormData] = useState({
name: agent?.name || "",
slug: agent?.slug || "",
description: agent?.description || "",
icon: agent?.icon || "",
categoryId: agent?.categoryId || "",
features: agent?.features || "",
status: agent?.status || "active",
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const url = agent?.id
? `/api/admin/agents/${agent.id}`
: "/api/admin/agents"
const method = agent?.id ? "PUT" : "POST"
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
})
if (res.ok) {
router.push("/admin/agents")
} else {
setError("保存失败")
}
} catch (err) {
setError("保存失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="智能客服助手"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Slug
</label>
<input
type="text"
required
value={formData.slug}
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="smart-customer-service"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
required
rows={4}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="描述智能体的功能和特点..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
(Emoji)
</label>
<input
type="text"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="🤖"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.categoryId}
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value=""></option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="active"></option>
<option value="maintenance"></option>
<option value="inactive"></option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
()
</label>
<input
type="text"
value={formData.features}
onChange={(e) => setFormData({ ...formData, features: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="智能问答, 知识库查询, 工单提交"
/>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? "保存中..." : "保存"}
</button>
<button
type="button"
onClick={() => window.history.back()}
className="border border-gray-300 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-50"
>
</button>
</div>
</form>
)
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { useRouter } from "next/navigation"
export default function DeleteButton({ id }: { id: number }) {
const router = useRouter()
const handleDelete = async () => {
if (!confirm("确定要删除这个智能体吗?")) {
return
}
try {
const res = await fetch(`/api/admin/agents/${id}`, {
method: "DELETE",
})
if (res.ok) {
router.refresh()
}
} catch (error) {
alert("删除失败")
}
}
return (
<button
onClick={handleDelete}
className="text-red-600 hover:text-red-700 text-sm"
>
</button>
)
}
+59
View File
@@ -0,0 +1,59 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import Link from "next/link"
import { prisma } from "@/app/lib/prisma"
import AgentForm from "../../AgentForm"
export default async function EditAgentPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const { id } = await params
const agent = await prisma.agent.findUnique({
where: { id: parseInt(id) },
})
if (!agent) {
redirect("/admin/agents")
}
const categories = await prisma.category.findMany()
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<Link href="/admin/agents" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
</div>
</nav>
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<AgentForm
categories={categories}
agent={{
id: agent.id,
name: agent.name,
slug: agent.slug,
description: agent.description,
icon: agent.icon || "",
categoryId: agent.categoryId || undefined,
features: agent.features,
status: agent.status,
}}
/>
</div>
</main>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import Link from "next/link"
import { prisma } from "@/app/lib/prisma"
import AgentForm from "../AgentForm"
export default async function NewAgentPage() {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const categories = await prisma.category.findMany()
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<Link href="/admin/agents" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
</div>
</nav>
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<AgentForm categories={categories} />
</div>
</main>
</div>
)
}
+99
View File
@@ -0,0 +1,99 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import { prisma } from "@//app/lib/prisma"
import Link from "next/link"
import DeleteButton from "./DeleteButton"
export default async function AdminAgentsPage() {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const agents = await prisma.agent.findMany({
include: { category: true },
orderBy: { createdAt: "desc" },
})
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<div className="flex items-center gap-4">
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
<Link href="/admin/agents/new" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700">
</Link>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">使</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{agents.map((agent) => (
<tr key={agent.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<span className="text-2xl">{agent.icon || "🤖"}</span>
<div>
<div className="font-medium text-gray-900">{agent.name}</div>
<div className="text-sm text-gray-500">{agent.slug}</div>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{agent.category?.name || "-"}
</td>
<td className="px-6 py-4">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
agent.status === "active"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-700"
}`}>
{agent.status === "active" ? "运行中" : agent.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{agent.usageCount}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/agents/${agent.id}/edit`}
className="text-blue-600 hover:text-blue-700 text-sm"
>
</Link>
<DeleteButton id={agent.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
{agents.length === 0 && (
<div className="text-center py-12 text-gray-500">
<Link href="/admin/agents/new" className="text-blue-600"></Link>
</div>
)}
</div>
</main>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
'use client'
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function CategoryForm({
category,
}: {
category?: {
id?: number
name: string
icon?: string
description?: string
}
}) {
const router = useRouter()
const [formData, setFormData] = useState({
name: category?.name || "",
icon: category?.icon || "",
description: category?.description || "",
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const url = category?.id
? `/api/admin/categories/${category.id}`
: "/api/admin/categories"
const method = category?.id ? "PUT" : "POST"
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
})
if (res.ok) {
router.push("/admin/categories")
} else {
setError("保存失败")
}
} catch (err) {
setError("保存失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="客服"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
(Emoji)
</label>
<input
type="text"
value={formData.icon}
onChange={(e) => setFormData({ ...formData, icon: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="🤖"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
rows={4}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="描述分类的用途和场景..."
/>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? "保存中..." : "保存"}
</button>
<button
type="button"
onClick={() => window.history.back()}
className="border border-gray-300 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-50"
>
</button>
</div>
</form>
)
}
@@ -0,0 +1,34 @@
'use client'
import { useRouter } from "next/navigation"
export default function DeleteCategoryButton({ id }: { id: number }) {
const router = useRouter()
const handleDelete = async () => {
if (!confirm("确定要删除这个分类吗?关联的智能体会变为无分类状态。")) {
return
}
try {
const res = await fetch(`/api/admin/categories/${id}`, {
method: "DELETE",
})
if (res.ok) {
router.refresh()
}
} catch (error) {
alert("删除失败")
}
}
return (
<button
onClick={handleDelete}
className="text-red-600 hover:text-red-700 text-sm"
>
</button>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import Link from "next/link"
import { prisma } from "@/app/lib/prisma"
import CategoryForm from "../../CategoryForm"
export default async function EditCategoryPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const { id } = await params
const category = await prisma.category.findUnique({
where: { id: parseInt(id) },
})
if (!category) {
redirect("/admin/categories")
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<Link href="/admin/categories" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
</div>
</nav>
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<CategoryForm
category={{
id: category.id,
name: category.name,
icon: category.icon || "",
description: category.description || "",
}}
/>
</div>
</main>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import Link from "next/link"
import CategoryForm from "../CategoryForm"
export default async function NewCategoryPage() {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<Link href="/admin/categories" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
</div>
</nav>
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<CategoryForm />
</div>
</main>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import { prisma } from "@//app/lib/prisma"
import Link from "next/link"
import DeleteCategoryButton from "./DeleteCategoryButton"
export default async function AdminCategoriesPage() {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const categories = await prisma.category.findMany({
include: { _count: { select: { agents: true } } },
orderBy: { createdAt: "desc" },
})
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<div className="flex items-center gap-4">
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
<Link href="/admin/categories/new" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700">
</Link>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{categories.map((cat) => (
<tr key={cat.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="font-medium text-gray-900">{cat.name}</div>
<div className="text-sm text-gray-500">{cat.description}</div>
</td>
<td className="px-6 py-4 text-2xl">
{cat.icon || "-"}
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{cat._count.agents}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/categories/${cat.id}/edit`}
className="text-blue-600 hover:text-blue-700 text-sm"
>
</Link>
<DeleteCategoryButton id={cat.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
{categories.length === 0 && (
<div className="text-center py-12 text-gray-500">
<Link href="/admin/categories/new" className="text-blue-600"></Link>
</div>
)}
</div>
</main>
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
'use client'
import { signIn } from "next-auth/react"
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function LoginPage() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const result = await signIn("credentials", {
username,
password,
redirect: false,
})
if (result?.error) {
setError("用户名或密码错误")
} else {
router.push("/admin")
}
} catch (err) {
setError("登录失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-2xl shadow-lg">
<div className="text-center">
<div className="w-16 h-16 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<span className="text-white font-bold text-2xl">A</span>
</div>
<h2 className="text-3xl font-bold text-gray-900"></h2>
<p className="mt-2 text-gray-600">广</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
id="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="请输入用户名"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="请输入密码"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition disabled:bg-blue-400"
>
{loading ? "登录中..." : "登录"}
</button>
</form>
<div className="text-center text-sm text-gray-500">
默认账号: admin / admin123
</div>
</div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { useRouter } from "next/navigation"
export default function DeleteNewsButton({ id }: { id: number }) {
const router = useRouter()
const handleDelete = async () => {
if (!confirm("确定要删除这条新闻吗?")) {
return
}
try {
const res = await fetch(`/api/admin/news/${id}`, {
method: "DELETE",
})
if (res.ok) {
router.refresh()
}
} catch (error) {
alert("删除失败")
}
}
return (
<button
onClick={handleDelete}
className="text-red-600 hover:text-red-700 text-sm"
>
</button>
)
}
+142
View File
@@ -0,0 +1,142 @@
'use client'
import { useState } from "react"
import { useRouter } from "next/navigation"
export default function NewsForm({
news,
}: {
news?: {
id?: number
title: string
content: string
type?: string
published?: boolean
}
}) {
const router = useRouter()
const [formData, setFormData] = useState({
title: news?.title || "",
content: news?.content || "",
type: news?.type || "news",
published: news?.published || false,
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const url = news?.id
? `/api/admin/news/${news.id}`
: "/api/admin/news"
const method = news?.id ? "PUT" : "POST"
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
})
if (res.ok) {
router.push("/admin/news")
} else {
setError("保存失败")
}
} catch (err) {
setError("保存失败,请稍后重试")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="输入新闻标题"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<textarea
required
rows={6}
value={formData.content}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="输入新闻内容..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="news"></option>
<option value="industry"></option>
<option value="cooperation"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
</label>
<select
value={formData.published ? "true" : "false"}
onChange={(e) => setFormData({ ...formData, published: e.target.value === "true" })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="true"></option>
<option value="false">稿</option>
</select>
</div>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:bg-blue-400"
>
{loading ? "保存中..." : "保存"}
</button>
<button
type="button"
onClick={() => window.history.back()}
className="border border-gray-300 text-gray-700 px-6 py-2 rounded-lg hover:bg-gray-50"
>
</button>
</div>
</form>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import Link from "next/link"
import { prisma } from "@/app/lib/prisma"
import NewsForm from "../../NewsForm"
export default async function EditNewsPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const { id } = await params
const news = await prisma.news.findUnique({
where: { id: parseInt(id) },
})
if (!news) {
redirect("/admin/news")
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<Link href="/admin/news" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
</div>
</nav>
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<NewsForm
news={{
id: news.id,
title: news.title,
content: news.content,
type: news.type,
published: news.published,
}}
/>
</div>
</main>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import Link from "next/link"
import NewsForm from "../NewsForm"
export default async function NewNewsPage() {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<Link href="/admin/news" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
</div>
</nav>
<main className="max-w-3xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<NewsForm />
</div>
</main>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import { prisma } from "@//app/lib/prisma"
import Link from "next/link"
import DeleteNewsButton from "./DeleteNewsButton"
export default async function AdminNewsPage() {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const news = await prisma.news.findMany({
orderBy: { createdAt: "desc" },
})
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<div className="flex items-center gap-4">
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900">
</Link>
<Link href="/admin/news/new" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700">
</Link>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase"></th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{news.map((item) => (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="font-medium text-gray-900">{item.title}</div>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{item.type}
</td>
<td className="px-6 py-4">
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
item.published
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}>
{item.published ? "已发布" : "草稿"}
</span>
</td>
<td className="px-6 py-4 text-sm text-gray-600">
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<Link
href={`/admin/news/${item.id}/edit`}
className="text-blue-600 hover:text-blue-700 text-sm"
>
</Link>
<DeleteNewsButton id={item.id} />
</div>
</td>
</tr>
))}
</tbody>
</table>
{news.length === 0 && (
<div className="text-center py-12 text-gray-500">
<Link href="/admin/news/new" className="text-blue-600"></Link>
</div>
)}
</div>
</main>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { getServerSession } from "next-auth/next"
import { redirect } from "next/navigation"
import Link from "next/link"
import { prisma } from "@//app/lib/prisma"
export default async function AdminDashboard() {
const session = await getServerSession()
if (!session) {
redirect("/admin/login")
}
const agentsCount = await prisma.agent.count()
const newsCount = await prisma.news.count()
const categoriesCount = await prisma.category.count()
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-gray-900"></h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user?.name}</span>
<Link
href="/api/auth/signout"
className="text-sm text-red-600 hover:text-red-700"
>
退
</Link>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center text-2xl">
🤖
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold text-gray-900">{agentsCount}</p>
</div>
</div>
<Link href="/admin/agents" className="text-blue-600 text-sm hover:underline">
</Link>
</div>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-2xl">
📰
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold text-gray-900">{newsCount}</p>
</div>
</div>
<Link href="/admin/news" className="text-blue-600 text-sm hover:underline">
</Link>
</div>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center text-2xl">
📂
</div>
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold text-gray-900">{categoriesCount}</p>
</div>
</div>
<Link href="/admin/categories" className="text-blue-600 text-sm hover:underline">
</Link>
</div>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4"></h2>
<div className="flex flex-wrap gap-4">
<Link
href="/admin/agents/new"
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
</Link>
<Link
href="/admin/news/new"
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
</Link>
<Link
href="/admin/categories/new"
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
</Link>
<Link
href="/"
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition"
>
</Link>
</div>
</div>
</main>
</div>
)
}
+602
View File
@@ -0,0 +1,602 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
}
interface Conversation {
id: number
title: string
updatedAt: string
messages: Message[]
}
interface Agent {
id: string
name: string
slug: string
icon: string | null
category: {
name: string
} | null
}
export default function AgentChatPage() {
const params = useParams()
const router = useRouter()
const slug = params.slug as string
const [agent, setAgent] = useState<Agent | null>(null)
const [loading, setLoading] = useState(true)
const [messages, setMessages] = useState<Message[]>([])
const [input, setInput] = useState('')
const [sending, setSending] = useState(false)
const [conversations, setConversations] = useState<Conversation[]>([])
const [currentConversationId, setCurrentConversationId] = useState<number | null>(null)
const [upstreamConversationId, setUpstreamConversationId] = useState<string>('')
const [loadingConversations, setLoadingConversations] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const CHAT_API_URL = '/api/chat'
useEffect(() => {
fetch(`/api/agents/${slug}`)
.then(res => {
if (!res.ok) throw new Error('Not found')
return res.json()
})
.then(data => setAgent(data))
.catch(() => router.push('/404'))
.finally(() => setLoading(false))
}, [slug, router])
useEffect(() => {
if (agent) {
loadConversations()
}
}, [agent])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const loadConversations = async () => {
if (!agent) return
setLoadingConversations(true)
try {
const res = await fetch(`/api/conversations?agentId=${agent.id}`)
const data = await res.json()
setConversations(data)
} catch (error) {
console.error('Failed to load conversations:', error)
} finally {
setLoadingConversations(false)
}
}
const loadConversation = async (conversationId: number) => {
try {
const res = await fetch(`/api/conversations/${conversationId}`)
const data = await res.json()
setCurrentConversationId(conversationId)
setMessages(data.messages.map((msg: any) => ({
id: msg.id.toString(),
role: msg.role as 'user' | 'assistant',
content: msg.content,
timestamp: new Date(msg.timestamp),
})))
} catch (error) {
console.error('Failed to load conversation:', error)
}
}
const createNewConversation = async () => {
if (!agent) return
try {
const res = await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: agent.id,
title: '新对话',
}),
})
const newConversation = await res.json()
setConversations(prev => [newConversation, ...prev])
setCurrentConversationId(newConversation.id)
setMessages([])
} catch (error) {
console.error('Failed to create conversation:', error)
}
}
const deleteConversation = async (conversationId: number, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm('确定要删除这个对话吗?')) return
try {
await fetch(`/api/conversations/${conversationId}`, {
method: 'DELETE',
})
setConversations(prev => prev.filter(c => c.id !== conversationId))
if (currentConversationId === conversationId) {
setCurrentConversationId(null)
setMessages([])
}
} catch (error) {
console.error('Failed to delete conversation:', error)
}
}
const sendMessage = async () => {
if (!input.trim() || !agent) return
let conversationId = currentConversationId
if (!conversationId) {
try {
const res = await fetch('/api/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
agentId: agent.id,
title: input.slice(0, 20) || '新对话',
}),
})
const newConversation = await res.json()
conversationId = newConversation.id
setCurrentConversationId(conversationId)
setConversations(prev => [newConversation, ...prev])
} catch (error) {
console.error('Failed to create conversation:', error)
return
}
}
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date(),
}
const currentInput = input
setMessages(prev => [...prev, userMessage])
setInput('')
setSending(true)
try {
await fetch(`/api/conversations/${conversationId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: 'user',
content: currentInput,
}),
})
const assistantMessageId = (Date.now() + 1).toString()
let assistantContent = ''
let upstreamConvId = upstreamConversationId
setMessages(prev => [...prev, {
id: assistantMessageId,
role: 'assistant',
content: '',
timestamp: new Date(),
}])
const response = await fetch(CHAT_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
inputs: {},
query: currentInput,
response_mode: 'streaming',
conversation_id: upstreamConversationId,
user: 'user-' + Date.now(),
}),
})
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (reader) {
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const json = JSON.parse(line.slice(6))
if (json.conversation_id) {
upstreamConvId = json.conversation_id
}
const token = json.answer || json.message || ''
if (token) {
assistantContent += token
setMessages(prev => prev.map(msg =>
msg.id === assistantMessageId
? { ...msg, content: assistantContent }
: msg
))
}
} catch (e) {
// ignore parse errors
}
}
}
}
}
setUpstreamConversationId(upstreamConvId)
if (!assistantContent) {
setMessages(prev => prev.map(msg =>
msg.id === assistantMessageId
? { ...msg, content: '抱歉,我暂时无法回答这个问题。' }
: msg
))
assistantContent = '抱歉,我暂时无法回答这个问题。'
}
await fetch(`/api/conversations/${conversationId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: 'assistant',
content: assistantContent,
}),
})
} catch (error) {
const errorMessage: Message = {
id: (Date.now() + 2).toString(),
role: 'assistant',
content: '抱歉,服务暂时不可用,请稍后再试。',
timestamp: new Date(),
}
setMessages(prev => [...prev, errorMessage])
} finally {
setSending(false)
}
}
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}
const handleQuickQuestion = (question: string) => {
setInput(question)
}
const suggestedQuestions = [
'养老政策有哪些最新变化?',
'如何申请养老服务补贴?',
'老年人健康管理需要注意什么?',
'养老机构如何选择?',
]
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500">...</div>
</div>
)
}
if (!agent) {
return null
}
return (
<div className="min-h-screen bg-gray-50">
{/* 导航栏 */}
<nav className="bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg">
<div className="max-w-screen-2xl mx-auto px-4 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
<span className="text-white font-bold text-lg">AI</span>
</div>
<div>
<h1 className="text-xl font-bold">广</h1>
<p className="text-xs text-blue-100">24</p>
</div>
</Link>
<div className="flex items-center gap-6">
<Link href="/" className="text-white/80 hover:text-white transition"></Link>
<Link href="/agents" className="text-white/80 hover:text-white transition">广</Link>
<Link href="/news" className="text-white/80 hover:text-white transition"></Link>
</div>
</div>
</div>
</nav>
<main className="max-w-screen-2xl mx-auto px-6 py-6">
<div className="flex gap-4 h-[calc(100vh-200px)]">
{/* 左侧历史问答 */}
<div className="w-1/6 bg-white rounded-2xl border border-gray-200 p-4 overflow-hidden flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<i className="fas fa-history text-blue-600 text-sm"></i>
</div>
<h3 className="font-medium text-gray-900"></h3>
</div>
<button
onClick={createNewConversation}
className="text-blue-600 text-sm flex items-center gap-1 hover:text-blue-700 transition"
>
<i className="fas fa-plus-circle"></i>
<span></span>
</button>
</div>
<div className="flex-1 overflow-y-auto space-y-2">
{loadingConversations ? (
<div className="text-center text-gray-400 text-sm py-4">...</div>
) : conversations.length === 0 ? (
<div className="text-center text-gray-400 text-sm py-4"></div>
) : (
conversations.map((conv) => (
<div
key={conv.id}
onClick={() => loadConversation(conv.id)}
className={`p-3 rounded-lg cursor-pointer group relative ${
currentConversationId === conv.id
? 'bg-blue-50 border border-blue-200'
: 'bg-white border border-gray-200 hover:bg-gray-50'
}`}
>
<p className="text-sm text-gray-900 font-medium truncate pr-6">{conv.title}</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(conv.updatedAt).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<button
onClick={(e) => deleteConversation(conv.id, e)}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity text-red-500 hover:text-red-700"
title="删除对话"
>
<i className="fas fa-trash-alt text-xs"></i>
</button>
</div>
))
)}
</div>
</div>
{/* 中间聊天区域 */}
<div className="w-1/2 flex flex-col">
<div className="flex flex-col h-full bg-white rounded-2xl shadow-lg overflow-hidden">
{/* 聊天头部 */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center">
<i className="fas fa-heartbeat text-white text-xl"></i>
</div>
<div>
<h3 className="font-semibold"></h3>
<p className="text-xs text-blue-100">
{sending ? '正在回复...' : '24小时为您服务'}
</p>
</div>
</div>
<span className="bg-white/20 px-3 py-1 rounded text-xs"></span>
</div>
{/* 消息区域 */}
<div className="flex-1 overflow-y-auto p-6 space-y-6 bg-gray-50">
{messages.length === 0 && !sending && (
<div className="text-center text-gray-400 mt-8">
<i className="fas fa-comments text-4xl mb-4"></i>
<p></p>
</div>
)}
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === 'user' ? 'justify-end' : ''}`}
>
{message.role === 'assistant' && (
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<i className="fas fa-robot text-blue-600"></i>
</div>
)}
<div className={`max-w-[80%]`}>
<div
className={`p-4 rounded-2xl shadow-sm ${
message.role === 'user'
? 'bg-blue-600 text-white rounded-tr-none'
: 'bg-white border border-gray-200 rounded-tl-none'
}`}
>
{message.role === 'user' ? (
<p className="text-sm">{message.content}</p>
) : (
<div className="text-sm prose prose-sm max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.content}</ReactMarkdown>
</div>
)}
</div>
<p className={`text-xs mt-1 ${message.role === 'user' ? 'text-right text-blue-400' : 'text-gray-400'}`}>
{message.timestamp.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
{message.role === 'user' && (
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center flex-shrink-0">
<i className="fas fa-user text-gray-600"></i>
</div>
)}
</div>
))}
{sending && (
<div className="flex gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<i className="fas fa-robot text-blue-600"></i>
</div>
<div className="bg-white border border-gray-200 p-4 rounded-2xl rounded-tl-none shadow-sm">
<div className="flex gap-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="border-t border-gray-200 p-4 bg-white">
<div className="flex items-center gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="请输入您的问题,例如:养老政策、健康咨询..."
className="flex-1 border border-gray-300 rounded-l-lg px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
disabled={sending}
/>
<button
className="bg-gray-100 hover:bg-gray-200 px-3 py-2 border-t border-r border-b border-gray-300 transition"
disabled={sending}
title="语音输入"
>
<i className="fas fa-microphone text-gray-500"></i>
</button>
<button
className="bg-gray-100 hover:bg-gray-200 px-3 py-2 border-t border-r border-b border-gray-300 transition"
disabled={sending}
title="附件上传"
>
<i className="fas fa-paperclip text-gray-500"></i>
</button>
<button
onClick={sendMessage}
disabled={sending || !input.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-r-lg hover:bg-blue-700 transition disabled:bg-blue-400 disabled:cursor-not-allowed"
>
<i className="fas fa-paper-plane"></i>
</button>
</div>
</div>
</div>
</div>
{/* 右侧服务区域 */}
<div className="w-1/3 bg-white rounded-2xl border border-gray-200 p-4 overflow-y-auto">
{/* 自助服务 */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<h3 className="font-medium text-gray-900 mb-3 flex items-center gap-2">
<i className="fas fa-cog text-blue-600"></i>
</h3>
<div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 border border-gray-200 rounded-lg hover:bg-blue-50 transition cursor-pointer">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-2">
<i className="fas fa-comments text-blue-600 text-xl"></i>
</div>
<p className="text-sm text-gray-700"></p>
</div>
<div className="text-center p-3 border border-gray-200 rounded-lg hover:bg-blue-50 transition cursor-pointer">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-2">
<i className="fas fa-phone-alt text-blue-600 text-xl"></i>
</div>
<p className="text-sm text-gray-700"></p>
</div>
<div className="text-center p-3 border border-gray-200 rounded-lg hover:bg-blue-50 transition cursor-pointer">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center mx-auto mb-2">
<i className="fas fa-file-alt text-blue-600 text-xl"></i>
</div>
<p className="text-sm text-gray-700"></p>
</div>
</div>
</div>
{/* 热点问题 */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<div className="flex justify-between items-center mb-3">
<h3 className="font-medium text-gray-900 flex items-center gap-2">
<i className="fas fa-fire text-red-500"></i>
</h3>
<a href="#" className="text-xs text-blue-600 hover:text-blue-700 transition">
<i className="fas fa-angle-right"></i>
</a>
</div>
<ul className="space-y-2">
{suggestedQuestions.map((question, index) => (
<li key={index}>
<button
onClick={() => handleQuickQuestion(question)}
className="text-sm text-gray-700 hover:text-blue-600 transition block py-1 text-left w-full"
>
{question}
</button>
</li>
))}
</ul>
</div>
{/* 快捷提问 */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-medium text-gray-900 mb-3 flex items-center gap-2">
<i className="fas fa-question-circle text-blue-600"></i>
</h3>
<div className="border-b border-gray-200 mb-3">
<div className="flex gap-4 text-sm">
<button className="pb-2 border-b-2 border-blue-600 text-blue-600 font-medium">
</button>
<button className="pb-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700 transition">
</button>
<button className="pb-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700 transition">
</button>
</div>
</div>
<ul className="space-y-1">
{['养老政策咨询', '养老机构推荐', '养老服务申请'].map((question, index) => (
<li key={index}>
<button
onClick={() => handleQuickQuestion(question)}
className="block py-2 px-3 text-sm text-gray-700 hover:bg-blue-50 rounded transition w-full text-left"
>
{question}
</button>
</li>
))}
</ul>
</div>
</div>
</div>
</main>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { prisma } from "@/app/lib/prisma"
import { notFound } from "next/navigation"
import Link from "next/link"
export default async function AgentDetailPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const agent = await prisma.agent.findUnique({
where: { slug },
include: { category: true },
})
if (!agent) {
notFound()
}
return (
<div className="min-h-screen bg-gray-50">
{/* 导航栏 */}
<nav className="bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<span className="font-bold text-gray-900">广</span>
</Link>
<div className="flex items-center gap-6">
<Link href="/" className="text-gray-600 hover:text-blue-600 transition"></Link>
<Link href="/agents" className="text-blue-600 font-medium">广</Link>
<Link href="/news" className="text-gray-600 hover:text-blue-600 transition"></Link>
</div>
</div>
</div>
</nav>
<main className="max-w-6xl mx-auto px-6 py-12">
<Link href="/agents" className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-blue-600 mb-8">
</Link>
<div className="bg-white rounded-2xl p-8 border border-gray-200">
<div className="flex items-start gap-6 mb-8">
<div className="w-20 h-20 bg-gradient-to-br from-blue-500 to-blue-600 rounded-2xl flex items-center justify-center text-4xl flex-shrink-0">
{agent.icon || "🤖"}
</div>
<div className="flex-1">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{agent.name}</h1>
{agent.category && (
<span className="inline-block px-3 py-1 bg-blue-50 text-blue-600 text-sm font-medium rounded-full">
{agent.category.name}
</span>
)}
</div>
</div>
<div className="prose max-w-none mb-8">
<p className="text-gray-600 leading-relaxed">{agent.description}</p>
</div>
{agent.features && (
<div className="mb-8">
<h3 className="text-lg font-semibold text-gray-900 mb-4"></h3>
<div className="flex flex-wrap gap-2">
{agent.features.split(",").map((feature, index) => (
<span key={index} className="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full">
{feature.trim()}
</span>
))}
</div>
</div>
)}
<div className="flex items-center gap-4 pt-6 border-t border-gray-200">
<Link
href={`/agents/${agent.slug}/chat`}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-medium"
>
使
</Link>
<div className="text-sm text-gray-500">
使: <span className="font-semibold text-gray-900">{agent.usageCount}</span>
</div>
<div className="text-sm text-gray-500">
: <span className={`font-medium ${agent.status === "active" ? "text-green-600" : "text-gray-600"}`}>
{agent.status === "active" ? "运行中" : agent.status}
</span>
</div>
</div>
</div>
</main>
</div>
)
}
+329
View File
@@ -0,0 +1,329 @@
import { prisma } from "@/app/lib/prisma"
import Link from "next/link"
function buildQueryString(params: Record<string, string>) {
const sp = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
if (value) sp.append(key, value)
})
const qs = sp.toString()
return qs ? `?${qs}` : ""
}
export default async function AgentsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string; q?: string; sort?: string; page?: string }>
}) {
const params = await searchParams
const { category, q, sort, page } = await searchParams
const categoryId = category ? parseInt(category) : undefined
const searchQuery = q || ""
const sortBy = sort || ""
const currentPage = page ? parseInt(page) : 1
const pageSize = 9
const where: any = {}
if (categoryId) where.categoryId = categoryId
if (searchQuery) {
where.OR = [
{ name: { contains: searchQuery } },
{ description: { contains: searchQuery } },
]
}
let orderBy: any = { createdAt: "desc" }
if (sortBy === "popular") orderBy = { usageCount: "desc" }
const totalAgents = await prisma.agent.count({ where })
const totalPages = Math.ceil(totalAgents / pageSize)
const agents = await prisma.agent.findMany({
where,
include: { category: true },
orderBy,
skip: (currentPage - 1) * pageSize,
take: pageSize,
})
const popularAgents = await prisma.agent.findMany({
include: { category: true },
orderBy: { usageCount: "desc" },
take: 6,
})
const categories = await prisma.category.findMany({
include: { _count: { select: { agents: true } } },
})
const totalCategories = await prisma.category.count()
const getPageUrl = (page: number) => {
const params: Record<string, string> = {}
if (categoryId) params.category = categoryId.toString()
if (searchQuery) params.q = searchQuery
if (sortBy) params.sort = sortBy
if (page > 1) params.page = page.toString()
return `/agents${buildQueryString(params)}`
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<span className="font-bold text-gray-900">广</span>
</Link>
<div className="flex items-center gap-6">
<Link href="/" className="text-gray-600 hover:text-blue-600 transition"></Link>
<Link href="/agents" className="text-blue-600 font-medium">广</Link>
<Link href="/news" className="text-gray-600 hover:text-blue-600 transition"></Link>
<Link href="/admin/login" className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition">
</Link>
</div>
</div>
</div>
</nav>
<main className="pt-16">
<div className="max-w-6xl mx-auto px-6 py-8">
{/* 面包屑 */}
<div className="flex items-center gap-2 mb-6 text-sm">
<Link href="/" className="text-gray-500 hover:text-blue-600 transition"></Link>
<span className="text-gray-400">/</span>
<span className="text-gray-900 font-medium">广</span>
</div>
{/* 页面标题 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">广</h1>
<p className="text-gray-500"> AI </p>
</div>
{/* 统计信息 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-2xl p-5 border border-gray-200 shadow-sm">
<div className="text-2xl font-bold text-blue-600 mb-1">{totalAgents}</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="bg-white rounded-2xl p-5 border border-gray-200 shadow-sm">
<div className="text-2xl font-bold text-green-600 mb-1">{totalCategories}</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="bg-white rounded-2xl p-5 border border-gray-200 shadow-sm">
<div className="text-2xl font-bold text-purple-600 mb-1">5000+</div>
<div className="text-sm text-gray-500"></div>
</div>
<div className="bg-white rounded-2xl p-5 border border-gray-200 shadow-sm">
<div className="text-2xl font-bold text-orange-600 mb-1">98%</div>
<div className="text-sm text-gray-500"></div>
</div>
</div>
{/* 搜索与筛选 */}
<div className="bg-white rounded-2xl border border-gray-200 p-6 mb-8 shadow-sm">
<h3 className="font-semibold text-gray-900 mb-4"></h3>
<form className="flex flex-wrap gap-4 items-center">
<div className="flex-1 min-w-64 relative">
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
name="q"
type="text"
defaultValue={searchQuery}
placeholder="搜索智能体名称或简介..."
className="w-full pl-12 pr-4 py-3 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition"
/>
</div>
<select
name="category"
defaultValue={categoryId || ""}
className="px-4 py-3 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value=""></option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<select
name="sort"
defaultValue={sortBy}
className="px-4 py-3 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white"
>
<option value=""></option>
<option value="date"></option>
<option value="popular"></option>
</select>
<button type="submit" className="px-6 py-3 bg-blue-600 text-white rounded-xl text-sm hover:bg-blue-700 transition">
</button>
<Link href="/agents" className="px-4 py-3 bg-gray-100 text-gray-600 rounded-xl text-sm hover:bg-gray-200 transition">
</Link>
</form>
<div className="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-100">
<span className="text-xs text-gray-400 self-center mr-2"></span>
<Link href="/agents" className={`px-3 py-1.5 rounded-full text-xs border border-gray-200 bg-white text-gray-600 hover:border-blue-400 hover:text-blue-600 transition-all ${!categoryId ? "bg-blue-50 border-blue-400 text-blue-600" : ""}`}>
</Link>
{categories.map((cat) => (
<Link
key={cat.id}
href={`/agents?category=${cat.id}`}
className={`px-3 py-1.5 rounded-full text-xs border border-gray-200 bg-white text-gray-600 hover:border-blue-400 hover:text-blue-600 transition-all ${categoryId === cat.id ? "bg-blue-50 border-blue-400 text-blue-600" : ""}`}
>
{cat.name}
</Link>
))}
</div>
</div>
{/* 热门推荐 */}
{popularAgents.length > 0 && (
<div className="mb-8">
<h2 className="text-xl font-bold text-gray-900 mb-4"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{popularAgents.map((agent) => (
<Link
key={agent.id}
href={`/agents/${agent.slug}`}
className="bg-white rounded-2xl border border-gray-200 p-5 hover:border-blue-300 shadow-sm hover:shadow-md transition-all duration-300"
>
<div className="flex items-center gap-4 mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center text-3xl flex-shrink-0">
{agent.icon || "🤖"}
</div>
<div className="min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{agent.name}</h3>
{agent.category && (
<span className="inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600">
{agent.category.name}
</span>
)}
</div>
</div>
<p className="text-gray-500 text-sm leading-relaxed mb-4">{agent.description}</p>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>使 {agent.usageCount} </span>
<span className="text-blue-600 font-medium"> </span>
</div>
</Link>
))}
</div>
</div>
)}
{/* 智能体列表头部 */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900"></h2>
<span className="text-sm text-gray-500"> {totalAgents} </span>
</div>
{/* 智能体列表 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{agents.map((agent) => (
<Link
key={agent.id}
href={`/agents/${agent.slug}`}
className="bg-white rounded-2xl border border-gray-200 p-5 hover:border-blue-300 shadow-sm hover:shadow-md transition-all duration-300"
>
<div className="flex items-center gap-4 mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center text-3xl flex-shrink-0">
{agent.icon || "🤖"}
</div>
<div className="min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{agent.name}</h3>
{agent.category && (
<span className="inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600">
{agent.category.name}
</span>
)}
</div>
</div>
<p className="text-gray-500 text-sm leading-relaxed mb-4">{agent.description}</p>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>使 {agent.usageCount} </span>
<span className={`font-medium ${agent.status === "active" ? "text-green-600" : "text-gray-600"}`}>
{agent.status === "active" ? "运行中" : agent.status}
</span>
</div>
</Link>
))}
</div>
{/* 空状态 */}
{agents.length === 0 && (
<div className="text-center py-24 bg-gray-50 rounded-2xl">
<div className="text-7xl mb-6">🔍</div>
<h3 className="text-xl font-medium text-gray-700 mb-3"></h3>
<p className="text-gray-400 mb-6 max-w-md mx-auto"></p>
<Link href="/agents" className="inline-block px-6 py-3 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 transition">
</Link>
</div>
)}
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-8 px-2">
<span className="text-sm text-gray-500">
{currentPage} {totalPages}
</span>
<div className="flex items-center gap-2">
<Link
href={getPageUrl(currentPage - 1)}
className={`px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition ${currentPage <= 1 ? "opacity-40 cursor-not-allowed" : ""}`}
>
</Link>
<div className="flex items-center gap-1">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((pageNum) => (
<Link
key={pageNum}
href={getPageUrl(pageNum)}
className={`w-8 h-8 flex items-center justify-center text-sm rounded-lg transition ${currentPage === pageNum ? "bg-blue-600 text-white" : "hover:bg-gray-50 text-gray-600"}`}
>
{pageNum}
</Link>
))}
</div>
<Link
href={getPageUrl(currentPage + 1)}
className={`px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition ${currentPage >= totalPages ? "opacity-40 cursor-not-allowed" : ""}`}
>
</Link>
</div>
</div>
)}
</div>
</main>
{/* 底部 */}
<div className="border-t border-gray-200 py-8 mt-16">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-xs">AI</span>
</div>
<span className="font-semibold text-gray-700 text-sm"></span>
</div>
<div className="text-gray-400 text-sm text-center">
© 2026 · AI 广
</div>
<div className="flex gap-4 text-xs text-gray-400">
<a className="hover:text-gray-600 cursor-pointer transition"></a>
<a className="hover:text-gray-600 cursor-pointer transition"></a>
<a className="hover:text-gray-600 cursor-pointer transition"></a>
</div>
</div>
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@//app/lib/prisma"
// 更新智能体
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const data = await request.json()
const agent = await prisma.agent.update({
where: { id: parseInt(id) },
data: {
name: data.name,
slug: data.slug,
description: data.description,
icon: data.icon || null,
categoryId: data.categoryId ? parseInt(data.categoryId) : null,
features: data.features || "",
status: data.status || "active",
},
})
return NextResponse.json(agent)
} catch (error) {
return NextResponse.json({ error: "更新失败" }, { status: 500 })
}
}
// 删除智能体
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await prisma.agent.delete({
where: { id: parseInt(id) },
})
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json({ error: "删除失败" }, { status: 500 })
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@//app/lib/prisma"
// 创建智能体
export async function POST(request: NextRequest) {
try {
const data = await request.json()
const agent = await prisma.agent.create({
data: {
name: data.name,
slug: data.slug,
description: data.description,
icon: data.icon || null,
categoryId: data.categoryId ? parseInt(data.categoryId) : null,
features: data.features || "",
status: data.status || "active",
},
})
return NextResponse.json(agent)
} catch (error) {
return NextResponse.json({ error: "创建失败" }, { status: 500 })
}
}
// 获取所有智能体
export async function GET() {
try {
const agents = await prisma.agent.findMany({
include: { category: true },
})
return NextResponse.json(agents)
} catch (error) {
return NextResponse.json({ error: "获取失败" }, { status: 500 })
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@//app/lib/prisma"
// 更新分类
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const data = await request.json()
const category = await prisma.category.update({
where: { id: parseInt(id) },
data: {
name: data.name,
icon: data.icon || null,
description: data.description || null,
},
})
return NextResponse.json(category)
} catch (error) {
return NextResponse.json({ error: "更新失败" }, { status: 500 })
}
}
// 删除分类
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await prisma.category.delete({
where: { id: parseInt(id) },
})
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json({ error: "删除失败" }, { status: 500 })
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@//app/lib/prisma"
// 创建分类
export async function POST(request: NextRequest) {
try {
const data = await request.json()
const category = await prisma.category.create({
data: {
name: data.name,
icon: data.icon || null,
description: data.description || null,
},
})
return NextResponse.json(category)
} catch (error) {
return NextResponse.json({ error: "创建失败" }, { status: 500 })
}
}
// 获取所有分类
export async function GET() {
try {
const categories = await prisma.category.findMany({
include: { _count: { select: { agents: true } } },
})
return NextResponse.json(categories)
} catch (error) {
return NextResponse.json({ error: "获取失败" }, { status: 500 })
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@//app/lib/prisma"
// 更新新闻
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const data = await request.json()
const news = await prisma.news.update({
where: { id: parseInt(id) },
data: {
title: data.title,
content: data.content,
type: data.type || "news",
published: data.published || false,
},
})
return NextResponse.json(news)
} catch (error) {
return NextResponse.json({ error: "更新失败" }, { status: 500 })
}
}
// 删除新闻
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await prisma.news.delete({
where: { id: parseInt(id) },
})
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json({ error: "删除失败" }, { status: 500 })
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server"
import { prisma } from "@//app/lib/prisma"
// 创建新闻
export async function POST(request: NextRequest) {
try {
const data = await request.json()
const news = await prisma.news.create({
data: {
title: data.title,
content: data.content,
type: data.type || "news",
published: data.published || false,
},
})
return NextResponse.json(news)
} catch (error) {
return NextResponse.json({ error: "创建失败" }, { status: 500 })
}
}
// 获取所有新闻
export async function GET() {
try {
const news = await prisma.news.findMany({
orderBy: { createdAt: "desc" },
})
return NextResponse.json(news)
} catch (error) {
return NextResponse.json({ error: "获取失败" }, { status: 500 })
}
}
+19
View File
@@ -0,0 +1,19 @@
import { prisma } from "@/app/lib/prisma"
import { NextResponse } from "next/server"
export async function GET(
request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params
const agent = await prisma.agent.findUnique({
where: { slug },
include: { category: true },
})
if (!agent) {
return NextResponse.json({ error: "Agent not found" }, { status: 404 })
}
return NextResponse.json(agent)
}
+67
View File
@@ -0,0 +1,67 @@
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { prisma } from "@/app/lib/prisma"
import bcrypt from "bcryptjs"
const handler = NextAuth({
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "用户名", type: "text" },
password: { label: "密码", type: "password" },
},
async authorize(credentials) {
if (!credentials?.username || !credentials?.password) {
return null
}
const user = await prisma.user.findUnique({
where: { username: credentials.username as string },
})
if (!user) {
return null
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
)
if (!isValid) {
return null
}
return {
id: user.id.toString(),
email: user.email,
name: user.name || user.username,
role: user.role,
}
},
}),
],
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
}
return token
},
async session({ session, token }) {
if (session.user) {
(session.user as any).role = token.role
}
return session
},
},
pages: {
signIn: "/admin/login",
},
})
export { handler as GET, handler as POST }
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server"
const API_KEY = 'app-lbe2lglt7taGtZk0dG7pAhbx'
const API_URL = 'http://df.clkeji.com/v1/chat-messages'
export async function POST(request: Request) {
try {
const body = await request.json()
const response = await fetch(API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (body.response_mode === 'streaming') {
return new NextResponse(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Chat API error:', error)
return NextResponse.json(
{ error: 'Failed to connect to chat service' },
{ status: 500 }
)
}
}
@@ -0,0 +1,30 @@
import { prisma } from "@/app/lib/prisma"
import { NextResponse } from "next/server"
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const body = await request.json()
const { role, content } = body
if (!role || !content) {
return NextResponse.json({ error: "role and content are required" }, { status: 400 })
}
const message = await prisma.message.create({
data: {
conversationId: parseInt(id),
role,
content,
},
})
await prisma.conversation.update({
where: { id: parseInt(id) },
data: { updatedAt: new Date() },
})
return NextResponse.json(message)
}
+37
View File
@@ -0,0 +1,37 @@
import { prisma } from "@/app/lib/prisma"
import { NextResponse } from "next/server"
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const conversation = await prisma.conversation.findUnique({
where: { id: parseInt(id) },
include: {
messages: {
orderBy: { timestamp: "asc" },
},
},
})
if (!conversation) {
return NextResponse.json({ error: "Conversation not found" }, { status: 404 })
}
return NextResponse.json(conversation)
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
await prisma.conversation.delete({
where: { id: parseInt(id) },
})
return NextResponse.json({ success: true })
}
+42
View File
@@ -0,0 +1,42 @@
import { prisma } from "@/app/lib/prisma"
import { NextResponse } from "next/server"
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const agentId = searchParams.get("agentId")
if (!agentId) {
return NextResponse.json({ error: "agentId is required" }, { status: 400 })
}
const conversations = await prisma.conversation.findMany({
where: { agentId: parseInt(agentId) },
orderBy: { updatedAt: "desc" },
include: {
messages: {
orderBy: { timestamp: "asc" },
take: 1,
},
},
})
return NextResponse.json(conversations)
}
export async function POST(request: Request) {
const body = await request.json()
const { agentId, title } = body
if (!agentId) {
return NextResponse.json({ error: "agentId is required" }, { status: 400 })
}
const conversation = await prisma.conversation.create({
data: {
agentId: parseInt(agentId),
title: title || "新对话",
},
})
return NextResponse.json(conversation)
}
+16
View File
@@ -0,0 +1,16 @@
'use client'
export default function TestButton() {
const handleClick = () => {
alert('hello')
}
return (
<button
onClick={handleClick}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
</button>
)
}
+1
View File
@@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
:root {
--background: #ffffff;
+4 -1
View File
@@ -24,9 +24,12 @@ export default function RootLayout({
}>) {
return (
<html
lang="en"
lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
</head>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const prisma =
globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+77
View File
@@ -0,0 +1,77 @@
import { prisma } from "@//app/lib/prisma"
import { notFound } from "next/navigation"
import Link from "next/link"
export default async function NewsDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const news = await prisma.news.findUnique({
where: { id: parseInt(id) },
})
if (!news || !news.published) {
notFound()
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<span className="font-bold text-gray-900">广</span>
</Link>
<div className="flex items-center gap-6">
<Link href="/" className="text-gray-600 hover:text-blue-600 transition"></Link>
<Link href="/agents" className="text-gray-600 hover:text-blue-600 transition">广</Link>
<Link href="/news" className="text-blue-600 font-medium"></Link>
</div>
</div>
</div>
</nav>
<main className="max-w-4xl mx-auto px-6 py-12">
<Link href="/news" className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-blue-600 mb-8">
</Link>
<article className="bg-white rounded-2xl p-8 border border-gray-200">
<div className="flex items-center gap-2 mb-4">
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs font-medium rounded-full">
{news.type === "news" ? "新闻" : news.type}
</span>
<span className="text-xs text-gray-400">
{new Date(news.createdAt).toLocaleDateString("zh-CN")}
</span>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-6">{news.title}</h1>
<div className="prose max-w-none">
<p className="text-gray-600 leading-relaxed">{news.content}</p>
</div>
<div className="mt-8 pt-6 border-t border-gray-200">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">
{new Date(news.createdAt).toLocaleDateString("zh-CN")}
</span>
<Link
href="/news"
className="text-blue-600 hover:text-blue-700 text-sm"
>
</Link>
</div>
</div>
</article>
</main>
</div>
)
}
+69
View File
@@ -0,0 +1,69 @@
import { prisma } from "@//app/lib/prisma"
import Link from "next/link"
export default async function NewsPage() {
const news = await prisma.news.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
})
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white border-b border-gray-200">
<div className="max-w-6xl mx-auto px-6 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<span className="font-bold text-gray-900">广</span>
</Link>
<div className="flex items-center gap-6">
<Link href="/" className="text-gray-600 hover:text-blue-600 transition"></Link>
<Link href="/agents" className="text-gray-600 hover:text-blue-600 transition">广</Link>
<Link href="/news" className="text-blue-600 font-medium"></Link>
</div>
</div>
</div>
</nav>
<main className="max-w-6xl mx-auto px-6 py-8">
<h1 className="text-2xl font-bold text-gray-900 mb-8"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{news.map((item) => (
<Link
key={item.id}
href={`/news/${item.id}`}
className="bg-white rounded-2xl border border-gray-200 overflow-hidden hover:border-blue-300 hover:shadow-md transition"
>
<div className="h-36 bg-gradient-to-br from-blue-100 to-indigo-100 flex items-center justify-center">
<span className="text-5xl">📰</span>
</div>
<div className="p-5">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs font-medium rounded-full">
{item.type === "news" ? "新闻" : item.type}
</span>
<span className="text-xs text-gray-400">
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
</span>
</div>
<h3 className="font-semibold text-gray-900 mb-2 leading-snug">{item.title}</h3>
<p className="text-gray-500 text-sm leading-relaxed">
{item.content.substring(0, 100)}...
</p>
</div>
</Link>
))}
</div>
{news.length === 0 && (
<div className="text-center py-12 text-gray-500">
</div>
)}
</main>
</div>
)
}
+226 -55
View File
@@ -1,65 +1,236 @@
import Image from "next/image";
import { prisma } from "@//app/lib/prisma"
import Link from "next/link"
import Image from "next/image"
export default async function HomePage() {
const agents = await prisma.agent.findMany({
include: { category: true },
take: 6,
})
const news = await prisma.news.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 3,
})
const categories = await prisma.category.findMany({
include: { _count: { select: { agents: true } },
},
})
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="min-h-screen bg-gray-50">
{/* 导航栏 */}
<nav className="bg-white border-b border-gray-200 fixed top-0 left-0 right-0 z-50 shadow-sm">
<div className="max-w-6xl mx-auto px-6 py-3">
<div className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">AI</span>
</div>
<span className="font-bold text-gray-900">广</span>
</Link>
<div className="flex items-center gap-6">
<Link href="/" className="text-gray-600 hover:text-blue-600 transition"></Link>
<Link href="/agents" className="text-gray-600 hover:text-blue-600 transition">广</Link>
<Link href="/news" className="text-gray-600 hover:text-blue-600 transition"></Link>
<Link
href="/admin/login"
className="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition"
>
</Link>
</div>
</div>
</div>
</nav>
<main className="pt-16">
{/* Hero 区域 */}
<section className="bg-gradient-to-r from-blue-600 to-blue-700 text-white py-20">
<div className="max-w-6xl mx-auto px-6">
<div className="flex flex-col lg:flex-row items-center gap-8">
<div className="lg:w-1/2 space-y-6">
<h1 className="text-4xl md:text-5xl font-bold">
AI <br />
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-lg text-white/90 leading-relaxed">
广 AI
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div className="flex flex-wrap gap-4">
<Link
href="/agents"
className="bg-white text-blue-600 px-6 py-3 rounded-lg font-medium hover:bg-gray-100 transition"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
</Link>
<Link
href="#features"
className="border border-white text-white px-6 py-3 rounded-lg font-medium hover:bg-white/10 transition"
>
Documentation
</a>
</Link>
</div>
</div>
<div className="lg:w-1/2">
<div className="bg-white p-6 rounded-2xl shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center text-white">
🤖
</div>
<div>
<h3 className="font-semibold text-gray-900"></h3>
<p className="text-xs text-gray-500">24/7 线</p>
</div>
</div>
<div className="space-y-4 mb-4">
<div className="flex gap-3">
<div className="w-8 h-8 bg-gray-200 rounded-full flex items-center justify-center flex-shrink-0">
👤
</div>
<div className="bg-gray-100 rounded-lg p-3 max-w-[80%]">
<p className="text-sm text-gray-800"></p>
</div>
</div>
<div className="flex gap-3 justify-end">
<div className="bg-blue-50 rounded-lg p-3 max-w-[80%]">
<p className="text-sm text-gray-800">24线</p>
</div>
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white flex-shrink-0">
🤖
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* 热门智能体 */}
<section className="py-16 bg-white">
<div className="max-w-6xl mx-auto px-6">
<div className="text-center mb-10">
<h2 className="text-2xl font-bold text-gray-900 mb-2"></h2>
<p className="text-gray-500"></p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
{agents.map((agent) => (
<Link
key={agent.id}
href={`/agents/${agent.slug}`}
className="bg-white rounded-2xl border border-gray-200 p-5 hover:border-blue-300 shadow-sm hover:shadow-md transition-all duration-300"
>
<div className="flex items-center gap-4 mb-4">
<div className="w-14 h-14 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center text-3xl flex-shrink-0">
{agent.icon || "🤖"}
</div>
<div className="min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{agent.name}</h3>
{agent.category && (
<span className="inline-block mt-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-600">
{agent.category.name}
</span>
)}
</div>
</div>
<p className="text-gray-500 text-sm leading-relaxed mb-4">{agent.description}</p>
<div className="flex items-center justify-end">
<span className="text-blue-600 font-medium hover:underline transition-colors"> </span>
</div>
</Link>
))}
</div>
<div className="text-center mt-10">
<Link href="/agents" className="text-blue-600 font-medium hover:text-blue-700 transition">
</Link>
</div>
</div>
</section>
{/* 智能分类与使用场景 */}
<section id="features" className="py-16 bg-white rounded-3xl shadow-xl -mt-10 relative z-20">
<div className="max-w-6xl mx-auto px-6">
<h2 className="text-2xl font-bold text-gray-900 mb-8 text-center">使</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.map((category) => (
<Link
key={category.id}
href={`/agents?category=${category.id}`}
className="bg-blue-50 rounded-2xl p-6 cursor-pointer hover:bg-blue-100 transition-colors"
>
<div className="w-16 h-16 bg-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4 text-3xl">
{category.icon || "📊"}
</div>
<h3 className="font-semibold text-gray-900 text-center mb-2">{category.name}</h3>
<p className="text-xs text-gray-500 text-center mb-4">{category._count.agents}</p>
<p className="text-sm text-gray-600 text-center">{category.description || "智能应用场景"}</p>
</Link>
))}
</div>
</div>
</section>
{/* 前沿动态 */}
<section className="py-16">
<div className="max-w-6xl mx-auto px-6">
<h2 className="text-2xl font-bold text-gray-900 mb-8">沿</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{news.map((item) => (
<Link
key={item.id}
href={`/news/${item.id}`}
className="bg-white rounded-2xl border border-gray-200 overflow-hidden hover:border-blue-300 hover:shadow-md transition"
>
<div className="h-36 bg-gradient-to-br from-blue-100 to-indigo-100 flex items-center justify-center">
<span className="text-5xl">📰</span>
</div>
<div className="p-5">
<div className="flex items-center gap-2 mb-3">
<span className="px-2 py-0.5 bg-blue-50 text-blue-600 text-xs font-medium rounded-full">
{item.type === "news" ? "新闻" : item.type}
</span>
<span className="text-xs text-gray-400">
{new Date(item.createdAt).toLocaleDateString("zh-CN")}
</span>
</div>
<h3 className="font-semibold text-gray-900 mb-2 leading-snug">{item.title}</h3>
<p className="text-gray-500 text-sm leading-relaxed">{item.content.substring(0, 100)}...</p>
</div>
</Link>
))}
</div>
<div className="text-center mt-10">
<Link href="/news" className="text-blue-600 font-medium hover:text-blue-700 transition">
</Link>
</div>
</div>
</section>
</main>
{/* 底部 */}
<div className="border-t border-gray-200 py-8">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-7 h-7 bg-blue-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-xs">AI</span>
</div>
);
<span className="font-semibold text-gray-700 text-sm"></span>
</div>
<div className="text-gray-400 text-sm text-center">
© 2026 · AI 广
</div>
<div className="flex gap-4 text-xs text-gray-400">
<a className="hover:text-gray-600 cursor-pointer transition"></a>
<a className="hover:text-gray-600 cursor-pointer transition"></a>
<a className="hover:text-gray-600 cursor-pointer transition"></a>
</div>
</div>
</div>
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
version: '3.8'
services:
nextapp:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=file:/app/data/dev.db
- NEXTAUTH_SECRET=nextapp-secret-key-2026
- NEXTAUTH_URL=http://localhost:3000
volumes:
- /opt/nextapp/data/dev.db:/app/data/dev.db
restart: unless-stopped
+9 -8
View File
@@ -1,18 +1,19 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
{
ignores: [
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
],
},
{
rules: {
// 自定义规则
},
},
]);
export default eslintConfig;
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
};
export default nextConfig;
+2800 -742
View File
File diff suppressed because it is too large Load Diff
+21 -12
View File
@@ -6,21 +6,30 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "next lint"
},
"dependencies": {
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4"
"@prisma/client": "^6.19.3",
"@tailwindcss/typography": "^0.5.19",
"@types/bcryptjs": "^2.4.6",
"bcryptjs": "^3.0.3",
"next": "15.2.4",
"next-auth": "^4.24.14",
"prisma": "^6.19.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.13.1",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"eslint": "^9.20.0",
"eslint-config-next": "15.2.4",
"tailwindcss": "^4.0.0",
"tsx": "^4.21.0",
"typescript": "^5.8.2"
}
}
BIN
View File
Binary file not shown.
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "Page" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Setting" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Page_slug_key" ON "Page"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "Setting_key_key" ON "Setting"("key");
@@ -0,0 +1,77 @@
/*
Warnings:
- You are about to drop the `Page` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `Setting` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "Page";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "Setting";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "User" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" TEXT NOT NULL,
"username" TEXT NOT NULL,
"password" TEXT NOT NULL,
"name" TEXT,
"role" TEXT NOT NULL DEFAULT 'admin',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Category" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"icon" TEXT,
"description" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Agent" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT NOT NULL,
"icon" TEXT,
"categoryId" INTEGER,
"features" TEXT NOT NULL DEFAULT '',
"usageCount" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Agent_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "News" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'news',
"published" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Agent_slug_key" ON "Agent"("slug");
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Conversation" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"agentId" INTEGER NOT NULL,
"userId" INTEGER,
"title" TEXT NOT NULL DEFAULT '新对话',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Conversation_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Conversation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Message" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"conversationId" INTEGER NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Message_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+82
View File
@@ -0,0 +1,82 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
username String @unique
password String
name String?
role String @default("admin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
conversations Conversation[]
}
model Category {
id Int @id @default(autoincrement())
name String @unique
icon String?
description String?
agents Agent[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Agent {
id Int @id @default(autoincrement())
name String
slug String @unique
description String
icon String?
categoryId Int?
category Category? @relation(fields: [categoryId], references: [id])
features String @default("")
usageCount Int @default(0)
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
conversations Conversation[]
}
model News {
id Int @id @default(autoincrement())
title String
content String
type String @default("news")
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Conversation {
id Int @id @default(autoincrement())
agentId Int
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
title String @default("新对话")
messages Message[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Message {
id Int @id @default(autoincrement())
conversationId Int
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
role String // 'user' 或 'assistant'
content String
timestamp DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+189
View File
@@ -0,0 +1,189 @@
import { PrismaClient } from "@prisma/client"
import bcrypt from "bcryptjs"
const prisma = new PrismaClient()
async function main() {
// 创建管理员用户
const hashedPassword = await bcrypt.hash("admin123", 10)
const admin = await prisma.user.upsert({
where: { username: "admin" },
update: {},
create: {
email: "admin@nextapp.com",
username: "admin",
password: hashedPassword,
name: "管理员",
role: "admin",
},
})
console.log("Created admin user:", admin.username)
// 创建分类
const categories = await Promise.all([
prisma.category.upsert({
where: { name: "客服" },
update: {},
create: {
name: "客服",
icon: "🤖",
description: "适用于企业客服、在线问答、售后支持等场景,7×24小时在线服务。",
},
}),
prisma.category.upsert({
where: { name: "写作" },
update: {},
create: {
name: "写作",
icon: "✍️",
description: "适用于营销文案、博客文章、邮件草稿等场景,快速生成高质量内容。",
},
}),
prisma.category.upsert({
where: { name: "数据分析" },
update: {},
create: {
name: "数据分析",
icon: "📊",
description: "适用于数据清洗、分析、可视化等场景,自动生成结构化分析报告。",
},
}),
prisma.category.upsert({
where: { name: "编程" },
update: {},
create: {
name: "编程",
icon: "💻",
description: "适用于代码审查、SQL生成等场景,提高开发效率,减少bug。",
},
}),
prisma.category.upsert({
where: { name: "生活娱乐" },
update: {},
create: {
name: "生活娱乐",
icon: "✈️",
description: "适用于旅行规划、菜谱推荐等场景,让生活更加便捷智能。",
},
}),
prisma.category.upsert({
where: { name: "数字人" },
update: {},
create: {
name: "数字人",
icon: "🧑‍💼",
description: "适用于虚拟主播、数字客服、形象代言等场景,提供逼真的数字人交互体验。",
},
}),
])
console.log("Created categories:", categories.map(c => c.name).join(", "))
// 创建智能体
const agents = await Promise.all([
prisma.agent.upsert({
where: { slug: "smart-customer-service" },
update: {},
create: {
name: "智能客服助手",
slug: "smart-customer-service",
description: "7×24 小时在线,精准理解用户意图,自动处理常见咨询,支持多轮对话。",
icon: "🤖",
categoryId: categories[0].id,
features: "智能问答, 知识库查询, 工单提交",
usageCount: 1000,
status: "active",
},
}),
prisma.agent.upsert({
where: { slug: "writing-assistant-pro" },
update: {},
create: {
name: "写作助手 Pro",
slug: "writing-assistant-pro",
description: "营销文案、博客文章、邮件草稿,输入关键词即可生成高质量内容。",
icon: "✍️",
categoryId: categories[1].id,
features: "营销文案, 博客文章, 邮件草稿",
usageCount: 850,
status: "active",
},
}),
prisma.agent.upsert({
where: { slug: "data-analysis-master" },
update: {},
create: {
name: "数据分析大师",
slug: "data-analysis-master",
description: "上传 CSV/Excel,自动清洗、分析、可视化,输出结构化分析报告。",
icon: "📊",
categoryId: categories[2].id,
features: "数据清洗, 数据可视化, 分析报告",
usageCount: 620,
status: "active",
},
}),
prisma.agent.upsert({
where: { slug: "digital-human-assistant" },
update: {},
create: {
name: "数字人助手",
slug: "digital-human-assistant",
description: "超写实数字人,支持语音交互、表情动画、多场景应用,适用于虚拟主播、智能客服、形象代言等场景。",
icon: "🧑‍💼",
categoryId: categories[5].id,
features: "语音交互, 表情动画, 虚拟主播, 智能客服",
usageCount: 380,
status: "active",
},
}),
])
console.log("Created agents:", agents.map(a => a.name).join(", "))
// 创建新闻
const news = await Promise.all([
prisma.news.upsert({
where: { id: 1 },
update: {},
create: {
title: "智能客服助手 3.0 正式上线,支持多模态交互",
content: "全新版本集成图像识别与语音交互能力,响应速度提升 40%,企业客户满意度评分达 4.9/5.0。",
type: "news",
published: true,
},
}),
prisma.news.upsert({
where: { id: 2 },
update: {},
create: {
title: "2026 AI Agent 市场白皮书:企业渗透率突破 35%",
content: "据最新行业报告,AI 智能体正在从尝鲜阶段走向规模化落地,客服与内容生成场景率先规模化。",
type: "industry",
published: true,
},
}),
prisma.news.upsert({
where: { id: 3 },
update: {},
create: {
title: "江苏冲浪软件科技与华为云达成战略合作",
content: "双方将在 AI 智能体研发、云端部署与行业解决方案展开深度合作,首期联合创新实验室正式揭牌。",
type: "cooperation",
published: true,
},
}),
])
console.log("Created news:", news.map(n => n.title).join(", "))
console.log("Seed data created successfully!")
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})
+22 -13
View File
@@ -1,34 +1,43 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"paths": {
"@/*": [
"./*"
]
},
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"strict": false,
"noEmit": true,
"esModuleInterop": true,
"incremental": true,
"module": "esnext",
"esModuleInterop": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"jsx": "preserve",
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
"**/*.mts",
"**/*.ts",
"**/*.tsx"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}
+21
View File
@@ -0,0 +1,21 @@
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user?: {
id: string
role: string
} & DefaultSession["user"]
}
interface User {
id: string
role: string
}
}
declare module "next-auth/jwt" {
interface JWT {
role?: string
}
}
+14
View File
@@ -0,0 +1,14 @@
发送对话消息接口如下,该接口能正常调用
curl -X POST 'http://df.clkeji.com/v1/chat-messages' \
--header 'Authorization: Bearer app-lbe2lglt7taGtZk0dG7pAhbx' \
--header 'Content-Type: application/json' \
--data '{
"inputs": {},
"query": "What are the specs of the iPhone 13 Pro Max?",
"response_mode": "streaming",
"conversation_id": "",
"user": "abc-123"
}'
问题描述:点击对话,功能异常,请排查原因并修复
+8
View File
@@ -0,0 +1,8 @@
背景:当前远程git库为空,将本地的代码推送到git库,并生成README和.gitignore文件
通过命令行推送一个已存在的版本库
git remote add origin http://admin@192.168.0.105:8440/r/ai-portal.git
git push -u origin master