新增表单查询和复制功能
This commit is contained in:
@@ -105,15 +105,22 @@ export type GatewayConfig = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 网关配置
|
// 网关配置
|
||||||
export async function getGatewayConfig(page?: number, mac?: string): Promise<Res<Page<GatewayConfig>>> {
|
export async function getGatewayConfig(page?: number, filters?: {
|
||||||
|
mac?: string
|
||||||
|
public?: string
|
||||||
|
city?: string
|
||||||
|
user?: string
|
||||||
|
inner_ip?: string
|
||||||
|
}): Promise<Res<Page<GatewayConfig>>> {
|
||||||
try {
|
try {
|
||||||
if (!page && !mac) {
|
if (!page && !filters?.mac) {
|
||||||
throw new Error('页码和MAC地址不能同时为空')
|
throw new Error('页码和MAC地址不能同时为空')
|
||||||
}
|
}
|
||||||
|
|
||||||
page = mac ? 1 : Math.max(1, page || 1)
|
page = filters?.mac ? 1 : Math.max(1, page || 1)
|
||||||
|
|
||||||
const [total, result] = await Promise.all([
|
const [total, result] = await Promise.all([
|
||||||
drizzle.$count(gateway, mac ? eq(gateway.macaddr, mac) : undefined),
|
drizzle.$count(gateway, filters?.mac ? eq(gateway.macaddr, filters?.mac) : undefined),
|
||||||
drizzle
|
drizzle
|
||||||
.select({
|
.select({
|
||||||
city: cityhash.city,
|
city: cityhash.city,
|
||||||
@@ -127,12 +134,11 @@ export async function getGatewayConfig(page?: number, mac?: string): Promise<Res
|
|||||||
.from(gateway)
|
.from(gateway)
|
||||||
.leftJoin(cityhash, eq(cityhash.hash, gateway.cityhash))
|
.leftJoin(cityhash, eq(cityhash.hash, gateway.cityhash))
|
||||||
.leftJoin(edge, eq(edge.macaddr, gateway.edge))
|
.leftJoin(edge, eq(edge.macaddr, gateway.edge))
|
||||||
.where(mac ? eq(gateway.macaddr, mac) : undefined)
|
.where(filters?.mac ? eq(gateway.macaddr, filters?.mac) : undefined)
|
||||||
.orderBy(sql`inet_aton(gateway.inner_ip)`)
|
.orderBy(sql`inet_aton(gateway.inner_ip)`)
|
||||||
.offset((page - 1) * 250)
|
.offset((page - 1) * 250)
|
||||||
.limit(250),
|
.limit(250),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -201,12 +207,44 @@ export async function getCityNodeCount() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取节点信息
|
export type Edge = {
|
||||||
export async function getEdgeNodes(page: number, size: number) {
|
id: number
|
||||||
try {
|
macaddr: string
|
||||||
const offset = Math.max(0, (page - 1)) * size
|
city: string
|
||||||
const limit = Math.min(100, Math.max(10, size))
|
public: string
|
||||||
|
isp: string
|
||||||
|
single: number | boolean
|
||||||
|
sole: number | boolean
|
||||||
|
arch: number
|
||||||
|
online: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点信息
|
||||||
|
export async function getEdgeNodes(props?: {
|
||||||
|
macaddr?: string
|
||||||
|
public?: string
|
||||||
|
city?: string
|
||||||
|
isp?: string
|
||||||
|
},
|
||||||
|
page: number = 1) {
|
||||||
|
try {
|
||||||
|
const size = 250
|
||||||
|
const offset = Math.max(0, (page - 1)) * size
|
||||||
|
// 构建查询条件
|
||||||
|
let whereCondition = eq(edge.active, 1)
|
||||||
|
|
||||||
|
if (props?.macaddr) {
|
||||||
|
whereCondition = sql`${whereCondition} AND ${edge.macaddr} = ${props.macaddr}`
|
||||||
|
}
|
||||||
|
if (props?.public) {
|
||||||
|
whereCondition = sql`${whereCondition} AND ${edge.public} LIKE ${`%${props.public}%`}`
|
||||||
|
}
|
||||||
|
if (props?.city) {
|
||||||
|
whereCondition = sql`${whereCondition} AND ${cityhash.city} LIKE ${`%${props.city}%`}`
|
||||||
|
}
|
||||||
|
if (props?.isp) {
|
||||||
|
whereCondition = sql`${whereCondition} AND ${edge.isp} LIKE ${`%${props.isp}%`}`
|
||||||
|
}
|
||||||
const [total, data] = await Promise.all([
|
const [total, data] = await Promise.all([
|
||||||
drizzle.$count(edge, eq(edge.active, 1)),
|
drizzle.$count(edge, eq(edge.active, 1)),
|
||||||
drizzle
|
drizzle
|
||||||
@@ -226,15 +264,14 @@ export async function getEdgeNodes(page: number, size: number) {
|
|||||||
.where(eq(edge.active, 1))
|
.where(eq(edge.active, 1))
|
||||||
.orderBy(edge.id)
|
.orderBy(edge.id)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
.limit(limit),
|
.limit(size),
|
||||||
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
success: true,
|
||||||
data,
|
data,
|
||||||
totalCount: total,
|
totalCount: total,
|
||||||
currentPage: Math.floor(offset / limit) + 1,
|
|
||||||
totalPages: Math.ceil(total / limit),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
|
|||||||
@@ -5,30 +5,82 @@ import LoadingCard from '@/components/ui/loadingCard'
|
|||||||
import ErrorCard from '@/components/ui/errorCard'
|
import ErrorCard from '@/components/ui/errorCard'
|
||||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
||||||
import { getAllocationStatus, type AllocationStatus } from '@/actions/stats'
|
import { getAllocationStatus, type AllocationStatus } from '@/actions/stats'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Form, FormField } from '@/components/ui/form'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'
|
||||||
|
|
||||||
|
const filterSchema = z.object({
|
||||||
|
timeFilter: z.string(),
|
||||||
|
})
|
||||||
|
type FilterSchema = z.infer<typeof filterSchema>
|
||||||
|
|
||||||
|
type SortKey = 'city' | 'count' | 'assigned' | 'overage'
|
||||||
|
|
||||||
export default function AllocationStatus({ detailed = false }: { detailed?: boolean }) {
|
export default function AllocationStatus({ detailed = false }: { detailed?: boolean }) {
|
||||||
const [data, setData] = useState<AllocationStatus[]>([])
|
const [data, setData] = useState<AllocationStatus[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [timeFilter, setTimeFilter] = useState('24') // 默认24小时
|
// 3.添加状态管理
|
||||||
const [customHours, setCustomHours] = useState('')
|
const [sortKey, setSortKey] = useState<SortKey>('count')
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
||||||
|
|
||||||
|
const form = useForm<FilterSchema>({
|
||||||
|
resolver: zodResolver(filterSchema),
|
||||||
|
defaultValues: {
|
||||||
|
timeFilter: '24',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const timeFilter = form.watch('timeFilter')
|
||||||
|
|
||||||
// 获取时间参数(小时数)
|
|
||||||
const getTimeHours = useCallback(() => {
|
const getTimeHours = useCallback(() => {
|
||||||
if (timeFilter === 'custom' && customHours) {
|
return parseInt(timeFilter) || 24
|
||||||
const hours = parseInt(customHours)
|
}, [timeFilter])
|
||||||
return isNaN(hours) ? 24 : Math.max(1, hours) // 默认24小时,最少1小时
|
// 1.准备计算超额量的工具函数
|
||||||
}
|
|
||||||
|
|
||||||
return parseInt(timeFilter) || 24 // 默认24小时
|
|
||||||
}, [timeFilter, customHours])
|
|
||||||
|
|
||||||
// 计算超额量
|
|
||||||
const calculateOverage = (assigned: number, count: number) => {
|
const calculateOverage = (assigned: number, count: number) => {
|
||||||
const overage = assigned - count
|
return Math.max(0, assigned - count)
|
||||||
return Math.max(0, overage)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.写一个排序数据的函数
|
||||||
|
// 5.计算排序后的数据
|
||||||
|
const sortedData = [...data].sort((a, b) => {
|
||||||
|
let aValue: string | number
|
||||||
|
let bValue: string | number
|
||||||
|
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'city':
|
||||||
|
aValue = a.city || '未知'
|
||||||
|
bValue = b.city || '未知'
|
||||||
|
break
|
||||||
|
case 'count':
|
||||||
|
aValue = Number(a.count)
|
||||||
|
bValue = Number(b.count)
|
||||||
|
break
|
||||||
|
case 'assigned':
|
||||||
|
aValue = Number(a.assigned)
|
||||||
|
bValue = Number(b.assigned)
|
||||||
|
break
|
||||||
|
case 'overage':
|
||||||
|
aValue = calculateOverage(Number(a.assigned), Number(a.count))
|
||||||
|
bValue = calculateOverage(Number(b.assigned), Number(b.count))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof aValue === 'string') {
|
||||||
|
return sortDirection === 'asc'
|
||||||
|
? aValue.localeCompare(bValue as string)
|
||||||
|
: (bValue as string).localeCompare(aValue)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return sortDirection === 'asc'
|
||||||
|
? aValue - (bValue as number)
|
||||||
|
: (bValue as number) - aValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -37,16 +89,13 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
|
|||||||
const hours = getTimeHours()
|
const hours = getTimeHours()
|
||||||
const result = await getAllocationStatus(hours)
|
const result = await getAllocationStatus(hours)
|
||||||
|
|
||||||
// 数据验证
|
const validatedData = result.data.map(item => ({
|
||||||
const validatedData = (result.data).map(item => ({
|
|
||||||
city: item.city || '未知',
|
city: item.city || '未知',
|
||||||
count: item.count,
|
count: item.count,
|
||||||
assigned: item.assigned,
|
assigned: item.assigned,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const sortedData = validatedData.sort((a, b) => b.count - a.count)
|
setData(validatedData)
|
||||||
|
|
||||||
setData(sortedData)
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('Failed to fetch allocation status:', error)
|
console.error('Failed to fetch allocation status:', error)
|
||||||
@@ -61,36 +110,67 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [fetchData])
|
}, [fetchData])
|
||||||
|
|
||||||
|
// 4.封装一个函数用于处理排序问题
|
||||||
|
const handleSort = (key: SortKey) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDirection(direction => direction === 'asc' ? 'desc' : 'asc')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortDirection('desc')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 渲染排序图标,然后集成到ui里
|
||||||
|
const renderSortIcon = (key: SortKey) => {
|
||||||
|
if (sortKey !== key) {
|
||||||
|
return <ArrowUpDown className="h-4 w-4 text-gray-400" />
|
||||||
|
}
|
||||||
|
return sortDirection === 'asc'
|
||||||
|
? <ArrowUp className="h-4 w-4 text-blue-600" />
|
||||||
|
: <ArrowDown className="h-4 w-4 text-blue-600" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = (data: FilterSchema) => {
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <LoadingCard title="节点分配状态" />
|
if (loading) return <LoadingCard title="节点分配状态" />
|
||||||
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
|
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
|
||||||
|
|
||||||
const problematicCities = data.filter(item => item.assigned > item.count)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden ">
|
<div className="flex flex-col bg-white shadow p-6 overflow-hidden">
|
||||||
<h2 className="text-lg font-semibold mb-4">节点分配状态</h2>
|
<h2 className="text-lg font-semibold mb-4">节点分配状态</h2>
|
||||||
|
|
||||||
{/* 时间筛选器 */}
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3">
|
<div className="mb-4 flex flex-wrap items-center gap-3">
|
||||||
<label className="font-medium">时间筛选:</label>
|
<Form {...form}>
|
||||||
<select
|
<form onSubmit={form.handleSubmit(onSubmit)} className="flex items-center gap-4">
|
||||||
value={timeFilter}
|
<FormField
|
||||||
onChange={e => setTimeFilter(e.target.value)}
|
name="timeFilter"
|
||||||
className="border rounded p-2"
|
render={({ field }) => (
|
||||||
>
|
<div className="flex items-center">
|
||||||
<option value="1">最近1小时</option>
|
<span className="text-sm mr-2">时间筛选:</span>
|
||||||
<option value="6">最近6小时</option>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<option value="12">最近12小时</option>
|
<SelectTrigger className="h-9 w-36">
|
||||||
<option value="24">最近24小时</option>
|
<SelectValue placeholder="选择时间范围" />
|
||||||
<option value="168">最近7天</option>
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent>
|
||||||
|
<SelectItem value="1">最近1小时</SelectItem>
|
||||||
|
<SelectItem value="4">最近4小时</SelectItem>
|
||||||
|
<SelectItem value="12">最近12小时</SelectItem>
|
||||||
|
<SelectItem value="24">最近24小时</SelectItem>
|
||||||
|
<SelectItem value="168">最近7天</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<Button type="submit" className="py-2 bg-blue-600 hover:bg-blue-700">
|
||||||
onClick={fetchData}
|
|
||||||
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
查询
|
查询
|
||||||
</button>
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-6 overflow-hidden">
|
<div className="flex gap-6 overflow-hidden">
|
||||||
@@ -98,20 +178,49 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50">
|
<TableRow className="bg-gray-50">
|
||||||
<TableHead className="px-4 py-2 text-left">城市</TableHead>
|
<TableHead
|
||||||
<TableHead className="px-4 py-2 text-left">可用IP量</TableHead>
|
className="px-4 py-2 text-left cursor-pointer hover:bg-blue-50 transition-colors"
|
||||||
<TableHead className="px-4 py-2 text-left">分配IP量</TableHead>
|
onClick={() => handleSort('city')}
|
||||||
<TableHead className="px-4 py-2 text-left">超额量</TableHead>
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>城市</span>
|
||||||
|
{renderSortIcon('city')}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="px-4 py-2 text-left cursor-pointer hover:bg-blue-50 transition-colors"
|
||||||
|
onClick={() => handleSort('count')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>可用IP量</span>
|
||||||
|
{renderSortIcon('count')}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="px-4 py-2 text-left cursor-pointer hover:bg-blue-50 transition-colors"
|
||||||
|
onClick={() => handleSort('assigned')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>分配IP量</span>
|
||||||
|
{renderSortIcon('assigned')}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="px-4 py-2 text-left cursor-pointer hover:bg-blue-50 transition-colors"
|
||||||
|
onClick={() => handleSort('overage')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>超额量</span>
|
||||||
|
{renderSortIcon('overage')}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((item, index) => {
|
{sortedData.map((item, index) => {
|
||||||
const overage = calculateOverage(Number(item.assigned), Number(item.count))
|
const overage = calculateOverage(Number(item.assigned), Number(item.count))
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
key={index}
|
|
||||||
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
|
|
||||||
>
|
|
||||||
<TableCell className="px-4 py-2">{item.city}</TableCell>
|
<TableCell className="px-4 py-2">{item.city}</TableCell>
|
||||||
<TableCell className="px-4 py-2">{item.count}</TableCell>
|
<TableCell className="px-4 py-2">{item.count}</TableCell>
|
||||||
<TableCell className="px-4 py-2">{item.assigned}</TableCell>
|
<TableCell className="px-4 py-2">{item.assigned}</TableCell>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export default function CityNodeStats() {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await getCityNodeCount()
|
const result = await getCityNodeCount()
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || '查询城市节点失败')
|
||||||
|
}
|
||||||
setData(result.data)
|
setData(result.data)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -27,7 +30,7 @@ export default function CityNodeStats() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg p-6 overflow-hidden">
|
<div className="bg-white p-6 overflow-hidden">
|
||||||
<h2 className="text-lg font-semibold mb-4">城市节点数量分布</h2>
|
<h2 className="text-lg font-semibold mb-4">城市节点数量分布</h2>
|
||||||
<div className="text-gray-600">加载中...</div>
|
<div className="text-gray-600">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +38,7 @@ export default function CityNodeStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-white rounded-lg p-6 overflow-hidden">
|
<div className="flex flex-col bg-white p-6 overflow-hidden">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-lg font-semibold">城市节点数量分布</h2>
|
<h2 className="text-lg font-semibold">城市节点数量分布</h2>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
@@ -66,7 +69,7 @@ export default function CityNodeStats() {
|
|||||||
<TableCell className="px-4 py-2">{item.hash}</TableCell>
|
<TableCell className="px-4 py-2">{item.hash}</TableCell>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
<span className="bg-gray-100 px-2 py-1 rounded text-gray-700">
|
<span className="bg-gray-100 px-2 py-1 rounded text-gray-700">
|
||||||
{item.label}
|
{item.label || '无标签'}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2">{item.offset}</TableCell>
|
<TableCell className="px-4 py-2">{item.offset}</TableCell>
|
||||||
|
|||||||
@@ -3,19 +3,24 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Pagination } from '@/components/ui/pagination'
|
import { Pagination } from '@/components/ui/pagination'
|
||||||
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
|
||||||
import { getEdgeNodes } from '@/actions/stats'
|
import { getEdgeNodes, type Edge } from '@/actions/stats'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface Edge {
|
// 定义表单验证规则
|
||||||
id: number
|
const filterSchema = z.object({
|
||||||
macaddr: string
|
macaddr: z.string().optional(),
|
||||||
city: string
|
public: z.string().optional(),
|
||||||
public: string
|
city: z.string().optional(),
|
||||||
isp: string
|
isp: z.string().optional(),
|
||||||
single: number | boolean
|
})
|
||||||
sole: number | boolean
|
|
||||||
arch: number
|
type FilterFormValues = z.infer<typeof filterSchema>
|
||||||
online: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Edge() {
|
export default function Edge() {
|
||||||
const [data, setData] = useState<Edge[]>([])
|
const [data, setData] = useState<Edge[]>([])
|
||||||
@@ -23,38 +28,36 @@ export default function Edge() {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
// 分页状态
|
// 分页状态
|
||||||
const [currentPage, setCurrentPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(100) // 默认100条
|
const [total, setTotal] = useState(0)
|
||||||
const [totalItems, setTotalItems] = useState(0)
|
|
||||||
|
// 初始化表单
|
||||||
|
const form = useForm<FilterFormValues>({
|
||||||
|
resolver: zodResolver(filterSchema),
|
||||||
|
defaultValues: {
|
||||||
|
macaddr: '',
|
||||||
|
public: '',
|
||||||
|
city: '',
|
||||||
|
isp: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData()
|
fetchData({}, page)
|
||||||
}, [currentPage, itemsPerPage]) // 监听页码和每页数量的变化
|
}, [page])
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async (val: {
|
||||||
|
macaddr?: string
|
||||||
|
public?: string
|
||||||
|
city?: string
|
||||||
|
isp?: string
|
||||||
|
}, page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
const result = await getEdgeNodes(val, page)
|
||||||
|
|
||||||
// 计算偏移量
|
const validatedData = (result.data).map(item => ({
|
||||||
const offset = (currentPage - 1) * itemsPerPage
|
|
||||||
|
|
||||||
// if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
||||||
|
|
||||||
const result = await getEdgeNodes(offset, itemsPerPage)
|
|
||||||
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.data as ResultEdge[]).map(item => ({
|
|
||||||
id: item.id,
|
id: item.id,
|
||||||
macaddr: item.macaddr || '',
|
macaddr: item.macaddr || '',
|
||||||
city: item.city || '',
|
city: item.city || '',
|
||||||
@@ -67,7 +70,7 @@ export default function Edge() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
setData(validatedData)
|
setData(validatedData)
|
||||||
setTotalItems(result.totalCount || 0)
|
setTotal(result.totalCount || 0)
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('Failed to fetch edge nodes:', error)
|
console.error('Failed to fetch edge nodes:', error)
|
||||||
@@ -78,6 +81,18 @@ export default function Edge() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (values: FilterFormValues) => {
|
||||||
|
setPage(1)
|
||||||
|
const filters = {
|
||||||
|
macaddr: values.macaddr || undefined,
|
||||||
|
public: values.public || undefined,
|
||||||
|
city: values.city || undefined,
|
||||||
|
isp: values.isp || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData(filters, 1)
|
||||||
|
}
|
||||||
|
|
||||||
// 多IP节点格式化
|
// 多IP节点格式化
|
||||||
const formatMultiIP = (value: number | boolean): string => {
|
const formatMultiIP = (value: number | boolean): string => {
|
||||||
if (typeof value === 'number') {
|
if (typeof value === 'number') {
|
||||||
@@ -149,28 +164,43 @@ export default function Edge() {
|
|||||||
|
|
||||||
// 处理页码变化
|
// 处理页码变化
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page)
|
setPage(page)
|
||||||
|
const formValues = form.getValues()
|
||||||
|
const filters = {
|
||||||
|
macaddr: formValues.macaddr || '',
|
||||||
|
public: formValues.public || '',
|
||||||
|
city: formValues.city || '',
|
||||||
|
isp: formValues.isp || '',
|
||||||
|
}
|
||||||
|
fetchData(filters, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理每页显示数量变化
|
// 处理每页显示数量变化
|
||||||
const handleSizeChange = (size: number) => {
|
const handleSizeChange = (size: number) => {
|
||||||
setItemsPerPage(size)
|
setPage(1)
|
||||||
setCurrentPage(1) // 重置到第一页
|
const formValues = form.getValues()
|
||||||
|
const filters = {
|
||||||
|
macaddr: formValues.macaddr || '',
|
||||||
|
public: formValues.public || '',
|
||||||
|
city: formValues.city || '',
|
||||||
|
isp: formValues.isp || '',
|
||||||
|
}
|
||||||
|
fetchData(filters, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow p-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">节点列表</h2>
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">节点列表</h2>
|
||||||
<div className="text-center py-8">加载节点数据中...</div>
|
<div className="text-center py-8">加载节点数据中...</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow p-6">
|
||||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">节点列表</h2>
|
<h2 className="text-xl font-semibold text-gray-800 mb-4">节点列表</h2>
|
||||||
<div className="text-center py-8 text-red-600">{error}</div>
|
<div className="text-center py-8 text-red-600">{error}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchData()}
|
onClick={() => fetchData({}, 1)}
|
||||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mx-auto block"
|
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors mx-auto block"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
@@ -179,7 +209,60 @@ export default function Edge() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex bg-white flex-col shadow overflow-hidden rounded-lg p-6">
|
<div className="flex bg-white flex-col shadow overflow-hidden p-6">
|
||||||
|
|
||||||
|
<div className="mb-6 p-4rounded-lg bg-white">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<FormField
|
||||||
|
name="macaddr"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>MAC地址</FormLabel>
|
||||||
|
<Input placeholder="输入MAC地址" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="public"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>公网IP</FormLabel>
|
||||||
|
<Input placeholder="输入公网IP" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="city"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>城市</FormLabel>
|
||||||
|
<Input placeholder="输入城市名称" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="isp"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>运营商</FormLabel>
|
||||||
|
<Input placeholder="输入运营商" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="mt-5 py-2 bg-blue-600 hover:bg-blue-700">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" className="mt-5 py-2" onClick={() => form.reset()}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{data.length === 0 ? (
|
{data.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="text-gray-400 text-4xl mb-4">📋</div>
|
<div className="text-gray-400 text-4xl mb-4">📋</div>
|
||||||
@@ -188,7 +271,7 @@ export default function Edge() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex gap-6 overflow-hidden">
|
<div className="flex gap-6 overflow-hidden">
|
||||||
<div className="flex-3 w-full overflow-y-auto">
|
<div className="flex-3 w-full flex overflow-y-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50">
|
<TableRow className="bg-gray-50">
|
||||||
@@ -209,30 +292,29 @@ export default function Edge() {
|
|||||||
<TableCell className="px-4 py-3 text-sm text-gray-700">{item.city}</TableCell>
|
<TableCell className="px-4 py-3 text-sm text-gray-700">{item.city}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-sm font-mono text-green-600">{item.public}</TableCell>
|
<TableCell className="px-4 py-3 text-sm font-mono text-green-600">{item.public}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-sm text-gray-700">
|
<TableCell className="px-4 py-3 text-sm text-gray-700">
|
||||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
<span className={cn(
|
||||||
item.isp === '移动'
|
'px-2 py-1 rounded-full text-xs',
|
||||||
? 'bg-blue-100 text-blue-800'
|
'bg-gray-100 text-gray-800',
|
||||||
: item.isp === '电信'
|
{
|
||||||
? 'bg-purple-100 text-purple-800'
|
移动: 'bg-blue-100 text-blue-800',
|
||||||
: item.isp === '联通'
|
电信: 'bg-purple-100 text-purple-800',
|
||||||
? 'bg-red-100 text-red-800'
|
联通: 'bg-red-100 text-red-800',
|
||||||
: 'bg-gray-100 text-gray-800'
|
}[item.isp],
|
||||||
}`}
|
)}>
|
||||||
>
|
|
||||||
{item.isp}
|
{item.isp}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-sm text-center">
|
<TableCell className="px-4 py-3 text-sm">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMultiIPColor(item.single)}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMultiIPColor(item.single)}`}>
|
||||||
{formatMultiIP(item.single)}
|
{formatMultiIP(item.single)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-sm text-center">
|
<TableCell className="px-4 py-3 text-sm">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExclusiveIPColor(item.sole)}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExclusiveIPColor(item.sole)}`}>
|
||||||
{formatExclusiveIP(item.sole)}
|
{formatExclusiveIP(item.sole)}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-sm text-center">
|
<TableCell className="px-4 py-3 text-sm">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getArchColor(item.arch)}`}>
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getArchColor(item.arch)}`}>
|
||||||
{formatArchType(item.arch)}
|
{formatArchType(item.arch)}
|
||||||
</span>
|
</span>
|
||||||
@@ -247,9 +329,10 @@ export default function Edge() {
|
|||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
<Pagination
|
<Pagination
|
||||||
page={currentPage}
|
total={total}
|
||||||
size={itemsPerPage}
|
page={page}
|
||||||
total={totalItems}
|
size={250}
|
||||||
|
sizeOptions={[250]}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
onSizeChange={handleSizeChange}
|
onSizeChange={handleSizeChange}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
|
|||||||
@@ -5,37 +5,71 @@ import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@
|
|||||||
import { Pagination } from '@/components/ui/pagination'
|
import { Pagination } from '@/components/ui/pagination'
|
||||||
import { getGatewayConfig, type GatewayConfig } from '@/actions/stats'
|
import { getGatewayConfig, type GatewayConfig } from '@/actions/stats'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Form, FormField, FormItem, FormLabel } from '@/components/ui/form'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
|
||||||
function GatewayConfigContent() {
|
function GatewayConfigContent() {
|
||||||
const [data, setData] = useState<GatewayConfig[]>([])
|
const [data, setData] = useState<GatewayConfig[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [macAddress, setMacAddress] = useState('')
|
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
// 分页状态
|
// 分页状态
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
|
|
||||||
|
// 定义表单验证规则
|
||||||
|
const filterSchema = z.object({
|
||||||
|
macaddr: z.string().optional(),
|
||||||
|
public: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
inner_ip: z.string().optional(),
|
||||||
|
user: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type FilterFormValues = z.infer<typeof filterSchema>
|
||||||
|
// 初始化表单
|
||||||
|
const form = useForm<FilterFormValues>({
|
||||||
|
resolver: zodResolver(filterSchema),
|
||||||
|
defaultValues: {
|
||||||
|
macaddr: '',
|
||||||
|
public: '',
|
||||||
|
city: '',
|
||||||
|
inner_ip: '',
|
||||||
|
user: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { watch, handleSubmit: formHandleSubmit, setValue } = form
|
||||||
|
const macaddrValue = watch('macaddr')
|
||||||
// 监听URL的mac参数变化
|
// 监听URL的mac参数变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlMac = searchParams.get('mac')
|
const urlMac = searchParams.get('mac')
|
||||||
if (urlMac) {
|
if (urlMac) {
|
||||||
setMacAddress(urlMac)
|
setValue('macaddr', urlMac)
|
||||||
setPage(1) // 重置到第一页
|
setPage(1)
|
||||||
fetchData(urlMac, 1)
|
fetchData({ mac: urlMac }, 1)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
setMacAddress('')
|
setValue('macaddr', '')
|
||||||
setPage(1) // 重置到第一页
|
setPage(1)
|
||||||
fetchData('', 1)
|
fetchData({}, 1)
|
||||||
}
|
}
|
||||||
}, [searchParams])
|
}, [searchParams, setValue])
|
||||||
|
|
||||||
const fetchData = async (mac: string, page: number = 1) => {
|
const fetchData = async (filters: {
|
||||||
|
mac?: string
|
||||||
|
public?: string
|
||||||
|
city?: string
|
||||||
|
user?: string
|
||||||
|
inner_ip?: string
|
||||||
|
}, page: number = 1) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
// 计算偏移量
|
const result = await getGatewayConfig(page, filters)
|
||||||
const result = await getGatewayConfig(page, mac)
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(result.error || '查询网关配置失败')
|
throw new Error(result.error || '查询网关配置失败')
|
||||||
}
|
}
|
||||||
@@ -60,22 +94,44 @@ function GatewayConfigContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const onSubmit = (data: FilterFormValues) => {
|
||||||
e.preventDefault()
|
setPage(1)
|
||||||
setPage(1) // 重置到第一页
|
const filters = {
|
||||||
fetchData(macAddress, 1)
|
mac: data.macaddr || '',
|
||||||
|
public: data.public || '',
|
||||||
|
city: data.city || '',
|
||||||
|
user: data.user || '',
|
||||||
|
inner_ip: data.inner_ip || '',
|
||||||
|
}
|
||||||
|
fetchData(filters, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理页码变化
|
// 处理页码变化
|
||||||
const handlePageChange = (page: number) => {
|
const handlePageChange = (page: number) => {
|
||||||
setPage(page)
|
setPage(page)
|
||||||
fetchData(macAddress, page)
|
const formValues = form.getValues()
|
||||||
|
const filters = {
|
||||||
|
mac: formValues.macaddr || undefined,
|
||||||
|
public: formValues.public || undefined,
|
||||||
|
city: formValues.city || undefined,
|
||||||
|
user: formValues.user || undefined,
|
||||||
|
inner_ip: formValues.inner_ip || undefined,
|
||||||
|
}
|
||||||
|
fetchData(filters, page)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理每页显示数量变化
|
// 处理每页显示数量变化
|
||||||
const handleSizeChange = (size: number) => {
|
const handleSizeChange = (size: number) => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
fetchData(macAddress, 1)
|
const formValues = form.getValues()
|
||||||
|
const filters = {
|
||||||
|
mac: formValues.macaddr || undefined,
|
||||||
|
public: formValues.public || undefined,
|
||||||
|
city: formValues.city || undefined,
|
||||||
|
user: formValues.user || undefined,
|
||||||
|
inner_ip: formValues.inner_ip || undefined,
|
||||||
|
}
|
||||||
|
fetchData(filters, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStatusBadge = (value: number, trueText: string = '是', falseText: string = '否') => {
|
const getStatusBadge = (value: number, trueText: string = '是', falseText: string = '否') => {
|
||||||
@@ -98,32 +154,65 @@ function GatewayConfigContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden">
|
<div className="flex flex-col bg-white shadow p-6 overflow-hidden">
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 查询表单 */}
|
{/* 查询表单 */}
|
||||||
<form onSubmit={handleSubmit} className="mb-6">
|
<div className="rounded-lg bg-white">
|
||||||
<div className="flex items-center gap-2">
|
<Form {...form}>
|
||||||
<input
|
<form onSubmit={formHandleSubmit(onSubmit)} className="mb-6">
|
||||||
type="text"
|
<div className="flex gap-4">
|
||||||
value={macAddress}
|
<FormField
|
||||||
onChange={e => setMacAddress(e.target.value)}
|
name="macaddr"
|
||||||
placeholder="输入MAC地址查询"
|
render={({ field }) => (
|
||||||
className="px-4 py-2 h-10 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
<FormItem>
|
||||||
|
<FormLabel>MAC地址</FormLabel>
|
||||||
|
<Input placeholder="输入MAC地址查询" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
<FormField
|
||||||
type="submit"
|
name="public"
|
||||||
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"
|
render={({ field }) => (
|
||||||
>
|
<FormItem>
|
||||||
|
<FormLabel>IP地址</FormLabel>
|
||||||
|
<Input placeholder="输入IP地址" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="user"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>线路</FormLabel>
|
||||||
|
<Input placeholder="输入线路" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="inner_ip"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>端口号</FormLabel>
|
||||||
|
<Input placeholder="输入端口" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
name="city"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>城市</FormLabel>
|
||||||
|
<Input placeholder="输入城市" {...field} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="ml-2 mt-6 px-4 py-2 bg-blue-600 hover:bg-blue-700">
|
||||||
查询
|
查询
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -149,15 +238,15 @@ function GatewayConfigContent() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{data.map((item, index) => (
|
{data.map((item, index) => (
|
||||||
<TableRow key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
<TableRow key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
<TableCell>{item.inner_ip}</TableCell>
|
<TableCell className="px-4 py-2">{item.inner_ip}</TableCell>
|
||||||
<TableCell>{item.user}</TableCell>
|
<TableCell className="px-4 py-2">{item.user}</TableCell>
|
||||||
<TableCell>{item.city}</TableCell>
|
<TableCell className="px-4 py-2">{item.city}</TableCell>
|
||||||
<TableCell>{item.edge}</TableCell>
|
<TableCell className="px-4 py-2">{item.edge}</TableCell>
|
||||||
<TableCell>{item.public}</TableCell>
|
<TableCell className="px-4 py-2">{item.public}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-4 py-2">
|
||||||
{getStatusBadge(item.ischange, '正常', '更新')}
|
{getStatusBadge(item.ischange, '正常', '更新')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="px-4 py-2">
|
||||||
{getOnlineStatus(item.isonline)}
|
{getOnlineStatus(item.isonline)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -186,7 +275,9 @@ function GatewayConfigContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分页组件 */}
|
{/* 分页组件 */}
|
||||||
{!macAddress && (
|
<div className="flex gap-6">
|
||||||
|
<div className="flex flex-3 justify-end">
|
||||||
|
{!macaddrValue && (
|
||||||
<Pagination
|
<Pagination
|
||||||
total={total}
|
total={total}
|
||||||
page={page}
|
page={page}
|
||||||
@@ -197,6 +288,9 @@ function GatewayConfigContent() {
|
|||||||
className="mt-4"
|
className="mt-4"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1"></div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -211,7 +305,7 @@ function GatewayConfigContent() {
|
|||||||
export default function GatewayConfig() {
|
export default function GatewayConfig() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={(
|
<Suspense fallback={(
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow p-6">
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
||||||
<p className="mt-4 text-gray-600">加载搜索参数...</p>
|
<p className="mt-4 text-gray-600">加载搜索参数...</p>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { z } from 'zod'
|
|||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { getGatewayInfo, type GatewayInfo } from '@/actions/stats'
|
import { getGatewayInfo, type GatewayInfo } from '@/actions/stats'
|
||||||
|
import { Copy, Check } from 'lucide-react'
|
||||||
|
|
||||||
const filterSchema = z.object({
|
const filterSchema = z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
@@ -16,6 +17,66 @@ const filterSchema = z.object({
|
|||||||
|
|
||||||
type FilterSchema = z.infer<typeof filterSchema>
|
type FilterSchema = z.infer<typeof filterSchema>
|
||||||
|
|
||||||
|
const SmartCopyButton = ({
|
||||||
|
data,
|
||||||
|
mode = 'single',
|
||||||
|
}: {
|
||||||
|
data: string | GatewayInfo[]
|
||||||
|
mode?: 'single' | 'batch'
|
||||||
|
}) => {
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
let textToCopy: string
|
||||||
|
|
||||||
|
if (mode === 'single' && typeof data === 'string') {
|
||||||
|
textToCopy = data
|
||||||
|
}
|
||||||
|
else if (mode === 'batch' && Array.isArray(data)) {
|
||||||
|
if (data.length === 0) return
|
||||||
|
textToCopy = data.map(item => item.macaddr).join('\n')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(textToCopy)
|
||||||
|
setIsCopied(true)
|
||||||
|
setTimeout(() => setIsCopied(false), 2000)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('复制失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBatch = mode === 'batch'
|
||||||
|
const disabled = isBatch && Array.isArray(data) && data.length === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-1 transition-colors
|
||||||
|
${isBatch
|
||||||
|
? 'px-2 py-1 text-xs bg-blue-50 text-blue-600 rounded hover:bg-blue-100'
|
||||||
|
: 'ml-2 p-1 rounded hover:bg-gray-100'
|
||||||
|
}
|
||||||
|
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||||
|
`}
|
||||||
|
title={isBatch ? '复制所有MAC地址' : '复制MAC地址'}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3 text-gray-500" />
|
||||||
|
)}
|
||||||
|
{isBatch && <span>{isCopied ? '已复制' : '复制全部'}</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Gatewayinfo() {
|
export default function Gatewayinfo() {
|
||||||
const [data, setData] = useState<GatewayInfo[]>([])
|
const [data, setData] = useState<GatewayInfo[]>([])
|
||||||
const [filteredData, setFilteredData] = useState<GatewayInfo[]>([])
|
const [filteredData, setFilteredData] = useState<GatewayInfo[]>([])
|
||||||
@@ -85,7 +146,7 @@ export default function Gatewayinfo() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg p-6 overflow-hidden">
|
<div className="bg-white shadow p-6 overflow-hidden">
|
||||||
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
||||||
<div className="text-center py-8">加载网关信息中...</div>
|
<div className="text-center py-8">加载网关信息中...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +155,7 @@ export default function Gatewayinfo() {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow p-6">
|
||||||
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
||||||
<div className="text-center py-8 text-red-600">{error}</div>
|
<div className="text-center py-8 text-red-600">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,7 +163,7 @@ export default function Gatewayinfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden">
|
<div className="flex flex-col bg-white p-6 overflow-hidden">
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div className="flex flex-3 justify-between ">
|
<div className="flex flex-3 justify-between ">
|
||||||
<span className="text-lg pt-2 font-semibold mb-4">网关基本信息</span>
|
<span className="text-lg pt-2 font-semibold mb-4">网关基本信息</span>
|
||||||
@@ -142,7 +203,12 @@ export default function Gatewayinfo() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50">
|
<TableRow className="bg-gray-50">
|
||||||
<TableHead className="px-4 py-2 text-left">MAC地址</TableHead>
|
<TableHead className="px-4 py-2 text-left">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span>MAC地址</span>
|
||||||
|
<SmartCopyButton data={filteredData} mode="batch" />
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="px-4 py-2 text-left">内网IP</TableHead>
|
<TableHead className="px-4 py-2 text-left">内网IP</TableHead>
|
||||||
<TableHead className="px-4 py-2 text-left">配置版本</TableHead>
|
<TableHead className="px-4 py-2 text-left">配置版本</TableHead>
|
||||||
<TableHead className="px-4 py-2 text-left">状态</TableHead>
|
<TableHead className="px-4 py-2 text-left">状态</TableHead>
|
||||||
@@ -155,6 +221,7 @@ export default function Gatewayinfo() {
|
|||||||
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
|
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
|
||||||
>
|
>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`)
|
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`)
|
||||||
@@ -163,6 +230,8 @@ export default function Gatewayinfo() {
|
|||||||
>
|
>
|
||||||
{item.macaddr}
|
{item.macaddr}
|
||||||
</button>
|
</button>
|
||||||
|
<SmartCopyButton data={item.macaddr} />
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-2">{item.inner_ip}</TableCell>
|
<TableCell className="px-4 py-2">{item.inner_ip}</TableCell>
|
||||||
<TableCell className="px-4 py-2">{item.setid}</TableCell>
|
<TableCell className="px-4 py-2">{item.setid}</TableCell>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function DashboardContent() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex flex-3 overflow-hidden px-4 sm:px-6 lg:px-8 py-8">
|
<div className="flex flex-3 overflow-hidden">
|
||||||
<div className="border-b border-gray-200 mb-6">
|
<div className="border-b border-gray-200 mb-6">
|
||||||
<nav className="flex flex-col w-64 -mb-px space-x-8">
|
<nav className="flex flex-col w-64 -mb-px space-x-8">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
@@ -107,7 +107,7 @@ function DashboardContent() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 flex-auto">
|
<div className="grid grid-cols-1 gap-6 border flex-auto">
|
||||||
{activeTab === 'gatewayInfo' && <Gatewayinfo />}
|
{activeTab === 'gatewayInfo' && <Gatewayinfo />}
|
||||||
{activeTab === 'gateway' && <GatewayConfig />}
|
{activeTab === 'gateway' && <GatewayConfig />}
|
||||||
{activeTab === 'city' && <CityNodeStats />}
|
{activeTab === 'city' && <CityNodeStats />}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function ErrorCard({
|
|||||||
onRetry: () => void
|
onRetry: () => void
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg p-6 text-red-600">
|
<div className="bg-white shadow p-6 text-red-600">
|
||||||
<h2 className="text-lg font-semibold mb-2">{title}</h2>
|
<h2 className="text-lg font-semibold mb-2">{title}</h2>
|
||||||
<p>加载失败: {error}</p>
|
<p>加载失败: {error}</p>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export default function LoadingCard({ title }: { title: string }) {
|
export default function LoadingCard({ title }: { title: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow p-6">
|
||||||
<div className="animate-pulse">
|
<div className="animate-pulse">
|
||||||
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
|
<div className="h-6 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user