重构网关配置总览 & 完善数据表格组件

This commit is contained in:
2025-10-21 11:18:59 +08:00
parent 15d5dc93c0
commit 5617502713
6 changed files with 135 additions and 466 deletions

View File

@@ -1,3 +1,5 @@
name: jihu-monitor
services:
mariadb:

View File

@@ -2,6 +2,7 @@
import { Page, Res } from '@/lib/api'
import drizzle, { and, change, cityhash, count, desc, edge, eq, gateway, is, sql, token } from '@/lib/drizzle'
import { cache } from 'react'
export type AllocationStatus = {
city: string
@@ -105,13 +106,13 @@ export type GatewayConfig = {
}
// 网关配置
export async function getGatewayConfig(page?: number, filters?: {
export const getGatewayConfig = cache(async (page?: number, filters?: {
mac?: string
public?: string
city?: string
user?: string
inner_ip?: string
}): Promise<Res<Page<GatewayConfig>>> {
}): Promise<Res<Page<GatewayConfig>>> => {
try {
if (!page && !filters?.mac) {
throw new Error('页码和MAC地址不能同时为空')
@@ -171,7 +172,7 @@ export async function getGatewayConfig(page?: number, filters?: {
error: '查询网关配置失败',
}
}
}
})
export type CityNode = {
city: string

View File

@@ -3,497 +3,157 @@ import { useEffect, useState, Suspense } from 'react'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { getGatewayInfo, getGatewayConfig, type GatewayConfig, type GatewayInfo } from '@/actions/stats'
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'
import { Page } from '@/components/page'
function GatewayConfigContent() {
const [data, setData] = useState<GatewayConfig[]>([])
const [loading, setLoading] = useState(false)
const [infoData, setInfoData] = useState<GatewayInfo[]>([])
const [totalCount, setTotalCount] = useState(0)
const [matrixData, setMatrixData] = useState<{ inner_ip: string, devices: { [macaddr: string]: GatewayConfig[] } }[]>([])
const [macAddresses, setMacAddresses] = useState<string[]>([])
const [currentTotal, setCurrentTotal] = useState(0)
// 缓存处理先保存初始MAC地址列表
const [initialMacAddresses, setInitialMacAddresses] = useState<string[]>([])
// 保存每个MAC地址对应的完整配置数据 { mac1: [config1, config2], mac2: [config3] }
const [initialConfigData, setInitialConfigData] = useState<{ [mac: string]: GatewayConfig[] }>({})
// 初始数据是否已加载完成
const [isInitialDataLoaded, setIsInitialDataLoaded] = useState(false)
// MAC 地址 - 内网 IP
const [gatewayPairs, setGatewayPairs] = useState<{ macaddr: string, inner_ip: string }[]>([])
// 表单验证规则
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 { handleSubmit: formHandleSubmit } = form
// 处理城市名称缩写
const processCityNames = (items: GatewayConfig[]) => {
const shrink = ['黔东南', '延边']
return items.map((item) => {
const processedItem = { ...item }
shrink.forEach((s) => {
if (processedItem.city?.startsWith(s)) {
processedItem.city = s
}
})
return processedItem
})
}
// 获取IP最后一段用于排序
const getLastOctet = (ip: string | undefined): number => {
if (!ip) return 0
const parts = ip.split('.')
if (parts.length !== 4) return 0
const last = parseInt(parts[3], 10)
return isNaN(last) ? 0 : last
}
// 按内网IP排序配置
const sortByInnerIp = (a: GatewayConfig, b: GatewayConfig): number => {
const lastOctetA = getLastOctet(a.inner_ip)
const lastOctetB = getLastOctet(b.inner_ip)
return lastOctetA - lastOctetB
}
// 为单个MAC获取配置数据
const fetchConfigForMac = async (mac: string, filters: {
mac?: string | undefined
public?: string | undefined
city?: string | undefined
user?: string | undefined
inner_ip?: string | undefined
}) => {
try {
const queryParams = { ...filters, mac }
const result = await getGatewayConfig(1, queryParams)
if (result.success) {
const processedData = processCityNames(result.data.items)
return [...processedData].sort(sortByInnerIp)
}
return []
}
catch (error) {
console.error(`获取MAC ${mac} 配置失败:`, error)
return []
}
}
// 本地筛选数据
const filterLocalData = (filters: {
mac?: string
public?: string
city?: string
user?: string
inner_ip?: string
}) => {
const { mac, public: publicIp, city, user, inner_ip } = filters
let filteredPairs = [...gatewayPairs]
// 按MAC筛选
if (mac) {
filteredPairs = filteredPairs.filter(pair =>
pair.macaddr.toLowerCase().includes(mac.toLowerCase()),
)
}
// 按内网IP筛选
if (inner_ip) {
filteredPairs = filteredPairs.filter(pair =>
pair.inner_ip.includes(inner_ip),
)
}
// 筛选配置数据
const filteredConfigs: { [mac: string]: GatewayConfig[] } = {}
filteredPairs.forEach((pair) => {
const configs = initialConfigData[pair.macaddr] || []
const filtered = configs.filter((config) => {
// 按各个字段筛选
const matchPublic = !publicIp || (config.public && config.public.includes(publicIp))
const matchCity = !city || (config.city && config.city.includes(city))
const matchUser = !user || (config.user && config.user.includes(user))
const matchInnerIp = !inner_ip || (config.inner_ip && config.inner_ip.includes(inner_ip))
return matchPublic && matchCity && matchUser && matchInnerIp
})
if (filtered.length > 0) {
filteredConfigs[pair.macaddr] = filtered
}
})
// 筛选后按内网IP重新排序确保表头与列顺序一致
const sortedFilteredPairs = filteredPairs
.filter(pair => filteredConfigs[pair.macaddr]?.length > 0)
.sort((a, b) => {
const lastOctetA = getLastOctet(a.inner_ip)
const lastOctetB = getLastOctet(b.inner_ip)
return lastOctetA - lastOctetB
})
return { filteredPairs: sortedFilteredPairs, filteredConfigs }
}
// 构建矩阵数据
const buildMatrixData = async (
gatewayPairsList: { macaddr: string, inner_ip: string }[],
configData: { [mac: string]: GatewayConfig[] } = {},
useLocalData: boolean = false,
) => {
setLoading(true)
try {
let macConfigMap: { [mac: string]: GatewayConfig[] } = {}
const macList = gatewayPairsList.map(pair => pair.macaddr)
// 加载配置数据
if (useLocalData) {
// 使用本地数据
macConfigMap = configData
}
else {
// 调用接口获取数据
const configPromises = macList.map(mac => fetchConfigForMac(mac, {}))
const configResults = await Promise.all(configPromises)
// 创建MAC地址到配置数据的映射
macList.forEach((mac, index) => {
macConfigMap[mac] = configResults[index] || []
})
}
// 获取所有端口并排序
const allPortLines = Array.from(
new Set(
Object.values(macConfigMap)
.flat()
.map(item => item.inner_ip)
.filter(Boolean),
),
).sort((a, b) => {
const portA = getLastOctet(a.split('|')[0])
const portB = getLastOctet(b.split('|')[0])
return portA - portB
})
// 构建矩阵
const matrix = allPortLines.map((portLine) => {
const [inner_ip] = portLine.split('|')
const row = { inner_ip: inner_ip || '', devices: {} as { [macaddr: string]: GatewayConfig[] } }
// 遍历排序后的网关对,双重校验配置归属
gatewayPairsList.forEach((pair) => {
const configsForPortLine = macConfigMap[pair.macaddr]
.filter(item => item.inner_ip === inner_ip) // 匹配当前行端口
.sort(sortByInnerIp)
row.devices[pair.macaddr] = configsForPortLine
})
return row
})
// 更新状态
setMatrixData(matrix)
setGatewayPairs(gatewayPairsList)
setCurrentTotal(matrix.length)
setTotalCount(Object.values(macConfigMap).reduce((sum, configs) => sum + configs.length, 0))
}
catch (error) {
toast.error('构建矩阵数据失败')
console.error('矩阵构建错误:', error)
}
finally {
setLoading(false)
}
}
export default function GatewayConfigs() {
const [gateways, setGateways] = useState<Map<string, GatewayInfo>>(new Map())
const [data, setData] = useState<Map<string, Map<string, GatewayConfig | undefined>>>(new Map())
// 初始化数据
useEffect(() => {
const initData = async () => {
setLoading(true)
try {
// 获取网关基本信息
const infoResult = await getGatewayInfo()
if (!infoResult.success) throw new Error(infoResult.error || '查询网关信息失败')
setInfoData(infoResult.data)
const initData = async () => {
const now = Date.now()
try {
// 固定端口信息
const slots = new Set<string>()
for (let i = 2; i <= 251; i++) {
slots.add(`172.30.168.${i}`)
}
// 构建排序后的网关对MAC+内网IP
const sortedGatewayPairs = infoResult.data
.filter(item => item.macaddr && item.inner_ip)
.sort((a, b) => {
const lastOctetA = getLastOctet(a.inner_ip)
const lastOctetB = getLastOctet(b.inner_ip)
return lastOctetA - lastOctetB
})
.map(item => ({ macaddr: item.macaddr!, inner_ip: item.inner_ip! }))
// 获取网关信息
const resp = await getGatewayInfo()
if (!resp.success) {
throw new Error(`查询网关信息失败:${resp.error}`)
}
// 加载初始配置
setGatewayPairs(sortedGatewayPairs)// 表头数据
const sortedMacAddresses = sortedGatewayPairs.map(pair => pair.macaddr)
const gateways = resp.data
const findGateways = gateways.reduce((map, gateway) => {
map.set(gateway.macaddr, gateway)
return map
}, new Map<string, GatewayInfo>())
setGateways(findGateways)
setInitialMacAddresses(sortedMacAddresses)
setMacAddresses(sortedMacAddresses)
// 获取网关配置
const data = new Map<string, Map<string, GatewayConfig | undefined>>()
for (const slot of slots) {
data.set(slot, new Map<string, GatewayConfig>())
}
const configPromises = sortedMacAddresses.map(mac => fetchConfigForMac(mac, {}))
const configResults = await Promise.all(configPromises)
const initialConfig: { [mac: string]: GatewayConfig[] } = {}
sortedMacAddresses.forEach((mac, index) => {
initialConfig[mac] = configResults[index] || []
await Promise.all(gateways.map((gateway, index) => {
return new Promise<void>(async (resolve) => {
const resp = await getGatewayConfig(1, { mac: gateway.macaddr })
if (!resp.success) {
throw new Error(`查询网关 ${gateway.inner_ip} 配置失败:${resp.error}`)
}
const configs = resp.data.items
const findConfig = configs.reduce((map, config) => {
map.set(config.inner_ip, config)
return map
}, new Map<string, GatewayConfig>())
for (const slot of slots) {
data.get(slot)!.set(gateway.macaddr, findConfig.get(slot))
}
resolve()
})
}))
setInitialConfigData(initialConfig)
setIsInitialDataLoaded(true)
// 构建初始矩阵
await buildMatrixData(sortedGatewayPairs, initialConfig, true)
}
catch (error) {
toast.error((error as Error).message || '获取数据失败')
}
finally {
setLoading(false)
}
setData(data)
}
catch (error) {
toast.error(`初始化页面数据失败:${(error as Error).message}`)
}
console.log('初始化数据耗时', Date.now() - now, 'ms')
}
useEffect(() => {
initData()
}, [])
// 查询数据表单查询
const fetchData = async (filters: {
mac?: string
public?: string
city?: string
user?: string
inner_ip?: string
}, page: number = 1) => {
setLoading(true)
try {
// 如果有初始数据,就本地筛选
if (isInitialDataLoaded) {
const { filteredPairs, filteredConfigs } = filterLocalData(filters)
setData(Object.values(filteredConfigs).flat())
setTotalCount(Object.values(filteredConfigs).flat().length)
await buildMatrixData(filteredPairs, filteredConfigs, true)
}
else {
// 如果没有初始数据,就接口查询
const result = await getGatewayConfig(page, filters)
if (!result.success) throw new Error(result.error || '查询网关配置失败')
const processedData = processCityNames(result.data.items)
const sortedData = [...processedData].sort(sortByInnerIp)
setData(sortedData)
setTotalCount(result.data.total)
const filteredMacs = Array.from(new Set(sortedData.map(item => item.edge).filter(Boolean))).sort() as string[]
const temporaryGatewayPairs = filteredMacs
.map(mac => ({ macaddr: mac, inner_ip: '' }))
.sort((a, b) => a.macaddr.localeCompare(b.macaddr))
await buildMatrixData(temporaryGatewayPairs, {}, false)
}
}
catch (error) {
toast.error((error as Error).message || '获取网关配置失败')
}
finally {
setLoading(false)
}
}
// 提交查询
const onSubmit = async (formData: FilterFormValues) => {
const filters = {
mac: formData.macaddr || '',
public: formData.public || '',
city: formData.city || '',
user: formData.user || '',
inner_ip: formData.inner_ip || '',
}
await fetchData(filters, 1)
}
return (
<Page>
{/* 查询表单 */}
<Form {...form}>
<form onSubmit={formHandleSubmit(onSubmit)} className="flex gap-3">
<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="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 type="button" variant="outline" className="ml-2 mt-6 px-4 py-2" onClick={() => form.reset()}></Button>
</form>
</Form>
<div className="flex gap-6 p-2 items-center justify-between text-sm">
<div className="flex gap-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-100 border border-blue-300 rounded"></div>
<span className="text-xs font-medium"></span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-green-100 border border-green-300 rounded"></div>
<span className="text-xs font-medium">绿</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-yellow-100 border border-yellow-300 rounded"></div>
<span className="text-xs font-medium"></span>
</div>
</div>
<div className="flex gap-4">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {infoData.filter(item => item.enable === 1).length}</span>
</div>
<div className="flex items-center">
<div className="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {infoData.filter(item => item.enable === 0).length}</span>
</div>
<Page className="gap-3">
<div className="flex gap-2">
<div className="flex items-center">
<div className="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {Array.from(gateways.values()).filter(item => item.enable).length}</span>
</div>
<div className="flex items-center">
<div className="text-sm text-gray-600">
<span className="font-semibold text-blue-600">{currentTotal}</span> |
<span className="font-semibold text-blue-600">{totalCount}</span>
</div>
<div className="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
<span className="text-sm font-medium">线 {Array.from(gateways.values()).filter(item => !item.enable).length}</span>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="border-r sticky left-0 bg-white z-10"></TableHead>
{gatewayPairs.map((pair, index) => (
<TableHead key={index} className="border-r">
<TableHead className="border-r sticky left-0 bg-gray-50"></TableHead>
{gateways.values().map((pair, index) => (
<TableHead key={index} className="border-r h-auto">
<div className="flex flex-col items-center">
<div className="font-medium">{pair.macaddr}</div>
<div className="text-xs text-gray-500 mt-1">{pair.inner_ip}</div>
<div className="font-medium">{pair.inner_ip}</div>
<div className="text-xs text-gray-500 mt-1">{pair.macaddr}</div>
</div>
</TableHead>
))}
)).toArray()}
</TableRow>
</TableHeader>
<TableBody>
{matrixData.map((row, rowIndex) => (
{data.entries().map(([slot, configs], rowIndex) => (
<TableRow key={rowIndex}>
<TableCell className="border-r sticky left-0 bg-white z-10">{row.inner_ip}</TableCell>
{gatewayPairs.map((pair, colIndex) => {
const configs = row.devices[pair.macaddr] || []
<TableCell className="border-r sticky left-0 bg-background">{slot}</TableCell>
{configs.entries().map(([_, config], colIndex) => {
if (!config) {
return (
<TableCell key={colIndex} className="not-last:border-r">
-
</TableCell>
)
}
const statusConfig = {
ischange: config.ischange === 0
? { bg: 'bg-green-100', text: 'text-green-800', label: '正常' }
: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: '更新' },
isonline: config.isonline === 0
? { bg: 'bg-green-100', text: 'text-green-800', label: '空闲' }
: { bg: 'bg-blue-100', text: 'text-blue-800', label: '在用' },
}
return (
<TableCell key={`${rowIndex}-${colIndex}`} className="border-r">
{configs.length === 0 ? (
<div className="text-center">-</div>
) : (
<div className="space-y-2">
{configs.map((item, itemIndex) => {
const statusConfig = {
ischange: item.ischange === 0
? { bg: 'bg-green-100', text: 'text-green-800', label: '正常' }
: { bg: 'bg-yellow-100', text: 'text-yellow-800', label: '更新' },
isonline: item.isonline === 0
? { bg: 'bg-green-100', text: 'text-green-800', label: '空闲' }
: { bg: 'bg-blue-100', text: 'text-blue-800', label: '在用' },
}
return (
<div key={itemIndex} className="flex flex-col gap-1">
<div className="text-sm font-medium">{item.public || 'N/A'}</div>
<div className="text-xs font-medium">{item.city || 'N/A'}</div>
<div className="flex justify-between items-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusConfig.ischange.bg} ${statusConfig.ischange.text}`}>
{statusConfig.ischange.label}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusConfig.isonline.bg} ${statusConfig.isonline.text}`}>
{statusConfig.isonline.label}
</span>
</div>
</div>
)
})}
<TableCell key={`${colIndex}`} className="not-last:border-r">
<div key={colIndex} className="flex flex-col gap-1">
<div className="text-sm font-medium">{config.public}</div>
<div className="text-xs font-medium flex justify-between">
<span>{config.user}</span>
<span>{shrinkCity(config.city || '?')}</span>
</div>
)}
<div className="flex justify-between items-center">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusConfig.ischange.bg} ${statusConfig.ischange.text}`}>
{statusConfig.ischange.label}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusConfig.isonline.bg} ${statusConfig.isonline.text}`}>
{statusConfig.isonline.label}
</span>
</div>
</div>
</TableCell>
)
})}
}).toArray()}
</TableRow>
))}
)).toArray()}
</TableBody>
</Table>
</Page>
)
}
export default function GatewayConfigs() {
return (
<Suspense fallback={(
<div className="bg-white shadow p-6">
<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>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
)}>
<GatewayConfigContent />
</Suspense>
)
function shrinkCity(city: string) {
switch (city) {
case '黔东南苗族侗族自治州':
return '黔东南'
case '延边朝鲜族自治州':
return '延边'
default:
return city
}
}

View File

@@ -70,7 +70,7 @@ export default function DashboardLayout({
{/* 主要内容区域 */}
<div className="flex-auto overflow-hidden flex">
{/* 侧边栏 */}
<nav className="flex-none basis-64 p-3 space-y-2 border-r flex flex-col">
<nav className="flex-none basis-64 border-r flex flex-col p-3 gap-2">
<NavbarItem href="/gatewayinfo" active={isActive('/gatewayinfo')}><DoorClosedIcon size={20} /></NavbarItem>
<NavbarItem href="/gatewayConfig" active={isActive('/gatewayConfig')}><DoorClosedLockedIcon size={20} /></NavbarItem>
<NavbarItem href="/gatewayMonitor" active={isActive('/gatewayMonitor')}><DoorOpenIcon size={20} /></NavbarItem>
@@ -99,7 +99,7 @@ function NavbarItem(props: {
href={props.href}
className={cn(
'transition-colors duration-150 ease-in-out',
'p-2 gap-2 rounded-md text-sm flex items-center',
'h-10 rounded-md text-sm flex items-center p-2 gap-2',
props.active
? 'text-primary bg-primary/10'
: 'hover:bg-muted',

View File

@@ -18,6 +18,7 @@ type Column = {
export function DataTable<T extends Data>(props: {
data: T[]
columns: Column[]
pinFirst?: boolean
}) {
const table = useReactTable({
getCoreRowModel: getCoreRowModel(),
@@ -41,12 +42,13 @@ export function DataTable<T extends Data>(props: {
<TableRow key={group.id}>
{/* 表头 */}
{group.headers.map(header => (
{group.headers.map((header, index) => (
<TableHead
key={header.id}
className={cn(
header.column.columnDef.enableSorting && 'hover:bg-gray-200 transition-colors duration-150 ease-in-out cursor-pointer',
header.column.getIsSorted() && 'text-primary',
props.pinFirst && index === 0 && 'sticky left-0 bg-gray-50 border-r',
)}
onClick={header.column.getToggleSortingHandler()}>
<div className="flex flex-row items-center justify-between">
@@ -74,8 +76,12 @@ export function DataTable<T extends Data>(props: {
<TableRow key={row.id}>
{/* 表格 */}
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{row.getVisibleCells().map((cell, index) => (
<TableCell
key={cell.id}
className={cn(
props.pinFirst && index === 0 && 'sticky left-0 bg-white border-r',
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}

View File

@@ -12,7 +12,7 @@ function Table({ className, ...props }: React.ComponentProps<'table'>) {
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
className={cn('w-full caption-bottom text-sm border-separate border-spacing-0', className)}
{...props}
/>
</div>
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('sticky top-0 bg-gray-50', className)}
className={cn('sticky top-0 bg-gray-50 z-10', className)}
{...props}
/>
)
@@ -33,7 +33,7 @@ function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
className={cn('[&>tr:last-child>td]:border-b-0', className)}
{...props}
/>
)
@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
<tr
data-slot="table-row"
className={cn(
'hover:data-[state=selected]:bg-muted border-b border-border/50 transition-colors',
'hover:data-[state=selected]:bg-muted border-border/50 transition-colors',
className,
)}
{...props}
@@ -71,7 +71,7 @@ function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
'text-sm text-gray-500',
'text-sm text-gray-500 border-b',
className,
)}
{...props}
@@ -84,7 +84,7 @@ function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
<td
data-slot="table-cell"
className={cn(
'p-2 h-10 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
'p-2 h-10 border-b align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}