feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -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)}
+16 -13
View File
@@ -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')}
/>
}
>