Commit 06f3cdb3 authored by root's avatar root
Browse files

Initial commit

parent cc883e90
...@@ -39,3 +39,5 @@ yarn-error.log* ...@@ -39,3 +39,5 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
/src/generated/prisma
...@@ -9,19 +9,26 @@ ...@@ -9,19 +9,26 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.2.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@prisma/client": "^6.14.0",
"bcrypt": "^6.0.0",
"next": "15.5.0",
"next-auth": "^4.24.11",
"prisma": "^6.14.0",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"next": "15.5.0" "swr": "^2.3.6"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.0", "eslint-config-next": "15.5.0",
"@eslint/eslintrc": "^3" "tailwindcss": "^4",
"typescript": "^5"
} }
} }
This diff is collapsed.
-- CreateTable
CREATE TABLE "public"."User" (
"id" SERIAL NOT NULL,
"username" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Room" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Room_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."Message" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" INTEGER NOT NULL,
"roomId" INTEGER NOT NULL,
CONSTRAINT "Message_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_username_key" ON "public"."User"("username");
-- CreateIndex
CREATE UNIQUE INDEX "Room_name_key" ON "public"."Room"("name");
-- AddForeignKey
ALTER TABLE "public"."Message" ADD CONSTRAINT "Message_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."Message" ADD CONSTRAINT "Message_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "public"."Room"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
createdAt DateTime @default(now())
messages Message[]
}
model Room {
id Int @id @default(autoincrement())
name String @unique
createdAt DateTime @default(now())
messages Message[]
}
model Message {
id Int @id @default(autoincrement())
content String
createdAt DateTime @default(now())
// 关联用户
user User @relation(fields: [userId], references: [id])
userId Int
// 关联房间
room Room @relation(fields: [roomId], references: [id])
roomId Int
}
\ No newline at end of file
import prisma from "@/lib/prisma";
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const roomId = searchParams.get("roomId");
if (!roomId) {
return new Response("缺少房间ID", { status: 400 });
}
const messages = await prisma.message.findMany({
where: { roomId: Number(roomId) },
include: { user: true },
orderBy: { createdAt: "asc" }
});
// 添加类型注解解决隐式 any 问题
return Response.json(messages.map((msg: {
id: number;
content: string;
user: { username: string };
createdAt: Date
}) => ({
id: msg.id.toString(),
content: msg.content,
sender: msg.user.username,
timestamp: msg.createdAt
})));
} catch (error) {
console.error("获取消息失败:", error);
return new Response("服务器错误", { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { content, roomId, sender } = await request.json();
if (!content || !roomId || !sender) {
return new Response("缺少必要参数", { status: 400 });
}
// 查找或创建用户
let user = await prisma.user.findUnique({ where: { username: sender } });
if (!user) {
user = await prisma.user.create({ data: { username: sender } });
}
// 创建消息
const message = await prisma.message.create({
data: {
content,
userId: user.id,
roomId: Number(roomId)
}
});
return Response.json({
id: message.id.toString(),
content: message.content,
sender: user.username,
timestamp: message.createdAt
});
} catch (error) {
console.error("发送消息失败:", error);
return new Response("服务器错误", { status: 500 });
}
}
\ No newline at end of file
import prisma from "@/lib/prisma";
export async function GET() {
try {
const rooms = await prisma.room.findMany();
return Response.json(rooms.map(room => ({
id: room.id.toString(),
name: room.name
})));
} catch (error) {
console.error("获取房间列表失败:", error);
return new Response("服务器错误", { status: 500 });
}
}
export async function POST(request: Request) {
try {
const { name } = await request.json();
if (!name || !name.trim()) {
return new Response("房间名称不能为空", { status: 400 });
}
const newRoom = await prisma.room.create({
data: { name: name.trim() }
});
return Response.json({
id: newRoom.id.toString(),
name: newRoom.name
});
} catch (error) {
console.error("创建房间失败:", error);
return new Response("服务器错误", { status: 500 });
}
}
\ No newline at end of file
"use client";
import { useState, useEffect } from "react";
import useSWR, { mutate } from "swr";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
type Room = {
id: string;
name: string;
};
type Message = {
id: string;
content: string;
sender: string;
timestamp: Date;
};
// 数据获取函数
const fetcher = (url: string) => fetch(url).then((res) => res.json());
export default function ChatRoom() {
const [activeRoom, setActiveRoom] = useState("1");
const [newMessage, setNewMessage] = useState("");
const [username, setUsername] = useState("Guest");
const [isCreatingRoom, setIsCreatingRoom] = useState(false);
const [newRoomName, setNewRoomName] = useState("");
// 从localStorage获取用户名
useEffect(() => {
const storedUsername = localStorage.getItem("chat-username");
if (storedUsername) {
setUsername(storedUsername);
}
}, []);
// 使用SWR获取房间列表
const { data: rooms, error: roomsError, mutate: mutateRooms } = useSWR<Room[]>(
"/api/rooms",
fetcher
);
// 使用SWR获取当前房间的消息
const { data: messages, error: messagesError } = useSWR<Message[]>(
`/api/messages?roomId=${activeRoom}`,
fetcher,
{ refreshInterval: 3000 } // 每3秒刷新一次
);
const handleSendMessage = async () => {
if (newMessage.trim()) {
try {
// 发送消息到后端
await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: newMessage.trim(),
roomId: activeRoom,
sender: username,
}),
});
setNewMessage("");
// 刷新消息数据
mutate(`/api/messages?roomId=${activeRoom}`);
} catch (error) {
console.error("发送消息失败:", error);
}
}
};
// 创建新房间的函数
const handleCreateRoom = async () => {
if (!newRoomName.trim()) return;
try {
const response = await fetch("/api/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newRoomName.trim() })
});
if (response.ok) {
// 刷新房间列表
mutateRooms();
setNewRoomName("");
setIsCreatingRoom(false);
}
} catch (error) {
console.error("创建房间失败:", error);
}
};
// 生成用户头像
const getAvatar = (name: string) => {
return name.charAt(0).toUpperCase();
};
return (
<div className="flex h-screen">
{/* 房间列表 */}
<div className="w-1/4 bg-gray-100 border-r p-4 flex flex-col">
{/* 标题和创建按钮 */}
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">聊天房间</h2>
<button
onClick={() => setIsCreatingRoom(true)}
className="p-1 text-blue-500 hover:text-blue-700"
title="创建新房间"
>
<PlusCircleIcon className="h-6 w-6" />
</button>
</div>
{/* 创建房间表单 */}
{isCreatingRoom && (
<div className="mb-4 p-2 bg-white rounded shadow">
<input
type="text"
value={newRoomName}
onChange={(e) => setNewRoomName(e.target.value)}
placeholder="输入房间名称"
className="w-full px-2 py-1 border rounded mb-2"
autoFocus
onKeyPress={(e) => e.key === "Enter" && handleCreateRoom()}
/>
<div className="flex space-x-2">
<button
onClick={handleCreateRoom}
className="flex-1 bg-blue-500 text-white py-1 rounded hover:bg-blue-600"
>
创建
</button>
<button
onClick={() => setIsCreatingRoom(false)}
className="flex-1 bg-gray-300 text-gray-700 py-1 rounded hover:bg-gray-400"
>
取消
</button>
</div>
</div>
)}
{/* 房间列表内容 */}
{roomsError ? (
<div className="text-red-500">加载房间失败</div>
) : !rooms ? (
<div>加载房间中...</div>
) : (
<ul>
{rooms.map((room: Room) => (
<li
key={room.id}
className={`p-3 mb-2 rounded cursor-pointer ${
activeRoom === room.id
? "bg-blue-500 text-white"
: "hover:bg-gray-200"
}`}
onClick={() => setActiveRoom(room.id)}
>
{room.name}
</li>
))}
</ul>
)}
</div>
{/* 聊天区域 */}
<div className="flex-1 flex flex-col">
{/* 消息列表 */}
<div className="flex-1 p-4 overflow-y-auto">
{messagesError ? (
<div className="text-red-500">加载消息失败</div>
) : !messages ? (
<div>加载消息中...</div>
) : (
messages.map((msg: Message) => (
<div key={msg.id} className="mb-4 flex">
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold mr-3">
{getAvatar(msg.sender)}
</div>
<div>
<div className="font-bold">
{msg.sender}
<span className="text-gray-500 text-sm ml-2">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
<div>{msg.content}</div>
</div>
</div>
))
)}
</div>
{/* 消息输入框 */}
<div className="p-4 border-t">
<div className="flex">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
placeholder="输入消息..."
className="flex-1 px-4 py-2 border rounded-l"
onKeyPress={(e) => e.key === "Enter" && handleSendMessage()}
/>
<button
onClick={handleSendMessage}
className="bg-blue-500 text-white px-4 py-2 rounded-r"
>
发送
</button>
</div>
</div>
</div>
</div>
);
}
\ No newline at end of file
import Image from "next/image"; "use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function SetName() {
const [name, setName] = useState("");
const router = useRouter();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
localStorage.setItem("chat-username", name.trim());
router.push("/chat");
}
};
export default function Home() {
return ( return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20"> <div className="min-h-screen flex items-center justify-center bg-gray-100">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> <form onSubmit={handleSubmit} className="bg-white p-8 rounded shadow-md">
<Image <h1 className="text-2xl font-bold mb-4">Set Your Nickname</h1>
className="dark:invert" <input
src="/next.svg" type="text"
alt="Next.js logo" value={name}
width={180} onChange={(e) => setName(e.target.value)}
height={38} placeholder="Enter your nickname"
priority className="w-full px-4 py-2 border border-gray-300 rounded mb-4"
required
/> />
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left"> <button
<li className="mb-2 tracking-[-.01em]"> type="submit"
Get started by editing{" "} className="w-full bg-blue-500 text-white py-2 rounded hover:bg-blue-600"
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
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"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto 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"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
> >
<Image Submit
aria-hidden </button>
src="/globe.svg" </form>
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</div> </div>
); );
} }
\ No newline at end of file
import { PrismaClient } from '@prisma/client'
// 创建全局 Prisma 实例以避免热重载问题
const globalForPrisma = global as unknown as { prisma: PrismaClient }
const prisma = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma
}
export default prisma
\ No newline at end of file
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment