15 changed files with 1082 additions and 36 deletions
@ -0,0 +1,295 @@ |
|||
import React, { useState } from 'react'; |
|||
import { ProtocolLogDto } from '@/services/protocolLogsService'; |
|||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; |
|||
import { Badge } from '@/components/ui/badge'; |
|||
import { Button } from '@/components/ui/button'; |
|||
import { Drawer, DrawerContent, DrawerHeader } from '@/components/ui/drawer'; |
|||
import { Eye, X } from 'lucide-react'; |
|||
import { cn } from '@/lib/utils'; |
|||
import { DensityType } from '@/components/ui/TableToolbar'; |
|||
import { getProtocolLayerLabel } from '@/constants/protocolLayerOptions'; |
|||
import ConfigContentViewer from '@/components/ui/ConfigContentViewer'; |
|||
|
|||
interface ProtocolLogsTableProps { |
|||
protocolLogs: ProtocolLogDto[]; |
|||
loading: boolean; |
|||
columns: { key: string; title: string; visible: boolean }[]; |
|||
density: DensityType; |
|||
} |
|||
|
|||
export default function ProtocolLogsTable({ |
|||
protocolLogs, |
|||
loading, |
|||
columns, |
|||
density, |
|||
}: ProtocolLogsTableProps) { |
|||
const [selectedMessageDetail, setSelectedMessageDetail] = useState<string>(''); |
|||
const [isDrawerOpen, setIsDrawerOpen] = useState(false); |
|||
|
|||
// 密度样式映射
|
|||
const densityStyles = { |
|||
relaxed: 'py-3', |
|||
compact: 'py-1', |
|||
default: 'py-2', |
|||
}; |
|||
|
|||
// 格式化时间戳
|
|||
const formatTimestamp = (timestamp: number) => { |
|||
try { |
|||
return new Date(timestamp).toLocaleString('zh-CN'); |
|||
} catch { |
|||
return timestamp.toString(); |
|||
} |
|||
}; |
|||
|
|||
// 获取方向描述
|
|||
const getDirectionDescription = (direction: number) => { |
|||
switch (direction) { |
|||
case 0: |
|||
return '上行'; |
|||
case 1: |
|||
return '下行'; |
|||
default: |
|||
return '未知'; |
|||
} |
|||
}; |
|||
|
|||
// 获取方向颜色
|
|||
const getDirectionColor = (direction: number) => { |
|||
switch (direction) { |
|||
case 0: |
|||
return 'bg-blue-100 text-blue-800 hover:bg-blue-100'; |
|||
case 1: |
|||
return 'bg-green-100 text-green-800 hover:bg-green-100'; |
|||
default: |
|||
return 'bg-gray-100 text-gray-800 hover:bg-gray-100'; |
|||
} |
|||
}; |
|||
|
|||
|
|||
|
|||
// 查看消息详情
|
|||
const handleViewMessageDetail = (messageDetailJson: string) => { |
|||
// 尝试格式化 JSON 数据
|
|||
let formattedContent = messageDetailJson; |
|||
try { |
|||
// 如果是 JSON 字符串,尝试解析并格式化
|
|||
if (messageDetailJson && (messageDetailJson.startsWith('[') || messageDetailJson.startsWith('{'))) { |
|||
const parsed = JSON.parse(messageDetailJson); |
|||
formattedContent = JSON.stringify(parsed, null, 2); |
|||
} |
|||
} catch (error) { |
|||
// 如果解析失败,保持原始内容
|
|||
console.log('messageDetailJson 不是有效的 JSON 格式,使用原始内容'); |
|||
} |
|||
setSelectedMessageDetail(formattedContent); |
|||
setIsDrawerOpen(true); |
|||
}; |
|||
|
|||
// 复制消息详情
|
|||
const handleCopyMessageDetail = async () => { |
|||
try { |
|||
await navigator.clipboard.writeText(selectedMessageDetail); |
|||
// 可以添加一个 toast 提示
|
|||
} catch (error) { |
|||
console.error('复制失败:', error); |
|||
} |
|||
}; |
|||
|
|||
// 下载消息详情
|
|||
const handleDownloadMessageDetail = () => { |
|||
const blob = new Blob([selectedMessageDetail], { type: 'text/plain' }); |
|||
const url = URL.createObjectURL(blob); |
|||
const a = document.createElement('a'); |
|||
a.href = url; |
|||
a.download = `message-detail-${Date.now()}.txt`; |
|||
document.body.appendChild(a); |
|||
a.click(); |
|||
document.body.removeChild(a); |
|||
URL.revokeObjectURL(url); |
|||
}; |
|||
|
|||
// 渲染单元格内容
|
|||
const renderCell = (log: ProtocolLogDto, columnKey: string) => { |
|||
switch (columnKey) { |
|||
case 'layerType': |
|||
return ( |
|||
<div className="text-sm"> |
|||
{getProtocolLayerLabel(log.layerType)} |
|||
</div> |
|||
); |
|||
|
|||
case 'time': |
|||
return ( |
|||
<div className="text-sm text-muted-foreground"> |
|||
{log.time ? log.time.substring(0, 12) : '-'} |
|||
</div> |
|||
); |
|||
|
|||
case 'plmn': |
|||
return ( |
|||
<div className="text-sm font-mono"> |
|||
{(log as any).plmn || log.PLMN || '-'} |
|||
</div> |
|||
); |
|||
|
|||
case 'info': |
|||
return ( |
|||
<div className="text-sm max-w-xs truncate" title={log.info}> |
|||
{log.info || '-'} |
|||
</div> |
|||
); |
|||
|
|||
case 'ueid': |
|||
return ( |
|||
<div className="text-sm text-muted-foreground"> |
|||
{(log as any).ueid || log.UEID || '-'} |
|||
</div> |
|||
); |
|||
|
|||
case 'imsi': |
|||
return ( |
|||
<div className="text-sm font-mono"> |
|||
{(log as any).imsi || log.IMSI || '-'} |
|||
</div> |
|||
); |
|||
|
|||
case 'cellID': |
|||
return ( |
|||
<div className="text-sm text-muted-foreground"> |
|||
{(log as any).cellID || log.CellID || '-'} |
|||
</div> |
|||
); |
|||
|
|||
case 'direction': |
|||
const directionDesc = getDirectionDescription(log.direction); |
|||
const directionColor = getDirectionColor(log.direction); |
|||
return ( |
|||
<Badge |
|||
variant="outline" |
|||
className={cn(directionColor)} |
|||
> |
|||
{directionDesc} |
|||
</Badge> |
|||
); |
|||
|
|||
case 'message': |
|||
return ( |
|||
<div className="text-sm max-w-xs truncate" title={log.message}> |
|||
{log.message || '-'} |
|||
</div> |
|||
); |
|||
|
|||
case 'MessageDetailJson': |
|||
return ( |
|||
<div className="text-center"> |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => handleViewMessageDetail(log.messageDetailJson || '')} |
|||
disabled={!log.messageDetailJson} |
|||
className="h-8 w-8 p-0 hover:bg-accent" |
|||
title="查看详情" |
|||
> |
|||
<Eye className="h-4 w-4 text-muted-foreground hover:text-foreground" /> |
|||
</Button> |
|||
</div> |
|||
); |
|||
|
|||
default: |
|||
return null; |
|||
} |
|||
}; |
|||
|
|||
// 过滤可见列
|
|||
const visibleColumns = columns.filter(col => col.visible); |
|||
|
|||
return ( |
|||
<> |
|||
<div className="rounded-md border overflow-hidden"> |
|||
<div className="overflow-x-auto"> |
|||
{/* 固定的表头 */} |
|||
<div className="bg-background border-b shadow-sm"> |
|||
<Table className="w-full table-fixed"> |
|||
<TableHeader> |
|||
<TableRow> |
|||
{visibleColumns.map((column) => ( |
|||
<TableHead key={column.key} className={cn("text-center whitespace-nowrap bg-background", densityStyles[density])} style={{ width: `${100 / visibleColumns.length}%` }}> |
|||
{column.title} |
|||
</TableHead> |
|||
))} |
|||
</TableRow> |
|||
</TableHeader> |
|||
</Table> |
|||
</div> |
|||
|
|||
{/* 可滚动的表体 */} |
|||
<div className="max-h-[600px] overflow-auto"> |
|||
<Table className="w-full table-fixed"> |
|||
<TableBody> |
|||
{loading ? ( |
|||
<TableRow> |
|||
<TableCell colSpan={visibleColumns.length} className="text-center py-8"> |
|||
<div className="flex items-center justify-center"> |
|||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary"></div> |
|||
<span className="ml-2">加载中...</span> |
|||
</div> |
|||
</TableCell> |
|||
</TableRow> |
|||
) : protocolLogs.length === 0 ? ( |
|||
<TableRow> |
|||
<TableCell colSpan={visibleColumns.length} className="text-center py-8"> |
|||
<div className="text-muted-foreground"> |
|||
暂无协议日志数据 |
|||
</div> |
|||
</TableCell> |
|||
</TableRow> |
|||
) : ( |
|||
protocolLogs.map((log, index) => ( |
|||
<TableRow key={`${log.id}-${index}`}> |
|||
{visibleColumns.map((column) => ( |
|||
<TableCell key={column.key} className={cn("text-center whitespace-nowrap", densityStyles[density])} style={{ width: `${100 / visibleColumns.length}%` }}> |
|||
{renderCell(log, column.key)} |
|||
</TableCell> |
|||
))} |
|||
</TableRow> |
|||
)) |
|||
)} |
|||
</TableBody> |
|||
</Table> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* 消息详情抽屉 */} |
|||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}> |
|||
<div className="flex flex-col h-full"> |
|||
<DrawerHeader className="flex-shrink-0"> |
|||
<div className="flex items-center justify-between w-full"> |
|||
<h2 className="text-lg font-semibold">Message Detail</h2> |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => setIsDrawerOpen(false)} |
|||
className="h-8 w-8 p-0" |
|||
> |
|||
<X className="h-4 w-4" /> |
|||
</Button> |
|||
</div> |
|||
</DrawerHeader> |
|||
|
|||
<DrawerContent className="flex flex-col flex-1 min-h-0"> |
|||
<div className="flex-1 p-4"> |
|||
<ConfigContentViewer |
|||
content={selectedMessageDetail || 'No message detail available'} |
|||
onCopy={handleCopyMessageDetail} |
|||
onDownload={handleDownloadMessageDetail} |
|||
className="h-full" |
|||
/> |
|||
</div> |
|||
</DrawerContent> |
|||
</div> |
|||
</Drawer> |
|||
</> |
|||
); |
|||
} |
@ -0,0 +1,71 @@ |
|||
// 完整的协议层类型枚举选项(用于标签显示)
|
|||
export const allProtocolLayerOptions = [ |
|||
{ value: '0', label: 'NONE' }, |
|||
{ value: '1', label: 'GTPU' }, |
|||
{ value: '2', label: 'LPPa' }, |
|||
{ value: '3', label: 'M2AP' }, |
|||
{ value: '4', label: 'MAC' }, |
|||
{ value: '5', label: 'NAS' }, |
|||
{ value: '6', label: 'NGAP' }, |
|||
{ value: '7', label: 'NRPPa' }, |
|||
{ value: '8', label: 'PDCP' }, |
|||
{ value: '9', label: 'PROD' }, |
|||
{ value: '10', label: 'PHY' }, |
|||
{ value: '11', label: 'RLC' }, |
|||
{ value: '12', label: 'RRC' }, |
|||
{ value: '13', label: 'S1AP' }, |
|||
{ value: '14', label: 'TRX' }, |
|||
{ value: '15', label: 'X2AP' }, |
|||
{ value: '16', label: 'XnAP' }, |
|||
{ value: '17', label: 'IP' }, |
|||
{ value: '18', label: 'IMS' }, |
|||
{ value: '19', label: 'CX' }, |
|||
{ value: '20', label: 'RX' }, |
|||
{ value: '21', label: 'S6' }, |
|||
{ value: '22', label: 'S13' }, |
|||
{ value: '23', label: 'SGsAP' }, |
|||
{ value: '24', label: 'SBcAP' }, |
|||
{ value: '25', label: 'LCSAP' }, |
|||
{ value: '26', label: 'N12' }, |
|||
{ value: '27', label: 'N8' }, |
|||
{ value: '28', label: 'N17' }, |
|||
{ value: '29', label: 'N50' }, |
|||
{ value: '30', label: 'N13' }, |
|||
{ value: '31', label: 'NL1' }, |
|||
{ value: '32', label: 'HTTP2' }, |
|||
{ value: '33', label: 'EPDG' }, |
|||
{ value: '34', label: 'IKEV2' }, |
|||
{ value: '35', label: 'IPSEC' }, |
|||
{ value: '36', label: 'MEDIA' }, |
|||
{ value: '37', label: 'MMS' }, |
|||
{ value: '38', label: 'SIP' }, |
|||
]; |
|||
|
|||
// 根据实际数据动态生成协议层类型选项
|
|||
export const generateProtocolLayerOptions = (protocolLogs: any[]): { value: string; label: string }[] => { |
|||
// 从数据中提取所有唯一的 layerType 值
|
|||
const uniqueLayerTypes = [...new Set(protocolLogs.map(log => log.layerType))].sort((a, b) => a - b); |
|||
|
|||
// 构建选项数组,始终包含"全部"选项
|
|||
const options = [{ value: 'ALL', label: '全部' }]; |
|||
|
|||
// 为每个唯一的 layerType 添加对应的选项
|
|||
uniqueLayerTypes.forEach(layerType => { |
|||
const option = allProtocolLayerOptions.find(opt => opt.value === String(layerType)); |
|||
if (option) { |
|||
options.push(option); |
|||
} |
|||
}); |
|||
|
|||
return options; |
|||
}; |
|||
|
|||
// 保持向后兼容的 protocolLayerOptions(现在使用动态生成)
|
|||
export const protocolLayerOptions = allProtocolLayerOptions; |
|||
|
|||
// 获取协议层类型标签的辅助函数
|
|||
export const getProtocolLayerLabel = (layerType: string | number): string => { |
|||
const layerTypeStr = String(layerType); |
|||
const option = allProtocolLayerOptions.find(opt => opt.value === layerTypeStr); |
|||
return option ? option.label : layerTypeStr; |
|||
}; |
@ -0,0 +1,300 @@ |
|||
import React, { useState, useEffect } from 'react'; |
|||
import { |
|||
ProtocolLogDto, |
|||
GetProtocolLogsRequest, |
|||
protocolLogsService |
|||
} from '@/services/protocolLogsService'; |
|||
import ProtocolLogsTable from '@/components/protocol-logs/ProtocolLogsTable'; |
|||
import { Input } from '@/components/ui/input'; |
|||
import TableToolbar, { DensityType } from '@/components/ui/TableToolbar'; |
|||
import { Button } from '@/components/ui/button'; |
|||
import { useToast } from '@/components/ui/use-toast'; |
|||
import { generateProtocolLayerOptions } from '@/constants/protocolLayerOptions'; |
|||
import { Checkbox } from '@/components/ui/checkbox'; |
|||
import { Badge } from '@/components/ui/badge'; |
|||
import { ChevronDown, X } from 'lucide-react'; |
|||
|
|||
const defaultColumns = [ |
|||
{ key: 'layerType', title: 'Layer Type', visible: true }, |
|||
{ key: 'time', title: 'Time', visible: true }, |
|||
{ key: 'plmn', title: 'PLMN', visible: true }, |
|||
{ key: 'info', title: 'Info', visible: true }, |
|||
{ key: 'ueid', title: 'UEID', visible: true }, |
|||
{ key: 'imsi', title: 'IMSI', visible: true }, |
|||
{ key: 'cellID', title: 'CellID', visible: true }, |
|||
{ key: 'direction', title: 'Direction', visible: true }, |
|||
{ key: 'message', title: 'Message', visible: true }, |
|||
{ key: 'MessageDetailJson', title: 'Detail', visible: true }, |
|||
]; |
|||
|
|||
export default function OnlineProtocolLogsView() { |
|||
const [protocolLogs, setProtocolLogs] = useState<ProtocolLogDto[]>([]); |
|||
const [loading, setLoading] = useState(false); |
|||
const [density, setDensity] = useState<DensityType>('default'); |
|||
const [columns, setColumns] = useState(defaultColumns); |
|||
const [layerTypeOpen, setLayerTypeOpen] = useState(false); |
|||
|
|||
// 动态生成协议层类型选项
|
|||
const protocolLayerOptions = generateProtocolLayerOptions(protocolLogs); |
|||
|
|||
const [searchParams, setSearchParams] = useState<GetProtocolLogsRequest>({ |
|||
deviceCode: '', |
|||
startTimestamp: undefined, |
|||
endTimestamp: undefined, |
|||
layerTypes: [], |
|||
deviceRuntimeStatus: undefined, |
|||
runtimeCodes: [], |
|||
runtimeStatuses: [1], // 默认只显示运行中的设备日志
|
|||
orderByDescending: true, |
|||
}); |
|||
|
|||
const { toast } = useToast(); |
|||
|
|||
const fetchProtocolLogs = async (params: GetProtocolLogsRequest = searchParams) => { |
|||
setLoading(true); |
|||
try { |
|||
// 确保所有字段都被包含在请求中,即使为空
|
|||
const requestParams = { |
|||
deviceCode: params.deviceCode || '', |
|||
startTimestamp: params.startTimestamp, |
|||
endTimestamp: params.endTimestamp, |
|||
layerTypes: params.layerTypes || [], |
|||
deviceRuntimeStatus: params.deviceRuntimeStatus, |
|||
runtimeCodes: params.runtimeCodes || [], |
|||
runtimeStatuses: params.runtimeStatuses || [], |
|||
orderByDescending: params.orderByDescending ?? true, |
|||
}; |
|||
|
|||
// 调试:打印请求参数
|
|||
console.log('发送在线协议日志请求参数:', JSON.stringify(requestParams, null, 2)); |
|||
|
|||
const result = await protocolLogsService.getProtocolLogs(requestParams); |
|||
|
|||
if (result.isSuccess && result.data) { |
|||
setProtocolLogs(result.data.items || []); |
|||
} else { |
|||
toast({ |
|||
title: '获取在线协议日志失败', |
|||
description: result.errorMessages?.join(', ') || '未知错误', |
|||
variant: 'destructive', |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
console.error('获取在线协议日志失败:', error); |
|||
toast({ |
|||
title: '获取在线协议日志失败', |
|||
description: '网络错误或服务器异常', |
|||
variant: 'destructive', |
|||
}); |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}; |
|||
|
|||
const handleQuery = () => { |
|||
fetchProtocolLogs(); |
|||
}; |
|||
|
|||
const handleReset = () => { |
|||
const resetParams: GetProtocolLogsRequest = { |
|||
deviceCode: '', |
|||
startTimestamp: undefined, |
|||
endTimestamp: undefined, |
|||
layerTypes: [], |
|||
deviceRuntimeStatus: undefined, |
|||
runtimeCodes: [], |
|||
runtimeStatuses: [1], // 重置时也保持只显示运行中的设备日志
|
|||
orderByDescending: true, |
|||
}; |
|||
setSearchParams(resetParams); |
|||
fetchProtocolLogs(resetParams); |
|||
}; |
|||
|
|||
useEffect(() => { |
|||
fetchProtocolLogs(); |
|||
}, []); |
|||
|
|||
// 点击外部关闭下拉框
|
|||
useEffect(() => { |
|||
const handleClickOutside = (event: MouseEvent) => { |
|||
const target = event.target as Element; |
|||
if (!target.closest('.layer-type-dropdown')) { |
|||
setLayerTypeOpen(false); |
|||
} |
|||
}; |
|||
|
|||
document.addEventListener('mousedown', handleClickOutside); |
|||
return () => { |
|||
document.removeEventListener('mousedown', handleClickOutside); |
|||
}; |
|||
}, []); |
|||
|
|||
return ( |
|||
<main className="flex-1 p-4 transition-all duration-300 ease-in-out sm:p-6"> |
|||
<div className="w-full space-y-4"> |
|||
<div> |
|||
<h1 className="text-2xl font-bold text-foreground">在线协议日志</h1> |
|||
<p className="text-sm text-muted-foreground mt-1"> |
|||
实时监控协议日志记录 |
|||
</p> |
|||
</div> |
|||
|
|||
<div className="flex flex-col bg-background p-4 rounded-md border mb-2"> |
|||
<form |
|||
className="flex gap-x-8 gap-y-4 items-center flex-wrap" |
|||
onSubmit={e => { |
|||
e.preventDefault(); |
|||
handleQuery(); |
|||
}} |
|||
> |
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
设备编号: |
|||
</label> |
|||
<Input |
|||
className="flex-1 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
placeholder="请输入设备编号" |
|||
value={searchParams.deviceCode || ''} |
|||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
|||
setSearchParams(prev => ({ ...prev, deviceCode: e.target.value })) |
|||
} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
协议层类型: |
|||
</label> |
|||
<div className="flex-1 relative layer-type-dropdown"> |
|||
<Button |
|||
variant="outline" |
|||
className="w-full justify-between bg-background text-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
onClick={() => setLayerTypeOpen(!layerTypeOpen)} |
|||
> |
|||
<span className="truncate"> |
|||
{searchParams.layerTypes?.length ? `${searchParams.layerTypes.length} 个类型` : '请选择协议层类型'} |
|||
</span> |
|||
<ChevronDown className="h-4 w-4 opacity-50" /> |
|||
</Button> |
|||
|
|||
{layerTypeOpen && ( |
|||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-background border border-border rounded-md shadow-lg max-h-60 overflow-y-auto"> |
|||
<div className="p-2"> |
|||
<div className="flex items-center justify-between mb-2"> |
|||
<span className="text-sm font-medium">选择协议层类型</span> |
|||
<Button |
|||
variant="ghost" |
|||
size="sm" |
|||
onClick={() => setSearchParams(prev => ({ ...prev, layerTypes: [] }))} |
|||
className="h-6 px-2 text-xs" |
|||
> |
|||
清空 |
|||
</Button> |
|||
</div> |
|||
{protocolLayerOptions.map((option) => ( |
|||
<div key={option.value} className="flex items-center space-x-2 py-1"> |
|||
<Checkbox |
|||
id={`layer-${option.value}`} |
|||
checked={searchParams.layerTypes?.includes(parseInt(option.value)) || false} |
|||
onCheckedChange={(checked) => { |
|||
const currentTypes = searchParams.layerTypes || []; |
|||
if (checked) { |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
layerTypes: [...currentTypes, parseInt(option.value)] |
|||
})); |
|||
} else { |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
layerTypes: currentTypes.filter(type => type !== parseInt(option.value)) |
|||
})); |
|||
} |
|||
}} |
|||
/> |
|||
<label |
|||
htmlFor={`layer-${option.value}`} |
|||
className="text-sm cursor-pointer flex-1" |
|||
> |
|||
{option.label} |
|||
</label> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
|
|||
|
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
开始时间戳: |
|||
</label> |
|||
<Input |
|||
type="number" |
|||
className="flex-1 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
placeholder="请输入开始时间戳" |
|||
value={searchParams.startTimestamp || ''} |
|||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
startTimestamp: e.target.value ? parseInt(e.target.value) : undefined |
|||
})) |
|||
} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
结束时间戳: |
|||
</label> |
|||
<Input |
|||
type="number" |
|||
className="flex-1 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
placeholder="请输入结束时间戳" |
|||
value={searchParams.endTimestamp || ''} |
|||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
endTimestamp: e.target.value ? parseInt(e.target.value) : undefined |
|||
})) |
|||
} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex flex-row items-center gap-2"> |
|||
<Button variant="outline" onClick={handleReset}>重置</Button> |
|||
<Button onClick={handleQuery}>查询</Button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
|
|||
<div className="rounded-md border bg-background p-4"> |
|||
<div className="flex items-center justify-between mb-2"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="text-sm text-muted-foreground"> |
|||
共 {protocolLogs.length} 条记录 |
|||
</span> |
|||
</div> |
|||
<TableToolbar |
|||
onRefresh={() => fetchProtocolLogs()} |
|||
onDensityChange={setDensity} |
|||
onColumnsChange={setColumns} |
|||
onColumnsReset={() => setColumns(defaultColumns)} |
|||
columns={columns} |
|||
density={density} |
|||
/> |
|||
</div> |
|||
|
|||
<ProtocolLogsTable |
|||
protocolLogs={protocolLogs} |
|||
loading={loading} |
|||
columns={columns} |
|||
density={density} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
); |
|||
} |
@ -0,0 +1,322 @@ |
|||
import React, { useState, useEffect } from 'react'; |
|||
import { |
|||
ProtocolLogDto, |
|||
GetProtocolLogsRequest, |
|||
protocolLogsService |
|||
} from '@/services/protocolLogsService'; |
|||
import ProtocolLogsTable from '@/components/protocol-logs/ProtocolLogsTable'; |
|||
import { Input } from '@/components/ui/input'; |
|||
import TableToolbar, { DensityType } from '@/components/ui/TableToolbar'; |
|||
import { Button } from '@/components/ui/button'; |
|||
import { useToast } from '@/components/ui/use-toast'; |
|||
import { generateProtocolLayerOptions } from '@/constants/protocolLayerOptions'; |
|||
import { Checkbox } from '@/components/ui/checkbox'; |
|||
import { Badge } from '@/components/ui/badge'; |
|||
import { ChevronDown, X } from 'lucide-react'; |
|||
|
|||
const defaultColumns = [ |
|||
{ key: 'layerType', title: 'Layer Type', visible: true }, |
|||
{ key: 'time', title: 'Time', visible: true }, |
|||
{ key: 'plmn', title: 'PLMN', visible: true }, |
|||
{ key: 'info', title: 'Info', visible: true }, |
|||
{ key: 'ueid', title: 'UEID', visible: true }, |
|||
{ key: 'imsi', title: 'IMSI', visible: true }, |
|||
{ key: 'cellID', title: 'CellID', visible: true }, |
|||
{ key: 'direction', title: 'Direction', visible: true }, |
|||
{ key: 'message', title: 'Message', visible: true }, |
|||
{ key: 'MessageDetailJson', title: 'Detail', visible: true }, |
|||
]; |
|||
|
|||
export default function HistoryProtocolLogsView() { |
|||
const [protocolLogs, setProtocolLogs] = useState<ProtocolLogDto[]>([]); |
|||
const [loading, setLoading] = useState(false); |
|||
const [density, setDensity] = useState<DensityType>('default'); |
|||
const [columns, setColumns] = useState(defaultColumns); |
|||
|
|||
// 动态生成协议层类型选项
|
|||
const protocolLayerOptions = generateProtocolLayerOptions(protocolLogs); |
|||
|
|||
const [searchParams, setSearchParams] = useState<GetProtocolLogsRequest>({ |
|||
deviceCode: '', |
|||
startTimestamp: undefined, |
|||
endTimestamp: undefined, |
|||
layerTypes: [], |
|||
deviceRuntimeStatus: undefined, |
|||
runtimeCodes: [], |
|||
runtimeStatuses: [0,2,3], |
|||
orderByDescending: true, |
|||
}); |
|||
|
|||
const { toast } = useToast(); |
|||
|
|||
const fetchProtocolLogs = async (params: GetProtocolLogsRequest = searchParams) => { |
|||
setLoading(true); |
|||
try { |
|||
// 确保所有字段都被包含在请求中,即使为空
|
|||
const requestParams = { |
|||
deviceCode: params.deviceCode || '', |
|||
startTimestamp: params.startTimestamp, |
|||
endTimestamp: params.endTimestamp, |
|||
layerTypes: params.layerTypes || [], |
|||
deviceRuntimeStatus: params.deviceRuntimeStatus, |
|||
runtimeCodes: params.runtimeCodes || [], |
|||
runtimeStatuses: params.runtimeStatuses || [], |
|||
orderByDescending: params.orderByDescending ?? true, |
|||
}; |
|||
|
|||
// 调试:打印请求参数
|
|||
console.log('发送协议日志请求参数:', JSON.stringify(requestParams, null, 2)); |
|||
|
|||
const result = await protocolLogsService.getProtocolLogs(requestParams); |
|||
|
|||
if (result.isSuccess && result.data) { |
|||
setProtocolLogs(result.data.items || []); |
|||
} else { |
|||
toast({ |
|||
title: '获取历史协议日志失败', |
|||
description: result.errorMessages?.join(', ') || '未知错误', |
|||
variant: 'destructive', |
|||
}); |
|||
} |
|||
} catch (error) { |
|||
console.error('获取历史协议日志失败:', error); |
|||
toast({ |
|||
title: '获取历史协议日志失败', |
|||
description: '网络错误或服务器异常', |
|||
variant: 'destructive', |
|||
}); |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}; |
|||
|
|||
const handleQuery = () => { |
|||
fetchProtocolLogs(); |
|||
}; |
|||
|
|||
const handleReset = () => { |
|||
const resetParams: GetProtocolLogsRequest = { |
|||
deviceCode: '', |
|||
startTimestamp: undefined, |
|||
endTimestamp: undefined, |
|||
layerTypes: [], |
|||
deviceRuntimeStatus: undefined, |
|||
runtimeCodes: [], |
|||
runtimeStatuses: [0,2,3], // 重置时显示非运行状态的设备日志
|
|||
orderByDescending: true, |
|||
}; |
|||
setSearchParams(resetParams); |
|||
fetchProtocolLogs(resetParams); |
|||
}; |
|||
|
|||
useEffect(() => { |
|||
fetchProtocolLogs(); |
|||
}, []); |
|||
|
|||
// 点击外部关闭下拉框
|
|||
useEffect(() => { |
|||
const handleClickOutside = (event: MouseEvent) => { |
|||
const dropdown = document.getElementById('layerTypeDropdown'); |
|||
const button = document.querySelector('[data-layer-type-button]'); |
|||
if (dropdown && !dropdown.contains(event.target as Node) && !button?.contains(event.target as Node)) { |
|||
dropdown.classList.add('hidden'); |
|||
} |
|||
}; |
|||
|
|||
document.addEventListener('mousedown', handleClickOutside); |
|||
return () => { |
|||
document.removeEventListener('mousedown', handleClickOutside); |
|||
}; |
|||
}, []); |
|||
|
|||
return ( |
|||
<main className="flex-1 p-4 transition-all duration-300 ease-in-out sm:p-6"> |
|||
<div className="w-full space-y-4"> |
|||
|
|||
|
|||
<div className="flex flex-col bg-background p-4 rounded-md border mb-2"> |
|||
<form |
|||
className="flex gap-x-8 gap-y-4 items-center flex-wrap" |
|||
onSubmit={e => { |
|||
e.preventDefault(); |
|||
handleQuery(); |
|||
}} |
|||
> |
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
设备编号: |
|||
</label> |
|||
<Input |
|||
className="flex-1 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
placeholder="请输入设备编号" |
|||
value={searchParams.deviceCode || ''} |
|||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
|||
setSearchParams(prev => ({ ...prev, deviceCode: e.target.value })) |
|||
} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
协议层类型: |
|||
</label> |
|||
<div className="flex-1 relative"> |
|||
<Button |
|||
variant="outline" |
|||
data-layer-type-button |
|||
className="w-full justify-between bg-background text-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
onClick={() => { |
|||
const dropdown = document.getElementById('layerTypeDropdown'); |
|||
if (dropdown) { |
|||
dropdown.classList.toggle('hidden'); |
|||
} |
|||
}} |
|||
> |
|||
<span className="truncate"> |
|||
{searchParams.layerTypes?.length |
|||
? `${searchParams.layerTypes.length} 个已选择` |
|||
: '请选择协议层类型' |
|||
} |
|||
</span> |
|||
<ChevronDown className="h-4 w-4" /> |
|||
</Button> |
|||
|
|||
<div |
|||
id="layerTypeDropdown" |
|||
className="hidden absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-60 overflow-auto" |
|||
> |
|||
<div className="p-2"> |
|||
<div className="flex items-center space-x-2 p-2 hover:bg-accent rounded"> |
|||
<Checkbox |
|||
id="select-all" |
|||
checked={(searchParams.layerTypes?.length || 0) === 0} |
|||
onCheckedChange={(checked) => { |
|||
setSearchParams(prev => ({ ...prev, layerTypes: [] })); |
|||
}} |
|||
/> |
|||
<label htmlFor="select-all" className="text-sm">全部</label> |
|||
</div> |
|||
|
|||
{protocolLayerOptions.filter(option => option.value !== 'ALL').map((option) => ( |
|||
<div key={option.value} className="flex items-center space-x-2 p-2 hover:bg-accent rounded"> |
|||
<Checkbox |
|||
id={`layer-${option.value}`} |
|||
checked={searchParams.layerTypes?.includes(parseInt(option.value)) || false} |
|||
onCheckedChange={(checked) => { |
|||
const currentTypes = searchParams.layerTypes || []; |
|||
if (checked) { |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
layerTypes: [...currentTypes, parseInt(option.value)] |
|||
})); |
|||
} else { |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
layerTypes: currentTypes.filter(type => type !== parseInt(option.value)) |
|||
})); |
|||
} |
|||
}} |
|||
/> |
|||
<label htmlFor={`layer-${option.value}`} className="text-sm">{option.label}</label> |
|||
</div> |
|||
))} |
|||
</div> |
|||
</div> |
|||
|
|||
{(searchParams.layerTypes?.length || 0) > 0 && ( |
|||
<div className="flex flex-wrap gap-1 mt-1"> |
|||
{(searchParams.layerTypes || []).map((type) => { |
|||
const option = protocolLayerOptions.find(opt => opt.value === String(type)); |
|||
return ( |
|||
<Badge key={type} variant="secondary" className="text-xs"> |
|||
{option?.label || type} |
|||
<X |
|||
className="h-3 w-3 ml-1 cursor-pointer" |
|||
onClick={() => { |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
layerTypes: prev.layerTypes?.filter(t => t !== type) || [] |
|||
})); |
|||
}} |
|||
/> |
|||
</Badge> |
|||
); |
|||
})} |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
|
|||
|
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
开始时间戳: |
|||
</label> |
|||
<Input |
|||
type="number" |
|||
className="flex-1 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
placeholder="请输入开始时间戳" |
|||
value={searchParams.startTimestamp || ''} |
|||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
startTimestamp: e.target.value ? parseInt(e.target.value) : undefined |
|||
})) |
|||
} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex flex-row items-center min-w-[200px] flex-1"> |
|||
<label className="mr-2 text-sm font-medium text-foreground whitespace-nowrap text-right" style={{ width: 80, minWidth: 80 }}> |
|||
结束时间戳: |
|||
</label> |
|||
<Input |
|||
type="number" |
|||
className="flex-1 bg-background text-foreground placeholder:text-muted-foreground border border-border focus:outline-none focus:ring-0 focus:border-border transition-all" |
|||
placeholder="请输入结束时间戳" |
|||
value={searchParams.endTimestamp || ''} |
|||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => |
|||
setSearchParams(prev => ({ |
|||
...prev, |
|||
endTimestamp: e.target.value ? parseInt(e.target.value) : undefined |
|||
})) |
|||
} |
|||
/> |
|||
</div> |
|||
|
|||
<div className="flex flex-row items-center gap-2"> |
|||
<Button variant="outline" onClick={handleReset}>重置</Button> |
|||
<Button onClick={handleQuery}>查询</Button> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
|
|||
<div className="rounded-md border bg-background p-4"> |
|||
<div className="flex items-center justify-between mb-2"> |
|||
<div className="flex items-center gap-2"> |
|||
<span className="text-sm text-muted-foreground"> |
|||
共 {protocolLogs.length} 条记录 |
|||
</span> |
|||
</div> |
|||
<TableToolbar |
|||
onRefresh={() => fetchProtocolLogs()} |
|||
onDensityChange={setDensity} |
|||
onColumnsChange={setColumns} |
|||
onColumnsReset={() => setColumns(defaultColumns)} |
|||
columns={columns} |
|||
density={density} |
|||
/> |
|||
</div> |
|||
|
|||
<ProtocolLogsTable |
|||
protocolLogs={protocolLogs} |
|||
loading={loading} |
|||
columns={columns} |
|||
density={density} |
|||
/> |
|||
</div> |
|||
</div> |
|||
</main> |
|||
); |
|||
} |
Loading…
Reference in new issue