Files
OpenFlare/frontend/app/(main)/access-logs/components/detail-tab.tsx
T
ryan 5a8722ff07 feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
2026-08-16 16:49:16 +08:00

249 lines
7.7 KiB
TypeScript

'use client';
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';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { AccessLogItem, AccessLogList } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { AccessLogDetailDialog } from './access-log-detail-dialog';
import {
cacheOutcomeLabel,
DETAIL_SORT_OPTIONS,
resolveCacheOutcome,
type CacheOutcome,
} from './access-log-utils';
function cacheOutcomeVariant(
outcome: CacheOutcome,
): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (outcome) {
case 'hit':
return 'default';
case 'origin':
return 'secondary';
default:
return 'outline';
}
}
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
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'>
{t('page', { page: page + 1 })}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
{t('prev')}
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
{t('next')}
</Button>
</div>
</div>
);
}
export function DetailTab({
data,
loading,
error,
page,
detailSort,
onDetailSortChange,
onRetry,
onPrevPage,
onNextPage,
isFetching,
}: {
data?: AccessLogList;
loading: boolean;
error: Error | null;
page: number;
detailSort: string;
onDetailSortChange: (value: string) => void;
onRetry: () => void;
onPrevPage: () => void;
onNextPage: () => void;
isFetching: boolean;
}) {
const t = useTranslations('accessLogs');
const [selected, setSelected] = useState<AccessLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
return (
<>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>{t('detail.listTitle')}</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || t('loadFailed')}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title={t('detail.empty')} />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<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'>{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>
<TableBody>
{(data?.items ?? []).map((item) => {
const outcome = resolveCacheOutcome(item.cache_status);
return (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs'>
<div className='flex flex-col gap-0.5'>
<span className='font-mono'>{item.remote_addr}</span>
{item.region ? (
<span className='text-[10px] text-muted-foreground'>
{item.region}
</span>
) : null}
</div>
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge
variant={cacheOutcomeVariant(outcome)}
className='text-[10px]'
title={item.cache_status || undefined}
>
{cacheOutcomeLabel(outcome, (key) => t(key))}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
{t('detail.view')}
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
<AccessLogDetailDialog
open={detailOpen}
item={selected}
onOpenChange={setDetailOpen}
/>
</>
);
}