Files
TJWaterFrontend_Refine/src/components/audit/AuditLogPanel.tsx
T

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>
);
};