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:
1 parent
e4f1018ca1
commit
f2069fe3f0
11 files changed
+673
-330
No files matched your search
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user