mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-14 02:46:37 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -60,6 +61,7 @@ export function AccessLogDetailDialog({
|
||||
item: AccessLogItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
// Keep last selected item while the dialog closes to avoid empty-state flash.
|
||||
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
|
||||
if (item && item !== displayItem) {
|
||||
@@ -71,21 +73,23 @@ export function AccessLogDetailDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>访问日志详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('detail.title')}</DialogTitle>
|
||||
<DialogDescription>{t('detail.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{activeItem ? (
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<DetailField label='日志 ID' value={activeItem.id || '—'} mono />
|
||||
<DetailField
|
||||
label='请求时间'
|
||||
label={t('detail.logId')}
|
||||
value={activeItem.id || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label={t('detail.requestTime')}
|
||||
value={formatDateTime(activeItem.logged_at)}
|
||||
/>
|
||||
<DetailField
|
||||
label='入库时间'
|
||||
label={t('detail.storedAt')}
|
||||
value={
|
||||
activeItem.created_at
|
||||
? formatDateTime(activeItem.created_at)
|
||||
@@ -93,23 +97,29 @@ export function AccessLogDetailDialog({
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点'
|
||||
label={t('detail.node')}
|
||||
value={activeItem.node_name || activeItem.node_id || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点 ID'
|
||||
label={t('detail.nodeId')}
|
||||
value={activeItem.node_id || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label='客户端 IP'
|
||||
label={t('detail.clientIp')}
|
||||
value={activeItem.remote_addr || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField label='地区' value={activeItem.region || '—'} />
|
||||
<DetailField label='域名' value={activeItem.host || '—'} />
|
||||
<DetailField
|
||||
label='状态码'
|
||||
label={t('detail.region')}
|
||||
value={activeItem.region || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label={t('detail.host')}
|
||||
value={activeItem.host || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label={t('detail.status')}
|
||||
value={
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{activeItem.status_code}
|
||||
@@ -117,12 +127,13 @@ export function AccessLogDetailDialog({
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='缓存'
|
||||
label={t('detail.cache')}
|
||||
value={
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{cacheOutcomeLabel(
|
||||
resolveCacheOutcome(activeItem.cache_status),
|
||||
(key) => t(key),
|
||||
)}
|
||||
</Badge>
|
||||
<span className='font-mono text-xs text-muted-foreground'>
|
||||
@@ -132,19 +143,19 @@ export function AccessLogDetailDialog({
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='出站流量'
|
||||
label={t('detail.bytesSent')}
|
||||
value={formatBytes(activeItem.bytes_sent ?? 0)}
|
||||
/>
|
||||
<DetailField
|
||||
label='入站流量'
|
||||
label={t('detail.requestLength')}
|
||||
value={formatBytes(activeItem.request_length ?? 0)}
|
||||
/>
|
||||
<DetailField
|
||||
label='请求耗时'
|
||||
label={t('detail.duration')}
|
||||
value={formatRequestTimeMs(activeItem.request_time_ms)}
|
||||
/>
|
||||
<DetailField
|
||||
label='路径'
|
||||
label={t('detail.path')}
|
||||
value={activeItem.path || '—'}
|
||||
full
|
||||
mono
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { CalendarIcon, ChevronDown, Search } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
@@ -91,6 +92,7 @@ function DateTimePicker({
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.filters');
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = value ? new Date(value) : undefined;
|
||||
|
||||
@@ -121,7 +123,7 @@ function DateTimePicker({
|
||||
{current ? (
|
||||
format(current, 'yyyy-MM-dd HH:mm')
|
||||
) : (
|
||||
<span className='text-muted-foreground'>选择时间</span>
|
||||
<span className='text-muted-foreground'>{t('pickTime')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
@@ -153,12 +155,13 @@ export function AccessLogFilters({
|
||||
onSearch,
|
||||
onReset,
|
||||
}: AccessLogFiltersProps) {
|
||||
const t = useTranslations('accessLogs.filters');
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className='space-y-3'>
|
||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<FilterField label='来源 IP'>
|
||||
<FilterField label={t('remoteAddr')}>
|
||||
<Input
|
||||
value={draft.remoteAddr}
|
||||
onChange={(e) =>
|
||||
@@ -167,22 +170,22 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按 IP 搜索'
|
||||
placeholder={t('remoteAddrPlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label='访问域名'>
|
||||
<FilterField label={t('host')}>
|
||||
<Input
|
||||
value={draft.host}
|
||||
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按域名搜索'
|
||||
placeholder={t('hostPlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label='状态码'>
|
||||
<FilterField label={t('statusCode')}>
|
||||
<Input
|
||||
value={draft.statusCode}
|
||||
onChange={(e) =>
|
||||
@@ -194,7 +197,7 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='输入状态码,如 404'
|
||||
placeholder={t('statusCodePlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
@@ -207,7 +210,7 @@ export function AccessLogFilters({
|
||||
size='sm'
|
||||
className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground'
|
||||
>
|
||||
更多筛选
|
||||
{t('more')}
|
||||
<ChevronDown
|
||||
className={`size-3.5 transition-transform ${
|
||||
moreOpen ? 'rotate-180' : ''
|
||||
@@ -217,7 +220,7 @@ export function AccessLogFilters({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className='grid gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3'>
|
||||
<FilterField label='节点 ID'>
|
||||
<FilterField label={t('nodeId')}>
|
||||
<div className='relative'>
|
||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
@@ -228,12 +231,12 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按 node_id 搜索'
|
||||
placeholder={t('nodeIdPlaceholder')}
|
||||
className='pl-8 h-9 text-xs'
|
||||
/>
|
||||
</div>
|
||||
</FilterField>
|
||||
<FilterField label='请求路径'>
|
||||
<FilterField label={t('path')}>
|
||||
<Input
|
||||
value={draft.path}
|
||||
onChange={(e) =>
|
||||
@@ -242,11 +245,11 @@ export function AccessLogFilters({
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSearch();
|
||||
}}
|
||||
placeholder='按路径搜索'
|
||||
placeholder={t('pathPlaceholder')}
|
||||
className='h-9 text-xs'
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label='时间范围'>
|
||||
<FilterField label={t('timeRange')}>
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<DateTimePicker
|
||||
value={draft.since}
|
||||
@@ -268,7 +271,9 @@ export function AccessLogFilters({
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
|
||||
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
|
||||
<p className='text-xs font-medium text-muted-foreground'>
|
||||
{t('pageSize')}
|
||||
</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) =>
|
||||
@@ -281,7 +286,7 @@ export function AccessLogFilters({
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option} 条
|
||||
{t('pageSizeItem', { count: option })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -289,10 +294,10 @@ export function AccessLogFilters({
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<Button size='sm' onClick={onSearch}>
|
||||
筛选
|
||||
{t('apply')}
|
||||
</Button>
|
||||
<Button variant='outline' size='sm' onClick={onReset}>
|
||||
清空
|
||||
{t('clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,43 +19,43 @@ export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
||||
|
||||
export const OVERVIEW_RANGE_OPTIONS: {
|
||||
value: OverviewRangeHours;
|
||||
label: string;
|
||||
labelKey: `range.h${OverviewRangeHours}`;
|
||||
}[] = [
|
||||
{ value: 24, label: '24 小时' },
|
||||
{ value: 168, label: '7 天' },
|
||||
{ value: 360, label: '15 天' },
|
||||
{ value: 720, label: '30 天' },
|
||||
{ value: 24, labelKey: 'range.h24' },
|
||||
{ value: 168, labelKey: 'range.h168' },
|
||||
{ value: 360, labelKey: 'range.h360' },
|
||||
{ value: 720, labelKey: 'range.h720' },
|
||||
];
|
||||
|
||||
export const RATE_LIMIT_RANGE_OPTIONS: {
|
||||
value: RateLimitRangeHours;
|
||||
label: string;
|
||||
labelKey: 'range.h24' | 'range.h72';
|
||||
}[] = [
|
||||
{ value: 24, label: '24 小时' },
|
||||
{ value: 72, label: '3 天' },
|
||||
{ value: 24, labelKey: 'range.h24' },
|
||||
{ value: 72, labelKey: 'range.h72' },
|
||||
];
|
||||
|
||||
export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: 'logged_at:desc', label: '时间从新到旧' },
|
||||
{ value: 'logged_at:asc', label: '时间从旧到新' },
|
||||
{ value: 'status_code:desc', label: '状态码从高到低' },
|
||||
{ value: 'status_code:asc', label: '状态码从低到高' },
|
||||
{ value: 'remote_addr:asc', label: 'IP 正序' },
|
||||
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
||||
];
|
||||
{ value: 'logged_at:desc', labelKey: 'sort.logged_at:desc' },
|
||||
{ value: 'logged_at:asc', labelKey: 'sort.logged_at:asc' },
|
||||
{ value: 'status_code:desc', labelKey: 'sort.status_code:desc' },
|
||||
{ value: 'status_code:asc', labelKey: 'sort.status_code:asc' },
|
||||
{ value: 'remote_addr:asc', labelKey: 'sort.remote_addr:asc' },
|
||||
{ value: 'remote_addr:desc', labelKey: 'sort.remote_addr:desc' },
|
||||
] as const;
|
||||
|
||||
export const IP_SORT_OPTIONS = [
|
||||
{ value: 'total_requests:desc', label: '请求数从高到低' },
|
||||
{ value: 'total_requests:asc', label: '请求数从低到高' },
|
||||
{ value: 'request_length:desc', label: '入站从高到低' },
|
||||
{ value: 'request_length:asc', label: '入站从低到高' },
|
||||
{ value: 'bytes_sent:desc', label: '出站从高到低' },
|
||||
{ value: 'bytes_sent:asc', label: '出站从低到高' },
|
||||
{ value: 'success_ratio:desc', label: '2xx 比例从高到低' },
|
||||
{ value: 'success_ratio:asc', label: '2xx 比例从低到高' },
|
||||
{ value: 'last_seen_at:desc', label: '最后访问从新到旧' },
|
||||
{ value: 'last_seen_at:asc', label: '最后访问从旧到新' },
|
||||
];
|
||||
{ value: 'total_requests:desc', labelKey: 'sort.total_requests:desc' },
|
||||
{ value: 'total_requests:asc', labelKey: 'sort.total_requests:asc' },
|
||||
{ value: 'request_length:desc', labelKey: 'sort.request_length:desc' },
|
||||
{ value: 'request_length:asc', labelKey: 'sort.request_length:asc' },
|
||||
{ value: 'bytes_sent:desc', labelKey: 'sort.bytes_sent:desc' },
|
||||
{ value: 'bytes_sent:asc', labelKey: 'sort.bytes_sent:asc' },
|
||||
{ value: 'success_ratio:desc', labelKey: 'sort.success_ratio:desc' },
|
||||
{ value: 'success_ratio:asc', labelKey: 'sort.success_ratio:asc' },
|
||||
{ value: 'last_seen_at:desc', labelKey: 'sort.last_seen_at:desc' },
|
||||
{ value: 'last_seen_at:asc', labelKey: 'sort.last_seen_at:asc' },
|
||||
] as const;
|
||||
|
||||
export function parseSortValue(value: string) {
|
||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||
@@ -72,10 +72,16 @@ export function formatCompactNumber(value: number) {
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function formatOverviewRangeHint(hours: number) {
|
||||
if (hours <= 24) return '近 24 小时';
|
||||
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
|
||||
return `近 ${hours} 小时`;
|
||||
export function formatOverviewRangeHint(
|
||||
hours: number,
|
||||
t: (
|
||||
key: 'rangeHint.hours24' | 'rangeHint.days' | 'rangeHint.hours',
|
||||
values?: { count: number },
|
||||
) => string,
|
||||
) {
|
||||
if (hours <= 24) return t('rangeHint.hours24');
|
||||
if (hours % 24 === 0) return t('rangeHint.days', { count: hours / 24 });
|
||||
return t('rangeHint.hours', { count: hours });
|
||||
}
|
||||
|
||||
export function formatOverviewTrendLabel(value: string, hours: number) {
|
||||
@@ -112,13 +118,9 @@ export function resolveCacheOutcome(
|
||||
return 'uncached';
|
||||
}
|
||||
|
||||
export function cacheOutcomeLabel(outcome: CacheOutcome) {
|
||||
switch (outcome) {
|
||||
case 'hit':
|
||||
return '命中';
|
||||
case 'origin':
|
||||
return '回源';
|
||||
default:
|
||||
return '未缓存';
|
||||
}
|
||||
export function cacheOutcomeLabel(
|
||||
outcome: CacheOutcome,
|
||||
t: (key: `cache.${CacheOutcome}`) => string,
|
||||
) {
|
||||
return t(`cache.${outcome}`);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -38,6 +39,8 @@ export function CleanupDialog({
|
||||
onConfirm,
|
||||
loading,
|
||||
}: CleanupDialogProps) {
|
||||
const t = useTranslations('accessLogs.cleanupDialog');
|
||||
const tCommon = useTranslations('common');
|
||||
const [mode, setMode] = useState<string>('7');
|
||||
const [customDays, setCustomDays] = useState('14');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -49,7 +52,7 @@ export function CleanupDialog({
|
||||
: Number.parseInt(mode, 10);
|
||||
|
||||
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
|
||||
setError('保留天数必须大于 0');
|
||||
setError(t('invalidDays'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -61,15 +64,13 @@ export function CleanupDialog({
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>清理访问日志</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除早于指定保留天数的访问日志记录,操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>{t('title')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('description')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className='space-y-3'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label>保留策略</Label>
|
||||
<Label>{t('policy')}</Label>
|
||||
<Select value={mode} onValueChange={setMode}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -77,17 +78,17 @@ export function CleanupDialog({
|
||||
<SelectContent>
|
||||
{CLEANUP_PRESETS.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
保留最近 {days} 天
|
||||
{t('keepDays', { days })}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value='custom'>自定义天数</SelectItem>
|
||||
<SelectItem value='custom'>{t('custom')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{mode === 'custom' ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='customDays'>自定义保留天数</Label>
|
||||
<Label htmlFor='customDays'>{t('customDays')}</Label>
|
||||
<Input
|
||||
id='customDays'
|
||||
type='number'
|
||||
@@ -103,7 +104,9 @@ export function CleanupDialog({
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleConfirm}
|
||||
@@ -112,10 +115,10 @@ export function CleanupDialog({
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
清理中...
|
||||
{t('cleaning')}
|
||||
</>
|
||||
) : (
|
||||
'确认清理'
|
||||
t('confirm')
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -65,9 +66,12 @@ function PaginationBar({
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.detail');
|
||||
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>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{t('page', { page: page + 1 })}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -75,7 +79,7 @@ function PaginationBar({
|
||||
disabled={loading || page <= 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
上一页
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -83,7 +87,7 @@ function PaginationBar({
|
||||
disabled={loading || !hasMore}
|
||||
onClick={onNext}
|
||||
>
|
||||
下一页
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,6 +117,7 @@ export function DetailTab({
|
||||
onNextPage: () => void;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const [selected, setSelected] = useState<AccessLogItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
@@ -120,7 +125,7 @@ export function DetailTab({
|
||||
<>
|
||||
<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>
|
||||
<p className='text-sm font-medium'>{t('detail.listTitle')}</p>
|
||||
<Select value={detailSort} onValueChange={onDetailSortChange}>
|
||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||
<SelectValue />
|
||||
@@ -128,7 +133,7 @@ export function DetailTab({
|
||||
<SelectContent>
|
||||
{DETAIL_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -137,25 +142,25 @@ export function DetailTab({
|
||||
{error ? (
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={error.message || '加载失败'}
|
||||
message={error.message || t('loadFailed')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title='暂无访问日志' />
|
||||
<EmptyStateWithBorder title={t('detail.empty')} />
|
||||
) : (
|
||||
<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'>{t('detail.time')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.node')}</TableHead>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>域名</TableHead>
|
||||
<TableHead className='text-xs'>路径</TableHead>
|
||||
<TableHead className='text-xs'>缓存</TableHead>
|
||||
<TableHead className='text-xs'>状态码</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.host')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.path')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.cache')}</TableHead>
|
||||
<TableHead className='text-xs'>{t('detail.status')}</TableHead>
|
||||
<TableHead className='w-12 text-center text-xs' />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -190,7 +195,7 @@ export function DetailTab({
|
||||
className='text-[10px]'
|
||||
title={item.cache_status || undefined}
|
||||
>
|
||||
{cacheOutcomeLabel(outcome)}
|
||||
{cacheOutcomeLabel(outcome, (key) => t(key))}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -214,7 +219,7 @@ export function DetailTab({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看详情
|
||||
{t('detail.view')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
@@ -50,9 +51,12 @@ import {
|
||||
type OverviewRangeHours,
|
||||
} from './access-log-utils';
|
||||
|
||||
const trendChartConfig = {
|
||||
requests: { label: '请求数', color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
function useTrendChartConfig() {
|
||||
const t = useTranslations('accessLogs');
|
||||
return {
|
||||
requests: { label: t('ip.requests'), color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
}
|
||||
|
||||
function resolveBucketMinutes(hours: number) {
|
||||
if (hours <= 24) return 30;
|
||||
@@ -104,6 +108,7 @@ function MiniRankCard({
|
||||
items: { label: string; value: number }[];
|
||||
color: string;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.analysis');
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='mb-2 text-sm font-medium'>{title}</p>
|
||||
@@ -111,7 +116,7 @@ function MiniRankCard({
|
||||
items={items}
|
||||
color={color}
|
||||
className='!h-[220px]'
|
||||
emptyMessage={`暂无 ${title} 数据`}
|
||||
emptyMessage={t('emptySeries', { title })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -126,6 +131,7 @@ function AddToIPGroupPanel({
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.analysis');
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
|
||||
|
||||
@@ -179,7 +185,7 @@ function AddToIPGroupPanel({
|
||||
const groupId = Number.parseInt(selectedGroupId, 10);
|
||||
const group = addableGroups.find((item) => item.id === groupId);
|
||||
if (!group) {
|
||||
toast.error('请选择要加入的 IP 组');
|
||||
toast.error(t('selectGroup'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -187,10 +193,10 @@ function AddToIPGroupPanel({
|
||||
group,
|
||||
nextList: [...(group.ip_list ?? []), ip.trim()],
|
||||
});
|
||||
toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`);
|
||||
toast.success(t('added', { ip, name: group.name }));
|
||||
setSelectedGroupId('');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '加入 IP 组失败');
|
||||
toast.error(error instanceof Error ? error.message : t('addFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,9 +208,9 @@ function AddToIPGroupPanel({
|
||||
(entry) => entry.trim() !== ip.trim(),
|
||||
),
|
||||
});
|
||||
toast.success(`已从 IP 组「${group.name}」移除 ${ip}`);
|
||||
toast.success(t('removed', { name: group.name, ip }));
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '移除失败');
|
||||
toast.error(error instanceof Error ? error.message : t('removeFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,21 +226,21 @@ function AddToIPGroupPanel({
|
||||
>
|
||||
<DialogContent className='max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
|
||||
<DialogTitle>{t('joinTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
目标 IP:
|
||||
{t('targetIp')}
|
||||
<span className='font-mono text-foreground'>{ip}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{groupsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder title='加载 IP 组' />
|
||||
<LoadingStateWithBorder title={t('loadingGroups')} />
|
||||
) : groupsQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
groupsQuery.error instanceof Error
|
||||
? groupsQuery.error.message
|
||||
: '加载 IP 组失败'
|
||||
: t('loadGroupsFailed')
|
||||
}
|
||||
onRetry={() => void groupsQuery.refetch()}
|
||||
/>
|
||||
@@ -243,10 +249,10 @@ function AddToIPGroupPanel({
|
||||
{matchedGroups.length > 0 ? (
|
||||
<div className='space-y-2 rounded-lg border border-dashed p-3'>
|
||||
<p className='text-sm font-medium text-foreground'>
|
||||
该 IP 已存在于以下 IP 组
|
||||
{t('alreadyIn')}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
可选择删除,或继续添加到其他 IP 组。
|
||||
{t('alreadyInHint')}
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{matchedGroups.map((group) => (
|
||||
@@ -259,7 +265,10 @@ function AddToIPGroupPanel({
|
||||
{group.name}
|
||||
</p>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
{group.type} · {group.ip_list?.length ?? 0} 条
|
||||
{t('entryCount', {
|
||||
type: group.type,
|
||||
count: group.ip_list?.length ?? 0,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{group.type === 'manual' ? (
|
||||
@@ -275,13 +284,13 @@ function AddToIPGroupPanel({
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className='mr-1 size-3.5' />
|
||||
删除
|
||||
{t('delete')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
不可手动删除
|
||||
{t('cannotDelete')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -290,15 +299,15 @@ function AddToIPGroupPanel({
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
该 IP 尚未加入任何 IP 组。
|
||||
{t('notInAny')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>添加到其他 IP 组</p>
|
||||
<p className='text-sm font-medium'>{t('addToOther')}</p>
|
||||
{addableGroups.length === 0 ? (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
没有可写入的手动 IP 组(或已全部包含该 IP)。
|
||||
{t('noWritable')}
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
@@ -306,7 +315,7 @@ function AddToIPGroupPanel({
|
||||
onValueChange={setSelectedGroupId}
|
||||
>
|
||||
<SelectTrigger className='h-9 text-xs'>
|
||||
<SelectValue placeholder='选择手动 IP 组' />
|
||||
<SelectValue placeholder={t('selectManual')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{addableGroups.map((group) => (
|
||||
@@ -323,7 +332,7 @@ function AddToIPGroupPanel({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={onClose}>
|
||||
关闭
|
||||
{t('close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAdd()}
|
||||
@@ -336,10 +345,10 @@ function AddToIPGroupPanel({
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
处理中...
|
||||
{t('processing')}
|
||||
</>
|
||||
) : (
|
||||
'加入所选 IP 组'
|
||||
t('joinSelected')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -361,6 +370,8 @@ export function IpAnalysisPanel({
|
||||
*/
|
||||
initialHours?: number;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const trendChartConfig = useTrendChartConfig();
|
||||
const [ipGroupOpen, setIpGroupOpen] = useState(false);
|
||||
const [rangeHours, setRangeHours] = useState(() =>
|
||||
clampAnalysisHours(initialHours),
|
||||
@@ -368,8 +379,8 @@ export function IpAnalysisPanel({
|
||||
|
||||
const bucketMinutes = resolveBucketMinutes(rangeHours);
|
||||
const rangeHint = isOverviewPreset(rangeHours)
|
||||
? formatOverviewRangeHint(rangeHours)
|
||||
: `近 ${rangeHours} 小时`;
|
||||
? formatOverviewRangeHint(rangeHours, (key, values) => t(key, values))
|
||||
: t('analysis.recentHours', { hours: rangeHours });
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: [
|
||||
@@ -411,7 +422,7 @@ export function IpAnalysisPanel({
|
||||
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
|
||||
|
||||
if (!ip) {
|
||||
return <EmptyStateWithBorder description='没有有效 IP。' />;
|
||||
return <EmptyStateWithBorder description={t('analysis.invalidIp')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -423,7 +434,8 @@ export function IpAnalysisPanel({
|
||||
variant='outline'
|
||||
onClick={() => setIpGroupOpen(true)}
|
||||
>
|
||||
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
|
||||
<ShieldPlus className='mr-1 size-3.5' />
|
||||
{t('analysis.addToGroup')}
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
@@ -441,14 +453,14 @@ export function IpAnalysisPanel({
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{isLoadingIP ? (
|
||||
<LoadingStateWithBorder title='加载 IP 分析' />
|
||||
<LoadingStateWithBorder title={t('analysis.loading')} />
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{analysisQuery.isError ? (
|
||||
@@ -456,34 +468,34 @@ export function IpAnalysisPanel({
|
||||
message={
|
||||
analysisQuery.error instanceof Error
|
||||
? analysisQuery.error.message
|
||||
: '加载 IP 分析失败'
|
||||
: t('analysis.loadFailed')
|
||||
}
|
||||
onRetry={() => void analysisQuery.refetch()}
|
||||
/>
|
||||
) : analysis ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
<MetricCard
|
||||
label='总请求'
|
||||
label={t('analysis.totalRequests')}
|
||||
value={formatCompactNumber(analysis.summary.total_requests)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='错误数'
|
||||
label={t('analysis.errors')}
|
||||
value={formatCompactNumber(analysis.summary.error_count)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='已提供带宽'
|
||||
label={t('analysis.bandwidth')}
|
||||
value={formatBytes(analysis.summary.bandwidth_served)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='接收数据'
|
||||
label={t('analysis.received')}
|
||||
value={formatBytes(analysis.summary.bytes_received)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立域名'
|
||||
label={t('analysis.uniqueHosts')}
|
||||
value={formatCompactNumber(analysis.summary.unique_hosts)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立路径'
|
||||
label={t('analysis.uniquePaths')}
|
||||
value={formatCompactNumber(analysis.summary.unique_paths)}
|
||||
/>
|
||||
</div>
|
||||
@@ -492,9 +504,13 @@ export function IpAnalysisPanel({
|
||||
<div className='space-y-3 rounded-lg border border-dashed p-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>IP 请求趋势</p>
|
||||
<p className='text-sm font-medium'>{t('analysis.trend')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
|
||||
{t('analysis.trendMeta', {
|
||||
ip,
|
||||
range: rangeHint,
|
||||
minutes: bucketMinutes,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -506,7 +522,7 @@ export function IpAnalysisPanel({
|
||||
void analysisQuery.refetch();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
{t('analysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -515,13 +531,13 @@ export function IpAnalysisPanel({
|
||||
message={
|
||||
trendQuery.error instanceof Error
|
||||
? trendQuery.error.message
|
||||
: '加载趋势失败'
|
||||
: t('analysis.trendFailed')
|
||||
}
|
||||
onRetry={() => void trendQuery.refetch()}
|
||||
/>
|
||||
) : trendChartData.every((point) => point.requests === 0) ? (
|
||||
<EmptyStateWithBorder
|
||||
description={`该 IP 在${rangeHint}内没有访问记录。`}
|
||||
description={t('analysis.trendEmpty', { range: rangeHint })}
|
||||
/>
|
||||
) : (
|
||||
<ChartContainer
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -31,19 +32,20 @@ export function IpDetailDialog({
|
||||
setDisplayAddr(remoteAddr);
|
||||
setDisplayRegion(region);
|
||||
}
|
||||
const t = useTranslations('accessLogs.ip');
|
||||
const ip = remoteAddr ?? displayAddr ?? '';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>IP 详情</DialogTitle>
|
||||
<DialogTitle>{t('dialogTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className='font-mono text-foreground'>{ip || '—'}</span>
|
||||
{displayRegion ? (
|
||||
<span className='text-muted-foreground'> · {displayRegion}</span>
|
||||
) : null}
|
||||
。查看该 IP 的访问趋势、分布与 WAF IP 组操作。
|
||||
{t('dialogDesc')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<IpAnalysisPanel
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -72,20 +73,21 @@ const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
|
||||
export function validateCustomTimeRange(
|
||||
sinceLocal: string,
|
||||
untilLocal: string,
|
||||
t?: (key: 'ip.needRange' | 'ip.invalidTime' | 'ip.endAfterStart' | 'ip.maxRange') => string,
|
||||
): string | null {
|
||||
if (!sinceLocal.trim() || !untilLocal.trim()) {
|
||||
return '请填写开始与结束时间';
|
||||
return t ? t('ip.needRange') : 'needRange';
|
||||
}
|
||||
const sinceMs = new Date(sinceLocal).getTime();
|
||||
const untilMs = new Date(untilLocal).getTime();
|
||||
if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
|
||||
return '时间格式无效';
|
||||
return t ? t('ip.invalidTime') : 'invalidTime';
|
||||
}
|
||||
if (untilMs <= sinceMs) {
|
||||
return '结束时间必须晚于开始时间';
|
||||
return t ? t('ip.endAfterStart') : 'endAfterStart';
|
||||
}
|
||||
if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
|
||||
return '时间范围不能超过 30 天';
|
||||
return t ? t('ip.maxRange') : 'maxRange';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -128,11 +130,12 @@ function PaginationBar({
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.ip');
|
||||
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} 页
|
||||
{typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}
|
||||
{t('page', { page: page + 1 })}
|
||||
{typeof totalIp === 'number' ? t('total', { count: totalIp }) : ''}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
@@ -141,7 +144,7 @@ function PaginationBar({
|
||||
disabled={loading || page <= 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
上一页
|
||||
{t('prev')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
@@ -149,7 +152,7 @@ function PaginationBar({
|
||||
disabled={loading || !hasMore}
|
||||
onClick={onNext}
|
||||
>
|
||||
下一页
|
||||
{t('next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,6 +204,7 @@ export function IpTab({
|
||||
onNextPage: () => void;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [draftSince, setDraftSince] = useState(customSince);
|
||||
@@ -232,13 +236,15 @@ export function IpTab({
|
||||
|
||||
const rangeHint =
|
||||
timeMode === 'custom' && customSince && customUntil
|
||||
? '自定义区间'
|
||||
? t('ip.customRange')
|
||||
: timeMode === 'custom'
|
||||
? '自定义(未应用)'
|
||||
: formatOverviewRangeHint(hours);
|
||||
? t('ip.customPending')
|
||||
: formatOverviewRangeHint(hours, (key, values) => t(key, values));
|
||||
|
||||
const handleApplyCustom = () => {
|
||||
const message = validateCustomTimeRange(draftSince, draftUntil);
|
||||
const message = validateCustomTimeRange(draftSince, draftUntil, (key) =>
|
||||
t(key),
|
||||
);
|
||||
if (message) {
|
||||
setCustomError(message);
|
||||
return;
|
||||
@@ -253,11 +259,11 @@ export function IpTab({
|
||||
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm font-medium'>时间范围</p>
|
||||
<p className='text-sm font-medium'>{t('ip.timeRange')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前:{rangeHint}
|
||||
{t('ip.current', { range: rangeHint })}
|
||||
{data?.total_ip != null
|
||||
? ` · ${formatCompactNumber(data.total_ip)} 个 IP`
|
||||
? t('ip.total', { count: data.total_ip })
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
@@ -281,7 +287,7 @@ export function IpTab({
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
@@ -291,7 +297,7 @@ export function IpTab({
|
||||
className='h-8 text-xs'
|
||||
onClick={() => onTimeModeChange('custom')}
|
||||
>
|
||||
自定义
|
||||
{t('ip.custom')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +306,7 @@ export function IpTab({
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-end gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>开始</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.start')}</p>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
className='h-9 w-52 text-xs'
|
||||
@@ -312,7 +318,7 @@ export function IpTab({
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>结束</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.end')}</p>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
className='h-9 w-52 text-xs'
|
||||
@@ -328,14 +334,14 @@ export function IpTab({
|
||||
className='h-9 text-xs'
|
||||
onClick={handleApplyCustom}
|
||||
>
|
||||
应用
|
||||
{t('ip.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
{customError ? (
|
||||
<p className='text-xs text-destructive'>{customError}</p>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。
|
||||
{t('ip.applyHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -343,7 +349,7 @@ export function IpTab({
|
||||
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>排序</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.sort')}</p>
|
||||
<Select value={sort} onValueChange={onSortChange}>
|
||||
<SelectTrigger className='h-8 w-48 text-xs'>
|
||||
<SelectValue />
|
||||
@@ -351,14 +357,14 @@ export function IpTab({
|
||||
<SelectContent>
|
||||
{IP_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{t(option.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>每页</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('ip.pageSize')}</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) =>
|
||||
@@ -382,30 +388,38 @@ export function IpTab({
|
||||
|
||||
<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'>IP 明细</p>
|
||||
<p className='text-sm font-medium'>{t('ip.title')}</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={error.message || '加载失败'}
|
||||
message={error.message || t('loadFailed')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title='暂无 IP 数据' />
|
||||
<EmptyStateWithBorder title={t('ip.empty')} />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>地区</TableHead>
|
||||
<TableHead className='text-xs text-right'>请求数</TableHead>
|
||||
<TableHead className='text-xs text-right'>2xx 比例</TableHead>
|
||||
<TableHead className='text-xs text-right'>入站</TableHead>
|
||||
<TableHead className='text-xs text-right'>出站</TableHead>
|
||||
<TableHead className='text-xs'>最后访问</TableHead>
|
||||
<TableHead className='text-xs'>{t('ip.region')}</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.requests')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.successRatio')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.inbound')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs text-right'>
|
||||
{t('ip.outbound')}
|
||||
</TableHead>
|
||||
<TableHead className='text-xs'>{t('ip.lastSeen')}</TableHead>
|
||||
<TableHead className='w-12 text-center text-xs' />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -453,7 +467,7 @@ export function IpTab({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看 IP 详情
|
||||
{t('ip.view')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMemo } from 'react';
|
||||
import type { EChartsOption } from 'echarts';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
@@ -278,6 +279,7 @@ export function OverviewTab({
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.overview');
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<OverviewToolbar
|
||||
@@ -289,18 +291,18 @@ export function OverviewTab({
|
||||
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder
|
||||
title='加载访问概览'
|
||||
description='正在聚合请求量、访问量与带宽趋势...'
|
||||
title={t('loadingTitle')}
|
||||
description={t('loadingDesc')}
|
||||
/>
|
||||
) : error ? (
|
||||
<ErrorInline
|
||||
message={error.message || '加载访问概览失败'}
|
||||
message={error.message || t('loadFailed')}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
) : !data ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无概览数据'
|
||||
description='当前时间范围内没有可展示的访问统计。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
<OverviewContent data={data} hours={hours} />
|
||||
@@ -316,13 +318,14 @@ function OverviewContent({
|
||||
data: AccessLogOverview;
|
||||
hours: number;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
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);
|
||||
const hint = formatOverviewRangeHint(hours, (key, values) => t(key, values));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -339,7 +342,7 @@ function OverviewContent({
|
||||
<SparklineMetricCard
|
||||
title='Total Visits'
|
||||
value={formatCompactNumber(data.summary.total_visits)}
|
||||
hint={`${hint} · 独立访客`}
|
||||
hint={t('overview.uniqueVisitors', { hint })}
|
||||
color='#38bdf8'
|
||||
fillColor='rgba(56, 189, 248, 0.16)'
|
||||
labels={requestLabels}
|
||||
@@ -348,7 +351,7 @@ function OverviewContent({
|
||||
<SparklineMetricCard
|
||||
title='Bandwidth Served'
|
||||
value={formatBytes(data.summary.bandwidth_served)}
|
||||
hint={`${hint} · 已提供数据`}
|
||||
hint={t('overview.served', { hint })}
|
||||
color='#34d399'
|
||||
fillColor='rgba(52, 211, 153, 0.16)'
|
||||
labels={requestLabels}
|
||||
@@ -363,7 +366,7 @@ function OverviewContent({
|
||||
Requests over time
|
||||
</CardTitle>
|
||||
<CardDescription className='text-xs'>
|
||||
观察请求量是否出现异常抬升或回落。
|
||||
{t('overview.requestsHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -373,7 +376,7 @@ function OverviewContent({
|
||||
showSummary={false}
|
||||
series={[
|
||||
{
|
||||
label: '请求量',
|
||||
label: t('overview.requests'),
|
||||
color: '#f59e0b',
|
||||
fillColor: 'rgba(245, 158, 11, 0.18)',
|
||||
variant: 'area',
|
||||
@@ -387,32 +390,32 @@ function OverviewContent({
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<PieDistributionCard
|
||||
title='Requests by device type'
|
||||
description='按设备类型统计请求占比。'
|
||||
description={t('overview.deviceDesc')}
|
||||
items={data.device_types ?? []}
|
||||
emptyMessage='暂无设备类型数据'
|
||||
emptyMessage={t('overview.deviceEmpty')}
|
||||
/>
|
||||
<PieDistributionCard
|
||||
title='Status code'
|
||||
description='按 HTTP 状态码统计请求占比。'
|
||||
description={t('overview.statusDesc')}
|
||||
items={data.status_codes ?? []}
|
||||
emptyMessage='暂无状态码分布数据'
|
||||
emptyMessage={t('overview.statusEmpty')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<RankCard
|
||||
title='Top Paths'
|
||||
description='访问量最高的请求路径。'
|
||||
description={t('overview.topPathsDesc')}
|
||||
items={toRankItems(data.top_paths)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top Hosts'
|
||||
description='流量集中的访问域名。'
|
||||
description={t('overview.topHostsDesc')}
|
||||
items={toRankItems(data.top_hosts)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top IPs'
|
||||
description='请求次数最多的来源 IP。'
|
||||
description={t('overview.topIpsDesc')}
|
||||
items={toRankItems(data.top_ips)}
|
||||
/>
|
||||
</div>
|
||||
@@ -420,17 +423,17 @@ function OverviewContent({
|
||||
<div className='grid gap-6 xl:grid-cols-3'>
|
||||
<RankCard
|
||||
title='Top browsers'
|
||||
description='按浏览器聚合的请求排行。'
|
||||
description={t('overview.browsersDesc')}
|
||||
items={toRankItems(data.top_browsers)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top Operating System'
|
||||
description='按操作系统聚合的请求排行。'
|
||||
description={t('overview.osDesc')}
|
||||
items={toRankItems(data.top_operating_systems)}
|
||||
/>
|
||||
<RankCard
|
||||
title='Top User-Agent'
|
||||
description='原始 User-Agent 请求排行。'
|
||||
description={t('overview.uaDesc')}
|
||||
items={toRankItems(data.top_user_agents)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -71,6 +72,7 @@ export function OverviewHostFilter({
|
||||
hosts: string[];
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs.toolbar');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
|
||||
@@ -160,10 +162,14 @@ export function OverviewHostFilter({
|
||||
hosts.length > 0 ? 'border-primary text-primary' : undefined,
|
||||
)}
|
||||
title={
|
||||
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
|
||||
hosts.length > 0
|
||||
? t('hostsFiltered', { count: hosts.length })
|
||||
: t('filterByHost')
|
||||
}
|
||||
aria-label={
|
||||
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
|
||||
hosts.length > 0
|
||||
? t('hostsFiltered', { count: hosts.length })
|
||||
: t('filterByHost')
|
||||
}
|
||||
>
|
||||
<Filter className='size-3.5' />
|
||||
@@ -171,7 +177,7 @@ export function OverviewHostFilter({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<p className='text-sm font-medium'>筛选域名</p>
|
||||
<p className='text-sm font-medium'>{t('filterHosts')}</p>
|
||||
{hosts.length > 0 ? (
|
||||
<Button
|
||||
type='button'
|
||||
@@ -181,25 +187,25 @@ export function OverviewHostFilter({
|
||||
onClick={() => onHostsChange([])}
|
||||
>
|
||||
<X className='mr-1 size-3' />
|
||||
清除
|
||||
{t('clear')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder='搜索 Zone 或域名'
|
||||
placeholder={t('searchPlaceholder')}
|
||||
className='h-8 text-xs'
|
||||
/>
|
||||
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
|
||||
{zonesQuery.isLoading ? (
|
||||
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
加载域名…
|
||||
{t('loadingHosts')}
|
||||
</div>
|
||||
) : zonesQuery.isError ? (
|
||||
<div className='space-y-2 py-2'>
|
||||
<p className='text-xs text-destructive'>加载域名失败</p>
|
||||
<p className='text-xs text-destructive'>{t('loadHostsFailed')}</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
@@ -207,12 +213,12 @@ export function OverviewHostFilter({
|
||||
className='h-7 text-xs'
|
||||
onClick={() => void zonesQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
{t('retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : filteredZones.length === 0 ? (
|
||||
<p className='py-6 text-center text-xs text-muted-foreground'>
|
||||
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'}
|
||||
{zones.length === 0 ? t('noRegistered') : t('noMatch')}
|
||||
</p>
|
||||
) : (
|
||||
filteredZones.map((zone) => {
|
||||
@@ -247,7 +253,7 @@ export function OverviewHostFilter({
|
||||
onCheckedChange={(checked) =>
|
||||
toggleZone(zone.domains, checked)
|
||||
}
|
||||
aria-label={`选择 Zone ${zone.zoneName}`}
|
||||
aria-label={t('selectZone', { name: zone.zoneName })}
|
||||
/>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
@@ -286,7 +292,7 @@ export function OverviewHostFilter({
|
||||
onCheckedChange={(checked) =>
|
||||
toggleHost(domain, checked)
|
||||
}
|
||||
aria-label={`选择域名 ${domain}`}
|
||||
aria-label={t('selectHost', { name: domain })}
|
||||
/>
|
||||
<span className='min-w-0 flex-1 truncate font-mono'>
|
||||
{domain}
|
||||
@@ -311,7 +317,7 @@ export function OverviewToolbar({
|
||||
hosts,
|
||||
onHoursChange,
|
||||
onHostsChange,
|
||||
rangeOptions = OVERVIEW_RANGE_OPTIONS,
|
||||
rangeOptions,
|
||||
}: {
|
||||
hours: OverviewRangeHours | RateLimitRangeHours;
|
||||
hosts: string[];
|
||||
@@ -319,11 +325,20 @@ export function OverviewToolbar({
|
||||
onHostsChange: (hosts: string[]) => void;
|
||||
rangeOptions?: ReadonlyArray<{ value: number; label: string }>;
|
||||
}) {
|
||||
const t = useTranslations('accessLogs');
|
||||
const options =
|
||||
rangeOptions ??
|
||||
OVERVIEW_RANGE_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
}));
|
||||
return (
|
||||
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||
{hosts.length > 0 ? (
|
||||
<Badge variant='secondary' className='max-w-[260px] truncate'>
|
||||
{hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`}
|
||||
{hosts.length === 1
|
||||
? hosts[0]
|
||||
: t('toolbar.selectedCount', { count: hosts.length })}
|
||||
</Badge>
|
||||
) : null}
|
||||
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
|
||||
@@ -338,7 +353,7 @@ export function OverviewToolbar({
|
||||
size='sm'
|
||||
className='justify-end'
|
||||
>
|
||||
{rangeOptions.map((option) => (
|
||||
{options.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
@@ -45,6 +46,7 @@ function resolveTab(value: string | null): AccessLogTab {
|
||||
}
|
||||
|
||||
function AccessLogsPageContent() {
|
||||
const t = useTranslations('accessLogs');
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -152,14 +154,14 @@ function AccessLogsPageContent() {
|
||||
mutationFn: (retentionDays: number) =>
|
||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||
onSuccess: async (result) => {
|
||||
toast.success(`已清理 ${result.deleted_count} 条日志`);
|
||||
toast.success(t('cleaned', { count: result.deleted_count }));
|
||||
setCleanupOpen(false);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'access-logs'],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '清理失败');
|
||||
toast.error(error instanceof Error ? error.message : t('cleanupFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,10 +215,10 @@ function AccessLogsPageContent() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<ScrollText className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看访问概览、IP 明细与请求日志。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
@@ -229,7 +231,7 @@ function AccessLogsPageContent() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
@@ -237,16 +239,16 @@ function AccessLogsPageContent() {
|
||||
onClick={() => setCleanupOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
清理日志
|
||||
{t('cleanup')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className='grid w-full max-w-lg grid-cols-3'>
|
||||
<TabsTrigger value='overview'>概览</TabsTrigger>
|
||||
<TabsTrigger value='ips'>IP 明细</TabsTrigger>
|
||||
<TabsTrigger value='list'>日志明细</TabsTrigger>
|
||||
<TabsTrigger value='overview'>{t('tabOverview')}</TabsTrigger>
|
||||
<TabsTrigger value='ips'>{t('tabIps')}</TabsTrigger>
|
||||
<TabsTrigger value='list'>{t('tabList')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='overview' className='mt-4'>
|
||||
@@ -343,12 +345,13 @@ function AccessLogsPageContent() {
|
||||
}
|
||||
|
||||
export default function AccessLogsPage() {
|
||||
const t = useTranslations('accessLogs');
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<LoadingStateWithBorder
|
||||
title='加载访问日志'
|
||||
description='正在准备页面...'
|
||||
title={t('loadingTitle')}
|
||||
description={t('loadingDesc')}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user