初始化项目
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
deploy.sh
|
||||
.next
|
||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
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).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```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.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
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.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [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!
|
||||
|
||||
## 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.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
25
eslint.config.mjs
Normal file
25
eslint.config.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
11
next.config.ts
Normal file
11
next.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "my-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/prisma-adapter": "^2.10.0",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
"@prisma/client": "^6.15.0",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.541.0",
|
||||
"next": "15.5.0",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"uuid": "^11.1.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/next-auth": "^3.15.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prisma": "^6.15.0",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.20.4",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "^5",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
5
postcss.config.mjs
Normal file
5
postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
16
prisma/prisma.config.ts
Normal file
16
prisma/prisma.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
declare global {
|
||||
var cachedPrisma: PrismaClient
|
||||
}
|
||||
|
||||
export let prisma: PrismaClient
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!global.cachedPrisma) {
|
||||
global.cachedPrisma = new PrismaClient()
|
||||
}
|
||||
prisma = global.cachedPrisma
|
||||
}
|
||||
160
prisma/schema.prisma
Normal file
160
prisma/schema.prisma
Normal file
@@ -0,0 +1,160 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model change {
|
||||
id Int @id @default(autoincrement())
|
||||
time DateTime? @db.Timestamp(0)
|
||||
city Int?
|
||||
macaddr String @db.VarChar(20)
|
||||
edge_new String @db.VarChar(20)
|
||||
edge_old String? @db.VarChar(20)
|
||||
info String @db.VarChar(500)
|
||||
network String @db.VarChar(20)
|
||||
createtime DateTime @default(now()) @db.DateTime(0)
|
||||
|
||||
@@index([edge_new], map: "edge_new")
|
||||
@@index([time], map: "change_time_index")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model cityhash {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
macaddr String? @db.VarChar(20)
|
||||
city String @db.VarChar(20)
|
||||
num Int
|
||||
hash String @db.VarChar(100)
|
||||
label String? @db.VarChar(20)
|
||||
count Int @default(0)
|
||||
offset Int @default(0)
|
||||
createtime DateTime @default(now()) @db.DateTime(0)
|
||||
updatetime DateTime @default(now()) @db.DateTime(0)
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model edge {
|
||||
id Int @id @default(autoincrement())
|
||||
macaddr String @unique(map: "edge_macaddr_idx") @db.VarChar(17)
|
||||
public String @db.VarChar(255)
|
||||
isp String @db.VarChar(255)
|
||||
single Boolean
|
||||
sole Boolean
|
||||
arch Boolean
|
||||
online Int @default(0)
|
||||
city_id Int
|
||||
active Boolean
|
||||
|
||||
@@index([active], map: "edge_active_index")
|
||||
@@index([city_id])
|
||||
@@index([isp], map: "edge_isp_index")
|
||||
@@index([public], map: "edge_public_index")
|
||||
}
|
||||
|
||||
model gateway {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
macaddr String @db.VarChar(20)
|
||||
table Int
|
||||
edge String @db.VarChar(20)
|
||||
network String @db.VarChar(20)
|
||||
cityhash String @db.VarChar(100)
|
||||
label String? @db.VarChar(20)
|
||||
user String? @db.VarChar(20)
|
||||
inner_ip String? @db.VarChar(20)
|
||||
ischange Int @default(0) @db.TinyInt
|
||||
isonline Int @default(0) @db.TinyInt
|
||||
onlinenum Int @default(0)
|
||||
createtime DateTime @default(now()) @db.DateTime(0)
|
||||
updatetime DateTime @default(now()) @db.DateTime(0)
|
||||
|
||||
@@index([inner_ip], map: "inner_ip")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model token {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
setid Int @default(1)
|
||||
change_count Int
|
||||
limit_count Int @default(32000)
|
||||
token String @db.VarChar(1000)
|
||||
macaddr String @db.VarChar(100)
|
||||
token_time DateTime @db.DateTime(0)
|
||||
inner_ip String? @db.VarChar(20)
|
||||
l2ip String? @db.VarChar(20)
|
||||
enable Boolean @default(true)
|
||||
createtime DateTime @default(now()) @db.DateTime(0)
|
||||
updatetime DateTime @default(now()) @db.DateTime(0)
|
||||
}
|
||||
|
||||
model change_city {
|
||||
id Int @id @default(autoincrement())
|
||||
time DateTime? @db.Timestamp(0)
|
||||
city_id Int?
|
||||
count Int?
|
||||
offset_old Int?
|
||||
offset_new Int?
|
||||
|
||||
@@index([time], map: "change_city_time_index")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId Int @map("user_id")
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String @map("provider_account_id")
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int?
|
||||
token_type String?
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
phone String @unique
|
||||
password String
|
||||
name String?
|
||||
verifiedPhone Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
expires DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model VerificationCode {
|
||||
id Int @id @default(autoincrement())
|
||||
phone String
|
||||
code String
|
||||
type String
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([phone, type])
|
||||
@@map("verification_codes")
|
||||
}
|
||||
48
prisma/seed.ts
Normal file
48
prisma/seed.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { hash } from 'bcryptjs'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 开始执行种子脚本...')
|
||||
|
||||
try {
|
||||
// 首先检查用户是否已存在
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { phone: '17516219072' }
|
||||
})
|
||||
|
||||
if (existingUser) {
|
||||
console.log('✅ 用户已存在:', existingUser)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔐 加密密码...')
|
||||
const password = await hash('123456', 10)
|
||||
console.log('✅ 加密完成')
|
||||
|
||||
console.log('👤 创建用户...')
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
phone: '17516219072',
|
||||
password: password,
|
||||
name: '测试用户',
|
||||
},
|
||||
})
|
||||
console.log('✅ 用户创建成功:', user)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 种子脚本错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('❌ 种子脚本执行失败:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect()
|
||||
console.log('🔚 数据库连接已关闭')
|
||||
})
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
0
src/app/(auth)/loayout.tsx
Normal file
0
src/app/(auth)/loayout.tsx
Normal file
139
src/app/(auth)/login/page.tsx
Normal file
139
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import * as z from 'zod'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { Lock, Phone } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { toast, Toaster } from 'sonner'
|
||||
|
||||
const formSchema = z.object({
|
||||
phone: z.string()
|
||||
.min(11, '手机号必须是11位')
|
||||
.max(11, '手机号必须是11位')
|
||||
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
|
||||
password: z.string().min(6, '密码至少需要6个字符'),
|
||||
})
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
phone: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '登录失败')
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
toast.success("登录成功", {
|
||||
description: "正在跳转到仪表盘...",
|
||||
})
|
||||
setAuth(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("登录失败", {
|
||||
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50">
|
||||
<Card className="w-[350px] shadow-lg">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold text-center">登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>手机号</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="请输入手机号"
|
||||
className="pl-8"
|
||||
{...field}
|
||||
maxLength={11}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input type="password" placeholder="请输入密码" className="pl-8" {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
登录中...
|
||||
</div>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</>
|
||||
)
|
||||
}
|
||||
0
src/app/api/auth/loayout.tsx
Normal file
0
src/app/api/auth/loayout.tsx
Normal file
84
src/app/api/auth/login/route.ts
Normal file
84
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma' // 使用统一的prisma实例
|
||||
import { compare } from 'bcryptjs'
|
||||
import { z } from 'zod'
|
||||
|
||||
const loginSchema = z.object({
|
||||
phone: z.string()
|
||||
.min(11, '手机号必须是11位')
|
||||
.max(11, '手机号必须是11位')
|
||||
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
|
||||
password: z.string().min(6, '密码至少需要6个字符'),
|
||||
})
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { phone, password } = loginSchema.parse(body)
|
||||
|
||||
console.log('登录尝试:', phone) // 添加日志
|
||||
|
||||
// 查找用户 - 使用正确的查询方式
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
phone: phone.trim() // 去除空格
|
||||
},
|
||||
})
|
||||
|
||||
console.log('找到用户:', user) // 添加日志
|
||||
|
||||
if (!user) {
|
||||
console.log('用户不存在:', phone)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '用户不存在' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
const passwordMatch = await compare(password, user.password || '')
|
||||
console.log('密码验证结果:', passwordMatch)
|
||||
|
||||
if (!passwordMatch) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: '密码错误'
|
||||
}, { status: 401 })
|
||||
}
|
||||
|
||||
// 创建会话
|
||||
const sessionToken = crypto.randomUUID()
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
id: sessionToken,
|
||||
userId: user.id,
|
||||
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
})
|
||||
|
||||
// 设置cookie
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
name: user.name
|
||||
}
|
||||
})
|
||||
|
||||
response.cookies.set('session', sessionToken, {
|
||||
httpOnly: true,
|
||||
// secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '服务器错误,请稍后重试' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
38
src/app/api/auth/logout/route.ts
Normal file
38
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { cookies } from 'next/headers'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
const sessionToken = cookieStore.get('session')?.value
|
||||
|
||||
// 删除数据库中的session(如果存在)
|
||||
if (sessionToken) {
|
||||
await prisma.session.deleteMany({
|
||||
where: { id: sessionToken }
|
||||
}).catch(() => {
|
||||
// 忽略删除错误,确保cookie被清除
|
||||
})
|
||||
}
|
||||
|
||||
// 清除cookie
|
||||
const response = NextResponse.json({ success: true })
|
||||
response.cookies.set('session', '', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 0, // 立即过期
|
||||
path: '/',
|
||||
})
|
||||
|
||||
return response
|
||||
|
||||
} catch (error) {
|
||||
console.error('退出错误:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '退出失败' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
174
src/app/api/stats/route.ts
Normal file
174
src/app/api/stats/route.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 处理 BigInt 序列化
|
||||
function safeSerialize(data: unknown) {
|
||||
return JSON.parse(JSON.stringify(data, (key, value) =>
|
||||
typeof value === 'bigint' ? value.toString() : value
|
||||
))
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const reportType = searchParams.get('type')
|
||||
|
||||
switch (reportType) {
|
||||
case 'gateway_info':
|
||||
return await getGatewayInfo()
|
||||
case 'gateway_config':
|
||||
return await getGatewayConfig(request)
|
||||
case 'city_config_count':
|
||||
return await getCityConfigCount()
|
||||
case 'city_node_count':
|
||||
return await getCityNodeCount()
|
||||
case 'allocation_status':
|
||||
return await getAllocationStatus()
|
||||
case 'edge_nodes':
|
||||
return await getEdgeNodes(request)
|
||||
default:
|
||||
return NextResponse.json({ error: 'Invalid report type' }, { status: 400 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API Error:', error)
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 获取网关基本信息
|
||||
async function getGatewayInfo() {
|
||||
try {
|
||||
const result = await prisma.$queryRaw`
|
||||
SELECT macaddr, inner_ip, setid, enable
|
||||
FROM token
|
||||
ORDER BY macaddr
|
||||
`
|
||||
return NextResponse.json(safeSerialize(result))
|
||||
} catch (error) {
|
||||
console.error('Gateway info query error:', error)
|
||||
return NextResponse.json({ error: '查询网关信息失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 网关配置
|
||||
async function getGatewayConfig(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const macAddress = searchParams.get('mac') || '000C29DF1647'
|
||||
|
||||
// 使用参数化查询防止SQL注入
|
||||
const result = await prisma.$queryRaw`
|
||||
SELECT edge, city, user, public, inner_ip, ischange, isonline
|
||||
FROM gateway
|
||||
LEFT JOIN cityhash ON cityhash.hash = gateway.cityhash
|
||||
LEFT JOIN edge ON edge.macaddr = gateway.edge
|
||||
WHERE gateway.macaddr = ${macAddress};
|
||||
`
|
||||
return NextResponse.json(safeSerialize(result))
|
||||
} catch (error) {
|
||||
console.error('Gateway config query error:', error)
|
||||
return NextResponse.json({ error: '查询网关配置失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 城市节点配置数量统计
|
||||
async function getCityConfigCount() {
|
||||
try {
|
||||
const result = await prisma.$queryRaw`
|
||||
SELECT c.city, COUNT(e.id) as node_count
|
||||
FROM cityhash c
|
||||
LEFT JOIN edge e ON c.id = e.city_id
|
||||
GROUP BY c.city
|
||||
`
|
||||
return NextResponse.json(safeSerialize(result))
|
||||
} catch (error) {
|
||||
console.error('City config count query error:', error)
|
||||
return NextResponse.json({ error: '查询城市配置失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 城市节点数量分布
|
||||
async function getCityNodeCount() {
|
||||
try {
|
||||
const result = await prisma.$queryRaw`
|
||||
SELECT c.city, c.hash, c.label, COUNT(e.id) as count, c.offset
|
||||
FROM cityhash c
|
||||
LEFT JOIN edge e ON c.id = e.city_id
|
||||
GROUP BY c.hash, c.city, c.label, c.offset
|
||||
ORDER BY count DESC
|
||||
`
|
||||
return NextResponse.json(safeSerialize(result))
|
||||
} catch (error) {
|
||||
console.error('City node count query error:', error)
|
||||
return NextResponse.json({ error: '查询城市节点失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
// 城市分配状态
|
||||
async function getAllocationStatus() {
|
||||
try {
|
||||
// 使用参数化查询防止SQL注入
|
||||
const result = await prisma.$queryRaw`
|
||||
SELECT
|
||||
city,
|
||||
c1.count AS count,
|
||||
c2.assigned AS assigned
|
||||
FROM
|
||||
cityhash
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
city_id,
|
||||
COUNT(*) AS count
|
||||
FROM
|
||||
edge
|
||||
WHERE
|
||||
active = 1
|
||||
GROUP BY
|
||||
city_id
|
||||
) c1 ON c1.city_id = cityhash.id
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
city AS city_id,
|
||||
COUNT(*) AS assigned
|
||||
FROM
|
||||
\`change\`
|
||||
WHERE
|
||||
time > NOW() - INTERVAL 1 DAY
|
||||
GROUP BY
|
||||
city
|
||||
) c2 ON c2.city_id = cityhash.id
|
||||
WHERE
|
||||
cityhash.macaddr IS NOT NULL;
|
||||
`
|
||||
return NextResponse.json(safeSerialize(result))
|
||||
} catch (error) {
|
||||
console.error('Allocation status query error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return NextResponse.json(
|
||||
{ error: '查询分配状态失败: ' + errorMessage },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取节点信息
|
||||
async function getEdgeNodes(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const threshold = searchParams.get('threshold') || '20'
|
||||
const limit = searchParams.get('limit') || '100'
|
||||
|
||||
// 使用参数化查询防止SQL注入
|
||||
const result = await prisma.$queryRaw`
|
||||
SELECT edge.id, edge.macaddr, city, public, isp, single, sole, arch, online
|
||||
FROM edge
|
||||
LEFT JOIN cityhash ON cityhash.id = edge.city_id
|
||||
WHERE edge.id > ${threshold} AND active = true
|
||||
LIMIT ${limit}
|
||||
`
|
||||
return NextResponse.json(safeSerialize(result))
|
||||
} catch (error) {
|
||||
console.error('Edge nodes query error:', error)
|
||||
return NextResponse.json({ error: '查询边缘节点失败' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
28
src/app/api/test/route.ts
Normal file
28
src/app/api/test/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// src/app/api/test/route.ts
|
||||
import { db } from '@/lib/db'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// src/app/api/test/route.ts
|
||||
export async function GET() {
|
||||
try {
|
||||
// 测试数据库连接
|
||||
await db.$queryRaw`SELECT 1`
|
||||
|
||||
// 暂时注释掉用户查询,因为表可能还不存在
|
||||
// const users = await db.user.findMany()
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: '数据库连接成功',
|
||||
// userCount: users.length,
|
||||
// users: users
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('数据库测试错误:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: '数据库连接失败'
|
||||
}, { status: 500 })
|
||||
}
|
||||
}
|
||||
186
src/app/dashboard/components/allocationStatus.tsx
Normal file
186
src/app/dashboard/components/allocationStatus.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { formatNumber, validateNumber } from '@/lib/formatters'
|
||||
import LoadingCard from '@/components/ui/loadingCard'
|
||||
import ErrorCard from '@/components/ui/errorCard'
|
||||
|
||||
interface AllocationStatus {
|
||||
city: string
|
||||
count: number
|
||||
assigned: number
|
||||
}
|
||||
|
||||
interface ApiAllocationStatus {
|
||||
city?: string
|
||||
count: number | string | bigint
|
||||
assigned: number | string | bigint
|
||||
unique_allocated_ips: number | string | bigint
|
||||
}
|
||||
|
||||
export default function AllocationStatus({ detailed = false }: { detailed?: boolean }) {
|
||||
const [data, setData] = useState<AllocationStatus[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [timeFilter, setTimeFilter] = useState('24h') // 默认24小时
|
||||
const [customTime, setCustomTime] = useState('')
|
||||
|
||||
// 生成时间筛选条件
|
||||
const getTimeCondition = useCallback(() => {
|
||||
if (timeFilter === 'custom' && customTime) {
|
||||
// 将datetime-local格式转换为SQL datetime格式
|
||||
return customTime.replace('T', ' ') + ':00'
|
||||
}
|
||||
const now = new Date()
|
||||
let filterDate
|
||||
|
||||
switch(timeFilter) {
|
||||
case '1h':
|
||||
filterDate = new Date(now.getTime() - 60 * 60 * 1000)
|
||||
break
|
||||
case '6h':
|
||||
filterDate = new Date(now.getTime() - 6 * 60 * 60 * 1000)
|
||||
break
|
||||
case '12h':
|
||||
filterDate = new Date(now.getTime() - 12 * 60 * 60 * 1000)
|
||||
break
|
||||
case '24h':
|
||||
filterDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case '7d':
|
||||
filterDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
|
||||
break
|
||||
case 'fixed':
|
||||
return '2025-08-24 11:27:00'
|
||||
case 'custom':
|
||||
if (customTime) {
|
||||
return customTime
|
||||
}
|
||||
// 如果自定义时间为空,默认使用24小时
|
||||
filterDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
default:
|
||||
filterDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
return filterDate.toISOString().slice(0, 19).replace('T', ' ')
|
||||
}, [timeFilter, customTime])
|
||||
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const timeCondition = getTimeCondition()
|
||||
console.log('查询时间条件:', timeCondition)
|
||||
const response = await fetch(`/api/stats?type=allocation_status&time=${encodeURIComponent(timeCondition)}`)
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
|
||||
const result = await response.json()
|
||||
console.log(result, 'AllocationStatus的查询结果')
|
||||
|
||||
// 数据验证
|
||||
const validatedData = (result as ApiAllocationStatus[]).map((item) => ({
|
||||
city: item.city || '未知',
|
||||
count: validateNumber(item.count),
|
||||
assigned: validateNumber(item.assigned),
|
||||
}))
|
||||
|
||||
setData(validatedData)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch allocation status:', error)
|
||||
setError(error instanceof Error ? error.message : 'Unknown error')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [getTimeCondition])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
if (loading) return <LoadingCard title="节点分配状态" />
|
||||
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
|
||||
|
||||
const problematicCities = data.filter(item => item.count < item.count)
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">节点分配状态</h2>
|
||||
|
||||
{/* 时间筛选器 */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||
<label className="font-medium">时间筛选:</label>
|
||||
<select
|
||||
value={timeFilter}
|
||||
onChange={(e) => setTimeFilter(e.target.value)}
|
||||
className="border rounded p-2"
|
||||
>
|
||||
<option value="1h">最近1小时</option>
|
||||
<option value="6h">最近6小时</option>
|
||||
<option value="12h">最近12小时</option>
|
||||
<option value="24h">最近24小时</option>
|
||||
<option value="7d">最近7天</option>
|
||||
<option value="custom">自定义时间</option>
|
||||
</select>
|
||||
|
||||
{timeFilter === 'custom' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={customTime}
|
||||
onChange={(e) => setCustomTime(e.target.value)}
|
||||
className="border rounded p-2"
|
||||
/>
|
||||
<small>格式: YYYY-MM-DDTHH:MM</small>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={fetchData}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">{formatNumber(data.length)}</div>
|
||||
<div className="text-sm text-blue-800">监控城市数量</div>
|
||||
</div>
|
||||
<div className="bg-orange-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">{formatNumber(problematicCities.length)}</div>
|
||||
<div className="text-sm text-orange-800">需关注城市</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailed && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-4 py-2 text-left">城市</th>
|
||||
<th className="px-4 py-2 text-left">可用IP量</th>
|
||||
<th className="px-4 py-2 text-left">分配IP量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-4 py-2">{item.city}</td>
|
||||
<td className="px-4 py-2">{formatNumber(item.count)}</td>
|
||||
<td className="px-4 py-2">{formatNumber(item.assigned)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
src/app/dashboard/components/cityNodeStats.tsx
Normal file
83
src/app/dashboard/components/cityNodeStats.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface CityNode {
|
||||
city: string
|
||||
count: number
|
||||
hash: string
|
||||
label: string
|
||||
offset: string
|
||||
}
|
||||
|
||||
export default function CityNodeStats() {
|
||||
const [data, setData] = useState<CityNode[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/stats?type=city_node_count')
|
||||
const result = await response.json()
|
||||
setData(result)
|
||||
} catch (error) {
|
||||
console.error('获取城市节点数据失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">城市节点数量分布</h2>
|
||||
<div className="text-gray-600">加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-semibold">城市节点数量分布</h2>
|
||||
<span className="text-sm text-gray-500">
|
||||
共 {data.length} 个城市
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600">城市</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600">节点数量</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600">Hash</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600">标签</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600">轮换顺位</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, index) => (
|
||||
<tr key={index} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm font-medium">{item.city}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className="font-semibold text-gray-700">{item.count}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500 font-mono">{item.hash}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span className="bg-gray-100 px-2 py-1 rounded text-gray-700">
|
||||
{item.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-semibold">{item.offset}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
355
src/app/dashboard/components/edge.tsx
Normal file
355
src/app/dashboard/components/edge.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { validateNumber } from '@/lib/formatters'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
|
||||
interface Edge {
|
||||
id: number
|
||||
macaddr: string
|
||||
city: string
|
||||
public: string
|
||||
isp: string
|
||||
single: number | boolean
|
||||
sole: number | boolean
|
||||
arch: number
|
||||
online: number
|
||||
}
|
||||
|
||||
export default function Edge() {
|
||||
const [data, setData] = useState<Edge[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [idThreshold, setIdThreshold] = useState(20)
|
||||
const [limit, setLimit] = useState(100)
|
||||
|
||||
// 分页状态
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10)
|
||||
const [totalItems, setTotalItems] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async (threshold: number = idThreshold, resultLimit: number = limit) => {
|
||||
try {
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
|
||||
const response = await fetch(`/api/stats?type=edge_nodes&threshold=${threshold}&limit=${resultLimit}`)
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||
|
||||
const result = await response.json()
|
||||
console.log('Edge节点返回结果:', result)
|
||||
|
||||
type ResultEdge = {
|
||||
id: number
|
||||
macaddr: string
|
||||
city: string
|
||||
public: string
|
||||
isp: string
|
||||
single: number | boolean
|
||||
sole: number | boolean
|
||||
arch: number
|
||||
online: number
|
||||
}
|
||||
|
||||
const validatedData = (result as ResultEdge[]).map((item) => ({
|
||||
id: validateNumber(item.id),
|
||||
macaddr: item.macaddr || '',
|
||||
city: item.city || '',
|
||||
public: item.public || '',
|
||||
isp: item.isp || '',
|
||||
single: item.single === 1 || item.single === true,
|
||||
sole: item.sole === 1 || item.sole === true,
|
||||
arch: validateNumber(item.arch),
|
||||
online: validateNumber(item.online)
|
||||
}))
|
||||
|
||||
setData(validatedData)
|
||||
setTotalItems(validatedData.length)
|
||||
setCurrentPage(1) // 重置到第一页
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch edge nodes:', error)
|
||||
setError(error instanceof Error ? error.message : '获取边缘节点数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
fetchData(idThreshold, limit)
|
||||
}
|
||||
|
||||
const formatBoolean = (value: boolean | number): string => {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
const formatOnlineTime = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}秒`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}小时`
|
||||
return `${Math.floor(seconds / 86400)}天`
|
||||
}
|
||||
|
||||
// 计算分页数据
|
||||
const indexOfLastItem = currentPage * itemsPerPage
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage
|
||||
const currentItems = data.slice(indexOfFirstItem, indexOfLastItem)
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage)
|
||||
|
||||
// 生成页码按钮
|
||||
const renderPageNumbers = () => {
|
||||
const pageNumbers = []
|
||||
const maxVisiblePages = 5
|
||||
|
||||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2))
|
||||
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
|
||||
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(1, endPage - maxVisiblePages + 1)
|
||||
}
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
pageNumbers.push(
|
||||
<PaginationItem key={i}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
isActive={currentPage === i}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setCurrentPage(i)
|
||||
}}
|
||||
>
|
||||
{i}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
)
|
||||
}
|
||||
|
||||
return pageNumbers
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">节点列表</h2>
|
||||
<div className="text-center py-8">加载节点数据中...</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">节点列表</h2>
|
||||
<div className="text-center py-8 text-red-600">{error}</div>
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mx-auto block"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">节点列表</h2>
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 查询表单 */}
|
||||
<form onSubmit={handleSubmit} className="bg-gray-50 p-4 rounded-lg mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label htmlFor="threshold" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
ID阈值 (ID大于此值)
|
||||
</label>
|
||||
<input
|
||||
id="threshold"
|
||||
type="number"
|
||||
value={idThreshold}
|
||||
onChange={(e) => setIdThreshold(Number(e.target.value))}
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="limit" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
返回结果数量
|
||||
</label>
|
||||
<input
|
||||
id="limit"
|
||||
type="number"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(Number(e.target.value))}
|
||||
min="1"
|
||||
max="1000"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-green-600 text-white font-medium rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-4xl mb-4">📋</div>
|
||||
<p className="text-gray-600">暂无节点数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-blue-50 p-4 rounded-lg mb-6">
|
||||
<p className="text-blue-800">
|
||||
共找到 <span className="font-bold">{totalItems}</span> 个节点
|
||||
{idThreshold > 0 && ` (ID大于${idThreshold})`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 每页显示数量选择器 */}
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-gray-700">每页显示</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => {
|
||||
setItemsPerPage(Number(e.target.value))
|
||||
setCurrentPage(1)
|
||||
}}
|
||||
className="border border-gray-300 rounded-md px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span className="text-sm text-gray-700">条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-lg shadow mb-4">
|
||||
<table className="min-w-full table-auto border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">MAC地址</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">城市</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">公网IP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">运营商</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">多IP节点</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">独享IP</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">设备类型</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">在线时长</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentItems.map((item, index) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={`hover:bg-gray-50 transition-colors ${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-900">{item.id}</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-blue-600">{item.macaddr}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">{item.city}</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-green-600">{item.public}</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
item.isp === '移动' ? 'bg-blue-100 text-blue-800' :
|
||||
item.isp === '电信' ? 'bg-purple-100 text-purple-800' :
|
||||
item.isp === '联通' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{item.isp}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
item.single ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{formatBoolean(item.single)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
item.sole ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{formatBoolean(item.sole)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
{item.arch}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
{formatOnlineTime(item.online)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页控件 */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
显示 {indexOfFirstItem + 1} 到 {Math.min(indexOfLastItem, totalItems)} 条,共 {totalItems} 条记录
|
||||
</div>
|
||||
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (currentPage > 1) setCurrentPage(currentPage - 1)
|
||||
}}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{renderPageNumbers()}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1)
|
||||
}}
|
||||
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
更新时间: {new Date().toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
284
src/app/dashboard/components/gatewayConfig.tsx
Normal file
284
src/app/dashboard/components/gatewayConfig.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
interface GatewayConfig {
|
||||
id: number
|
||||
city: string
|
||||
edge: string
|
||||
user: string
|
||||
public: string
|
||||
inner_ip: string
|
||||
ischange: number
|
||||
isonline: number
|
||||
}
|
||||
|
||||
export default function GatewayConfig() {
|
||||
const [data, setData] = useState<GatewayConfig[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [macAddress, setMacAddress] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
// 监听URL的mac参数变化:同步到输入框并触发查询
|
||||
useEffect(() => {
|
||||
const urlMac = searchParams.get('mac')
|
||||
if (urlMac) {
|
||||
setMacAddress(urlMac)
|
||||
fetchData(urlMac)
|
||||
} else {
|
||||
// 如果没有mac参数,显示空状态或默认查询
|
||||
setData([])
|
||||
setSuccess('请输入MAC地址查询网关配置信息')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const fetchData = async (mac: string) => {
|
||||
if (!mac.trim()) {
|
||||
setError('请输入MAC地址')
|
||||
setSuccess('')
|
||||
setData([])
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setSuccess('')
|
||||
try {
|
||||
const response = await fetch(`/api/stats?type=gateway_config&mac=${encodeURIComponent(mac)}`)
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || '查询失败')
|
||||
}
|
||||
|
||||
console.log('API返回数据:', result)
|
||||
|
||||
// 检查返回的数据是否有效
|
||||
if (!result || result.length === 0) {
|
||||
setError(`未找到MAC地址为 ${mac} 的网关配置信息`)
|
||||
setData([])
|
||||
return
|
||||
}
|
||||
|
||||
const validatedData = result.map((item: {
|
||||
city: string
|
||||
edge: string
|
||||
user: string
|
||||
public: string
|
||||
inner_ip: string
|
||||
ischange: number
|
||||
isonline: number
|
||||
}) => ({
|
||||
city: item.city,
|
||||
edge: item.edge,
|
||||
user: item.user,
|
||||
public: item.public,
|
||||
inner_ip: item.inner_ip,
|
||||
ischange: item.ischange,
|
||||
isonline: item.isonline,
|
||||
}))
|
||||
|
||||
setData(validatedData)
|
||||
setSuccess(`成功查询到 ${validatedData.length} 条网关配置信息`)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gateway config:', error)
|
||||
setError(error instanceof Error ? error.message : '获取网关配置失败')
|
||||
setData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (macAddress.trim()) {
|
||||
fetchData(macAddress)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (value: number, trueText: string = '是', falseText: string = '否') => {
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
value === 1
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{value === 1 ? trueText : falseText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const getOnlineStatus = (isonline: number) => {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className={`w-2 h-2 rounded-full mr-2 ${
|
||||
isonline === 1 ? 'bg-green-500' : 'bg-red-500'
|
||||
}`} />
|
||||
{getStatusBadge(isonline, '在线', '离线')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-800">网关配置状态</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">查询和管理网关设备的配置信息</p>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
更新时间: {new Date().toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 查询表单 */}
|
||||
<form onSubmit={handleSubmit} className="mb-6">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={macAddress}
|
||||
onChange={(e) => setMacAddress(e.target.value)}
|
||||
placeholder="请输入MAC地址"
|
||||
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-2 px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="flex items-center text-red-800">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && !error && (
|
||||
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
|
||||
<div className="flex items-center text-green-800">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-4xl mb-4">⏳</div>
|
||||
<p className="text-gray-600">正在查询网关配置信息...</p>
|
||||
</div>
|
||||
) : data.length > 0 ? (
|
||||
<>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||
<div className="text-2xl font-bold text-blue-600">{data.length}</div>
|
||||
<div className="text-sm text-blue-800">网关数量</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-100">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{data.filter(item => item.isonline === 1).length}
|
||||
</div>
|
||||
<div className="text-sm text-green-800">在线网关</div>
|
||||
</div>
|
||||
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{data.filter(item => item.ischange === 1).length}
|
||||
</div>
|
||||
<div className="text-sm text-orange-800">已更新配置</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{new Set(data.map(item => item.city)).size}
|
||||
</div>
|
||||
<div className="text-sm text-purple-800">覆盖城市</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 详细表格 */}
|
||||
<div className="overflow-hidden rounded-lg shadow-sm border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">MAC地址</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">城市</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内部账号</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP地址</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">内网入口</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">配置更新</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">在线状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{data.map((item, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-mono text-sm text-blue-600 font-medium">
|
||||
{item.edge}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
|
||||
{item.city}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{item.user}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-mono text-sm text-green-600">
|
||||
{item.public}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="font-mono text-sm text-purple-600">
|
||||
{item.inner_ip}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getStatusBadge(item.ischange, '已更新', '未更新')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{getOnlineStatus(item.isonline)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页信息 */}
|
||||
<div className="mt-4 flex justify-between items-center text-sm text-gray-600">
|
||||
<span>显示 1 到 {data.length} 条,共 {data.length} 条记录</span>
|
||||
<button
|
||||
onClick={() => fetchData(macAddress)}
|
||||
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-4xl mb-4">🔍</div>
|
||||
<p className="text-gray-600">暂无数据,请输入MAC地址查询网关配置信息</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
您可以通过上方的搜索框查询特定MAC地址的网关配置
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
139
src/app/dashboard/components/gatewayinfo.tsx
Normal file
139
src/app/dashboard/components/gatewayinfo.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface GatewayInfo {
|
||||
macaddr: string
|
||||
inner_ip: string
|
||||
setid: string
|
||||
enable: number
|
||||
}
|
||||
|
||||
export default function Gatewayinfo() {
|
||||
const [data, setData] = useState<GatewayInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
const response = await fetch('/api/stats?type=gateway_info')
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取网关信息失败')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('网关信息API返回数据:', result)
|
||||
|
||||
setData(result)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch gateway info:', error)
|
||||
setError(error instanceof Error ? error.message : '获取网关信息失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (enable: number) => {
|
||||
return enable === 1 ? '启用' : '禁用'
|
||||
}
|
||||
|
||||
const getStatusClass = (enable: number) => {
|
||||
return enable === 1
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
||||
<div className="text-center py-8">加载网关信息中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
||||
<div className="text-center py-8 text-red-600">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">{data.length}</div>
|
||||
<div className="text-sm text-blue-800">网关总数</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{data.filter(item => item.enable === 1).length}
|
||||
</div>
|
||||
<div className="text-sm text-green-800">启用网关</div>
|
||||
</div>
|
||||
<div className="bg-red-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{data.filter(item => item.enable === 0).length}
|
||||
</div>
|
||||
<div className="text-sm text-red-800">禁用网关</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<div className="text-2xl font-bold text-purple-600">
|
||||
{new Set(data.map(item => item.setid)).size}
|
||||
</div>
|
||||
<div className="text-sm text-purple-800">配置版本数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full table-auto">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="px-4 py-2 text-left">MAC地址</th>
|
||||
<th className="px-4 py-2 text-left">内网IP</th>
|
||||
<th className="px-4 py-2 text-left">配置版本</th>
|
||||
<th className="px-4 py-2 text-left">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item, index) => (
|
||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`);
|
||||
}}
|
||||
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
|
||||
>
|
||||
{item.macaddr}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2">{item.inner_ip}</td>
|
||||
<td className="px-4 py-2">{item.setid}</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}>
|
||||
{getStatusText(item.enable)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
115
src/app/dashboard/page.tsx
Normal file
115
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Gatewayinfo from './components/gatewayinfo'
|
||||
import GatewayConfig from './components/gatewayConfig'
|
||||
import CityNodeStats from './components/cityNodeStats'
|
||||
import AllocationStatus from './components/allocationStatus'
|
||||
import Edge from './components/edge'
|
||||
import { LogOut } from 'lucide-react'
|
||||
|
||||
const tabs = [
|
||||
{ id: 'gatewayInfo', label: '网关信息' },
|
||||
{ id: 'gateway', label: '网关配置' },
|
||||
{ id: 'city', label: '城市信息' },
|
||||
{ id: 'allocation', label: '分配状态' },
|
||||
{ id: 'edge', label: '节点信息' }
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState('gatewayInfo')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
// 从 URL 中获取 tab 参数
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const urlTab = urlParams.get('tab')
|
||||
if (urlTab && tabs.some(tab => tab.id === urlTab)) {
|
||||
setActiveTab(urlTab)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/auth/logout', {
|
||||
method: 'POST',
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// 退出成功后跳转到登录页
|
||||
router.push('/login')
|
||||
router.refresh()
|
||||
} else {
|
||||
console.error('退出失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('退出错误:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabClick = (tabId: string) => {
|
||||
setActiveTab(tabId)
|
||||
// 更新 URL 参数
|
||||
router.push(`/dashboard?tab=${tabId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16 items-center">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-bold text-gray-900">网络节点管理系统</h1>
|
||||
</div>
|
||||
|
||||
{/* 简化的退出按钮 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
disabled={isLoading}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>{isLoading ? '退出中...' : '退出登录'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="border-b border-gray-200 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{activeTab === 'gatewayInfo' && <Gatewayinfo />}
|
||||
{activeTab === 'gateway' && <GatewayConfig />}
|
||||
{activeTab === 'city' && <CityNodeStats />}
|
||||
{activeTab === 'allocation' && <AllocationStatus detailed />}
|
||||
{activeTab === 'edge' && <Edge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
src/app/debug/page.tsx
Normal file
77
src/app/debug/page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
export default function DebugPage() {
|
||||
const [results, setResults] = useState<Record<string, unknown>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const testAllEndpoints = async () => {
|
||||
setLoading(true)
|
||||
const endpoints = [
|
||||
'gateway_config',
|
||||
'city_config_count',
|
||||
'city_node_count',
|
||||
'allocation_status',
|
||||
'duplicate_nodes'
|
||||
]
|
||||
|
||||
const results: Record<string, unknown> = {}
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const response = await fetch(`/api/stats?type=${endpoint}`)
|
||||
results[endpoint] = {
|
||||
status: response.status,
|
||||
data: await response.json()
|
||||
}
|
||||
} catch (error) {
|
||||
results[endpoint] = {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResults(results)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">API调试页面</h1>
|
||||
<button
|
||||
onClick={testAllEndpoints}
|
||||
disabled={loading}
|
||||
className="bg-blue-500 text-white px-4 py-2 rounded disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? '测试中...' : '测试所有API端点'}
|
||||
</button>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="text-xl font-semibold mb-4">测试结果:</h2>
|
||||
<pre className="bg-gray-100 p-4 rounded overflow-auto">
|
||||
{JSON.stringify(results, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="text-xl font-semibold mb-4">数据库连接测试:</h2>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await fetch('/api/test')
|
||||
const result = await response.json()
|
||||
console.log('数据库测试:', result)
|
||||
alert(JSON.stringify(result, null, 2))
|
||||
} catch (error) {
|
||||
alert('测试失败: ' + (error instanceof Error ? error.message : 'Unknown error'))
|
||||
}
|
||||
}}
|
||||
className="bg-green-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
测试数据库连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
122
src/app/globals.css
Normal file
122
src/app/globals.css
Normal file
@@ -0,0 +1,122 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
39
src/app/layout.tsx
Normal file
39
src/app/layout.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Toaster } from "sonner";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<SessionProvider>
|
||||
{children}
|
||||
<Toaster richColors />
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
13
src/app/page.tsx
Normal file
13
src/app/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
export default async function Home() {
|
||||
const prisma = new PrismaClient()
|
||||
const data = await prisma.change.findMany({ take: 10 })
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>数据库连接成功!</h1>
|
||||
<pre>{JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
src/app/test.ts
Normal file
20
src/app/test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// 测试查询
|
||||
const changes = await prisma.change.findMany({
|
||||
take: 5
|
||||
})
|
||||
console.log('✅ 数据库连接成功!')
|
||||
console.log('获取到的数据:', changes)
|
||||
} catch (error) {
|
||||
console.error('❌ 连接失败:', error)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
66
src/components/ui/alert.tsx
Normal file
66
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
22
src/components/ui/errorCard.tsx
Normal file
22
src/components/ui/errorCard.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function ErrorCard({
|
||||
title,
|
||||
error,
|
||||
onRetry
|
||||
}: {
|
||||
title: string
|
||||
error: string
|
||||
onRetry: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 text-red-600">
|
||||
<h2 className="text-lg font-semibold mb-2">{title}</h2>
|
||||
<p>加载失败: {error}</p>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
167
src/components/ui/form.tsx
Normal file
167
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
14
src/components/ui/loadingCard.tsx
Normal file
14
src/components/ui/loadingCard.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function LoadingCard({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
<div className="h-16 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
}
|
||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
13
src/components/ui/skeleton.tsx
Normal file
13
src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
25
src/components/ui/sonner.tsx
Normal file
25
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
66
src/components/ui/tabs.tsx
Normal file
66
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
8
src/lib/auth.ts
Normal file
8
src/lib/auth.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import NextAuth from "next-auth"
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter"
|
||||
import { prisma } from "./prisma"
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
adapter: PrismaAdapter(prisma),
|
||||
providers: [],
|
||||
})
|
||||
8
src/lib/db.ts
Normal file
8
src/lib/db.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// import { PrismaClient } from '@prisma/client'
|
||||
|
||||
// const globalForPrisma = global as { prisma?: PrismaClient }
|
||||
|
||||
// export const db = globalForPrisma.prisma || new PrismaClient()
|
||||
|
||||
// if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
|
||||
export { prisma as db } from './prisma'
|
||||
29
src/lib/definitions.ts
Normal file
29
src/lib/definitions.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SignupFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, { message: 'Name must be at least 2 characters long.' })
|
||||
.trim(),
|
||||
email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: 'Be at least 8 characters long' })
|
||||
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
|
||||
.regex(/[0-9]/, { message: 'Contain at least one number.' })
|
||||
.regex(/[^a-zA-Z0-9]/, {
|
||||
message: 'Contain at least one special character.',
|
||||
})
|
||||
.trim(),
|
||||
})
|
||||
|
||||
export type FormState =
|
||||
| {
|
||||
errors?: {
|
||||
name?: string[]
|
||||
email?: string[]
|
||||
password?: string[]
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
| undefined
|
||||
26
src/lib/formatters.ts
Normal file
26
src/lib/formatters.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// 数字格式化工具函数
|
||||
export const formatNumber = (num: number | string): string => {
|
||||
const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num
|
||||
return numberValue.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
export const formatLargeNumber = (num: number | string): string => {
|
||||
const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num
|
||||
|
||||
if (numberValue > 1e9) return `${(numberValue / 1e9).toFixed(1)}亿`
|
||||
if (numberValue > 1e6) return `${(numberValue / 1e6).toFixed(1)}百万`
|
||||
if (numberValue > 1e4) return `${(numberValue / 1e4).toFixed(1)}万`
|
||||
if (numberValue > 1e3) return `${(numberValue / 1e3).toFixed(1)}千`
|
||||
|
||||
return numberValue.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 数据验证函数
|
||||
export const validateNumber = (value: unknown): number => {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const num = parseInt(value)
|
||||
return isNaN(num) ? 0 : num
|
||||
}
|
||||
return 0
|
||||
}
|
||||
13
src/lib/prisma.ts
Normal file
13
src/lib/prisma.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const globalForPrisma = global as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma
|
||||
}
|
||||
|
||||
export default prisma
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
29
src/middleware.ts
Normal file
29
src/middleware.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { NextRequest } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const session = request.cookies.get('session')
|
||||
console.log(session, 'sessionsessionsession');
|
||||
|
||||
// 保护的路由需要验证会话
|
||||
if (request.nextUrl.pathname.startsWith('/dashboard')) {
|
||||
if (!session?.value) {
|
||||
return NextResponse.redirect(new URL('/login', request.url))
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户重定向
|
||||
if (session?.value && (
|
||||
request.nextUrl.pathname === '/login' ||
|
||||
request.nextUrl.pathname === '/register'
|
||||
)) {
|
||||
console.log(session, 'sessionsessionsession');
|
||||
return NextResponse.redirect(new URL('/dashboard', request.url))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/login', '/register']
|
||||
}
|
||||
19
src/store/auth.ts
Normal file
19
src/store/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
setAuth: (state: boolean) => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
isAuthenticated: false,
|
||||
setAuth: (state) => set({ isAuthenticated: state }),
|
||||
}),
|
||||
{
|
||||
name: 'auth-storage',
|
||||
}
|
||||
)
|
||||
)
|
||||
27
src/types/auth.d.ts
vendored
Normal file
27
src/types/auth.d.ts
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
export interface User {
|
||||
id: number
|
||||
phone: string
|
||||
name?: string | null
|
||||
verifiedPhone: boolean
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
userId: number
|
||||
expires: Date
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
user?: {
|
||||
id: number
|
||||
phone: string
|
||||
name?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
43
tsconfig.json
Normal file
43
tsconfig.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@/lib/*": [
|
||||
"./src/lib/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user