"use client"; import React, { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { Alert, alpha, Box, Button, Chip, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, IconButton, InputLabel, LinearProgress, MenuItem, Paper, Select, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TablePagination, TableRow, TextField, Tooltip, Typography, } from "@mui/material"; import "dayjs/locale/zh-cn"; import dayjs from "dayjs"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { zhCN as pickerZhCN } from "@mui/x-date-pickers/locales"; import { AdminPanelSettings as AdminPanelSettingsIcon, Close as CloseIcon, Download as DownloadIcon, FactCheck as FactCheckIcon, FilterAltOff as FilterAltOffIcon, InfoOutlined as InfoOutlinedIcon, Refresh as RefreshIcon, Search as SearchIcon, Visibility as VisibilityIcon, } from "@mui/icons-material"; import { config } from "@config/config"; import { apiFetch } from "@/lib/apiFetch"; import { permissionCodes } from "@/lib/permissions"; import { useAccessStore } from "@/store/accessStore"; type AuditLog = { id: string; user_id: string | null; project_id: string | null; action: string; resource_type: string | null; resource_id: string | null; ip_address: string | null; request_method: string | null; request_path: string | null; request_data: Record | null; response_status: number | null; timestamp: string; }; type MetadataUser = { id: string; username: string; email: string; }; type AdminProject = { project_id: string; name: string; code: string; }; type AuditFilters = { user_id: string; project_id: string; action: string; resource_type: string; status: AuditStatusFilter; start_time: string; end_time: string; }; type AuditStatusFilter = | "all" | "success" | "redirect" | "client_error" | "server_error" | "no_status"; const defaultFilters: AuditFilters = { user_id: "", project_id: "", action: "", resource_type: "", status: "all", start_time: "", end_time: "", }; const AUDIT_LOGS_PATH = "/api/v1/audit-logs"; const statusFilterOptions: Array<{ value: AuditStatusFilter; label: string }> = [ { value: "all", label: "全部状态" }, { value: "success", label: "成功 2xx" }, { value: "redirect", label: "重定向 3xx" }, { value: "client_error", label: "客户端错误 4xx" }, { value: "server_error", label: "服务端错误 5xx" }, { value: "no_status", label: "无响应状态" }, ]; const cardSx = { borderRadius: 2, borderColor: "divider", boxShadow: "0 10px 30px rgba(15, 23, 42, 0.06)", }; const tableSx = { minWidth: 1080, "& .MuiTableCell-head": { bgcolor: "action.hover", color: "text.secondary", fontSize: 12, fontWeight: 700, letterSpacing: 0, }, "& .MuiTableRow-root:last-child .MuiTableCell-body": { borderBottom: 0, }, }; const selectMenuProps = { disableScrollLock: true, }; const readErrorText = async (response: Response) => { const text = await response.text(); return text || `HTTP ${response.status}`; }; const normalizeDateTime = (value: string) => { if (!value) return ""; const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; return date.toISOString(); }; const formatDateTime = (value: string) => { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).format(date); }; const getStatusMeta = (status: number | null) => { if (status == null) { return { label: "无状态", color: "default" as const }; } if (status >= 200 && status < 300) { return { label: String(status), color: "success" as const }; } if (status >= 300 && status < 400) { return { label: String(status), color: "info" as const }; } if (status >= 400 && status < 500) { return { label: String(status), color: "warning" as const }; } if (status >= 500) { return { label: String(status), color: "error" as const }; } return { label: String(status), color: "default" as const }; }; const matchesStatusFilter = (log: AuditLog, filter: AuditStatusFilter) => { const status = log.response_status; if (filter === "all") return true; if (filter === "no_status") return status == null; if (status == null) return false; if (filter === "success") return status >= 200 && status < 300; if (filter === "redirect") return status >= 300 && status < 400; if (filter === "client_error") return status >= 400 && status < 500; if (filter === "server_error") return status >= 500; return true; }; const appendCsvValue = (value: unknown) => { const text = typeof value === "string" ? value : value == null ? "" : JSON.stringify(value); return `"${text.replaceAll('"', '""')}"`; }; const buildCsv = (logs: AuditLog[], userMap: Map, projectMap: Map) => { const header = [ "时间", "操作者", "用户ID", "项目", "项目ID", "动作", "资源类型", "资源ID", "状态", "方法", "路径", "IP", "请求数据", ]; const rows = logs.map((log) => [ formatDateTime(log.timestamp), log.user_id ? userMap.get(log.user_id)?.username ?? "" : "", log.user_id ?? "", log.project_id ? projectMap.get(log.project_id)?.name ?? "" : "", log.project_id ?? "", log.action, log.resource_type ?? "", log.resource_id ?? "", log.response_status ?? "", log.request_method ?? "", log.request_path ?? "", log.ip_address ?? "", log.request_data ?? "", ]); return [header, ...rows] .map((row) => row.map(appendCsvValue).join(",")) .join("\n"); }; const buildServerParams = (filters: AuditFilters, skip: number, limit: number) => { const params = new URLSearchParams(); if (filters.user_id) params.set("user_id", filters.user_id); if (filters.project_id) params.set("project_id", filters.project_id); if (filters.action.trim()) params.set("action", filters.action.trim()); if (filters.resource_type.trim()) { params.set("resource_type", filters.resource_type.trim()); } const startTime = normalizeDateTime(filters.start_time); const endTime = normalizeDateTime(filters.end_time); if (startTime) params.set("start_time", startTime); if (endTime) params.set("end_time", endTime); params.set("skip", String(skip)); params.set("limit", String(limit)); return params; }; const buildCountParams = (filters: AuditFilters) => { const params = buildServerParams(filters, 0, 1); params.delete("skip"); params.delete("limit"); return params; }; const EmptyRow = ({ colSpan, label }: { colSpan: number; label: string }) => ( {label} ); const StatusChip = ({ status }: { status: number | null }) => { const meta = getStatusMeta(status); return ; }; const DetailLine = ({ label, value, }: { label: string; value: React.ReactNode; }) => ( {label} {value || "-"} ); const DetailSection = ({ title, children, }: { title: string; children: React.ReactNode; }) => ( {title} {children} ); export const AuditLogPanel = () => { const accessLoading = useAccessStore((state) => state.loading); const permissions = useAccessStore((state) => state.permissions); const isSystemAdmin = useAccessStore( (state) => state.context?.is_system_admin === true, ); const canViewAudit = permissions.includes(permissionCodes.auditView); const adminChecked = !accessLoading; const isAuthorized = adminChecked && canViewAudit && isSystemAdmin; const [logs, setLogs] = useState([]); const [users, setUsers] = useState([]); const [projects, setProjects] = useState([]); const [filters, setFilters] = useState(defaultFilters); const [appliedFilters, setAppliedFilters] = useState(defaultFilters); const [page, setPage] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(25); const [totalCount, setTotalCount] = useState(0); const [loading, setLoading] = useState(false); const [exporting, setExporting] = useState(false); const [error, setError] = useState(null); const [selectedLog, setSelectedLog] = useState(null); const [lastLoadedAt, setLastLoadedAt] = useState(null); const userMap = useMemo( () => new Map(users.map((user) => [user.id, user])), [users], ); const projectMap = useMemo( () => new Map(projects.map((project) => [project.project_id, project])), [projects], ); const usesClientStatusFilter = appliedFilters.status !== "all"; const visibleLogs = useMemo(() => { if (!usesClientStatusFilter) return logs; const filtered = logs.filter((log) => matchesStatusFilter(log, appliedFilters.status)); return filtered.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); }, [appliedFilters.status, logs, page, rowsPerPage, usesClientStatusFilter]); const loadOptions = useCallback(async () => { const [usersResult, projectsResult] = await Promise.allSettled([ apiFetch(`${config.BACKEND_URL}/api/v1/admin/users`), apiFetch(`${config.BACKEND_URL}/api/v1/admin/projects`), ]); if (usersResult.status === "fulfilled" && usersResult.value.ok) { setUsers((await usersResult.value.json()) as MetadataUser[]); } if (projectsResult.status === "fulfilled" && projectsResult.value.ok) { setProjects((await projectsResult.value.json()) as AdminProject[]); } }, []); const loadLogs = useCallback(async () => { setLoading(true); setError(null); try { if (appliedFilters.status === "all") { const params = buildServerParams(appliedFilters, page * rowsPerPage, rowsPerPage); const countParams = buildCountParams(appliedFilters); const [logsResponse, countResponse] = await Promise.all([ apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`), apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}/count?${countParams.toString()}`), ]); if (!logsResponse.ok) throw new Error(await readErrorText(logsResponse)); if (!countResponse.ok) throw new Error(await readErrorText(countResponse)); const countPayload = (await countResponse.json()) as { count?: number }; setLogs((await logsResponse.json()) as AuditLog[]); setTotalCount(Number(countPayload.count ?? 0)); } else { const params = buildServerParams(appliedFilters, 0, 1000); const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`); if (!response.ok) throw new Error(await readErrorText(response)); const payload = (await response.json()) as AuditLog[]; setLogs(payload); setTotalCount(payload.filter((log) => matchesStatusFilter(log, appliedFilters.status)).length); } setLastLoadedAt(new Date().toISOString()); } catch (err) { setError(String(err)); } finally { setLoading(false); } }, [appliedFilters, page, rowsPerPage]); useEffect(() => { if (!isAuthorized) return; void loadOptions(); }, [isAuthorized, loadOptions]); useEffect(() => { if (!adminChecked || !isAuthorized) return; void loadLogs(); }, [adminChecked, isAuthorized, loadLogs]); const applyFilters = (event: FormEvent) => { event.preventDefault(); setPage(0); setAppliedFilters(filters); }; const resetFilters = () => { setFilters(defaultFilters); setAppliedFilters(defaultFilters); setPage(0); }; const exportLogs = async () => { setExporting(true); setError(null); try { const params = buildServerParams(appliedFilters, 0, 1000); const response = await apiFetch(`${config.BACKEND_URL}${AUDIT_LOGS_PATH}?${params.toString()}`); if (!response.ok) throw new Error(await readErrorText(response)); const payload = ((await response.json()) as AuditLog[]).filter((log) => matchesStatusFilter(log, appliedFilters.status), ); const csv = buildCsv(payload, userMap, projectMap); const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); const href = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = href; link.download = `tjwater-audit-logs-${new Date().toISOString().slice(0, 10)}.csv`; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(href); } catch (err) { setError(String(err)); } finally { setExporting(false); } }; return ( ({ ...cardSx, p: { xs: 2, md: 3 }, bgcolor: theme.palette.mode === "dark" ? alpha(theme.palette.info.main, 0.1) : alpha(theme.palette.info.main, 0.04), })} > ({ width: 48, height: 48, borderRadius: 2, display: "grid", placeItems: "center", color: "info.main", bgcolor: alpha(theme.palette.info.main, 0.12), })} > 审计日志 全局审计查询 {adminChecked && isAuthorized && ( } label="系统管理员权限已验证" sx={{ alignSelf: { xs: "flex-start", md: "center" } }} /> )} {!adminChecked && ( 正在校验审计权限 )} {adminChecked && !isAuthorized && ( 无审计日志访问权限 )} {error && ( setError(null)} sx={{ borderRadius: 2 }}> {error} )} {adminChecked && isAuthorized && ( <> 匹配记录 {totalCount} 最近刷新 {lastLoadedAt ? formatDateTime(lastLoadedAt) : "-"} 操作者 项目 setFilters((current) => ({ ...current, action: event.target.value })) } sx={{ minWidth: { xs: "100%", md: 180 } }} /> setFilters((current) => ({ ...current, resource_type: event.target.value })) } sx={{ minWidth: { xs: "100%", md: 180 } }} /> 状态 setFilters((current) => ({ ...current, start_time: value?.isValid() ? value.toISOString() : "", })) } maxDateTime={filters.end_time ? dayjs(filters.end_time) : undefined} slotProps={{ textField: { size: "small", sx: { minWidth: { xs: "100%", md: 220 } }, }, }} /> setFilters((current) => ({ ...current, end_time: value?.isValid() ? value.toISOString() : "", })) } minDateTime={filters.start_time ? dayjs(filters.start_time) : undefined} slotProps={{ textField: { size: "small", sx: { minWidth: { xs: "100%", md: 220 } }, }, }} /> {usesClientStatusFilter && ( } sx={{ borderRadius: 2 }}> 状态筛选基于当前查询最多 1000 条记录处理。 )} 查询结果 {totalCount} 条匹配记录 {loading && } 时间 操作者 动作 资源 状态 请求 IP 详情 {visibleLogs.length === 0 && ( )} {visibleLogs.map((log) => { const user = log.user_id ? userMap.get(log.user_id) : null; return ( {formatDateTime(log.timestamp)} {user?.username ?? "未知用户"} {log.user_id ?? "-"} {log.action} {log.resource_type ?? "-"} {log.resource_id ?? "-"} {log.request_method ?? "-"} {log.request_path ?? "-"} {log.ip_address ?? "-"} setSelectedLog(log)}> ); })}
setPage(nextPage)} rowsPerPage={rowsPerPage} onRowsPerPageChange={(event) => { setRowsPerPage(Number(event.target.value)); setPage(0); }} SelectProps={{ MenuProps: selectMenuProps }} rowsPerPageOptions={[10, 25, 50, 100]} labelRowsPerPage="每页行数" />
)}
setSelectedLog(null)} maxWidth="md" fullWidth disableScrollLock transitionDuration={{ enter: 120, exit: 0 }} > 审计详情 {selectedLog ? formatDateTime(selectedLog.timestamp) : ""} setSelectedLog(null)} sx={{ position: "absolute", right: 12, top: 12 }} > {selectedLog && ( ({ p: 2, borderRadius: 2, bgcolor: alpha(theme.palette.info.main, 0.05), borderColor: alpha(theme.palette.info.main, 0.2), })} > 审计动作 {selectedLog.action} {selectedLog.request_method ?? "-"} {selectedLog.request_path ?? "-"} } /> 请求数据 {selectedLog.request_data ? JSON.stringify(selectedLog.request_data, null, 2) : "{}"} )}
); };