初始化项目
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user