"use client"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Alert, Button, Card, Empty, Form, Input, Space, Spin, Table, Tag, Typography, type CardProps } from "antd"; import type { ColumnsType } from "antd/es/table"; import type { CSSProperties, ComponentType } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "@/components/auth-provider"; import { useTopicSubscription } from "@/hooks/use-topic-subscription"; import { readApiError } from "@/lib/api"; import type { AuditLogItem, AuditLogListResponse } from "@/types/auth"; const PAGE_SIZE = 50; const SYSLOG_TABLE_MIN_SCROLL_Y = 180; const SYSLOG_TABLE_VIEWPORT_GAP = 40; const SYSLOG_TABLE_FALLBACK_RESERVE = 220; const AntCard = Card as unknown as ComponentType; type Filters = { action: string; user_id: string; }; const EMPTY_FILTERS: Filters = { action: "", user_id: "", }; function formatDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { return "-"; } return date.toLocaleString(); } export default function AdminSyslogPage() { const queryClient = useQueryClient(); const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); const [offset, setOffset] = useState(0); const [draftFilters, setDraftFilters] = useState(EMPTY_FILTERS); const [filters, setFilters] = useState(EMPTY_FILTERS); const [tableScrollY, setTableScrollY] = useState(SYSLOG_TABLE_MIN_SCROLL_Y); const tableScrollAnchorRef = useRef(null); const canRead = hasPermission("menu.read") || hasPermission("menu.manage"); const logsPath = useMemo(() => { const params = new URLSearchParams(); params.set("limit", String(PAGE_SIZE)); params.set("offset", String(offset)); if (filters.action.trim()) { params.set("action", filters.action.trim()); } if (filters.user_id.trim()) { params.set("user_id", filters.user_id.trim()); } return `/api/v1/admin/audit-logs?${params.toString()}`; }, [filters.action, filters.user_id, offset]); const loadLogs = useCallback(async () => { const response = await fetchWithAuth(logsPath); if (!response.ok) { throw new Error(await readApiError(response)); } return (await response.json()) as AuditLogListResponse; }, [fetchWithAuth, logsPath]); const logsQuery = useQuery({ queryKey: [logsPath], queryFn: loadLogs, enabled: !!user && canRead, }); useTopicSubscription( "admin.audit_logs", useCallback(() => { void queryClient.invalidateQueries({ queryKey: [logsPath] }); }, [logsPath, queryClient]), ); const logs = logsQuery.data?.items ?? []; const total = logsQuery.data?.total ?? 0; const error = logsQuery.error instanceof Error ? logsQuery.error.message : ""; const currentPage = Math.floor(offset / PAGE_SIZE) + 1; const columns = useMemo>( () => [ { title: "时间", dataIndex: "created_at", key: "created_at", width: 220, render: (value: string) => ( {formatDate(value)} ), }, { title: "用户", key: "user", width: 260, render: (_value, record) => ( {record.username ?? "-"} {record.user_id ?? "-"} ), }, { title: "动作", dataIndex: "action", key: "action", width: 220, render: (value: string) => {value}, }, { title: "详情", dataIndex: "detail", key: "detail", render: (value: string | null) => ( {value || "-"} ), }, ], [], ); const updateTableScrollY = useCallback(() => { if (typeof window === "undefined") { return; } const anchor = tableScrollAnchorRef.current; if (!anchor) { return; } const anchorTop = anchor.getBoundingClientRect().top; const tableWrapper = anchor.querySelector(".ant-table-wrapper"); const tableBody = anchor.querySelector(".ant-table-body"); let nextHeight = Math.floor(window.innerHeight - anchorTop - SYSLOG_TABLE_FALLBACK_RESERVE); if (tableWrapper) { const wrapperRect = tableWrapper.getBoundingClientRect(); const bodyHeight = tableBody?.getBoundingClientRect().height ?? SYSLOG_TABLE_MIN_SCROLL_Y; const nonBodyHeight = Math.max(0, wrapperRect.height - bodyHeight); const topGap = Math.max(0, wrapperRect.top - anchorTop); nextHeight = Math.floor(window.innerHeight - anchorTop - topGap - nonBodyHeight - SYSLOG_TABLE_VIEWPORT_GAP); } const clampedHeight = Math.max(SYSLOG_TABLE_MIN_SCROLL_Y, nextHeight); setTableScrollY((previous) => (Math.abs(previous - clampedHeight) <= 1 ? previous : clampedHeight)); }, []); useEffect(() => { updateTableScrollY(); }, [error, logs.length, logsQuery.isFetching, total, updateTableScrollY]); useEffect(() => { if (typeof window === "undefined") { return; } const onViewportChange = () => { window.requestAnimationFrame(updateTableScrollY); }; window.addEventListener("resize", onViewportChange); return () => { window.removeEventListener("resize", onViewportChange); }; }, [updateTableScrollY]); useEffect(() => { if (typeof window === "undefined" || typeof ResizeObserver === "undefined") { return; } const anchor = tableScrollAnchorRef.current; if (!anchor) { return; } const resizeObserver = new ResizeObserver(() => { window.requestAnimationFrame(updateTableScrollY); }); resizeObserver.observe(anchor); return () => { resizeObserver.disconnect(); }; }, [updateTableScrollY]); if (initializing || logsQuery.isLoading) { return (
); } if (!user) { return (

请先登录后再访问系统日志页面。

返回首页
); } if (!canRead) { return (

你没有访问该页面的权限(需要 `menu.read`)。

返回首页
); } return (
{error ? : null}
setDraftFilters((prev) => ({ ...prev, action: event.target.value, })) } /> setDraftFilters((prev) => ({ ...prev, user_id: event.target.value, })) } />
rowKey={(record) => String(record.id)} columns={columns} dataSource={logs} loading={logsQuery.isFetching} pagination={{ current: currentPage, pageSize: PAGE_SIZE, total, onChange: (page) => setOffset((page - 1) * PAGE_SIZE), showSizeChanger: false, showQuickJumper: false, showTotal: (value) => `共 ${value} 条`, style: { marginBottom: 0 }, }} locale={{ emptyText: , }} scroll={{ x: 980, y: tableScrollY }} />
); }