mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat(waf): add search and filter in ip group view dialog
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Loader2, Trash2 } from 'lucide-react';
|
import { Loader2, Search, Trash2, X } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -59,6 +60,14 @@ export function IPGroupViewDialog({
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
|
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearchKeyword('');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -68,6 +77,13 @@ export function IPGroupViewDialog({
|
|||||||
[group, t],
|
[group, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const trimmedKeyword = searchKeyword.trim().toLowerCase();
|
||||||
|
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
if (!trimmedKeyword) return entries;
|
||||||
|
return entries.filter((e) => e.ip.toLowerCase().includes(trimmedKeyword));
|
||||||
|
}, [entries, trimmedKeyword]);
|
||||||
|
|
||||||
const showAutomaticMeta = group?.type === 'automatic';
|
const showAutomaticMeta = group?.type === 'automatic';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,6 +94,7 @@ export function IPGroupViewDialog({
|
|||||||
onOpenChange(nextOpen);
|
onOpenChange(nextOpen);
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
setSearchKeyword('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -90,10 +107,16 @@ export function IPGroupViewDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{group
|
{group
|
||||||
? t('viewDialog.summary', {
|
? trimmedKeyword
|
||||||
type: t(`types.${group.type}`),
|
? t('viewDialog.summaryFiltered', {
|
||||||
count: entries.length,
|
type: t(`types.${group.type}`),
|
||||||
})
|
count: entries.length,
|
||||||
|
matched: filteredEntries.length,
|
||||||
|
})
|
||||||
|
: t('viewDialog.summary', {
|
||||||
|
type: t(`types.${group.type}`),
|
||||||
|
count: entries.length,
|
||||||
|
})
|
||||||
: t('viewDialog.fallbackDesc')}
|
: t('viewDialog.fallbackDesc')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -122,64 +145,95 @@ export function IPGroupViewDialog({
|
|||||||
{t('viewDialog.subscriptionHint')}
|
{t('viewDialog.subscriptionHint')}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div className='rounded-lg border border-dashed'>
|
<div className='relative'>
|
||||||
<Table>
|
<Search className='pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||||
<TableHeader>
|
<Input
|
||||||
<TableRow>
|
value={searchKeyword}
|
||||||
<TableHead>{t('viewDialog.ipAddress')}</TableHead>
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
{showAutomaticMeta ? (
|
placeholder={t('viewDialog.searchPlaceholder')}
|
||||||
<>
|
className='pl-8 pr-8'
|
||||||
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
|
/>
|
||||||
<TableHead>{t('viewDialog.banRemaining')}</TableHead>
|
{searchKeyword ? (
|
||||||
</>
|
<Button
|
||||||
) : null}
|
type='button'
|
||||||
<TableHead className='w-[80px] text-right'>
|
variant='ghost'
|
||||||
{t('viewDialog.actions')}
|
size='icon'
|
||||||
</TableHead>
|
className='absolute right-1 top-1/2 size-7 -translate-y-1/2 text-muted-foreground hover:text-foreground'
|
||||||
</TableRow>
|
onClick={() => setSearchKeyword('')}
|
||||||
</TableHeader>
|
>
|
||||||
<TableBody>
|
<X className='size-3.5' />
|
||||||
{entries.map((entry) => (
|
<span className='sr-only'>
|
||||||
<TableRow key={entry.ip}>
|
{t('viewDialog.clearSearch')}
|
||||||
<TableCell className='font-mono text-sm'>
|
</span>
|
||||||
{entry.ip}
|
</Button>
|
||||||
</TableCell>
|
) : null}
|
||||||
|
</div>
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
description={t('viewDialog.noSearchResult')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='rounded-lg border border-dashed'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t('viewDialog.ipAddress')}</TableHead>
|
||||||
{showAutomaticMeta ? (
|
{showAutomaticMeta ? (
|
||||||
<>
|
<>
|
||||||
<TableCell className='text-sm text-muted-foreground'>
|
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
|
||||||
{entry.capturedAt
|
<TableHead>
|
||||||
? formatDateTime(entry.capturedAt)
|
{t('viewDialog.banRemaining')}
|
||||||
: '—'}
|
</TableHead>
|
||||||
</TableCell>
|
|
||||||
<TableCell className='text-sm'>
|
|
||||||
{entry.banRemaining ?? '—'}
|
|
||||||
</TableCell>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<TableCell className='text-right'>
|
<TableHead className='w-[80px] text-right'>
|
||||||
<Button
|
{t('viewDialog.actions')}
|
||||||
type='button'
|
</TableHead>
|
||||||
variant='ghost'
|
|
||||||
size='icon'
|
|
||||||
className='size-8 text-destructive hover:text-destructive'
|
|
||||||
disabled={removingIp === entry.ip}
|
|
||||||
onClick={() => setDeleteTarget(entry)}
|
|
||||||
>
|
|
||||||
{removingIp === entry.ip ? (
|
|
||||||
<Loader2 className='size-4 animate-spin' />
|
|
||||||
) : (
|
|
||||||
<Trash2 className='size-4' />
|
|
||||||
)}
|
|
||||||
<span className='sr-only'>
|
|
||||||
{t('viewDialog.deleteIp', { ip: entry.ip })}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{filteredEntries.map((entry) => (
|
||||||
</div>
|
<TableRow key={entry.ip}>
|
||||||
|
<TableCell className='font-mono text-sm'>
|
||||||
|
{entry.ip}
|
||||||
|
</TableCell>
|
||||||
|
{showAutomaticMeta ? (
|
||||||
|
<>
|
||||||
|
<TableCell className='text-sm text-muted-foreground'>
|
||||||
|
{entry.capturedAt
|
||||||
|
? formatDateTime(entry.capturedAt)
|
||||||
|
: '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='text-sm'>
|
||||||
|
{entry.banRemaining ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<TableCell className='text-right'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='size-8 text-destructive hover:text-destructive'
|
||||||
|
disabled={removingIp === entry.ip}
|
||||||
|
onClick={() => setDeleteTarget(entry)}
|
||||||
|
>
|
||||||
|
{removingIp === entry.ip ? (
|
||||||
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Trash2 className='size-4' />
|
||||||
|
)}
|
||||||
|
<span className='sr-only'>
|
||||||
|
{t('viewDialog.deleteIp', { ip: entry.ip })}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,10 @@
|
|||||||
"titleNamed": "View {name}",
|
"titleNamed": "View {name}",
|
||||||
"title": "View IP group",
|
"title": "View IP group",
|
||||||
"summary": "{type} · {count} IPs",
|
"summary": "{type} · {count} IPs",
|
||||||
|
"summaryFiltered": "{type} · {count} IPs ({matched} matched)",
|
||||||
|
"searchPlaceholder": "Search IP addresses...",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"noSearchResult": "No matching IP addresses found.",
|
||||||
"fallbackDesc": "View IPs in this group and remove entries you no longer need.",
|
"fallbackDesc": "View IPs in this group and remove entries you no longer need.",
|
||||||
"loading": "Loading IP list...",
|
"loading": "Loading IP list...",
|
||||||
"noGroup": "No IP group selected.",
|
"noGroup": "No IP group selected.",
|
||||||
|
|||||||
@@ -257,6 +257,10 @@
|
|||||||
"titleNamed": "查看 {name}",
|
"titleNamed": "查看 {name}",
|
||||||
"title": "查看 IP 组",
|
"title": "查看 IP 组",
|
||||||
"summary": "{type} · 共 {count} 条 IP",
|
"summary": "{type} · 共 {count} 条 IP",
|
||||||
|
"summaryFiltered": "{type} · 共 {count} 条 IP(匹配 {matched} 条)",
|
||||||
|
"searchPlaceholder": "搜索 IP 地址...",
|
||||||
|
"clearSearch": "清空搜索",
|
||||||
|
"noSearchResult": "未找到匹配的 IP 地址。",
|
||||||
"fallbackDesc": "查看当前 IP 组中的 IP 列表,并可移除不需要的条目。",
|
"fallbackDesc": "查看当前 IP 组中的 IP 列表,并可移除不需要的条目。",
|
||||||
"loading": "加载 IP 列表...",
|
"loading": "加载 IP 列表...",
|
||||||
"noGroup": "未选择 IP 组。",
|
"noGroup": "未选择 IP 组。",
|
||||||
|
|||||||
Reference in New Issue
Block a user