新增表单查询和复制功能

This commit is contained in:
wmp
2025-09-29 09:49:53 +08:00
parent e36cfbca83
commit 969d49ab50
9 changed files with 601 additions and 206 deletions

View File

@@ -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) {

View File

@@ -5,29 +5,81 @@ 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.准备计算超额量的工具函数
const calculateOverage = (assigned: number, count: number) => {
return Math.max(0, assigned - count)
}
// 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
} }
return parseInt(timeFilter) || 24 // 默认24小时 if (typeof aValue === 'string') {
}, [timeFilter, customHours]) return sortDirection === 'asc'
? aValue.localeCompare(bValue as string)
// 计算超额量 : (bValue as string).localeCompare(aValue)
const calculateOverage = (assigned: number, count: number) => { }
const overage = assigned - count else {
return Math.max(0, overage) return sortDirection === 'asc'
} ? aValue - (bValue as number)
: (bValue as number) - aValue
}
})
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
@@ -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,57 +110,117 @@ 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>
> </form>
</Form>
</button>
</div> </div>
<div className="flex gap-6 overflow-hidden"> <div className="flex gap-6 overflow-hidden">
<div className="flex w-full"> <div className="flex w-full">
<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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
<button <Input placeholder="输入MAC地址查询" {...field} />
type="submit" </FormItem>
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" )}
> />
<FormField
</button> name="public"
</div> render={({ field }) => (
</form> <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>
</div>
</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,17 +275,22 @@ function GatewayConfigContent() {
</div> </div>
{/* 分页组件 */} {/* 分页组件 */}
{!macAddress && ( <div className="flex gap-6">
<Pagination <div className="flex flex-3 justify-end">
total={total} {!macaddrValue && (
page={page} <Pagination
size={250} total={total}
sizeOptions={[250]} page={page}
onPageChange={handlePageChange} size={250}
onSizeChange={handleSizeChange} sizeOptions={[250]}
className="mt-4" onPageChange={handlePageChange}
/> onSizeChange={handleSizeChange}
)} 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>

View File

@@ -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,14 +221,17 @@ 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">
<button <div className="flex items-center gap-2">
onClick={() => { <button
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`) onClick={() => {
}} router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`)
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer" }}
> className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
{item.macaddr} >
</button> {item.macaddr}
</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>

View File

@@ -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 />}

View File

@@ -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

View File

@@ -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">