添加设置模块修复网关信息MAC地址跳转问题,删除冗余的文件和代码

This commit is contained in:
wmp
2025-09-16 18:12:55 +08:00
parent a25ce604f0
commit 3322d6a8e4
11 changed files with 641 additions and 80 deletions

View File

@@ -15,6 +15,7 @@
"@auth/prisma-adapter": "^2.10.0", "@auth/prisma-adapter": "^2.10.0",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@prisma/client": "^6.15.0", "@prisma/client": "^6.15.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@@ -52,6 +53,5 @@
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
"typescript": "^5", "typescript": "^5",
"zod": "^4.1.5" "zod": "^4.1.5"
}, }
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

View File

@@ -39,7 +39,6 @@ CREATE TABLE `users` (
`password` varchar(191) NOT NULL, `password` varchar(191) NOT NULL,
`phone` varchar(191) NOT NULL, `phone` varchar(191) NOT NULL,
`updatedAt` datetime(3) NOT NULL, `updatedAt` datetime(3) NOT NULL,
`verifiedPhone` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `users_phone_key` (`phone`) UNIQUE KEY `users_phone_key` (`phone`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,16 +0,0 @@
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
}

View File

@@ -124,10 +124,9 @@ model Account {
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
phone String @unique account String @unique
password String password String
name String? name String?
verifiedPhone Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]

View File

@@ -8,16 +8,13 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Lock, Phone } from 'lucide-react' import { Lock, User } from 'lucide-react'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { toast, Toaster } from 'sonner' import { toast, Toaster } from 'sonner'
const formSchema = z.object({ const formSchema = z.object({
phone: z.string() account: z.string().min(3, '账号至少需要3个字符'),
.min(11, '手机号必须是11位') password: z.string().min(6, '密码至少需要6个字符'),
.max(11, '手机号必须是11位')
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
password: z.string().min(6, '密码至少需要6个字符'),
}) })
export default function LoginPage() { export default function LoginPage() {
@@ -27,7 +24,7 @@ export default function LoginPage() {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
phone: '', account: '',
password: '', password: '',
}, },
}) })
@@ -78,18 +75,17 @@ export default function LoginPage() {
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="phone" name="account"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Phone className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <User className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="请输入手机号" placeholder="请输入您的账号"
className="pl-8" className="pl-8"
{...field} {...field}
maxLength={11}
/> />
</div> </div>
</FormControl> </FormControl>

View File

@@ -4,23 +4,22 @@ import { compare } from 'bcryptjs'
import { z } from 'zod' import { z } from 'zod'
const loginSchema = z.object({ const loginSchema = z.object({
phone: z.string() account: z.string().min(3, '账号至少需要3个字符'),
.min(11, '手机号必须是11位')
.max(11, '手机号必须是11位')
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
password: z.string().min(6, '密码至少需要6个字符'), password: z.string().min(6, '密码至少需要6个字符'),
}) })
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json() const body = await request.json()
const { phone, password } = loginSchema.parse(body) const { account, password } = loginSchema.parse(body)
// 查找用户 - 使用正确的查询方式 const user = await prisma.user.findFirst({
const user = await prisma.user.findUnique({
where: { where: {
phone: phone.trim() // 去除空格 OR: [
{ account: account.trim() },
{ password: account.trim() }
]
}, },
}) })
@@ -57,7 +56,7 @@ export async function POST(request: Request) {
success: true, success: true,
user: { user: {
id: user.id, id: user.id,
phone: user.phone, account: user.account,
name: user.name name: user.name
} }
}) })

132
src/app/api/users/route.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { hash } from 'bcryptjs'
// 获取所有用户
export async function GET() {
try {
const users = await prisma.user.findMany({
select: {
id: true,
account: true,
name: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json({
success: true,
users
})
} catch (error) {
console.error('获取用户列表错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 }
)
}
}
// 创建用户
export async function POST(request: Request) {
try {
const { account, password, name } = await request.json()
// 检查用户是否已存在
const existingUser = await prisma.user.findUnique({
where: { account }
})
if (existingUser) {
return NextResponse.json(
{ success: false, error: '用户账号已存在' },
{ status: 400 }
)
}
// 加密密码
const hashedPassword = await hash(password, 10)
// 创建用户
const user = await prisma.user.create({
data: {
account,
password: hashedPassword,
name: name || account,
},
})
// 不返回密码字段
const { password: _, ...userWithoutPassword } = user
return NextResponse.json({
success: true,
user: userWithoutPassword
})
} catch (error) {
console.error('创建用户错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 }
)
}
}
// 删除用户
export async function DELETE(request: Request) {
try {
// 从URL中获取查询参数
const url = new URL(request.url)
const id = url.searchParams.get('id')
if (!id) {
return NextResponse.json(
{ success: false, error: '用户ID不能为空' },
{ status: 400 }
)
}
const userId = parseInt(id)
if (isNaN(userId)) {
return NextResponse.json(
{ success: false, error: '无效的用户ID' },
{ status: 400 }
)
}
// 检查用户是否存在
const existingUser = await prisma.user.findUnique({
where: { id: userId }
})
if (!existingUser) {
return NextResponse.json(
{ success: false, error: '用户不存在' },
{ status: 404 }
)
}
// 删除用户
await prisma.user.delete({
where: { id: userId }
})
return NextResponse.json({
success: true,
message: '用户删除成功'
})
} catch (error) {
console.error('删除用户错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,333 @@
'use client'
import { useState, useEffect } from 'react'
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, CardDescription } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { User, Lock, Search, Trash2, Plus, X } from 'lucide-react'
import { toast, Toaster } from 'sonner'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// 用户类型定义
interface UserData {
id: string
account: string
createdAt: string
}
const formSchema = z.object({
account: z.string().min(3, '账号至少需要3个字符'),
password: z.string().min(6, '密码至少需要6个字符'),
confirmPassword: z.string().min(6, '密码至少需要6个字符'),
}).refine((data) => data.password === data.confirmPassword, {
message: "密码不匹配",
path: ["confirmPassword"],
})
export default function Settings() {
const [loading, setLoading] = useState(false)
const [users, setUsers] = useState<UserData[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [isCreateMode, setIsCreateMode] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
account: '',
password: '',
confirmPassword: '',
},
})
// 获取用户列表
const fetchUsers = async () => {
try {
const response = await fetch('/api/users')
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || '获取用户列表失败')
}
if (data.success) {
setUsers(data.users)
}
} catch (error) {
toast.error("获取用户列表失败", {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
})
}
}
// 初始加载用户列表
useEffect(() => {
fetchUsers()
}, [])
// 创建用户
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true)
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
account: values.account,
password: values.password,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || '创建用户失败')
}
if (data.success) {
toast.success("用户创建成功", {
description: "新账户已成功添加",
})
form.reset()
setIsCreateMode(false)
fetchUsers() // 刷新用户列表
}
} catch (error) {
toast.error("创建用户失败", {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
})
} finally {
setLoading(false)
}
}
// 删除用户
const handleDeleteUser = async (userId: number) => {
if (!confirm('确定要删除这个用户吗?此操作不可恢复。')) {
return
}
try {
// 使用查询参数传递ID
const response = await fetch(`/api/users?id=${userId}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || '删除用户失败')
}
if (data.success) {
toast.success("用户删除成功", {
description: "用户账户已删除",
})
fetchUsers() // 刷新用户列表
}
} catch (error) {
toast.error("删除用户失败", {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
})
}
}
// 过滤用户列表
const filteredUsers = users.filter(user =>
user.account.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<div className="min-h-screen bg-white p-4 md:p-8">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"></h1>
<Button onClick={() => setIsCreateMode(!isCreateMode)}>
{isCreateMode ? (
<>
<X className="mr-2 h-4 w-4" />
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{isCreateMode && (
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="请输入需要添加的账号"
className="pl-8"
{...field}
/>
</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>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
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>
)}
/>
<div className="flex gap-2">
<Button
type="submit"
disabled={loading}
>
{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>
<Button
type="button"
variant="outline"
onClick={() => setIsCreateMode(false)}
>
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
<div className="relative mt-4 max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索用户..."
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-4">
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.account}</TableCell>
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteUser(Number(user.id))}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
<Toaster richColors />
</div>
)
}

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import Gatewayinfo from './components/gatewayinfo' import Gatewayinfo from './components/gatewayinfo'
import GatewayConfig from './components/gatewayConfig' import GatewayConfig from './components/gatewayConfig'
import CityNodeStats from './components/cityNodeStats' import CityNodeStats from './components/cityNodeStats'
import AllocationStatus from './components/allocationStatus' import AllocationStatus from './components/allocationStatus'
import Settings from './components/settings'
import Edge from './components/edge' import Edge from './components/edge'
import { LogOut } from 'lucide-react' import { LogOut } from 'lucide-react'
@@ -14,24 +15,23 @@ const tabs = [
{ id: 'gateway', label: '网关配置' }, { id: 'gateway', label: '网关配置' },
{ id: 'city', label: '城市信息' }, { id: 'city', label: '城市信息' },
{ id: 'allocation', label: '分配状态' }, { id: 'allocation', label: '分配状态' },
{ id: 'edge', label: '节点信息' } { id: 'edge', label: '节点信息' },
{ id: 'setting', label: '设置'}
] ]
export default function Dashboard() { export default function Dashboard() {
const [activeTab, setActiveTab] = useState('gatewayInfo') const [activeTab, setActiveTab] = useState('gatewayInfo')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
// URL 中获取 tab 参数 // 监听URL参数变化
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { const urlTab = searchParams.get('tab')
const urlParams = new URLSearchParams(window.location.search) if (urlTab && tabs.some(tab => tab.id === urlTab)) {
const urlTab = urlParams.get('tab') setActiveTab(urlTab)
if (urlTab && tabs.some(tab => tab.id === urlTab)) {
setActiveTab(urlTab)
}
} }
}, []) }, [searchParams])
// 退出登录 // 退出登录
const handleLogout = async () => { const handleLogout = async () => {
@@ -58,7 +58,9 @@ export default function Dashboard() {
const handleTabClick = (tabId: string) => { const handleTabClick = (tabId: string) => {
setActiveTab(tabId) setActiveTab(tabId)
// 更新 URL 参数 // 更新 URL 参数
router.push(`/dashboard?tab=${tabId}`) const params = new URLSearchParams(searchParams.toString())
params.set('tab', tabId)
router.push(`/dashboard?${params.toString()}`)
} }
return ( return (
@@ -108,6 +110,7 @@ export default function Dashboard() {
{activeTab === 'city' && <CityNodeStats />} {activeTab === 'city' && <CityNodeStats />}
{activeTab === 'allocation' && <AllocationStatus detailed />} {activeTab === 'allocation' && <AllocationStatus detailed />}
{activeTab === 'edge' && <Edge />} {activeTab === 'edge' && <Edge />}
{activeTab === 'setting' && <Settings/>}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background 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 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

27
src/types/auth.d.ts vendored
View File

@@ -1,27 +0,0 @@
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
}