1033 lines
36 KiB
TypeScript
1033 lines
36 KiB
TypeScript
"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<string, unknown> | 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<string, MetadataUser>, projectMap: Map<string, AdminProject>) => {
|
|
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 }) => (
|
|
<TableRow>
|
|
<TableCell colSpan={colSpan} align="center" sx={{ py: 6 }}>
|
|
<Typography color="text.secondary">{label}</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
|
|
const StatusChip = ({ status }: { status: number | null }) => {
|
|
const meta = getStatusMeta(status);
|
|
return <Chip size="small" color={meta.color} label={meta.label} variant={status == null ? "outlined" : "filled"} />;
|
|
};
|
|
|
|
const DetailLine = ({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
}) => (
|
|
<Box
|
|
sx={{
|
|
minWidth: 0,
|
|
p: 1.5,
|
|
borderRadius: 1.5,
|
|
border: 1,
|
|
borderColor: "divider",
|
|
bgcolor: "background.default",
|
|
}}
|
|
>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{label}
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
mt: 0.5,
|
|
color: "text.primary",
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
lineHeight: 1.6,
|
|
overflowWrap: "anywhere",
|
|
}}
|
|
>
|
|
{value || "-"}
|
|
</Box>
|
|
</Box>
|
|
);
|
|
|
|
const DetailSection = ({
|
|
title,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}) => (
|
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
|
<Typography variant="subtitle2" fontWeight={800} sx={{ mb: 1.5 }}>
|
|
{title}
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" },
|
|
gap: 1.5,
|
|
}}
|
|
>
|
|
{children}
|
|
</Box>
|
|
</Paper>
|
|
);
|
|
|
|
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<AuditLog[]>([]);
|
|
const [users, setUsers] = useState<MetadataUser[]>([]);
|
|
const [projects, setProjects] = useState<AdminProject[]>([]);
|
|
const [filters, setFilters] = useState<AuditFilters>(defaultFilters);
|
|
const [appliedFilters, setAppliedFilters] = useState<AuditFilters>(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<string | null>(null);
|
|
const [selectedLog, setSelectedLog] = useState<AuditLog | null>(null);
|
|
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(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 (
|
|
<Box
|
|
sx={{
|
|
minHeight: "100%",
|
|
overflow: "auto",
|
|
bgcolor: "background.default",
|
|
p: { xs: 2, md: 3 },
|
|
}}
|
|
>
|
|
<Stack spacing={2.5} sx={{ maxWidth: 1440, mx: "auto" }}>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={(theme) => ({
|
|
...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),
|
|
})}
|
|
>
|
|
<Stack
|
|
direction={{ xs: "column", md: "row" }}
|
|
spacing={2}
|
|
alignItems={{ xs: "stretch", md: "center" }}
|
|
justifyContent="space-between"
|
|
>
|
|
<Stack direction="row" spacing={1.5} alignItems="center">
|
|
<Box
|
|
sx={(theme) => ({
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 2,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
color: "info.main",
|
|
bgcolor: alpha(theme.palette.info.main, 0.12),
|
|
})}
|
|
>
|
|
<FactCheckIcon />
|
|
</Box>
|
|
<Box sx={{ minWidth: 0 }}>
|
|
<Typography variant="h5" fontWeight={800}>
|
|
审计日志
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
全局审计查询
|
|
</Typography>
|
|
</Box>
|
|
</Stack>
|
|
{adminChecked && isAuthorized && (
|
|
<Chip
|
|
color="success"
|
|
icon={<AdminPanelSettingsIcon />}
|
|
label="系统管理员权限已验证"
|
|
sx={{ alignSelf: { xs: "flex-start", md: "center" } }}
|
|
/>
|
|
)}
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{!adminChecked && (
|
|
<Paper variant="outlined" sx={{ ...cardSx, p: 5 }}>
|
|
<Stack alignItems="center" spacing={2}>
|
|
<CircularProgress size={28} />
|
|
<Typography color="text.secondary">正在校验审计权限</Typography>
|
|
</Stack>
|
|
</Paper>
|
|
)}
|
|
|
|
{adminChecked && !isAuthorized && (
|
|
<Alert severity="error" sx={{ borderRadius: 2 }}>
|
|
无审计日志访问权限
|
|
</Alert>
|
|
)}
|
|
|
|
{error && (
|
|
<Alert severity="error" onClose={() => setError(null)} sx={{ borderRadius: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{adminChecked && isAuthorized && (
|
|
<>
|
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
|
<Paper variant="outlined" sx={{ ...cardSx, p: 2, flex: 1 }}>
|
|
<Stack spacing={0.5}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
匹配记录
|
|
</Typography>
|
|
<Typography variant="h5" fontWeight={800}>
|
|
{totalCount}
|
|
</Typography>
|
|
</Stack>
|
|
</Paper>
|
|
<Paper variant="outlined" sx={{ ...cardSx, p: 2, flex: 1 }}>
|
|
<Stack spacing={0.5}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
最近刷新
|
|
</Typography>
|
|
<Typography variant="h6" fontWeight={800}>
|
|
{lastLoadedAt ? formatDateTime(lastLoadedAt) : "-"}
|
|
</Typography>
|
|
</Stack>
|
|
</Paper>
|
|
</Stack>
|
|
|
|
<Paper
|
|
component="form"
|
|
variant="outlined"
|
|
sx={{ ...cardSx, p: 2 }}
|
|
onSubmit={applyFilters}
|
|
>
|
|
<Stack spacing={2}>
|
|
<Stack
|
|
direction={{ xs: "column", md: "row" }}
|
|
spacing={1.5}
|
|
alignItems={{ xs: "stretch", md: "center" }}
|
|
>
|
|
<FormControl size="small" sx={{ minWidth: { xs: "100%", md: 220 } }}>
|
|
<InputLabel shrink>操作者</InputLabel>
|
|
<Select
|
|
label="操作者"
|
|
value={filters.user_id}
|
|
MenuProps={selectMenuProps}
|
|
displayEmpty
|
|
notched
|
|
renderValue={(value) => {
|
|
if (!value) return "全部操作者";
|
|
const user = userMap.get(String(value));
|
|
return user ? `${user.username} / ${user.email}` : String(value);
|
|
}}
|
|
onChange={(event) =>
|
|
setFilters((current) => ({ ...current, user_id: event.target.value }))
|
|
}
|
|
>
|
|
<MenuItem value="">全部操作者</MenuItem>
|
|
{users.map((user) => (
|
|
<MenuItem key={user.id} value={user.id}>
|
|
{user.username} / {user.email}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
<FormControl size="small" sx={{ minWidth: { xs: "100%", md: 220 } }}>
|
|
<InputLabel shrink>项目</InputLabel>
|
|
<Select
|
|
label="项目"
|
|
value={filters.project_id}
|
|
MenuProps={selectMenuProps}
|
|
displayEmpty
|
|
notched
|
|
renderValue={(value) => {
|
|
if (!value) return "全部项目";
|
|
const project = projectMap.get(String(value));
|
|
return project ? `${project.name} / ${project.code}` : String(value);
|
|
}}
|
|
onChange={(event) =>
|
|
setFilters((current) => ({ ...current, project_id: event.target.value }))
|
|
}
|
|
>
|
|
<MenuItem value="">全部项目</MenuItem>
|
|
{projects.map((project) => (
|
|
<MenuItem key={project.project_id} value={project.project_id}>
|
|
{project.name} / {project.code}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
<TextField
|
|
size="small"
|
|
label="动作"
|
|
value={filters.action}
|
|
onChange={(event) =>
|
|
setFilters((current) => ({ ...current, action: event.target.value }))
|
|
}
|
|
sx={{ minWidth: { xs: "100%", md: 180 } }}
|
|
/>
|
|
<TextField
|
|
size="small"
|
|
label="资源"
|
|
value={filters.resource_type}
|
|
onChange={(event) =>
|
|
setFilters((current) => ({ ...current, resource_type: event.target.value }))
|
|
}
|
|
sx={{ minWidth: { xs: "100%", md: 180 } }}
|
|
/>
|
|
</Stack>
|
|
<Stack
|
|
direction={{ xs: "column", md: "row" }}
|
|
spacing={1.5}
|
|
alignItems={{ xs: "stretch", md: "center" }}
|
|
>
|
|
<LocalizationProvider
|
|
dateAdapter={AdapterDayjs}
|
|
adapterLocale="zh-cn"
|
|
localeText={
|
|
pickerZhCN.components.MuiLocalizationProvider.defaultProps.localeText
|
|
}
|
|
>
|
|
<FormControl size="small" sx={{ minWidth: { xs: "100%", md: 180 } }}>
|
|
<InputLabel>状态</InputLabel>
|
|
<Select
|
|
label="状态"
|
|
value={filters.status}
|
|
MenuProps={selectMenuProps}
|
|
onChange={(event) =>
|
|
setFilters((current) => ({
|
|
...current,
|
|
status: event.target.value as AuditStatusFilter,
|
|
}))
|
|
}
|
|
>
|
|
{statusFilterOptions.map((option) => (
|
|
<MenuItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</MenuItem>
|
|
))}
|
|
</Select>
|
|
</FormControl>
|
|
<DateTimePicker
|
|
label="开始时间"
|
|
value={filters.start_time ? dayjs(filters.start_time) : null}
|
|
onChange={(value) =>
|
|
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 } },
|
|
},
|
|
}}
|
|
/>
|
|
<DateTimePicker
|
|
label="结束时间"
|
|
value={filters.end_time ? dayjs(filters.end_time) : null}
|
|
onChange={(value) =>
|
|
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 } },
|
|
},
|
|
}}
|
|
/>
|
|
</LocalizationProvider>
|
|
<Box sx={{ flex: 1 }} />
|
|
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
|
<Button
|
|
type="button"
|
|
variant="outlined"
|
|
startIcon={<FilterAltOffIcon />}
|
|
onClick={resetFilters}
|
|
>
|
|
重置
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outlined"
|
|
startIcon={<RefreshIcon />}
|
|
onClick={loadLogs}
|
|
disabled={loading}
|
|
>
|
|
刷新
|
|
</Button>
|
|
<Button type="submit" variant="contained" startIcon={<SearchIcon />}>
|
|
查询
|
|
</Button>
|
|
</Stack>
|
|
</Stack>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{usesClientStatusFilter && (
|
|
<Alert severity="info" icon={<InfoOutlinedIcon />} sx={{ borderRadius: 2 }}>
|
|
状态筛选基于当前查询最多 1000 条记录处理。
|
|
</Alert>
|
|
)}
|
|
|
|
<Paper variant="outlined" sx={{ ...cardSx, overflow: "hidden" }}>
|
|
<Stack
|
|
direction={{ xs: "column", md: "row" }}
|
|
spacing={1}
|
|
alignItems={{ xs: "stretch", md: "center" }}
|
|
justifyContent="space-between"
|
|
sx={{ p: 2, borderBottom: 1, borderColor: "divider" }}
|
|
>
|
|
<Box>
|
|
<Typography variant="subtitle1" fontWeight={800}>
|
|
查询结果
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{totalCount} 条匹配记录
|
|
</Typography>
|
|
</Box>
|
|
<Button
|
|
variant="outlined"
|
|
startIcon={exporting ? <CircularProgress size={16} /> : <DownloadIcon />}
|
|
onClick={exportLogs}
|
|
disabled={exporting || loading}
|
|
sx={{ minWidth: 116 }}
|
|
>
|
|
导出 CSV
|
|
</Button>
|
|
</Stack>
|
|
<Box sx={{ height: 3 }}>
|
|
{loading && <LinearProgress sx={{ height: 3 }} />}
|
|
</Box>
|
|
<TableContainer>
|
|
<Table size="small" sx={tableSx}>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>时间</TableCell>
|
|
<TableCell>操作者</TableCell>
|
|
<TableCell>动作</TableCell>
|
|
<TableCell>资源</TableCell>
|
|
<TableCell>状态</TableCell>
|
|
<TableCell>请求</TableCell>
|
|
<TableCell>IP</TableCell>
|
|
<TableCell align="right">详情</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{visibleLogs.length === 0 && (
|
|
<EmptyRow
|
|
colSpan={8}
|
|
label={loading ? "正在加载审计日志" : "暂无审计日志"}
|
|
/>
|
|
)}
|
|
{visibleLogs.map((log) => {
|
|
const user = log.user_id ? userMap.get(log.user_id) : null;
|
|
return (
|
|
<TableRow key={log.id} hover>
|
|
<TableCell sx={{ whiteSpace: "nowrap" }}>
|
|
{formatDateTime(log.timestamp)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Stack spacing={0.25}>
|
|
<Typography variant="body2" fontWeight={700}>
|
|
{user?.username ?? "未知用户"}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{log.user_id ?? "-"}
|
|
</Typography>
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2" fontWeight={700}>
|
|
{log.action}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Stack spacing={0.25}>
|
|
<Typography variant="body2">
|
|
{log.resource_type ?? "-"}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{log.resource_id ?? "-"}
|
|
</Typography>
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell>
|
|
<StatusChip status={log.response_status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Stack spacing={0.25} sx={{ maxWidth: 280 }}>
|
|
<Typography variant="body2" fontWeight={700}>
|
|
{log.request_method ?? "-"}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" noWrap>
|
|
{log.request_path ?? "-"}
|
|
</Typography>
|
|
</Stack>
|
|
</TableCell>
|
|
<TableCell>{log.ip_address ?? "-"}</TableCell>
|
|
<TableCell align="right">
|
|
<Tooltip title="查看详情">
|
|
<IconButton size="small" onClick={() => setSelectedLog(log)}>
|
|
<VisibilityIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
<TablePagination
|
|
component="div"
|
|
count={totalCount}
|
|
page={page}
|
|
onPageChange={(_, nextPage) => setPage(nextPage)}
|
|
rowsPerPage={rowsPerPage}
|
|
onRowsPerPageChange={(event) => {
|
|
setRowsPerPage(Number(event.target.value));
|
|
setPage(0);
|
|
}}
|
|
SelectProps={{ MenuProps: selectMenuProps }}
|
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
|
labelRowsPerPage="每页行数"
|
|
/>
|
|
</Paper>
|
|
</>
|
|
)}
|
|
</Stack>
|
|
|
|
<Dialog
|
|
open={Boolean(selectedLog)}
|
|
onClose={() => setSelectedLog(null)}
|
|
maxWidth="md"
|
|
fullWidth
|
|
disableScrollLock
|
|
transitionDuration={{ enter: 120, exit: 0 }}
|
|
>
|
|
<DialogTitle sx={{ px: 3, py: 2.25, pr: 7 }}>
|
|
<Stack spacing={0.25}>
|
|
<Typography variant="h6" fontWeight={800}>
|
|
审计详情
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{selectedLog ? formatDateTime(selectedLog.timestamp) : ""}
|
|
</Typography>
|
|
</Stack>
|
|
<IconButton
|
|
aria-label="关闭"
|
|
onClick={() => setSelectedLog(null)}
|
|
sx={{ position: "absolute", right: 12, top: 12 }}
|
|
>
|
|
<CloseIcon />
|
|
</IconButton>
|
|
</DialogTitle>
|
|
<DialogContent dividers sx={{ bgcolor: "background.default", p: 2.5 }}>
|
|
{selectedLog && (
|
|
<Stack spacing={2.5}>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={(theme) => ({
|
|
p: 2,
|
|
borderRadius: 2,
|
|
bgcolor: alpha(theme.palette.info.main, 0.05),
|
|
borderColor: alpha(theme.palette.info.main, 0.2),
|
|
})}
|
|
>
|
|
<Stack
|
|
direction={{ xs: "column", sm: "row" }}
|
|
spacing={1.5}
|
|
alignItems={{ xs: "flex-start", sm: "center" }}
|
|
justifyContent="space-between"
|
|
>
|
|
<Stack spacing={0.5} sx={{ minWidth: 0 }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
审计动作
|
|
</Typography>
|
|
<Typography variant="h6" fontWeight={800} sx={{ overflowWrap: "anywhere" }}>
|
|
{selectedLog.action}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>
|
|
{selectedLog.request_method ?? "-"} {selectedLog.request_path ?? "-"}
|
|
</Typography>
|
|
</Stack>
|
|
<StatusChip status={selectedLog.response_status} />
|
|
</Stack>
|
|
</Paper>
|
|
|
|
<DetailSection title="审计主体">
|
|
<DetailLine
|
|
label="操作者"
|
|
value={
|
|
selectedLog.user_id
|
|
? userMap.get(selectedLog.user_id)?.username ?? selectedLog.user_id
|
|
: "-"
|
|
}
|
|
/>
|
|
<DetailLine label="用户 ID" value={selectedLog.user_id ?? "-"} />
|
|
<DetailLine
|
|
label="项目"
|
|
value={
|
|
selectedLog.project_id
|
|
? projectMap.get(selectedLog.project_id)?.name ?? selectedLog.project_id
|
|
: "-"
|
|
}
|
|
/>
|
|
<DetailLine label="项目 ID" value={selectedLog.project_id ?? "-"} />
|
|
<DetailLine label="来源 IP" value={selectedLog.ip_address ?? "-"} />
|
|
<DetailLine label="审计 ID" value={selectedLog.id} />
|
|
</DetailSection>
|
|
|
|
<DetailSection title="资源与请求">
|
|
<DetailLine label="资源类型" value={selectedLog.resource_type ?? "-"} />
|
|
<DetailLine label="资源 ID" value={selectedLog.resource_id ?? "-"} />
|
|
<DetailLine label="请求方法" value={selectedLog.request_method ?? "-"} />
|
|
<DetailLine label="请求路径" value={selectedLog.request_path ?? "-"} />
|
|
<DetailLine
|
|
label="响应状态"
|
|
value={<StatusChip status={selectedLog.response_status} />}
|
|
/>
|
|
<DetailLine label="记录时间" value={formatDateTime(selectedLog.timestamp)} />
|
|
</DetailSection>
|
|
|
|
<Paper variant="outlined" sx={{ p: 2, borderRadius: 2 }}>
|
|
<Stack
|
|
direction="row"
|
|
alignItems="center"
|
|
justifyContent="space-between"
|
|
sx={{ mb: 1.5 }}
|
|
>
|
|
<Typography variant="subtitle2" fontWeight={800}>
|
|
请求数据
|
|
</Typography>
|
|
<Chip
|
|
size="small"
|
|
variant="outlined"
|
|
label={selectedLog.request_data ? "JSON" : "空对象"}
|
|
/>
|
|
</Stack>
|
|
<Box
|
|
component="pre"
|
|
sx={{
|
|
p: 2,
|
|
borderRadius: 2,
|
|
bgcolor: "action.hover",
|
|
overflow: "auto",
|
|
fontSize: 13,
|
|
lineHeight: 1.6,
|
|
maxHeight: 320,
|
|
m: 0,
|
|
}}
|
|
>
|
|
{selectedLog.request_data
|
|
? JSON.stringify(selectedLog.request_data, null, 2)
|
|
: "{}"}
|
|
</Box>
|
|
</Paper>
|
|
</Stack>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions sx={{ px: 3, py: 2 }}>
|
|
<Button variant="contained" onClick={() => setSelectedLog(null)}>
|
|
关闭
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|