diff --git a/src/app/(main)/audit-logs/page.tsx b/src/app/(main)/audit-logs/page.tsx
new file mode 100644
index 0000000..a6e3cb1
--- /dev/null
+++ b/src/app/(main)/audit-logs/page.tsx
@@ -0,0 +1,5 @@
+import { AuditLogPanel } from "@/components/audit/AuditLogPanel";
+
+export default function AuditLogsPage() {
+ return ;
+}
diff --git a/src/app/_refine_context.tsx b/src/app/_refine_context.tsx
index 29511e2..3b76364 100644
--- a/src/app/_refine_context.tsx
+++ b/src/app/_refine_context.tsx
@@ -26,6 +26,7 @@ import { AiOutlineSecurityScan } from "react-icons/ai";
import { MdWater, MdOutlineWaterDrop, MdCleaningServices } from "react-icons/md";
import {
ManageAccounts as ManageAccountsIcon,
+ FactCheck as FactCheckIcon,
MyLocation as MyLocationIcon,
Search as SearchIcon,
} from "@mui/icons-material";
@@ -270,6 +271,14 @@ const App = (props: React.PropsWithChildren) => {
label: "系统管理",
},
},
+ {
+ name: "审计日志",
+ list: "/audit-logs",
+ meta: {
+ icon: ,
+ label: "审计日志",
+ },
+ },
]
: []),
]}
diff --git a/src/components/audit/AuditLogPanel.tsx b/src/components/audit/AuditLogPanel.tsx
new file mode 100644
index 0000000..d82c5e7
--- /dev/null
+++ b/src/components/audit/AuditLogPanel.tsx
@@ -0,0 +1,1055 @@
+"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";
+
+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 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 [adminChecked, setAdminChecked] = useState(false);
+ const [isAuthorized, setIsAuthorized] = useState(false);
+ 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}/api/v1/audit/logs?${params.toString()}`),
+ apiFetch(`${config.BACKEND_URL}/api/v1/audit/logs/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}/api/v1/audit/logs?${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(() => {
+ let cancelled = false;
+
+ const checkAdmin = async () => {
+ try {
+ const response = await apiFetch(`${config.BACKEND_URL}/api/v1/admin/me`, {
+ projectHeaderMode: "omit",
+ skipAuthRedirect: true,
+ });
+ if (cancelled) return;
+ if (!response.ok) {
+ setIsAuthorized(false);
+ setAdminChecked(true);
+ return;
+ }
+ const payload = await response.json();
+ setIsAuthorized(Boolean(payload?.is_superuser || payload?.role === "admin"));
+ setAdminChecked(true);
+ } catch (err) {
+ if (!cancelled) {
+ setIsAuthorized(false);
+ setAdminChecked(true);
+ setError(String(err));
+ }
+ }
+ };
+
+ void checkAdmin();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!adminChecked || !isAuthorized) return;
+ void loadOptions();
+ }, [adminChecked, 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}/api/v1/audit/logs?${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 } },
+ },
+ }}
+ />
+
+
+
+ }
+ onClick={resetFilters}
+ >
+ 重置
+
+ }
+ onClick={loadLogs}
+ disabled={loading}
+ >
+ 刷新
+
+ }>
+ 查询
+
+
+
+
+
+
+ {usesClientStatusFilter && (
+ } sx={{ borderRadius: 2 }}>
+ 状态筛选基于当前查询最多 1000 条记录处理。
+
+ )}
+
+
+
+
+
+ 查询结果
+
+
+ {totalCount} 条匹配记录
+
+
+ : }
+ onClick={exportLogs}
+ disabled={exporting || loading}
+ sx={{ minWidth: 116 }}
+ >
+ 导出 CSV
+
+
+
+ {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="每页行数"
+ />
+
+ >
+ )}
+
+
+
+
+ );
+};