/* Copyright 2026 Arctel.net Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ "use client" import {useMemo, useState} from "react" import {useQuery} from "@tanstack/react-query" import {toast} from "sonner" import {format} from "date-fns" import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react" import services from "@/lib/services" import {ErrorInline} from "@/components/layout/error" import {LoadingStateWithBorder} from "@/components/layout/loading" import {EmptyStateWithBorder} from "@/components/layout/empty" import {Badge} from "@/components/ui/badge" import {Button} from "@/components/ui/button" import {Input} from "@/components/ui/input" import {Label} from "@/components/ui/label" import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet" import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table" interface AccessLog { id: string user_id: string username: string nickname: string path: string method: string ip: string user_agent: string headers: string status: number latency: number created_at: string } const PAGE_SIZE = 15 function formatDateTime(value?: string | null) { if (!value) return "-" const date = new Date(value) if (Number.isNaN(date.getTime())) return value return format(date, "yyyy-MM-dd HH:mm:ss") } function formatLatency(ms: number) { if (ms < 1000) return `${ms}ms` return `${(ms / 1000).toFixed(2)}s` } function statusVariant(status: number) { if (status >= 500) return "destructive" if (status >= 400) return "destructive" if (status >= 300) return "outline" return "secondary" } function isClickhouseDisabledError(error: Error) { const errMsg = error.message || "" return errMsg.includes("ClickHouse") || errMsg.includes("未启用") } export function AccessLogs() { const [page, setPage] = useState(1) // 搜索过滤条件 const [usernameFilter, setUsernameFilter] = useState("") const [pathFilter, setPathFilter] = useState("") const [startTimeFilter, setStartTimeFilter] = useState("") const [endTimeFilter, setEndTimeFilter] = useState("") // 实际提交的搜索条件 const [searchParams, setSearchParams] = useState({ username: "", path: "", start_time: "", end_time: "", }) const [selectedLog, setSelectedLog] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const logsQuery = useQuery({ queryKey: ["admin", "access-logs", page, searchParams], queryFn: async () => { let startISO = "" if (searchParams.start_time) { startISO = new Date(searchParams.start_time).toISOString() } let endISO = "" if (searchParams.end_time) { endISO = new Date(searchParams.end_time).toISOString() } return services.adminLog.getAccessLogs({ page, page_size: PAGE_SIZE, username: searchParams.username || undefined, path: searchParams.path || undefined, start_time: startISO || undefined, end_time: endISO || undefined, }) }, retry: false, }) const logs = logsQuery.data?.list ?? [] const total = logsQuery.data?.total ?? 0 const loading = logsQuery.isPending || logsQuery.isFetching const clickhouseDisabled = logsQuery.isError && isClickhouseDisabledError(logsQuery.error) const error = logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total]) const handleSearch = (e: React.FormEvent) => { e.preventDefault() setPage(1) setSearchParams({ username: usernameFilter.trim(), path: pathFilter.trim(), start_time: startTimeFilter, end_time: endTimeFilter, }) } const handleReset = () => { setUsernameFilter("") setPathFilter("") setStartTimeFilter("") setEndTimeFilter("") setPage(1) setSearchParams({ username: "", path: "", start_time: "", end_time: "", }) } const handlePageChange = (newPage: number) => { setPage(newPage) } const copyToClipboard = (text: string, subject: string) => { navigator.clipboard.writeText(text) toast.success(`${subject}已复制到剪贴板`) } // Prettify Headers JSON string const getPrettyHeaders = (headersRaw?: string) => { if (!headersRaw) return "暂无头部数据" try { const parsed = JSON.parse(headersRaw) return JSON.stringify(parsed, null, 2) } catch { return headersRaw } } if (clickhouseDisabled) { return (

ClickHouse 未启用

当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。

) } if (error) { return void logsQuery.refetch()} /> } return (
{/* Filters */}
setUsernameFilter(e.target.value)} className="h-8 text-xs" />
setPathFilter(e.target.value)} className="h-8 text-xs" />
setStartTimeFilter(e.target.value)} className="h-8 text-xs" />
setEndTimeFilter(e.target.value)} className="h-8 text-xs" />
{/* Loading Table */} {loading && logs.length === 0 ? ( ) : logs.length === 0 ? ( ) : (
请求方法 路径 用户 IP 状态 耗时 请求时间 详情 {logs.map((log) => ( {log.method} {log.path}
{log.username || "未知用户"} {log.nickname && ( ({log.nickname}) )}
{log.ip} {log.status} {formatLatency(log.latency)} {formatDateTime(log.created_at)}
))}
)} {/* Pagination */} {logs.length > 0 && (
共 {total} 条记录,当前第 {page}/{totalPages} 页
)} {/* Detail Drawer */} 访问日志详情 访问 ID: {selectedLog?.id} {selectedLog ? (
{/* Summary Details */}
请求方法
{selectedLog.method}
响应状态
{selectedLog.status}
耗时
{formatLatency(selectedLog.latency)}
IP 地址
{selectedLog.ip}
用户
{selectedLog.username ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` : "未知/游客"} ID: {selectedLog.user_id}
{/* Path */}
{selectedLog.path}
{/* Request Time */}
{formatDateTime(selectedLog.created_at)}
{/* User Agent */}
{selectedLog.user_agent}
{/* Headers */}
									{getPrettyHeaders(selectedLog.headers)}
								
) : (
)}
) }