mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
feat(access-logs): 重构访问日志为概览与明细双 Tab
新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行, 明细列表保留检索;排行榜改为紧凑列表样式。
This commit is contained in:
@@ -11,11 +11,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { AccessLogTab, SearchDraft } from './access-log-utils';
|
||||
import type { SearchDraft } from './access-log-utils';
|
||||
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
|
||||
|
||||
interface AccessLogFiltersProps {
|
||||
tab: AccessLogTab;
|
||||
draft: SearchDraft;
|
||||
pageSize: number;
|
||||
onDraftChange: (draft: SearchDraft) => void;
|
||||
@@ -25,7 +24,6 @@ interface AccessLogFiltersProps {
|
||||
}
|
||||
|
||||
export function AccessLogFilters({
|
||||
tab,
|
||||
draft,
|
||||
pageSize,
|
||||
onDraftChange,
|
||||
@@ -33,8 +31,6 @@ export function AccessLogFilters({
|
||||
onSearch,
|
||||
onReset,
|
||||
}: AccessLogFiltersProps) {
|
||||
const showPath = tab === 'list' || tab === 'folds';
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
@@ -81,24 +77,18 @@ export function AccessLogFilters({
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
{showPath ? (
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
请求路径
|
||||
</p>
|
||||
<Input
|
||||
value={draft.path}
|
||||
onChange={(e) =>
|
||||
onDraftChange({ ...draft, path: e.target.value })
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按路径搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className='space-y-1.5'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>请求路径</p>
|
||||
<Input
|
||||
value={draft.path}
|
||||
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按路径搜索'
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AccessLogTab = 'list' | 'folds' | 'ip-summary' | 'ip-trend';
|
||||
export type AccessLogTab = 'overview' | 'list';
|
||||
|
||||
export type SearchDraft = {
|
||||
nodeId: string;
|
||||
@@ -7,8 +7,20 @@ export type SearchDraft = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type OverviewRangeHours = 24 | 168 | 360 | 720;
|
||||
|
||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
||||
|
||||
export const OVERVIEW_RANGE_OPTIONS: {
|
||||
value: OverviewRangeHours;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: 24, label: '24 小时' },
|
||||
{ value: 168, label: '7 天' },
|
||||
{ value: 360, label: '15 天' },
|
||||
{ value: 720, label: '30 天' },
|
||||
];
|
||||
|
||||
export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: 'logged_at:desc', label: '时间从新到旧' },
|
||||
{ value: 'logged_at:asc', label: '时间从旧到新' },
|
||||
@@ -18,20 +30,6 @@ export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
||||
];
|
||||
|
||||
export const FOLD_SORT_OPTIONS = [
|
||||
{ value: 'bucket_started_at:desc', label: '时间桶从新到旧' },
|
||||
{ value: 'bucket_started_at:asc', label: '时间桶从旧到新' },
|
||||
{ value: 'request_count:desc', label: '访问次数从高到低' },
|
||||
{ value: 'request_count:asc', label: '访问次数从低到高' },
|
||||
];
|
||||
|
||||
export const IP_SORT_OPTIONS = [
|
||||
{ value: 'total_requests:desc', label: '总访问次数从高到低' },
|
||||
{ value: 'total_requests:asc', label: '总访问次数从低到高' },
|
||||
{ value: 'recent_requests:desc', label: '3 小时访问次数从高到低' },
|
||||
{ value: 'last_seen_at:desc', label: '最后访问时间从新到旧' },
|
||||
];
|
||||
|
||||
export function parseSortValue(value: string) {
|
||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||
return {
|
||||
@@ -46,3 +44,23 @@ export function formatCompactNumber(value: number) {
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatOverviewRangeHint(hours: number) {
|
||||
if (hours <= 24) return '近 24 小时';
|
||||
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
|
||||
return `近 ${hours} 小时`;
|
||||
}
|
||||
|
||||
export function formatOverviewTrendLabel(value: string, hours: number) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—';
|
||||
}
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hour = `${date.getHours()}`.padStart(2, '0');
|
||||
if (hours <= 24) {
|
||||
return `${hour}:00`;
|
||||
}
|
||||
return `${month}/${day} ${hour}:00`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { AccessLogList } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { DETAIL_SORT_OPTIONS } from './access-log-utils';
|
||||
|
||||
function PaginationBar({
|
||||
page,
|
||||
hasMore,
|
||||
loading,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
||||
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loading || page <= 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loading || !hasMore}
|
||||
onClick={onNext}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DetailTab({
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
page,
|
||||
detailSort,
|
||||
onDetailSortChange,
|
||||
onRetry,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isFetching,
|
||||
}: {
|
||||
data?: AccessLogList;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
page: number;
|
||||
detailSort: string;
|
||||
onDetailSortChange: (value: string) => void;
|
||||
onRetry: () => void;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||
<p className='text-sm font-medium'>日志明细</p>
|
||||
<Select value={detailSort} onValueChange={onDetailSortChange}>
|
||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DETAIL_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={error.message || '加载失败'}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title='暂无访问日志' />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>时间</TableHead>
|
||||
<TableHead className='text-xs'>节点</TableHead>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>域名</TableHead>
|
||||
<TableHead className='text-xs'>路径</TableHead>
|
||||
<TableHead className='text-xs'>状态码</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id} className='border-dashed'>
|
||||
<TableCell className='text-xs'>
|
||||
{formatDateTime(item.logged_at)}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{item.node_name || item.node_id}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs font-mono'>
|
||||
{item.remote_addr}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>{item.host}</TableCell>
|
||||
<TableCell className='text-xs max-w-48 truncate'>
|
||||
{item.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{item.status_code}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PaginationBar
|
||||
page={page}
|
||||
hasMore={data?.has_more ?? false}
|
||||
loading={isFetching}
|
||||
onPrev={onPrevPage}
|
||||
onNext={onNextPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { TrendChart } from '@/components/data/trend-chart';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { AccessLogOverview } from '@/lib/services/openflare';
|
||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
|
||||
import {
|
||||
formatOverviewRangeHint,
|
||||
formatOverviewTrendLabel,
|
||||
OVERVIEW_RANGE_OPTIONS,
|
||||
type OverviewRangeHours,
|
||||
} from './access-log-utils';
|
||||
|
||||
function SparklineMetricCard({
|
||||
title,
|
||||
value,
|
||||
hint,
|
||||
color,
|
||||
fillColor,
|
||||
labels,
|
||||
values,
|
||||
valueFormatter,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
color: string;
|
||||
fillColor: string;
|
||||
labels: string[];
|
||||
values: number[];
|
||||
valueFormatter?: (value: number) => string;
|
||||
}) {
|
||||
const option = useMemo<EChartsOption>(
|
||||
() => ({
|
||||
animationDuration: 400,
|
||||
grid: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 8,
|
||||
bottom: 0,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false,
|
||||
boundaryGap: false,
|
||||
data: labels,
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false,
|
||||
min: 0,
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.92)',
|
||||
borderWidth: 0,
|
||||
textStyle: {
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
},
|
||||
formatter: (params: unknown) => {
|
||||
const items = Array.isArray(params) ? params : [];
|
||||
const item = items[0] as
|
||||
{ axisValueLabel?: string; value?: number } | undefined;
|
||||
if (!item) return '';
|
||||
const raw = typeof item.value === 'number' ? item.value : 0;
|
||||
const formatted = valueFormatter
|
||||
? valueFormatter(raw)
|
||||
: formatCompactNumber(raw);
|
||||
return `${item.axisValueLabel ?? ''}<br/>${formatted}`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: values,
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
color,
|
||||
width: 2,
|
||||
},
|
||||
areaStyle: {
|
||||
color: fillColor,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[color, fillColor, labels, valueFormatter, values],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardContent className='p-4'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div className='min-w-0 space-y-1'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
{title}
|
||||
</p>
|
||||
<p className='text-2xl font-semibold tracking-tight'>{value}</p>
|
||||
<p className='text-[11px] text-muted-foreground'>{hint}</p>
|
||||
</div>
|
||||
<div className='h-16 w-28 shrink-0 sm:w-36'>
|
||||
{labels.length > 0 ? (
|
||||
<ReactECharts
|
||||
option={option}
|
||||
notMerge
|
||||
lazyUpdate
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RankCard({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: { label: string; value: number }[];
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-sm font-semibold text-foreground'>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs text-muted-foreground'>
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-0'>
|
||||
<RankChart
|
||||
items={items}
|
||||
color={color}
|
||||
emptyMessage={`暂无 ${title} 数据`}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewRangeSwitch({
|
||||
hours,
|
||||
onHoursChange,
|
||||
}: {
|
||||
hours: OverviewRangeHours;
|
||||
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className='flex justify-end'>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={String(hours)}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='justify-end'
|
||||
>
|
||||
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
hours,
|
||||
onHoursChange,
|
||||
onRetry,
|
||||
}: {
|
||||
data?: AccessLogOverview;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
hours: OverviewRangeHours;
|
||||
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<OverviewRangeSwitch hours={hours} onHoursChange={onHoursChange} />
|
||||
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder
|
||||
title='加载访问概览'
|
||||
description='正在聚合请求量、访问量与带宽趋势...'
|
||||
/>
|
||||
) : error ? (
|
||||
<ErrorInline
|
||||
message={error.message || '加载访问概览失败'}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
) : !data ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无概览数据'
|
||||
description='当前时间范围内没有可展示的访问统计。'
|
||||
/>
|
||||
) : (
|
||||
<OverviewContent data={data} hours={hours} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewContent({
|
||||
data,
|
||||
hours,
|
||||
}: {
|
||||
data: AccessLogOverview;
|
||||
hours: number;
|
||||
}) {
|
||||
const requestLabels = data.trends.requests.map((point) =>
|
||||
formatOverviewTrendLabel(point.bucket_started_at, hours),
|
||||
);
|
||||
const requestValues = data.trends.requests.map((point) => point.value);
|
||||
const visitValues = data.trends.visits.map((point) => point.value);
|
||||
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
|
||||
const hint = formatOverviewRangeHint(hours);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='grid gap-4 lg:grid-cols-3'>
|
||||
<SparklineMetricCard
|
||||
title='Total Requests'
|
||||
value={formatCompactNumber(data.summary.total_requests)}
|
||||
hint={hint}
|
||||
color='#f59e0b'
|
||||
fillColor='rgba(245, 158, 11, 0.18)'
|
||||
labels={requestLabels}
|
||||
values={requestValues}
|
||||
/>
|
||||
<SparklineMetricCard
|
||||
title='Total Visits'
|
||||
value={formatCompactNumber(data.summary.total_visits)}
|
||||
hint={`${hint} · 独立访客`}
|
||||
color='#38bdf8'
|
||||
fillColor='rgba(56, 189, 248, 0.16)'
|
||||
labels={requestLabels}
|
||||
values={visitValues}
|
||||
/>
|
||||
<SparklineMetricCard
|
||||
title='Bandwidth Served'
|
||||
value={formatBytes(data.summary.bandwidth_served)}
|
||||
hint={`${hint} · 已提供数据`}
|
||||
color='#34d399'
|
||||
fillColor='rgba(52, 211, 153, 0.16)'
|
||||
labels={requestLabels}
|
||||
values={bandwidthValues}
|
||||
valueFormatter={formatBytes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-sm font-semibold'>
|
||||
Requests over time
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
观察请求量是否出现异常抬升或回落。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TrendChart
|
||||
labels={requestLabels}
|
||||
height={280}
|
||||
showSummary={false}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
values: requestValues,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<RankCard
|
||||
title='Top Paths'
|
||||
description='访问量最高的请求路径。'
|
||||
color='#a78bfa'
|
||||
items={data.top_paths.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top Hosts'
|
||||
description='流量集中的访问域名。'
|
||||
color='#34d399'
|
||||
items={data.top_hosts.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top IPs'
|
||||
description='请求次数最多的来源 IP。'
|
||||
color='#38bdf8'
|
||||
items={data.top_ips.map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user