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