'use client'; import { useEffect, useMemo, useState } from 'react'; import { Eye } from 'lucide-react'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import type { AccessLogIPSummaryItem, AccessLogIPSummaryList, } from '@/lib/services/openflare'; import { formatDateTime } from '@/lib/utils'; import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics'; import { formatOverviewRangeHint, IP_SORT_OPTIONS, OVERVIEW_RANGE_OPTIONS, PAGE_SIZE_OPTIONS, type OverviewRangeHours, } from './access-log-utils'; import { IpDetailDialog } from './ip-detail-dialog'; export function toLocalInputValue(date: Date) { const pad = (n: number) => `${n}`.padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** Default local range ending now, spanning `hours`. */ export function defaultLocalRangeForHours(hours: number) { const until = new Date(); const since = new Date(until.getTime() - hours * 3_600_000); return { since: toLocalInputValue(since), until: toLocalInputValue(until), }; } function localInputToRFC3339(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; return date.toISOString(); } const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000; /** Validate custom local datetime-local range; returns error message or null. */ export function validateCustomTimeRange( sinceLocal: string, untilLocal: string, ): string | null { if (!sinceLocal.trim() || !untilLocal.trim()) { return '请填写开始与结束时间'; } const sinceMs = new Date(sinceLocal).getTime(); const untilMs = new Date(untilLocal).getTime(); if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) { return '时间格式无效'; } if (untilMs <= sinceMs) { return '结束时间必须晚于开始时间'; } if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) { return '时间范围不能超过 30 天'; } return null; } function formatRatio(ratio: number) { if (!Number.isFinite(ratio) || ratio < 0) return '0%'; return `${(ratio * 100).toFixed(1)}%`; } /** Exact hours for analysis API (1–720), matching list window duration. */ export function resolveAnalysisHours(input: { timeMode: IpTabTimeMode; hours: OverviewRangeHours; customSince: string; customUntil: string; }): number { if (input.timeMode === 'custom' && input.customSince && input.customUntil) { const ms = new Date(input.customUntil).getTime() - new Date(input.customSince).getTime(); if (Number.isFinite(ms) && ms > 0) { return Math.min(720, Math.max(1, Math.ceil(ms / 3_600_000))); } } return input.hours; } function PaginationBar({ page, hasMore, loading, totalIp, onPrev, onNext, }: { page: number; hasMore: boolean; loading: boolean; totalIp?: number; onPrev: () => void; onNext: () => void; }) { return (

当前第 {page + 1} 页 {typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}

); } export type IpTabTimeMode = 'preset' | 'custom'; export function IpTab({ data, loading, error, page, pageSize, sort, hours, timeMode, customSince, customUntil, onHoursChange, onTimeModeChange, onApplyCustomRange, onPageSizeChange, onSortChange, onRetry, onPrevPage, onNextPage, isFetching, }: { data?: AccessLogIPSummaryList; loading: boolean; error: Error | null; page: number; pageSize: number; sort: string; hours: OverviewRangeHours; timeMode: IpTabTimeMode; /** Applied custom range (local datetime-local strings). */ customSince: string; customUntil: string; onHoursChange: (hours: OverviewRangeHours) => void; onTimeModeChange: (mode: IpTabTimeMode) => void; /** Commit validated custom range for list query. */ onApplyCustomRange: (since: string, until: string) => void; onPageSizeChange: (size: number) => void; onSortChange: (value: string) => void; onRetry: () => void; onPrevPage: () => void; onNextPage: () => void; isFetching: boolean; }) { const [selected, setSelected] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [draftSince, setDraftSince] = useState(customSince); const [draftUntil, setDraftUntil] = useState(customUntil); const [customError, setCustomError] = useState(null); const defaultCustomRange = useMemo( () => defaultLocalRangeForHours(hours), [hours], ); useEffect(() => { if (timeMode !== 'custom') { setCustomError(null); return; } // Sync draft from applied range when entering custom or after apply. setDraftSince(customSince || defaultCustomRange.since); setDraftUntil(customUntil || defaultCustomRange.until); setCustomError(null); }, [timeMode, customSince, customUntil, defaultCustomRange]); const analysisHours = resolveAnalysisHours({ timeMode, hours, customSince, customUntil, }); const rangeHint = timeMode === 'custom' && customSince && customUntil ? '自定义区间' : timeMode === 'custom' ? '自定义(未应用)' : formatOverviewRangeHint(hours); const handleApplyCustom = () => { const message = validateCustomTimeRange(draftSince, draftUntil); if (message) { setCustomError(message); return; } setCustomError(null); onApplyCustomRange(draftSince, draftUntil); }; return ( <>

时间范围

当前:{rangeHint} {data?.total_ip != null ? ` · ${formatCompactNumber(data.total_ip)} 个 IP` : ''}

{ if (!value) return; onTimeModeChange('preset'); onHoursChange( Number.parseInt(value, 10) as OverviewRangeHours, ); }} variant='outline' size='sm' > {OVERVIEW_RANGE_OPTIONS.map((option) => ( {option.label} ))}
{timeMode === 'custom' ? (

开始

{ setDraftSince(e.target.value); setCustomError(null); }} />

结束

{ setDraftUntil(e.target.value); setCustomError(null); }} />
{customError ? (

{customError}

) : (

修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。

)}
) : null}

排序

每页

IP 明细

{error ? (
) : loading ? ( ) : (data?.items ?? []).length === 0 ? ( ) : ( IP 地区 请求数 2xx 比例 入站 出站 最后访问 {(data?.items ?? []).map((item) => ( {item.remote_addr} {item.region || '—'} {formatCompactNumber(item.total_requests)} {formatRatio(item.success_ratio)} {formatBytes(item.bytes_received)} {formatBytes(item.bytes_sent)} {formatDateTime(item.last_seen_at)} 查看 IP 详情 ))}
)}
); } /** Build list API time params from IP tab state (applied values only). */ export function buildIpSummaryTimeParams(input: { timeMode: IpTabTimeMode; hours: OverviewRangeHours; customSince: string; customUntil: string; }): { hours?: number; since?: string; until?: string } | null { if (input.timeMode === 'custom') { if (validateCustomTimeRange(input.customSince, input.customUntil)) { return null; } const since = localInputToRFC3339(input.customSince); const until = localInputToRFC3339(input.customUntil); if (!since || !until) { return null; } return { since, until }; } return { hours: input.hours }; }