159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
|
import { Card, Select, Spin, Tag, theme } from 'antd';
|
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
|
import type { RealtimeLog } from '../hooks/useWebSocketLogs';
|
|
|
|
/** 日志等级定义:value 越小越严格(只显示该等级及以上) */
|
|
const LOG_LEVELS = [
|
|
{ value: 0, label: '错误', key: 'error' },
|
|
{ value: 1, label: '警告+', key: 'warning' },
|
|
{ value: 2, label: '成功+', key: 'success' },
|
|
{ value: 3, label: '全部', key: 'info' },
|
|
] as const;
|
|
|
|
/** 等级权重映射 */
|
|
const LEVEL_WEIGHT: Record<string, number> = {
|
|
error: 0,
|
|
warning: 1,
|
|
success: 2,
|
|
info: 3,
|
|
debug: 4,
|
|
};
|
|
|
|
interface RealtimeLogPanelProps {
|
|
logs: RealtimeLog[];
|
|
connected?: boolean;
|
|
title?: string;
|
|
emptyText?: ReactNode;
|
|
height?: number | string;
|
|
mode?: 'inline' | 'card';
|
|
collapsible?: boolean;
|
|
defaultVisible?: boolean;
|
|
spinWhenEmpty?: boolean;
|
|
style?: CSSProperties;
|
|
bodyStyle?: CSSProperties;
|
|
}
|
|
|
|
export default function RealtimeLogPanel({
|
|
logs,
|
|
connected = false,
|
|
title = '实时日志',
|
|
emptyText = '暂无日志',
|
|
height = '20vh',
|
|
mode = 'inline',
|
|
collapsible = false,
|
|
defaultVisible = true,
|
|
spinWhenEmpty = false,
|
|
style,
|
|
bodyStyle,
|
|
}: RealtimeLogPanelProps) {
|
|
const { token } = theme.useToken();
|
|
const [visible, setVisible] = useState(defaultVisible);
|
|
const [levelFilter, setLevelFilter] = useState<number>(3); // 默认:全部
|
|
const endRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// 按等级过滤日志
|
|
const filteredLogs = useMemo(() => {
|
|
if (levelFilter >= 3) return logs;
|
|
return logs.filter((log) => (LEVEL_WEIGHT[log.level] ?? 3) <= levelFilter);
|
|
}, [logs, levelFilter]);
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
endRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
}, [filteredLogs, visible]);
|
|
|
|
const logColors: Record<string, string> = {
|
|
error: token.colorError,
|
|
success: token.colorSuccess,
|
|
warning: token.colorWarning,
|
|
info: token.colorText,
|
|
};
|
|
|
|
const logBody = (
|
|
<div
|
|
style={{
|
|
height,
|
|
overflow: 'auto',
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
padding: mode === 'card' ? '8px 16px' : 4,
|
|
backgroundColor: mode === 'card' ? undefined : token.colorBgLayout,
|
|
borderRadius: mode === 'card' ? undefined : 4,
|
|
...bodyStyle,
|
|
}}
|
|
>
|
|
{filteredLogs.length === 0 ? (
|
|
spinWhenEmpty && connected ? (
|
|
<Spin spinning size="small" />
|
|
) : (
|
|
<span style={{ color: token.colorTextTertiary }}>{emptyText}</span>
|
|
)
|
|
) : (
|
|
filteredLogs.map((log, index) => (
|
|
<div
|
|
key={`${index}-${log.message}`}
|
|
style={{ color: logColors[log.level] || token.colorText, lineHeight: '20px' }}
|
|
>
|
|
{log.message}
|
|
</div>
|
|
))
|
|
)}
|
|
<div ref={endRef} />
|
|
</div>
|
|
);
|
|
|
|
if (mode === 'card') {
|
|
return (
|
|
<Card
|
|
title={title}
|
|
size="small"
|
|
extra={
|
|
<Select
|
|
size="small"
|
|
value={levelFilter}
|
|
onChange={setLevelFilter}
|
|
options={LOG_LEVELS.map((l) => ({ value: l.value, label: l.label }))}
|
|
style={{ width: 80 }}
|
|
popupMatchSelectWidth={false}
|
|
/>
|
|
}
|
|
style={{ flex: 1, overflow: 'hidden', ...style }}
|
|
styles={{ body: { height: '100%', overflow: 'hidden', padding: 0 } }}
|
|
>
|
|
{logBody}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ flexShrink: 0, borderTop: `1px solid ${token.colorBorderSecondary}`, ...style }}>
|
|
<div
|
|
style={{ display: 'flex', alignItems: 'center', padding: '4px 0', userSelect: 'none' }}
|
|
>
|
|
<span
|
|
style={{ fontWeight: 500, fontSize: 13, cursor: collapsible ? 'pointer' : 'default' }}
|
|
onClick={() => collapsible && setVisible((value) => !value)}
|
|
>
|
|
{title}
|
|
</span>
|
|
<Select
|
|
size="small"
|
|
value={levelFilter}
|
|
onChange={setLevelFilter}
|
|
options={LOG_LEVELS.map((l) => ({ value: l.value, label: l.label }))}
|
|
style={{ marginLeft: 8, width: 80 }}
|
|
popupMatchSelectWidth={false}
|
|
/>
|
|
{collapsible && (
|
|
visible ? <UpOutlined style={{ marginLeft: 6, fontSize: 10 }} /> : <DownOutlined style={{ marginLeft: 6, fontSize: 10 }} />
|
|
)}
|
|
{logs.length > 0 && <span style={{ marginLeft: 8, fontSize: 12, color: token.colorTextTertiary }}>{logs.length} 条</span>}
|
|
{connected && <Tag color="processing" style={{ marginLeft: 8 }}>连接中</Tag>}
|
|
</div>
|
|
{visible && logBody}
|
|
</div>
|
|
);
|
|
}
|