feat(ux): U1 虚拟滚动 — Pod/事件/审计/命令列表大数据量渲染优化

- 安装 @tanstack/react-virtual@3.14.4
- 新增 hooks/useVirtualRows.ts 可复用虚拟滚动 Hook
- PodsTab: 全量渲染→虚拟滚动, 支持 1000+ Pod + 动态行高(展开行)
- EventTable: 移除 maxRows=200 截断, 万级事件流畅渲染
- AuditOperationsTab: shadcn Table→虚拟滚动 CSS Grid 布局
- SessionCommandsPanel: 虚拟滚动, page_size 200→500
- npm run build ✅, tsc --noEmit ✅, 17/17 集成测试通过
This commit is contained in:
attacker committed 2026-06-27 09:36:17 +08:00
1 parent e4f1018ca1
commit f2069fe3f0
11 files changed
+673 -330

No files matched your search

+93 -67
View File
@@ -1,17 +1,11 @@
// 操作审计 Tab — 增强版审计日志列表,含筛选/导出/统计/清除
import { useState, useEffect, useCallback, useMemo } from 'react';
// v2: 虚拟滚动渲染,支持万级日志流畅查看
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Search,
RefreshCw,
@@ -148,6 +142,20 @@ export function AuditOperationsTab() {
setDetailLog(log);
};
// 虚拟滚动
const scrollRef = useRef<HTMLDivElement>(null);
const GRID_COLS = 'minmax(140px,1fr) minmax(80px,1fr) minmax(100px,1fr) minmax(100px,1fr) minmax(140px,1fr) minmax(100px,1fr) minmax(80px,0.8fr) 48px';
const virtualizer = useVirtualizer({
count: filteredLogs.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 52,
overscan: 10,
});
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
return (
<div className="space-y-4">
{/* Filters */}
@@ -208,7 +216,7 @@ export function AuditOperationsTab() {
</Button>
</div>
{/* Logs Table */}
{/* Logs Table — 虚拟滚动 */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
@@ -222,64 +230,82 @@ export function AuditOperationsTab() {
<p className="text-sm">暂无操作日志</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">模块</TableHead>
<TableHead className="text-slate-400">操作类型</TableHead>
<TableHead className="text-slate-400">资源</TableHead>
<TableHead className="text-slate-400">IP</TableHead>
<TableHead className="text-slate-400">结果</TableHead>
<TableHead className="text-slate-400 text-right">详情</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{log.username || '--'}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.module || '--'}</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
{log.action || '--'}
</Badge>
</TableCell>
<TableCell>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
{log.resource || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{log.ip || '--'}</span>
</TableCell>
<TableCell>{getLogResultBadge(log.result)}</TableCell>
<TableCell>
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => viewDetail(log)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
<>
{/* 表头 */}
<div
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400 sticky top-0 z-10"
style={{ gridTemplateColumns: GRID_COLS }}
>
<span>时间</span>
<span>用户</span>
<span>模块</span>
<span>操作类型</span>
<span>资源</span>
<span>IP</span>
<span>结果</span>
<span className="text-right">详情</span>
</div>
{/* 虚拟滚动列表 */}
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{virtualRows.map((vr) => {
const log = filteredLogs[vr.index];
if (!log) return null;
return (
<div
key={log.id}
data-index={vr.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vr.start}px)`,
display: 'grid',
gridTemplateColumns: GRID_COLS,
gap: '0.5rem',
}}
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
>
<span className="text-sm text-slate-400 truncate">
{log.createdAt ? new Date(log.createdAt).toLocaleString() : '--'}
</span>
<span className="text-sm text-slate-200 truncate">{log.username || '--'}</span>
<span className="text-sm text-slate-300 truncate">{log.module || '--'}</span>
<div>
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
{log.action || '--'}
</Badge>
</div>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded truncate">
{log.resource || '--'}
</code>
<span className="text-sm text-slate-400 truncate">{log.ip || '--'}</span>
<div>{getLogResultBadge(log.result)}</div>
<div className="flex items-center justify-end">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400 hover:bg-slate-800"
onClick={() => viewDetail(log)}
title="查看详情"
>
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
})}
</div>
</div>
{/* 行数统计 */}
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
共 {filteredLogs.length} 条日志 {filteredLogs.length > 200 && '· 虚拟滚动渲染'}
</div>
</>
)}
</div>
</CardContent>
@@ -1,21 +1,17 @@
// 终端审计 — 会话命令子面板 // Logan.Li
// v2: 虚拟滚动,支持万级命令记录流畅查看
import { useMemo, useState } from 'react';
import { useMemo, useState, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, RefreshCw } from 'lucide-react';
import { useSessionCommands } from '@/hooks/api';
import { formatSize } from './helpers';
const GRID_COLS = 'minmax(150px,1fr) minmax(80px,0.8fr) minmax(200px,2fr) minmax(100px,0.8fr) minmax(80px,0.5fr)';
export function SessionCommandsPanel() {
const [keyword, setKeyword] = useState('');
const [sessionIdInput, setSessionIdInput] = useState('');
@@ -23,7 +19,7 @@ export function SessionCommandsPanel() {
const queryParams = useMemo(
() => ({
page: 1,
page_size: 200,
page_size: 500,
...(sessionIdInput ? { session_id: Number(sessionIdInput) } : {}),
}),
[sessionIdInput],
@@ -40,6 +36,17 @@ export function SessionCommandsPanel() {
);
});
// 虚拟滚动
const scrollRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: filtered.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 48,
overscan: 10,
});
const virtualRows = virtualizer.getVirtualItems();
const totalHeight = virtualizer.getTotalSize();
return (
<>
<div className="flex items-center gap-3 flex-wrap">
@@ -81,46 +88,66 @@ export function SessionCommandsPanel() {
<p className="text-sm">暂无会话命令记录</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">命令</TableHead>
<TableHead className="text-slate-400">输出大小</TableHead>
<TableHead className="text-slate-400">关联会话</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((c) => (
<TableRow key={c.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{c.username || '--'}</span>
</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
{c.command || '--'}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
</TableCell>
<TableCell>
<span className="text-xs text-slate-500">#{c.session_id}</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<>
{/* 表头 */}
<div
className="grid gap-2 px-4 py-2 border-b border-slate-800 bg-slate-900 text-xs font-medium text-slate-400"
style={{ gridTemplateColumns: GRID_COLS }}
>
<span>时间</span>
<span>用户</span>
<span>命令</span>
<span>输出大小</span>
<span>关联会话</span>
</div>
{/* 虚拟滚动列表 */}
<div ref={scrollRef} style={{ height: 'min(600px, calc(100vh - 340px))', overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
{virtualRows.map((vr) => {
const c = filtered[vr.index];
if (!c) return null;
return (
<div
key={c.id}
data-index={vr.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vr.start}px)`,
display: 'grid',
gridTemplateColumns: GRID_COLS,
gap: '0.5rem',
}}
className="px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50"
>
<span className="text-sm text-slate-400 truncate">
{c.created_at ? new Date(c.created_at).toLocaleString() : '--'}
</span>
<span className="text-sm text-slate-200 truncate">{c.username || '--'}</span>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded max-w-[400px] truncate block">
{c.command || '--'}
</code>
<span className="text-sm text-slate-400">{formatSize(c.output_size)}</span>
<span className="text-xs text-slate-500">#{c.session_id}</span>
</div>
);
})}
</div>
</div>
{/* 行数统计 */}
<div className="text-center py-2 text-xs text-slate-500 border-t border-slate-800">
共 {filtered.length} 条命令 {filtered.length > 200 && '· 虚拟滚动渲染'}
</div>
</>
)}
</div>
</CardContent>
</Card>
</>
);
}
}