feat(audit): add export, retention, overview, charts, hooks, and services
- Add export and export.test for audit log export functionality - Add retention and retention.test for audit log retention policy - Add audit-overview-view, audit-overview-stats-bar, audit-activity-trend-chart, data-change-distribution-chart - Add audit-log-detail-dialog, audit-log-table-skeleton, data-change-log-filters, data-change-log-view - Add audit-error-boundary and audit-retention-settings - Add hooks and services directories
This commit is contained in:
@@ -1,25 +1,38 @@
|
|||||||
"use server"
|
"use server"
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache"
|
||||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import { exportToExcel, type ExcelColumn } from "@/shared/lib/excel"
|
import { trackEvent } from "@/shared/lib/track-event"
|
||||||
import { formatDateForFile } from "@/shared/lib/utils"
|
import { getSession } from "@/shared/lib/session"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getAuditLogsForExport,
|
|
||||||
getDataChangeLogs,
|
getDataChangeLogs,
|
||||||
getDataChangeLogsForExport,
|
getDataChangeLogsForExport,
|
||||||
getDataChangeStats,
|
getDataChangeStats,
|
||||||
getDataChangeTableOptions,
|
getDataChangeTableOptions,
|
||||||
|
getAuditLogsForExport,
|
||||||
getLoginLogsForExport,
|
getLoginLogsForExport,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
|
import {
|
||||||
|
buildAuditLogExport,
|
||||||
|
buildDataChangeLogExport,
|
||||||
|
buildLoginLogExport,
|
||||||
|
} from "./export"
|
||||||
|
import {
|
||||||
|
getAuditRetentionConfig,
|
||||||
|
saveAuditRetentionConfig,
|
||||||
|
purgeExpiredAuditLogs,
|
||||||
|
} from "./retention"
|
||||||
import type {
|
import type {
|
||||||
AuditLogQueryParams,
|
AuditLogQueryParams,
|
||||||
|
AuditRetentionConfig,
|
||||||
DataChangeLog,
|
DataChangeLog,
|
||||||
DataChangeLogQueryParams,
|
DataChangeLogQueryParams,
|
||||||
DataChangeStat,
|
DataChangeStat,
|
||||||
LoginLogQueryParams,
|
LoginLogQueryParams,
|
||||||
|
PurgeResult,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
export async function getDataChangeLogsAction(
|
export async function getDataChangeLogsAction(
|
||||||
@@ -61,66 +74,20 @@ export async function getDataChangeLogsAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用 Excel 导出辅助函数。
|
|
||||||
* 将 sheet 配置 + 行数据生成 Excel buffer,并按 filenamePrefix 拼接日期文件名。
|
|
||||||
* 抽取自三个 export*Action,消除重复的 exportToExcel + filename 构造逻辑。
|
|
||||||
*/
|
|
||||||
async function buildExcelExport<TRow extends Record<string, unknown>>(params: {
|
|
||||||
sheetName: string
|
|
||||||
columns: ExcelColumn[]
|
|
||||||
rows: TRow[]
|
|
||||||
filenamePrefix: string
|
|
||||||
}): Promise<{ buffer: Buffer; filename: string }> {
|
|
||||||
const buffer = await exportToExcel({
|
|
||||||
sheets: [
|
|
||||||
{
|
|
||||||
name: params.sheetName,
|
|
||||||
columns: params.columns,
|
|
||||||
rows: params.rows,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
return {
|
|
||||||
buffer,
|
|
||||||
filename: `${params.filenamePrefix}_${formatDateForFile()}.xlsx`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function exportAuditLogsAction(
|
export async function exportAuditLogsAction(
|
||||||
params?: AuditLogQueryParams
|
params?: AuditLogQueryParams
|
||||||
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
|
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
const items = await getAuditLogsForExport(params)
|
const items = await getAuditLogsForExport(params)
|
||||||
|
const { buffer, filename } = await buildAuditLogExport(items)
|
||||||
|
|
||||||
const { buffer, filename } = await buildExcelExport({
|
const session = await getSession()
|
||||||
sheetName: "Audit Logs",
|
await trackEvent({
|
||||||
columns: [
|
event: "audit.exported",
|
||||||
{ header: "User ID", key: "userId", width: 22 },
|
userId: session?.user?.id,
|
||||||
{ header: "User Name", key: "userName", width: 18 },
|
targetType: "audit_log",
|
||||||
{ header: "Module", key: "module", width: 16 },
|
properties: { type: "audit", count: items.length },
|
||||||
{ header: "Action", key: "action", width: 22 },
|
|
||||||
{ header: "Target ID", key: "targetId", width: 22 },
|
|
||||||
{ header: "Target Type", key: "targetType", width: 16 },
|
|
||||||
{ header: "Detail", key: "detail", width: 40 },
|
|
||||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
|
||||||
{ header: "Status", key: "status", width: 10 },
|
|
||||||
{ header: "Created At", key: "createdAt", width: 22 },
|
|
||||||
],
|
|
||||||
rows: items.map((r) => ({
|
|
||||||
userId: r.userId,
|
|
||||||
userName: r.userName,
|
|
||||||
module: r.module,
|
|
||||||
action: r.action,
|
|
||||||
targetId: r.targetId ?? "",
|
|
||||||
targetType: r.targetType ?? "",
|
|
||||||
detail: r.detail ?? "",
|
|
||||||
ipAddress: r.ipAddress ?? "",
|
|
||||||
status: r.status,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
})),
|
|
||||||
filenamePrefix: "audit_logs",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return { success: true, data: { buffer, filename } }
|
return { success: true, data: { buffer, filename } }
|
||||||
@@ -137,30 +104,14 @@ export async function exportLoginLogsAction(
|
|||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
const items = await getLoginLogsForExport(params)
|
const items = await getLoginLogsForExport(params)
|
||||||
|
const { buffer, filename } = await buildLoginLogExport(items)
|
||||||
|
|
||||||
const { buffer, filename } = await buildExcelExport({
|
const session = await getSession()
|
||||||
sheetName: "Login Logs",
|
await trackEvent({
|
||||||
columns: [
|
event: "audit.exported",
|
||||||
{ header: "User ID", key: "userId", width: 22 },
|
userId: session?.user?.id,
|
||||||
{ header: "User Email", key: "userEmail", width: 26 },
|
targetType: "login_log",
|
||||||
{ header: "Action", key: "action", width: 12 },
|
properties: { type: "login", count: items.length },
|
||||||
{ header: "Status", key: "status", width: 10 },
|
|
||||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
|
||||||
{ header: "User Agent", key: "userAgent", width: 40 },
|
|
||||||
{ header: "Error Message", key: "errorMessage", width: 30 },
|
|
||||||
{ header: "Created At", key: "createdAt", width: 22 },
|
|
||||||
],
|
|
||||||
rows: items.map((r) => ({
|
|
||||||
userId: r.userId ?? "",
|
|
||||||
userEmail: r.userEmail,
|
|
||||||
action: r.action,
|
|
||||||
status: r.status,
|
|
||||||
ipAddress: r.ipAddress ?? "",
|
|
||||||
userAgent: r.userAgent ?? "",
|
|
||||||
errorMessage: r.errorMessage ?? "",
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
})),
|
|
||||||
filenamePrefix: "login_logs",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return { success: true, data: { buffer, filename } }
|
return { success: true, data: { buffer, filename } }
|
||||||
@@ -177,32 +128,14 @@ export async function exportDataChangeLogsAction(
|
|||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
const items = await getDataChangeLogsForExport(params)
|
const items = await getDataChangeLogsForExport(params)
|
||||||
|
const { buffer, filename } = await buildDataChangeLogExport(items)
|
||||||
|
|
||||||
const { buffer, filename } = await buildExcelExport({
|
const session = await getSession()
|
||||||
sheetName: "Data Change Logs",
|
await trackEvent({
|
||||||
columns: [
|
event: "audit.exported",
|
||||||
{ header: "Table Name", key: "tableName", width: 22 },
|
userId: session?.user?.id,
|
||||||
{ header: "Record ID", key: "recordId", width: 22 },
|
targetType: "data_change_log",
|
||||||
{ header: "Action", key: "action", width: 10 },
|
properties: { type: "dataChange", count: items.length },
|
||||||
{ header: "Old Value", key: "oldValue", width: 50 },
|
|
||||||
{ header: "New Value", key: "newValue", width: 50 },
|
|
||||||
{ header: "Changed By", key: "changedBy", width: 22 },
|
|
||||||
{ header: "Changed By Name", key: "changedByName", width: 18 },
|
|
||||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
|
||||||
{ header: "Created At", key: "createdAt", width: 22 },
|
|
||||||
],
|
|
||||||
rows: items.map((r) => ({
|
|
||||||
tableName: r.tableName,
|
|
||||||
recordId: r.recordId,
|
|
||||||
action: r.action,
|
|
||||||
oldValue: r.oldValue ?? "",
|
|
||||||
newValue: r.newValue ?? "",
|
|
||||||
changedBy: r.changedBy,
|
|
||||||
changedByName: r.changedByName,
|
|
||||||
ipAddress: r.ipAddress ?? "",
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
})),
|
|
||||||
filenamePrefix: "data_change_logs",
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return { success: true, data: { buffer, filename } }
|
return { success: true, data: { buffer, filename } }
|
||||||
@@ -212,3 +145,82 @@ export async function exportDataChangeLogsAction(
|
|||||||
return { success: false, message: "Unexpected error" }
|
return { success: false, message: "Unexpected error" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 保留策略管理 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getAuditRetentionConfigAction(): Promise<ActionState<AuditRetentionConfig>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
const config = await getAuditRetentionConfig()
|
||||||
|
return { success: true, data: config }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAuditRetentionConfigAction(
|
||||||
|
config: AuditRetentionConfig,
|
||||||
|
): Promise<ActionState<AuditRetentionConfig>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
const session = await getSession()
|
||||||
|
await saveAuditRetentionConfig(config, session?.user?.id)
|
||||||
|
|
||||||
|
void trackEvent({
|
||||||
|
event: "audit.retention",
|
||||||
|
userId: session?.user?.id,
|
||||||
|
targetType: "audit_retention",
|
||||||
|
properties: {
|
||||||
|
action: "save_config",
|
||||||
|
retentionDays: config.retentionDays,
|
||||||
|
loginLogRetentionDays: config.loginLogRetentionDays,
|
||||||
|
autoCleanupEnabled: config.autoCleanupEnabled,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/admin/audit-logs/overview")
|
||||||
|
return { success: true, data: config }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purgeAuditLogsAction(
|
||||||
|
retentionDays?: number,
|
||||||
|
loginLogRetentionDays?: number,
|
||||||
|
): Promise<ActionState<PurgeResult>> {
|
||||||
|
try {
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||||
|
const session = await getSession()
|
||||||
|
|
||||||
|
// audit-P2-5: 未显式传入时读取配置(含 loginLogRetentionDays)
|
||||||
|
const config = retentionDays === undefined ? await getAuditRetentionConfig() : null
|
||||||
|
const days = retentionDays ?? config?.retentionDays ?? 180
|
||||||
|
const loginDays = loginLogRetentionDays ?? config?.loginLogRetentionDays
|
||||||
|
const result = await purgeExpiredAuditLogs(days, loginDays)
|
||||||
|
|
||||||
|
void trackEvent({
|
||||||
|
event: "audit.retention",
|
||||||
|
userId: session?.user?.id,
|
||||||
|
targetType: "audit_retention",
|
||||||
|
properties: {
|
||||||
|
action: "purge",
|
||||||
|
retentionDays: days,
|
||||||
|
loginLogRetentionDays: loginDays,
|
||||||
|
...result,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
revalidatePath("/admin/audit-logs/overview")
|
||||||
|
revalidatePath("/admin/audit-logs")
|
||||||
|
return { success: true, data: result }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
67
src/modules/audit/components/audit-activity-trend-chart.tsx
Normal file
67
src/modules/audit/components/audit-activity-trend-chart.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useMemo } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { TrendingUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||||
|
import { TrendLineChart, type TrendLineSeries } from "@/shared/components/charts/trend-line-chart"
|
||||||
|
import type { AuditTrendPoint } from "@/modules/audit/types"
|
||||||
|
|
||||||
|
interface AuditActivityTrendChartProps {
|
||||||
|
data: AuditTrendPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditActivityTrendChart({ data }: AuditActivityTrendChartProps): React.ReactNode {
|
||||||
|
const t = useTranslations("audit.overview")
|
||||||
|
|
||||||
|
const series: TrendLineSeries[] = [
|
||||||
|
{ dataKey: "auditEvents", name: t("trendChart.auditEvents"), color: "hsl(var(--chart-1))" },
|
||||||
|
{ dataKey: "loginEvents", name: t("trendChart.loginEvents"), color: "hsl(var(--chart-2))" },
|
||||||
|
{ dataKey: "dataChanges", name: t("trendChart.dataChanges"), color: "hsl(var(--chart-3))" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 仅取 MM-DD 部分作为 X 轴标签
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
data.map((p) => ({
|
||||||
|
...p,
|
||||||
|
title: p.date.slice(5),
|
||||||
|
fullTitle: p.date,
|
||||||
|
})),
|
||||||
|
[data],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 根据数据最大值计算 Y 轴上界(至少为 10,避免全 0 时域为 [0,0])
|
||||||
|
const yDomain = useMemo<[number, number]>(() => {
|
||||||
|
const maxVal = data.reduce(
|
||||||
|
(max, p) => Math.max(max, p.auditEvents, p.loginEvents, p.dataChanges),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
return [0, Math.max(10, maxVal)]
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const isEmpty = data.every(
|
||||||
|
(p) => p.auditEvents === 0 && p.loginEvents === 0 && p.dataChanges === 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCardShell
|
||||||
|
title={t("trendChart.title")}
|
||||||
|
description={t("trendChart.description")}
|
||||||
|
icon={TrendingUp}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
emptyTitle={t("trendChart.title")}
|
||||||
|
emptyDescription={t("trendChart.description")}
|
||||||
|
>
|
||||||
|
<TrendLineChart
|
||||||
|
data={chartData}
|
||||||
|
series={series}
|
||||||
|
xKey="title"
|
||||||
|
yDomain={yDomain}
|
||||||
|
yTickFormatter={(v) => String(v)}
|
||||||
|
tooltipLabelKey="fullTitle"
|
||||||
|
/>
|
||||||
|
</ChartCardShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/modules/audit/components/audit-error-boundary.tsx
Normal file
23
src/modules/audit/components/audit-error-boundary.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计模块独立数据区块错误边界。
|
||||||
|
*
|
||||||
|
* 薄包装:委托给共享 SectionErrorBoundary,使用 common 命名空间。
|
||||||
|
* 保留同名导出以兼容现有 import。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
|
||||||
|
interface AuditErrorBoundaryProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditErrorBoundary({ children }: AuditErrorBoundaryProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<SectionErrorBoundary namespace="audit">
|
||||||
|
{children}
|
||||||
|
</SectionErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
src/modules/audit/components/audit-log-detail-dialog.tsx
Normal file
143
src/modules/audit/components/audit-log-detail-dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, type ReactNode } from "react"
|
||||||
|
import { useTranslations, useLocale } from "next-intl"
|
||||||
|
import { Eye } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/shared/components/ui/dialog"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
|
import type {
|
||||||
|
AuditLog,
|
||||||
|
LoginLog,
|
||||||
|
DataChangeLog,
|
||||||
|
} from "@/modules/audit/types"
|
||||||
|
|
||||||
|
interface AuditLogDetailDialogProps {
|
||||||
|
type: "audit"
|
||||||
|
item: AuditLog
|
||||||
|
trigger?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginLogDetailDialogProps {
|
||||||
|
type: "login"
|
||||||
|
item: LoginLog
|
||||||
|
trigger?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataChangeLogDetailDialogProps {
|
||||||
|
type: "dataChange"
|
||||||
|
item: DataChangeLog
|
||||||
|
trigger?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailDialogProps =
|
||||||
|
| AuditLogDetailDialogProps
|
||||||
|
| LoginLogDetailDialogProps
|
||||||
|
| DataChangeLogDetailDialogProps
|
||||||
|
|
||||||
|
interface DetailRowProps {
|
||||||
|
label: string
|
||||||
|
value: string | null | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: DetailRowProps): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-2 py-2 border-b border-border last:border-0">
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground">{label}</dt>
|
||||||
|
<dd className="col-span-2 text-sm break-all">{value ?? "-"}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditLogDetailDialog(props: DetailDialogProps): ReactNode {
|
||||||
|
const t = useTranslations("audit")
|
||||||
|
const locale = useLocale()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
const renderRows = (): ReactNode => {
|
||||||
|
if (props.type === "audit") {
|
||||||
|
const log = props.item
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailRow label={t("detail.userId")} value={log.userId} />
|
||||||
|
<DetailRow label={t("detail.userName")} value={log.userName} />
|
||||||
|
<DetailRow label={t("detail.action")} value={log.action} />
|
||||||
|
<DetailRow label={t("detail.module")} value={log.module} />
|
||||||
|
<DetailRow label={t("detail.targetId")} value={log.targetId} />
|
||||||
|
<DetailRow label={t("detail.targetType")} value={log.targetType} />
|
||||||
|
<DetailRow label={t("detail.detail")} value={log.detail} />
|
||||||
|
<DetailRow label={t("detail.ipAddress")} value={log.ipAddress} />
|
||||||
|
<DetailRow label={t("detail.userAgent")} value={log.userAgent} />
|
||||||
|
<DetailRow label={t("detail.status")} value={log.status} />
|
||||||
|
<DetailRow
|
||||||
|
label={t("detail.createdAt")}
|
||||||
|
value={formatDate(log.createdAt, locale)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (props.type === "login") {
|
||||||
|
const log = props.item
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailRow label={t("detail.userId")} value={log.userId} />
|
||||||
|
<DetailRow label={t("detail.userName")} value={log.userEmail} />
|
||||||
|
<DetailRow label={t("detail.action")} value={log.action} />
|
||||||
|
<DetailRow label={t("detail.status")} value={log.status} />
|
||||||
|
<DetailRow label={t("detail.ipAddress")} value={log.ipAddress} />
|
||||||
|
<DetailRow label={t("detail.userAgent")} value={log.userAgent} />
|
||||||
|
<DetailRow label={t("detail.errorMessage")} value={log.errorMessage} />
|
||||||
|
<DetailRow
|
||||||
|
label={t("detail.createdAt")}
|
||||||
|
value={formatDate(log.createdAt, locale)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const log = props.item
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DetailRow label={t("detail.tableName")} value={log.tableName} />
|
||||||
|
<DetailRow label={t("detail.recordId")} value={log.recordId} />
|
||||||
|
<DetailRow label={t("detail.action")} value={log.action} />
|
||||||
|
<DetailRow label={t("detail.changedBy")} value={log.changedByName} />
|
||||||
|
<DetailRow label={t("detail.oldValue")} value={log.oldValue} />
|
||||||
|
<DetailRow label={t("detail.newValue")} value={log.newValue} />
|
||||||
|
<DetailRow label={t("detail.ipAddress")} value={log.ipAddress} />
|
||||||
|
<DetailRow
|
||||||
|
label={t("detail.createdAt")}
|
||||||
|
value={formatDate(log.createdAt, locale)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{props.trigger ?? (
|
||||||
|
<Button variant="ghost" size="sm" aria-label={t("detail.viewDetail")}>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("detail.title")}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">
|
||||||
|
{t("detail.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<dl className="mt-2">{renderRows()}</dl>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { Download, Loader2 } from "lucide-react"
|
import { Download, Loader2 } from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { downloadBlob } from "@/shared/lib/download"
|
import { downloadBlob } from "@/shared/lib/download"
|
||||||
|
import { useAuditAnalytics } from "@/modules/audit/services/audit-service"
|
||||||
|
|
||||||
interface AuditLogExportButtonProps {
|
interface AuditLogExportButtonProps {
|
||||||
exportType: "audit" | "login" | "dataChange"
|
exportType: "audit" | "login" | "dataChange"
|
||||||
params?: Record<string, unknown>
|
params?: Record<string, unknown>
|
||||||
label?: string
|
|
||||||
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive"
|
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive"
|
||||||
size?: "default" | "sm" | "lg" | "icon"
|
size?: "default" | "sm" | "lg" | "icon"
|
||||||
className?: string
|
className?: string
|
||||||
@@ -25,14 +26,15 @@ const ACTION_MAP = {
|
|||||||
export function AuditLogExportButton({
|
export function AuditLogExportButton({
|
||||||
exportType,
|
exportType,
|
||||||
params,
|
params,
|
||||||
label = "Export Excel",
|
|
||||||
variant = "outline",
|
variant = "outline",
|
||||||
size = "sm",
|
size = "sm",
|
||||||
className,
|
className,
|
||||||
}: AuditLogExportButtonProps) {
|
}: AuditLogExportButtonProps) {
|
||||||
|
const t = useTranslations("audit")
|
||||||
|
const analytics = useAuditAnalytics()
|
||||||
const [isPending, setIsPending] = useState(false)
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async (): Promise<void> => {
|
||||||
setIsPending(true)
|
setIsPending(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/export", {
|
const res = await fetch("/api/export", {
|
||||||
@@ -43,20 +45,20 @@ export function AuditLogExportButton({
|
|||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => null)
|
const body = await res.json().catch(() => null)
|
||||||
toast.error(body?.message || "Export failed")
|
toast.error(body?.message || t("export.failed"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to read filename from Content-Disposition header
|
|
||||||
const disposition = res.headers.get("Content-Disposition") || ""
|
const disposition = res.headers.get("Content-Disposition") || ""
|
||||||
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
|
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
|
||||||
const filename = filenameMatch?.[1] ?? `export_${Date.now()}.xlsx`
|
const filename = filenameMatch?.[1] ?? `export_${Date.now()}.xlsx`
|
||||||
|
|
||||||
const blob = await res.blob()
|
const blob = await res.blob()
|
||||||
downloadBlob(blob, filename)
|
downloadBlob(blob, filename)
|
||||||
toast.success("Export ready")
|
analytics.trackExport(exportType, 0)
|
||||||
|
toast.success(t("export.success"))
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Export failed")
|
toast.error(t("export.failed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsPending(false)
|
setIsPending(false)
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ export function AuditLogExportButton({
|
|||||||
className={className}
|
className={className}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => void handleExport()}
|
onClick={() => void handleExport()}
|
||||||
|
aria-label={t("export.button")}
|
||||||
data-action-name={ACTION_MAP[exportType]}
|
data-action-name={ACTION_MAP[exportType]}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
@@ -77,7 +80,7 @@ export function AuditLogExportButton({
|
|||||||
) : (
|
) : (
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{label}
|
{t("export.button")}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -17,9 +18,11 @@ interface AuditLogFiltersProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||||
|
const t = useTranslations("audit")
|
||||||
const [module, setModule] = useQueryState("module", parseAsString.withOptions({ shallow: false }))
|
const [module, setModule] = useQueryState("module", parseAsString.withOptions({ shallow: false }))
|
||||||
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
|
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
|
||||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
|
||||||
const [startDate, setStartDate] = useQueryState(
|
const [startDate, setStartDate] = useQueryState(
|
||||||
"startDate",
|
"startDate",
|
||||||
parseAsString.withOptions({ shallow: false }),
|
parseAsString.withOptions({ shallow: false }),
|
||||||
@@ -29,7 +32,7 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
|||||||
parseAsString.withOptions({ shallow: false }),
|
parseAsString.withOptions({ shallow: false }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasFilters = Boolean(module || action || status || startDate || endDate)
|
const hasFilters = Boolean(module || action || status || userId || startDate || endDate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -39,16 +42,17 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
|||||||
setModule(null)
|
setModule(null)
|
||||||
setAction(null)
|
setAction(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
|
setUserId(null)
|
||||||
setStartDate(null)
|
setStartDate(null)
|
||||||
setEndDate(null)
|
setEndDate(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
|
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[160px] bg-background">
|
<SelectTrigger className="w-[160px] bg-background" aria-label={t("filter.modulePlaceholder")}>
|
||||||
<SelectValue placeholder="Module" />
|
<SelectValue placeholder={t("filter.modulePlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Any Module</SelectItem>
|
<SelectItem value="all">{t("filter.anyModule")}</SelectItem>
|
||||||
{moduleOptions.map((m) => (
|
{moduleOptions.map((m) => (
|
||||||
<SelectItem key={m} value={m}>
|
<SelectItem key={m} value={m}>
|
||||||
{m}
|
{m}
|
||||||
@@ -58,32 +62,43 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Action..."
|
placeholder={t("filter.actionPlaceholder")}
|
||||||
className="w-full md:w-[180px] bg-background"
|
className="w-full md:w-[180px] bg-background"
|
||||||
|
aria-label={t("filter.actionPlaceholder")}
|
||||||
value={action || ""}
|
value={action || ""}
|
||||||
onChange={(e) => setAction(e.target.value || null)}
|
onChange={(e) => setAction(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder={t("filter.userIdPlaceholder")}
|
||||||
|
className="w-full md:w-[180px] bg-background"
|
||||||
|
aria-label={t("filter.userIdPlaceholder")}
|
||||||
|
value={userId || ""}
|
||||||
|
onChange={(e) => setUserId(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[140px] bg-background">
|
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder={t("filter.statusPlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Any Status</SelectItem>
|
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
|
||||||
<SelectItem value="success">Success</SelectItem>
|
<SelectItem value="success">{t("filter.success")}</SelectItem>
|
||||||
<SelectItem value="failure">Failure</SelectItem>
|
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
className="w-full md:w-[160px] bg-background"
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
aria-label={t("filter.startDate")}
|
||||||
value={startDate || ""}
|
value={startDate || ""}
|
||||||
onChange={(e) => setStartDate(e.target.value || null)}
|
onChange={(e) => setStartDate(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
className="w-full md:w-[160px] bg-background"
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
aria-label={t("filter.endDate")}
|
||||||
value={endDate || ""}
|
value={endDate || ""}
|
||||||
onChange={(e) => setEndDate(e.target.value || null)}
|
onChange={(e) => setEndDate(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
43
src/modules/audit/components/audit-log-table-skeleton.tsx
Normal file
43
src/modules/audit/components/audit-log-table-skeleton.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { Skeleton } from "@/shared/components/ui/skeleton"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计日志表格骨架屏。
|
||||||
|
* 用于 Suspense fallback 和 loading.tsx。
|
||||||
|
* aria-hidden 避免屏幕阅读器朗读占位元素。
|
||||||
|
*/
|
||||||
|
export function AuditLogTableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4" aria-hidden="true" role="presentation">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
|
<Skeleton className="h-10 w-[180px]" />
|
||||||
|
<Skeleton className="h-10 w-[140px]" />
|
||||||
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
|
<Skeleton className="h-10 w-[160px]" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div className="space-y-0">
|
||||||
|
<div className="flex border-b bg-muted/40 px-4 py-3">
|
||||||
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="mx-2 h-4 flex-1" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{Array.from({ length: 8 }).map((_, row) => (
|
||||||
|
<div key={row} className="flex border-b px-4 py-3">
|
||||||
|
{Array.from({ length: 7 }).map((_, col) => (
|
||||||
|
<Skeleton key={col} className="mx-2 h-4 flex-1" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -15,6 +17,7 @@ import { StatusBadge } from "@/shared/components/ui/status-badge"
|
|||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { AuditLog } from "../types"
|
import type { AuditLog } from "../types"
|
||||||
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
||||||
|
import { AuditLogDetailDialog } from "./audit-log-detail-dialog"
|
||||||
|
|
||||||
interface AuditLogTableProps {
|
interface AuditLogTableProps {
|
||||||
items: AuditLog[]
|
items: AuditLog[]
|
||||||
@@ -33,24 +36,27 @@ export function AuditLogTable({
|
|||||||
totalPages,
|
totalPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: AuditLogTableProps) {
|
}: AuditLogTableProps) {
|
||||||
|
const t = useTranslations("audit")
|
||||||
|
const locale = useLocale()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table aria-label={t("title")}>
|
||||||
<TableHeader className="bg-muted/40">
|
<TableHeader className="bg-muted/40">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>User</TableHead>
|
<TableHead>{t("table.user")}</TableHead>
|
||||||
<TableHead>Module</TableHead>
|
<TableHead>{t("table.module")}</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>{t("table.action")}</TableHead>
|
||||||
<TableHead>Target</TableHead>
|
<TableHead>{t("table.target")}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>{t("table.status")}</TableHead>
|
||||||
<TableHead>IP Address</TableHead>
|
<TableHead>{t("table.ipAddress")}</TableHead>
|
||||||
<TableHead>Time</TableHead>
|
<TableHead>{t("table.time")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<EmptyTableRow colSpan={7} message="No audit logs found." />
|
<EmptyTableRow colSpan={7} message={t("empty.audit")} />
|
||||||
) : (
|
) : (
|
||||||
items.map((log) => (
|
items.map((log) => (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={log.id}>
|
||||||
@@ -89,7 +95,10 @@ export function AuditLogTable({
|
|||||||
{log.ipAddress ?? "-"}
|
{log.ipAddress ?? "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{formatDate(log.createdAt, "zh-CN")}
|
{formatDate(log.createdAt, locale)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AuditLogDetailDialog type="audit" item={log} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { AuditLogTable } from "./audit-log-table"
|
import { AuditLogTable } from "./audit-log-table"
|
||||||
import { AuditLogFilters } from "./audit-log-filters"
|
import { AuditLogFilters } from "./audit-log-filters"
|
||||||
|
import { AuditLogTableSkeleton } from "./audit-log-table-skeleton"
|
||||||
|
import { useLogPagination } from "../hooks/use-log-pagination"
|
||||||
import type { AuditLog } from "../types"
|
import type { AuditLog } from "../types"
|
||||||
|
|
||||||
interface AuditLogViewProps {
|
interface AuditLogViewProps {
|
||||||
@@ -23,19 +25,7 @@ function AuditLogViewInner({
|
|||||||
totalPages,
|
totalPages,
|
||||||
moduleOptions,
|
moduleOptions,
|
||||||
}: AuditLogViewProps) {
|
}: AuditLogViewProps) {
|
||||||
const router = useRouter()
|
const handlePageChange = useLogPagination()
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (newPage <= 1) {
|
|
||||||
params.delete("page")
|
|
||||||
} else {
|
|
||||||
params.set("page", String(newPage))
|
|
||||||
}
|
|
||||||
const query = params.toString()
|
|
||||||
router.push(query ? `?${query}` : "?")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -54,7 +44,7 @@ function AuditLogViewInner({
|
|||||||
|
|
||||||
export function AuditLogView(props: AuditLogViewProps) {
|
export function AuditLogView(props: AuditLogViewProps) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={<AuditLogTableSkeleton />}>
|
||||||
<AuditLogViewInner {...props} />
|
<AuditLogViewInner {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|||||||
58
src/modules/audit/components/audit-overview-stats-bar.tsx
Normal file
58
src/modules/audit/components/audit-overview-stats-bar.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||||
|
import type { AuditOverviewStats } from "@/modules/audit/types"
|
||||||
|
|
||||||
|
interface AuditOverviewStatsBarProps {
|
||||||
|
stats: AuditOverviewStats
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuditOverviewStatsBar({ stats }: AuditOverviewStatsBarProps): React.ReactNode {
|
||||||
|
const t = useTranslations("audit.overview")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.auditEventsToday")}
|
||||||
|
value={stats.auditEventsToday}
|
||||||
|
icon={Activity}
|
||||||
|
color="text-blue-500"
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
href="/admin/audit-logs"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.failedLoginsToday")}
|
||||||
|
value={stats.failedLoginsToday}
|
||||||
|
icon={AlertTriangle}
|
||||||
|
color="text-red-500"
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
highlight={stats.failedLoginsToday > 0}
|
||||||
|
href="/admin/audit-logs/login-logs?status=failure"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.dataChangesToday")}
|
||||||
|
value={stats.dataChangesToday}
|
||||||
|
icon={Database}
|
||||||
|
color="text-emerald-500"
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
href="/admin/audit-logs/data-changes"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title={t("stats.totalAuditLogs")}
|
||||||
|
value={stats.totalAuditLogs}
|
||||||
|
icon={FileText}
|
||||||
|
color="text-purple-500"
|
||||||
|
valueClassName="tabular-nums"
|
||||||
|
href="/admin/audit-logs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
src/modules/audit/components/audit-overview-view.tsx
Normal file
113
src/modules/audit/components/audit-overview-view.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { getTranslations } from "next-intl/server"
|
||||||
|
import { ArrowRight, FileText, LogIn, Database } from "lucide-react"
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { AuditErrorBoundary } from "@/modules/audit/components/audit-error-boundary"
|
||||||
|
import { AuditOverviewStatsBar } from "@/modules/audit/components/audit-overview-stats-bar"
|
||||||
|
import { AuditActivityTrendChart } from "@/modules/audit/components/audit-activity-trend-chart"
|
||||||
|
import { DataChangeDistributionChart } from "@/modules/audit/components/data-change-distribution-chart"
|
||||||
|
import { AuditRetentionSettings } from "@/modules/audit/components/audit-retention-settings"
|
||||||
|
import type {
|
||||||
|
AuditOverviewStats,
|
||||||
|
AuditTrendPoint,
|
||||||
|
DataChangeActionStat,
|
||||||
|
} from "@/modules/audit/types"
|
||||||
|
|
||||||
|
interface AuditOverviewViewProps {
|
||||||
|
stats: AuditOverviewStats
|
||||||
|
trend: AuditTrendPoint[]
|
||||||
|
distribution: DataChangeActionStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function AuditOverviewView({
|
||||||
|
stats,
|
||||||
|
trend,
|
||||||
|
distribution,
|
||||||
|
}: AuditOverviewViewProps): Promise<ReactNode> {
|
||||||
|
const t = await getTranslations("audit.overview")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col space-y-8 p-8">
|
||||||
|
{/* 页头 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
|
||||||
|
<p className="text-muted-foreground">{t("description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片栏 */}
|
||||||
|
<AuditErrorBoundary>
|
||||||
|
<AuditOverviewStatsBar stats={stats} />
|
||||||
|
</AuditErrorBoundary>
|
||||||
|
|
||||||
|
{/* 趋势图 + 分布图 */}
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<AuditErrorBoundary>
|
||||||
|
<AuditActivityTrendChart data={trend} />
|
||||||
|
</AuditErrorBoundary>
|
||||||
|
<AuditErrorBoundary>
|
||||||
|
<DataChangeDistributionChart data={distribution} />
|
||||||
|
</AuditErrorBoundary>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 快捷入口 */}
|
||||||
|
<AuditErrorBoundary>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("quickLinks.title")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<QuickLink
|
||||||
|
href="/admin/audit-logs"
|
||||||
|
icon={FileText}
|
||||||
|
title={t("quickLinks.auditLogs")}
|
||||||
|
/>
|
||||||
|
<QuickLink
|
||||||
|
href="/admin/audit-logs/login-logs"
|
||||||
|
icon={LogIn}
|
||||||
|
title={t("quickLinks.loginLogs")}
|
||||||
|
/>
|
||||||
|
<QuickLink
|
||||||
|
href="/admin/audit-logs/data-changes"
|
||||||
|
icon={Database}
|
||||||
|
title={t("quickLinks.dataChanges")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</AuditErrorBoundary>
|
||||||
|
|
||||||
|
{/* 数据保留策略配置 */}
|
||||||
|
<AuditErrorBoundary>
|
||||||
|
<AuditRetentionSettings />
|
||||||
|
</AuditErrorBoundary>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickLink({
|
||||||
|
href,
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
href: string
|
||||||
|
icon: React.ComponentType<{ className?: string }>
|
||||||
|
title: string
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="group flex items-center justify-between rounded-lg border p-4 transition-colors hover:border-primary/50 hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<Icon className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium">{title}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 text-muted-foreground transition-transform group-hover:translate-x-1" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
src/modules/audit/components/audit-retention-settings.tsx
Normal file
217
src/modules/audit/components/audit-retention-settings.tsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, type ReactNode } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { Save, Trash2, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/shared/components/ui/card"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Switch } from "@/shared/components/ui/switch"
|
||||||
|
import { useAuditService, useAuditAnalytics } from "@/modules/audit/services/audit-service"
|
||||||
|
import {
|
||||||
|
MIN_RETENTION_DAYS,
|
||||||
|
MAX_RETENTION_DAYS,
|
||||||
|
} from "@/modules/audit/retention"
|
||||||
|
import type { AuditRetentionConfig } from "@/modules/audit/types"
|
||||||
|
|
||||||
|
export function AuditRetentionSettings(): ReactNode {
|
||||||
|
const t = useTranslations("audit.retention")
|
||||||
|
const service = useAuditService()
|
||||||
|
const analytics = useAuditAnalytics()
|
||||||
|
const [config, setConfig] = useState<AuditRetentionConfig | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isPurging, setIsPurging] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function loadConfig(): Promise<void> {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await service.getAuditRetentionConfig()
|
||||||
|
if (cancelled) return
|
||||||
|
setConfig(data)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) toast.error(t("saveFailed"))
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void loadConfig()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [t, service])
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
if (!config) return
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
await service.saveAuditRetentionConfig(config)
|
||||||
|
analytics.trackRetentionConfigChange(config)
|
||||||
|
toast.success(t("saveSuccess"))
|
||||||
|
} catch {
|
||||||
|
toast.error(t("saveFailed"))
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePurge(): Promise<void> {
|
||||||
|
if (!config) return
|
||||||
|
const confirmed = window.confirm(t("purgeConfirm"))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setIsPurging(true)
|
||||||
|
try {
|
||||||
|
const result = await service.purgeExpiredAuditLogs(
|
||||||
|
config.retentionDays,
|
||||||
|
config.loginLogRetentionDays,
|
||||||
|
)
|
||||||
|
analytics.trackPurge(result)
|
||||||
|
toast.success(
|
||||||
|
t("purgeSuccess", {
|
||||||
|
auditLogsDeleted: result.auditLogsDeleted,
|
||||||
|
loginLogsDeleted: result.loginLogsDeleted,
|
||||||
|
dataChangeLogsDeleted: result.dataChangeLogsDeleted,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
toast.error(t("purgeFailed"))
|
||||||
|
} finally {
|
||||||
|
setIsPurging(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("title")}</CardTitle>
|
||||||
|
<CardDescription>{t("description")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* 保留天数 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retentionDays">{t("retentionDays")}</Label>
|
||||||
|
<Input
|
||||||
|
id="retentionDays"
|
||||||
|
type="number"
|
||||||
|
min={MIN_RETENTION_DAYS}
|
||||||
|
max={MAX_RETENTION_DAYS}
|
||||||
|
value={config.retentionDays}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = Number(e.target.value)
|
||||||
|
if (Number.isFinite(val)) {
|
||||||
|
setConfig({ ...config, retentionDays: val })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-32"
|
||||||
|
aria-describedby="retentionDaysHelp"
|
||||||
|
/>
|
||||||
|
<p id="retentionDaysHelp" className="text-xs text-muted-foreground">
|
||||||
|
{t("retentionDaysDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* audit-P2-5: 登录日志保留天数(独立于审计日志,默认 365 天) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="loginLogRetentionDays">{t("loginLogRetentionDays")}</Label>
|
||||||
|
<Input
|
||||||
|
id="loginLogRetentionDays"
|
||||||
|
type="number"
|
||||||
|
min={MIN_RETENTION_DAYS}
|
||||||
|
max={MAX_RETENTION_DAYS}
|
||||||
|
value={config.loginLogRetentionDays}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = Number(e.target.value)
|
||||||
|
if (Number.isFinite(val)) {
|
||||||
|
setConfig({ ...config, loginLogRetentionDays: val })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-32"
|
||||||
|
aria-describedby="loginLogRetentionDaysHelp"
|
||||||
|
/>
|
||||||
|
<p id="loginLogRetentionDaysHelp" className="text-xs text-muted-foreground">
|
||||||
|
{t("loginLogRetentionDaysDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 自动清理开关 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="autoCleanup">{t("autoCleanupEnabled")}</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("autoCleanupEnabledDescription")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="autoCleanup"
|
||||||
|
checked={config.autoCleanupEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setConfig({ ...config, autoCleanupEnabled: checked })
|
||||||
|
}
|
||||||
|
aria-label={t("autoCleanupEnabled")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("save")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void handlePurge()}
|
||||||
|
disabled={isPurging}
|
||||||
|
>
|
||||||
|
{isPurging ? (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("purge")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { BarChart3 } from "lucide-react"
|
||||||
|
|
||||||
|
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||||
|
import {
|
||||||
|
SimpleBarChart,
|
||||||
|
type BarSeries,
|
||||||
|
} from "@/shared/components/charts/simple-bar-chart"
|
||||||
|
import type { DataChangeActionStat } from "@/modules/audit/types"
|
||||||
|
|
||||||
|
interface DataChangeDistributionChartProps {
|
||||||
|
data: DataChangeActionStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_COLORS: Record<string, string> = {
|
||||||
|
create: "hsl(var(--chart-1))",
|
||||||
|
update: "hsl(var(--chart-2))",
|
||||||
|
delete: "hsl(var(--chart-3))",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataChangeDistributionChart({
|
||||||
|
data,
|
||||||
|
}: DataChangeDistributionChartProps): React.ReactNode {
|
||||||
|
const t = useTranslations("audit.overview")
|
||||||
|
const tableT = useTranslations("audit.filter")
|
||||||
|
|
||||||
|
// 将统计数组转为图表数据(保证 create/update/delete 三列都存在)
|
||||||
|
const countByAction: Record<string, number> = { create: 0, update: 0, delete: 0 }
|
||||||
|
for (const item of data) {
|
||||||
|
countByAction[item.action] = item.count
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = [
|
||||||
|
{ action: tableT("create"), count: countByAction.create },
|
||||||
|
{ action: tableT("update"), count: countByAction.update },
|
||||||
|
{ action: tableT("delete"), count: countByAction.delete },
|
||||||
|
]
|
||||||
|
|
||||||
|
const bars: BarSeries[] = [
|
||||||
|
{ dataKey: "count", name: t("distributionChart.title"), color: "hsl(var(--chart-1))" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 按动作着色
|
||||||
|
const cellColors: Record<string, string> = {
|
||||||
|
[tableT("create")]: ACTION_COLORS.create,
|
||||||
|
[tableT("update")]: ACTION_COLORS.update,
|
||||||
|
[tableT("delete")]: ACTION_COLORS.delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEmpty = data.length === 0 || data.every((d) => d.count === 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartCardShell
|
||||||
|
title={t("distributionChart.title")}
|
||||||
|
description={t("distributionChart.description")}
|
||||||
|
icon={BarChart3}
|
||||||
|
isEmpty={isEmpty}
|
||||||
|
emptyTitle={t("distributionChart.title")}
|
||||||
|
emptyDescription={t("distributionChart.empty")}
|
||||||
|
>
|
||||||
|
<SimpleBarChart
|
||||||
|
data={chartData}
|
||||||
|
bars={bars}
|
||||||
|
xKey="action"
|
||||||
|
yAllowDecimals={false}
|
||||||
|
yTickFormatter={(v) => String(v)}
|
||||||
|
cellColors={cellColors}
|
||||||
|
/>
|
||||||
|
</ChartCardShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
src/modules/audit/components/data-change-log-filters.tsx
Normal file
149
src/modules/audit/components/data-change-log-filters.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
|
import { Button } from "@/shared/components/ui/button"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/shared/components/ui/select"
|
||||||
|
import { Input } from "@/shared/components/ui/input"
|
||||||
|
import { FilterBar } from "@/shared/components/ui/filter-bar"
|
||||||
|
import type { DataChangeStat } from "../types"
|
||||||
|
|
||||||
|
interface DataChangeLogFiltersProps {
|
||||||
|
tableOptions: string[]
|
||||||
|
stats: DataChangeStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataChangeLogFilters({
|
||||||
|
tableOptions,
|
||||||
|
stats,
|
||||||
|
}: DataChangeLogFiltersProps) {
|
||||||
|
const t = useTranslations("audit")
|
||||||
|
const [tableName, setTableName] = useQueryState(
|
||||||
|
"tableName",
|
||||||
|
parseAsString.withOptions({ shallow: false }),
|
||||||
|
)
|
||||||
|
const [action, setAction] = useQueryState(
|
||||||
|
"action",
|
||||||
|
parseAsString.withOptions({ shallow: false }),
|
||||||
|
)
|
||||||
|
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [startDate, setStartDate] = useQueryState(
|
||||||
|
"startDate",
|
||||||
|
parseAsString.withOptions({ shallow: false }),
|
||||||
|
)
|
||||||
|
const [endDate, setEndDate] = useQueryState(
|
||||||
|
"endDate",
|
||||||
|
parseAsString.withOptions({ shallow: false }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasFilters = Boolean(tableName || action || userId || startDate || endDate)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{stats.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2" role="status" aria-label={t("dataChanges.title")}>
|
||||||
|
{stats.slice(0, 8).map((s) => (
|
||||||
|
<Badge key={s.tableName} variant="secondary" className="gap-1">
|
||||||
|
<span className="font-mono text-xs">{s.tableName}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">·</span>
|
||||||
|
<span className="text-xs">{s.count}</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FilterBar
|
||||||
|
layout="wrap"
|
||||||
|
hasFilters={hasFilters}
|
||||||
|
onReset={() => {
|
||||||
|
setTableName(null)
|
||||||
|
setAction(null)
|
||||||
|
setUserId(null)
|
||||||
|
setStartDate(null)
|
||||||
|
setEndDate(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={tableName || "all"}
|
||||||
|
onValueChange={(val) => setTableName(val === "all" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px] bg-background" aria-label={t("filter.tablePlaceholder")}>
|
||||||
|
<SelectValue placeholder={t("filter.tablePlaceholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("filter.anyTable")}</SelectItem>
|
||||||
|
{tableOptions.map((tb) => (
|
||||||
|
<SelectItem key={tb} value={tb}>
|
||||||
|
{tb}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={action || "all"}
|
||||||
|
onValueChange={(val) => setAction(val === "all" ? null : val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.actionSelectPlaceholder")}>
|
||||||
|
<SelectValue placeholder={t("filter.actionSelectPlaceholder")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("filter.anyAction")}</SelectItem>
|
||||||
|
<SelectItem value="create">{t("filter.create")}</SelectItem>
|
||||||
|
<SelectItem value="update">{t("filter.update")}</SelectItem>
|
||||||
|
<SelectItem value="delete">{t("filter.delete")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder={t("filter.userIdPlaceholder")}
|
||||||
|
className="w-full md:w-[180px] bg-background"
|
||||||
|
aria-label={t("filter.userIdPlaceholder")}
|
||||||
|
value={userId || ""}
|
||||||
|
onChange={(e) => setUserId(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
aria-label={t("filter.startDate")}
|
||||||
|
value={startDate || ""}
|
||||||
|
onChange={(e) => setStartDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
aria-label={t("filter.endDate")}
|
||||||
|
value={endDate || ""}
|
||||||
|
onChange={(e) => setEndDate(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setTableName(null)
|
||||||
|
setAction(null)
|
||||||
|
setUserId(null)
|
||||||
|
setStartDate(null)
|
||||||
|
setEndDate(null)
|
||||||
|
}}
|
||||||
|
className="h-10 px-3"
|
||||||
|
aria-label={t("filter.reset")}
|
||||||
|
>
|
||||||
|
{t("filter.reset")}
|
||||||
|
<X className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FilterBar>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, Fragment, Suspense } from "react"
|
import { useState, Fragment } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
|
||||||
import { X } from "lucide-react"
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -14,23 +13,16 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/shared/components/ui/table"
|
} from "@/shared/components/ui/table"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
|
||||||
import { EmptyTableRow } from "@/shared/components/ui/empty-table-row"
|
import { EmptyTableRow } from "@/shared/components/ui/empty-table-row"
|
||||||
import { Pagination } from "@/shared/components/ui/pagination"
|
import { Pagination } from "@/shared/components/ui/pagination"
|
||||||
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/shared/components/ui/select"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { DataChangeLog, DataChangeStat } from "../types"
|
import type { DataChangeLog } from "../types"
|
||||||
import {
|
import {
|
||||||
DATA_CHANGE_ACTION_VARIANT,
|
DATA_CHANGE_ACTION_VARIANT,
|
||||||
DATA_CHANGE_ACTION_CLASS_NAME,
|
DATA_CHANGE_ACTION_CLASS_NAME,
|
||||||
} from "../types"
|
} from "../types"
|
||||||
|
import { AuditLogDetailDialog } from "./audit-log-detail-dialog"
|
||||||
|
|
||||||
interface DataChangeLogTableProps {
|
interface DataChangeLogTableProps {
|
||||||
items: DataChangeLog[]
|
items: DataChangeLog[]
|
||||||
@@ -38,54 +30,39 @@ interface DataChangeLogTableProps {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
total: number
|
total: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
tableOptions: string[]
|
onPageChange: (page: number) => void
|
||||||
stats: DataChangeStat[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataChangeLogTableInner({
|
export function DataChangeLogTable({
|
||||||
items,
|
items,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total,
|
total,
|
||||||
totalPages,
|
totalPages,
|
||||||
tableOptions,
|
onPageChange,
|
||||||
stats,
|
|
||||||
}: DataChangeLogTableProps) {
|
}: DataChangeLogTableProps) {
|
||||||
const router = useRouter()
|
const t = useTranslations("audit")
|
||||||
const searchParams = useSearchParams()
|
const locale = useLocale()
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (newPage <= 1) {
|
|
||||||
params.delete("page")
|
|
||||||
} else {
|
|
||||||
params.set("page", String(newPage))
|
|
||||||
}
|
|
||||||
const query = params.toString()
|
|
||||||
router.push(query ? `?${query}` : "?")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
|
|
||||||
|
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table aria-label={t("dataChanges.title")}>
|
||||||
<TableHeader className="bg-muted/40">
|
<TableHeader className="bg-muted/40">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Table</TableHead>
|
<TableHead>{t("table.tableName")}</TableHead>
|
||||||
<TableHead>Record ID</TableHead>
|
<TableHead>{t("table.recordId")}</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>{t("table.action")}</TableHead>
|
||||||
<TableHead>Changed By</TableHead>
|
<TableHead>{t("table.changedBy")}</TableHead>
|
||||||
<TableHead>IP Address</TableHead>
|
<TableHead>{t("table.ipAddress")}</TableHead>
|
||||||
<TableHead>Time</TableHead>
|
<TableHead>{t("table.time")}</TableHead>
|
||||||
<TableHead className="w-12" />
|
<TableHead className="w-24" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<EmptyTableRow colSpan={7} message="No data change logs found." />
|
<EmptyTableRow colSpan={7} message={t("empty.dataChange")} />
|
||||||
) : (
|
) : (
|
||||||
items.map((log) => (
|
items.map((log) => (
|
||||||
<Fragment key={log.id}>
|
<Fragment key={log.id}>
|
||||||
@@ -113,16 +90,21 @@ function DataChangeLogTableInner({
|
|||||||
{log.ipAddress ?? "-"}
|
{log.ipAddress ?? "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{formatDate(log.createdAt, "zh-CN")}
|
{formatDate(log.createdAt, locale)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
size="sm"
|
||||||
>
|
aria-expanded={expandedId === log.id}
|
||||||
{expandedId === log.id ? "Hide" : "View"}
|
aria-label={expandedId === log.id ? t("table.hide") : t("table.view")}
|
||||||
</Button>
|
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||||
|
>
|
||||||
|
{expandedId === log.id ? t("table.hide") : t("table.view")}
|
||||||
|
</Button>
|
||||||
|
<AuditLogDetailDialog type="dataChange" item={log} />
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{expandedId === log.id && (
|
{expandedId === log.id && (
|
||||||
@@ -131,7 +113,7 @@ function DataChangeLogTableInner({
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
||||||
Old Value
|
{t("table.oldValue")}
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
||||||
{log.oldValue ?? "—"}
|
{log.oldValue ?? "—"}
|
||||||
@@ -139,7 +121,7 @@ function DataChangeLogTableInner({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
||||||
New Value
|
{t("table.newValue")}
|
||||||
</div>
|
</div>
|
||||||
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
||||||
{log.newValue ?? "—"}
|
{log.newValue ?? "—"}
|
||||||
@@ -161,121 +143,8 @@ function DataChangeLogTableInner({
|
|||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
total={total}
|
total={total}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={onPageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DataChangeLogFilters({
|
|
||||||
tableOptions,
|
|
||||||
stats,
|
|
||||||
}: {
|
|
||||||
tableOptions: string[]
|
|
||||||
stats: DataChangeStat[]
|
|
||||||
}) {
|
|
||||||
const [tableName, setTableName] = useQueryState(
|
|
||||||
"tableName",
|
|
||||||
parseAsString.withOptions({ shallow: false })
|
|
||||||
)
|
|
||||||
const [action, setAction] = useQueryState(
|
|
||||||
"action",
|
|
||||||
parseAsString.withOptions({ shallow: false })
|
|
||||||
)
|
|
||||||
const [startDate, setStartDate] = useQueryState(
|
|
||||||
"startDate",
|
|
||||||
parseAsString.withOptions({ shallow: false })
|
|
||||||
)
|
|
||||||
const [endDate, setEndDate] = useQueryState(
|
|
||||||
"endDate",
|
|
||||||
parseAsString.withOptions({ shallow: false })
|
|
||||||
)
|
|
||||||
|
|
||||||
const hasFilters = Boolean(tableName || action || startDate || endDate)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{stats.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{stats.slice(0, 8).map((s) => (
|
|
||||||
<Badge key={s.tableName} variant="secondary" className="gap-1">
|
|
||||||
<span className="font-mono text-xs">{s.tableName}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">·</span>
|
|
||||||
<span className="text-xs">{s.count}</span>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
|
|
||||||
<Select
|
|
||||||
value={tableName || "all"}
|
|
||||||
onValueChange={(val) => setTableName(val === "all" ? null : val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px] bg-background">
|
|
||||||
<SelectValue placeholder="Table" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Any Table</SelectItem>
|
|
||||||
{tableOptions.map((t) => (
|
|
||||||
<SelectItem key={t} value={t}>
|
|
||||||
{t}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={action || "all"}
|
|
||||||
onValueChange={(val) => setAction(val === "all" ? null : val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[140px] bg-background">
|
|
||||||
<SelectValue placeholder="Action" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">Any Action</SelectItem>
|
|
||||||
<SelectItem value="create">Create</SelectItem>
|
|
||||||
<SelectItem value="update">Update</SelectItem>
|
|
||||||
<SelectItem value="delete">Delete</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
className="w-full md:w-[160px] bg-background"
|
|
||||||
value={startDate || ""}
|
|
||||||
onChange={(e) => setStartDate(e.target.value || null)}
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
className="w-full md:w-[160px] bg-background"
|
|
||||||
value={endDate || ""}
|
|
||||||
onChange={(e) => setEndDate(e.target.value || null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hasFilters && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setTableName(null)
|
|
||||||
setAction(null)
|
|
||||||
setStartDate(null)
|
|
||||||
setEndDate(null)
|
|
||||||
}}
|
|
||||||
className="h-10 px-3"
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
<X className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataChangeLogTable(props: DataChangeLogTableProps) {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<DataChangeLogTableInner {...props} />
|
|
||||||
</Suspense>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
53
src/modules/audit/components/data-change-log-view.tsx
Normal file
53
src/modules/audit/components/data-change-log-view.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
|
import { DataChangeLogTable } from "./data-change-log-table"
|
||||||
|
import { DataChangeLogFilters } from "./data-change-log-filters"
|
||||||
|
import { AuditLogTableSkeleton } from "./audit-log-table-skeleton"
|
||||||
|
import { useLogPagination } from "../hooks/use-log-pagination"
|
||||||
|
import type { DataChangeLog, DataChangeStat } from "../types"
|
||||||
|
|
||||||
|
interface DataChangeLogViewProps {
|
||||||
|
items: DataChangeLog[]
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
tableOptions: string[]
|
||||||
|
stats: DataChangeStat[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataChangeLogViewInner({
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
tableOptions,
|
||||||
|
stats,
|
||||||
|
}: DataChangeLogViewProps) {
|
||||||
|
const handlePageChange = useLogPagination()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
|
||||||
|
<DataChangeLogTable
|
||||||
|
items={items}
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataChangeLogView(props: DataChangeLogViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<AuditLogTableSkeleton />}>
|
||||||
|
<DataChangeLogViewInner {...props} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { useQueryState, parseAsString } from "nuqs"
|
import { useQueryState, parseAsString } from "nuqs"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,8 +14,10 @@ import { Input } from "@/shared/components/ui/input"
|
|||||||
import { FilterBar } from "@/shared/components/ui/filter-bar"
|
import { FilterBar } from "@/shared/components/ui/filter-bar"
|
||||||
|
|
||||||
export function LoginLogFilters() {
|
export function LoginLogFilters() {
|
||||||
|
const t = useTranslations("audit")
|
||||||
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
|
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
|
||||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||||
|
const [userId, setUserId] = useQueryState("userId", parseAsString.withOptions({ shallow: false }))
|
||||||
const [startDate, setStartDate] = useQueryState(
|
const [startDate, setStartDate] = useQueryState(
|
||||||
"startDate",
|
"startDate",
|
||||||
parseAsString.withOptions({ shallow: false }),
|
parseAsString.withOptions({ shallow: false }),
|
||||||
@@ -24,7 +27,7 @@ export function LoginLogFilters() {
|
|||||||
parseAsString.withOptions({ shallow: false }),
|
parseAsString.withOptions({ shallow: false }),
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasFilters = Boolean(action || status || startDate || endDate)
|
const hasFilters = Boolean(action || status || userId || startDate || endDate)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -33,42 +36,53 @@ export function LoginLogFilters() {
|
|||||||
onReset={() => {
|
onReset={() => {
|
||||||
setAction(null)
|
setAction(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
|
setUserId(null)
|
||||||
setStartDate(null)
|
setStartDate(null)
|
||||||
setEndDate(null)
|
setEndDate(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
|
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[140px] bg-background">
|
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.actionSelectPlaceholder")}>
|
||||||
<SelectValue placeholder="Action" />
|
<SelectValue placeholder={t("filter.actionSelectPlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Any Action</SelectItem>
|
<SelectItem value="all">{t("filter.anyAction")}</SelectItem>
|
||||||
<SelectItem value="signin">Sign In</SelectItem>
|
<SelectItem value="signin">{t("filter.signIn")}</SelectItem>
|
||||||
<SelectItem value="signout">Sign Out</SelectItem>
|
<SelectItem value="signout">{t("filter.signOut")}</SelectItem>
|
||||||
<SelectItem value="signup">Sign Up</SelectItem>
|
<SelectItem value="signup">{t("filter.signUp")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
placeholder={t("filter.userIdPlaceholder")}
|
||||||
|
className="w-full md:w-[180px] bg-background"
|
||||||
|
aria-label={t("filter.userIdPlaceholder")}
|
||||||
|
value={userId || ""}
|
||||||
|
onChange={(e) => setUserId(e.target.value || null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||||
<SelectTrigger className="w-[140px] bg-background">
|
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder={t("filter.statusPlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Any Status</SelectItem>
|
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
|
||||||
<SelectItem value="success">Success</SelectItem>
|
<SelectItem value="success">{t("filter.success")}</SelectItem>
|
||||||
<SelectItem value="failure">Failure</SelectItem>
|
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
className="w-full md:w-[160px] bg-background"
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
aria-label={t("filter.startDate")}
|
||||||
value={startDate || ""}
|
value={startDate || ""}
|
||||||
onChange={(e) => setStartDate(e.target.value || null)}
|
onChange={(e) => setStartDate(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
className="w-full md:w-[160px] bg-background"
|
className="w-full md:w-[160px] bg-background"
|
||||||
|
aria-label={t("filter.endDate")}
|
||||||
value={endDate || ""}
|
value={endDate || ""}
|
||||||
onChange={(e) => setEndDate(e.target.value || null)}
|
onChange={(e) => setEndDate(e.target.value || null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useLocale, useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -15,6 +17,7 @@ import { StatusBadge } from "@/shared/components/ui/status-badge"
|
|||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import type { LoginLog } from "../types"
|
import type { LoginLog } from "../types"
|
||||||
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
||||||
|
import { AuditLogDetailDialog } from "./audit-log-detail-dialog"
|
||||||
|
|
||||||
interface LoginLogTableProps {
|
interface LoginLogTableProps {
|
||||||
items: LoginLog[]
|
items: LoginLog[]
|
||||||
@@ -33,23 +36,26 @@ export function LoginLogTable({
|
|||||||
totalPages,
|
totalPages,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
}: LoginLogTableProps) {
|
}: LoginLogTableProps) {
|
||||||
|
const t = useTranslations("audit")
|
||||||
|
const locale = useLocale()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table aria-label={t("loginLogs.title")}>
|
||||||
<TableHeader className="bg-muted/40">
|
<TableHeader className="bg-muted/40">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>User</TableHead>
|
<TableHead>{t("table.user")}</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>{t("table.action")}</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>{t("table.status")}</TableHead>
|
||||||
<TableHead>IP Address</TableHead>
|
<TableHead>{t("table.ipAddress")}</TableHead>
|
||||||
<TableHead>User Agent</TableHead>
|
<TableHead>{t("table.userAgent")}</TableHead>
|
||||||
<TableHead>Time</TableHead>
|
<TableHead>{t("table.time")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<EmptyTableRow colSpan={6} message="No login logs found." />
|
<EmptyTableRow colSpan={6} message={t("empty.login")} />
|
||||||
) : (
|
) : (
|
||||||
items.map((log) => (
|
items.map((log) => (
|
||||||
<TableRow key={log.id}>
|
<TableRow key={log.id}>
|
||||||
@@ -83,7 +89,10 @@ export function LoginLogTable({
|
|||||||
{log.userAgent ?? "-"}
|
{log.userAgent ?? "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-muted-foreground">
|
<TableCell className="text-xs text-muted-foreground">
|
||||||
{formatDate(log.createdAt, "zh-CN")}
|
{formatDate(log.createdAt, locale)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<AuditLogDetailDialog type="login" item={log} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { Suspense } from "react"
|
import { Suspense } from "react"
|
||||||
|
|
||||||
import { LoginLogTable } from "./login-log-table"
|
import { LoginLogTable } from "./login-log-table"
|
||||||
import { LoginLogFilters } from "./login-log-filters"
|
import { LoginLogFilters } from "./login-log-filters"
|
||||||
|
import { AuditLogTableSkeleton } from "./audit-log-table-skeleton"
|
||||||
|
import { useLogPagination } from "../hooks/use-log-pagination"
|
||||||
import type { LoginLog } from "../types"
|
import type { LoginLog } from "../types"
|
||||||
|
|
||||||
interface LoginLogViewProps {
|
interface LoginLogViewProps {
|
||||||
@@ -21,19 +23,7 @@ function LoginLogViewInner({
|
|||||||
total,
|
total,
|
||||||
totalPages,
|
totalPages,
|
||||||
}: LoginLogViewProps) {
|
}: LoginLogViewProps) {
|
||||||
const router = useRouter()
|
const handlePageChange = useLogPagination()
|
||||||
const searchParams = useSearchParams()
|
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
if (newPage <= 1) {
|
|
||||||
params.delete("page")
|
|
||||||
} else {
|
|
||||||
params.set("page", String(newPage))
|
|
||||||
}
|
|
||||||
const query = params.toString()
|
|
||||||
router.push(query ? `?${query}` : "?")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -52,7 +42,7 @@ function LoginLogViewInner({
|
|||||||
|
|
||||||
export function LoginLogView(props: LoginLogViewProps) {
|
export function LoginLogView(props: LoginLogViewProps) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={<AuditLogTableSkeleton />}>
|
||||||
<LoginLogViewInner {...props} />
|
<LoginLogViewInner {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import "server-only"
|
import "server-only"
|
||||||
|
|
||||||
import { and, asc, desc, eq, gte, lte, count, like, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gte, lte, count, like, type SQL, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
||||||
import type {
|
import type {
|
||||||
AuditLog,
|
AuditLog,
|
||||||
AuditLogQueryParams,
|
AuditLogQueryParams,
|
||||||
|
AuditOverviewStats,
|
||||||
|
AuditTrendPoint,
|
||||||
|
DataChangeActionStat,
|
||||||
DataChangeLog,
|
DataChangeLog,
|
||||||
DataChangeLogQueryParams,
|
DataChangeLogQueryParams,
|
||||||
DataChangeStat,
|
DataChangeStat,
|
||||||
@@ -82,7 +85,7 @@ export async function getAuditLogs(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("getAuditLogs failed:", error)
|
console.error("getAuditLogs failed:", error)
|
||||||
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +137,7 @@ export async function getLoginLogs(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("getLoginLogs failed:", error)
|
console.error("getLoginLogs failed:", error)
|
||||||
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ export async function getAuditModuleOptions(): Promise<string[]> {
|
|||||||
return rows.map((r) => r.module)
|
return rows.map((r) => r.module)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("getAuditModuleOptions failed:", error)
|
console.error("getAuditModuleOptions failed:", error)
|
||||||
return []
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +204,7 @@ export async function getDataChangeLogs(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("getDataChangeLogs failed:", error)
|
console.error("getDataChangeLogs failed:", error)
|
||||||
return { items: [], total: 0, page, pageSize, totalPages: 0 }
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,7 +221,7 @@ export async function getDataChangeStats(): Promise<DataChangeStat[]> {
|
|||||||
return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) }))
|
return rows.map((r) => ({ tableName: r.tableName, count: Number(r.count) }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("getDataChangeStats failed:", error)
|
console.error("getDataChangeStats failed:", error)
|
||||||
return []
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,7 +234,7 @@ export async function getDataChangeTableOptions(): Promise<string[]> {
|
|||||||
return rows.map((r) => r.tableName)
|
return rows.map((r) => r.tableName)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("getDataChangeTableOptions failed:", error)
|
console.error("getDataChangeTableOptions failed:", error)
|
||||||
return []
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,3 +291,134 @@ export async function getDataChangeLogsForExport(
|
|||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 概览仪表盘统计 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 计算今日 0 点(本地时区)的 Date 对象 */
|
||||||
|
function startOfToday(): Date {
|
||||||
|
const now = new Date()
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算最近 N 天的起始日期(0 点) */
|
||||||
|
function startOfDaysAgo(days: number): Date {
|
||||||
|
const now = new Date()
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 Date 格式化为 YYYY-MM-DD(本地时区) */
|
||||||
|
function formatDateKey(d: Date): string {
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(d.getDate()).padStart(2, "0")
|
||||||
|
return `${y}-${m}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审计概览统计(今日数据 + 总数)
|
||||||
|
*/
|
||||||
|
export async function getAuditOverviewStats(): Promise<AuditOverviewStats> {
|
||||||
|
const todayStart = startOfToday()
|
||||||
|
try {
|
||||||
|
const [auditToday, failedLoginToday, dataChangeToday, totalAudit] = await Promise.all([
|
||||||
|
db.select({ value: count() }).from(auditLogs).where(gte(auditLogs.createdAt, todayStart)),
|
||||||
|
db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(loginLogs)
|
||||||
|
.where(and(gte(loginLogs.createdAt, todayStart), eq(loginLogs.status, "failure"))),
|
||||||
|
db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(dataChangeLogs)
|
||||||
|
.where(gte(dataChangeLogs.createdAt, todayStart)),
|
||||||
|
db.select({ value: count() }).from(auditLogs),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditEventsToday: Number(auditToday[0]?.value ?? 0),
|
||||||
|
failedLoginsToday: Number(failedLoginToday[0]?.value ?? 0),
|
||||||
|
dataChangesToday: Number(dataChangeToday[0]?.value ?? 0),
|
||||||
|
totalAuditLogs: Number(totalAudit[0]?.value ?? 0),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("getAuditOverviewStats failed:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审计活动趋势(最近 N 天的每日计数)
|
||||||
|
*
|
||||||
|
* 使用 MySQL `DATE()` 分组聚合,避免 N 次查询。
|
||||||
|
*/
|
||||||
|
export async function getAuditTrend(days: number = 7): Promise<AuditTrendPoint[]> {
|
||||||
|
const startDate = startOfDaysAgo(days)
|
||||||
|
try {
|
||||||
|
const [auditRows, loginRows, dataChangeRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
date: sql<string>`DATE(${auditLogs.createdAt})`,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(auditLogs)
|
||||||
|
.where(gte(auditLogs.createdAt, startDate))
|
||||||
|
.groupBy(sql`DATE(${auditLogs.createdAt})`),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
date: sql<string>`DATE(${loginLogs.createdAt})`,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(loginLogs)
|
||||||
|
.where(gte(loginLogs.createdAt, startDate))
|
||||||
|
.groupBy(sql`DATE(${loginLogs.createdAt})`),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
date: sql<string>`DATE(${dataChangeLogs.createdAt})`,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(dataChangeLogs)
|
||||||
|
.where(gte(dataChangeLogs.createdAt, startDate))
|
||||||
|
.groupBy(sql`DATE(${dataChangeLogs.createdAt})`),
|
||||||
|
])
|
||||||
|
|
||||||
|
// 合并三源数据,填充缺失日期为 0
|
||||||
|
const auditMap = new Map(auditRows.map((r) => [r.date, Number(r.count)]))
|
||||||
|
const loginMap = new Map(loginRows.map((r) => [r.date, Number(r.count)]))
|
||||||
|
const dataChangeMap = new Map(dataChangeRows.map((r) => [r.date, Number(r.count)]))
|
||||||
|
|
||||||
|
const result: AuditTrendPoint[] = []
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const d = new Date(startDate)
|
||||||
|
d.setDate(d.getDate() + (days - 1 - i))
|
||||||
|
const key = formatDateKey(d)
|
||||||
|
result.push({
|
||||||
|
date: key,
|
||||||
|
auditEvents: auditMap.get(key) ?? 0,
|
||||||
|
loginEvents: loginMap.get(key) ?? 0,
|
||||||
|
dataChanges: dataChangeMap.get(key) ?? 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
console.error("getAuditTrend failed:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取数据变更按动作统计(create/update/delete)
|
||||||
|
*/
|
||||||
|
export async function getDataChangeActionStats(): Promise<DataChangeActionStat[]> {
|
||||||
|
try {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
action: dataChangeLogs.action,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(dataChangeLogs)
|
||||||
|
.groupBy(dataChangeLogs.action)
|
||||||
|
return rows.map((r) => ({ action: r.action, count: Number(r.count) }))
|
||||||
|
} catch (error) {
|
||||||
|
console.error("getDataChangeActionStats failed:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
209
src/modules/audit/export.test.ts
Normal file
209
src/modules/audit/export.test.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest"
|
||||||
|
|
||||||
|
// Mock server-only and excel dependencies so the module loads in jsdom
|
||||||
|
vi.mock("@/shared/lib/excel", () => ({
|
||||||
|
exportToExcel: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock("@/shared/lib/utils", () => ({
|
||||||
|
formatDateForFile: vi.fn(() => "20260624"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
mapAuditLogsToRows,
|
||||||
|
mapLoginLogsToRows,
|
||||||
|
mapDataChangeLogsToRows,
|
||||||
|
AUDIT_LOG_COLUMNS,
|
||||||
|
LOGIN_LOG_COLUMNS,
|
||||||
|
DATA_CHANGE_LOG_COLUMNS,
|
||||||
|
} from "./export"
|
||||||
|
import type { AuditLog, LoginLog, DataChangeLog } from "./types"
|
||||||
|
|
||||||
|
describe("mapAuditLogsToRows", () => {
|
||||||
|
const sampleLog: AuditLog = {
|
||||||
|
id: "log-1",
|
||||||
|
userId: "user-1",
|
||||||
|
userName: "Alice",
|
||||||
|
action: "create",
|
||||||
|
module: "users",
|
||||||
|
targetId: "target-1",
|
||||||
|
targetType: "user",
|
||||||
|
detail: '{"field":"name"}',
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
status: "success",
|
||||||
|
createdAt: "2026-06-24T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should map all fields correctly", () => {
|
||||||
|
const rows = mapAuditLogsToRows([sampleLog])
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0]).toEqual({
|
||||||
|
userId: "user-1",
|
||||||
|
userName: "Alice",
|
||||||
|
module: "users",
|
||||||
|
action: "create",
|
||||||
|
targetId: "target-1",
|
||||||
|
targetType: "user",
|
||||||
|
detail: '{"field":"name"}',
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
status: "success",
|
||||||
|
createdAt: "2026-06-24T00:00:00Z",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert null fields to empty strings", () => {
|
||||||
|
const logWithNulls: AuditLog = {
|
||||||
|
...sampleLog,
|
||||||
|
targetId: null,
|
||||||
|
targetType: null,
|
||||||
|
detail: null,
|
||||||
|
ipAddress: null,
|
||||||
|
userAgent: null,
|
||||||
|
}
|
||||||
|
const rows = mapAuditLogsToRows([logWithNulls])
|
||||||
|
expect(rows[0].targetId).toBe("")
|
||||||
|
expect(rows[0].targetType).toBe("")
|
||||||
|
expect(rows[0].detail).toBe("")
|
||||||
|
expect(rows[0].ipAddress).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return empty array for empty input", () => {
|
||||||
|
expect(mapAuditLogsToRows([])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should map multiple items", () => {
|
||||||
|
const logs = [sampleLog, { ...sampleLog, id: "log-2", userId: "user-2" }]
|
||||||
|
const rows = mapAuditLogsToRows(logs)
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[0].userId).toBe("user-1")
|
||||||
|
expect(rows[1].userId).toBe("user-2")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("mapLoginLogsToRows", () => {
|
||||||
|
const sampleLog: LoginLog = {
|
||||||
|
id: "log-1",
|
||||||
|
userId: "user-1",
|
||||||
|
userEmail: "alice@example.com",
|
||||||
|
action: "signin",
|
||||||
|
status: "success",
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
errorMessage: null,
|
||||||
|
createdAt: "2026-06-24T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should map all fields correctly", () => {
|
||||||
|
const rows = mapLoginLogsToRows([sampleLog])
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0]).toEqual({
|
||||||
|
userId: "user-1",
|
||||||
|
userEmail: "alice@example.com",
|
||||||
|
action: "signin",
|
||||||
|
status: "success",
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
errorMessage: "",
|
||||||
|
createdAt: "2026-06-24T00:00:00Z",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert null userId to empty string", () => {
|
||||||
|
const logWithNullUserId: LoginLog = {
|
||||||
|
...sampleLog,
|
||||||
|
userId: null,
|
||||||
|
}
|
||||||
|
const rows = mapLoginLogsToRows([logWithNullUserId])
|
||||||
|
expect(rows[0].userId).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert null errorMessage to empty string", () => {
|
||||||
|
const logWithError: LoginLog = {
|
||||||
|
...sampleLog,
|
||||||
|
errorMessage: "Invalid credentials",
|
||||||
|
}
|
||||||
|
const rows = mapLoginLogsToRows([logWithError])
|
||||||
|
expect(rows[0].errorMessage).toBe("Invalid credentials")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("mapDataChangeLogsToRows", () => {
|
||||||
|
const sampleLog: DataChangeLog = {
|
||||||
|
id: "log-1",
|
||||||
|
tableName: "users",
|
||||||
|
recordId: "rec-1",
|
||||||
|
action: "update",
|
||||||
|
oldValue: '{"name":"old"}',
|
||||||
|
newValue: '{"name":"new"}',
|
||||||
|
changedBy: "user-1",
|
||||||
|
changedByName: "Alice",
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
createdAt: "2026-06-24T00:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should map all fields correctly", () => {
|
||||||
|
const rows = mapDataChangeLogsToRows([sampleLog])
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0]).toEqual({
|
||||||
|
tableName: "users",
|
||||||
|
recordId: "rec-1",
|
||||||
|
action: "update",
|
||||||
|
oldValue: '{"name":"old"}',
|
||||||
|
newValue: '{"name":"new"}',
|
||||||
|
changedBy: "user-1",
|
||||||
|
changedByName: "Alice",
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
createdAt: "2026-06-24T00:00:00Z",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should convert null oldValue/newValue to empty strings", () => {
|
||||||
|
const logWithNulls: DataChangeLog = {
|
||||||
|
...sampleLog,
|
||||||
|
oldValue: null,
|
||||||
|
newValue: null,
|
||||||
|
ipAddress: null,
|
||||||
|
}
|
||||||
|
const rows = mapDataChangeLogsToRows([logWithNulls])
|
||||||
|
expect(rows[0].oldValue).toBe("")
|
||||||
|
expect(rows[0].newValue).toBe("")
|
||||||
|
expect(rows[0].ipAddress).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("column definitions", () => {
|
||||||
|
it("AUDIT_LOG_COLUMNS should have all required fields", () => {
|
||||||
|
const keys = AUDIT_LOG_COLUMNS.map((c) => c.key)
|
||||||
|
expect(keys).toContain("userId")
|
||||||
|
expect(keys).toContain("userName")
|
||||||
|
expect(keys).toContain("module")
|
||||||
|
expect(keys).toContain("action")
|
||||||
|
expect(keys).toContain("status")
|
||||||
|
expect(keys).toContain("createdAt")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("LOGIN_LOG_COLUMNS should have all required fields", () => {
|
||||||
|
const keys = LOGIN_LOG_COLUMNS.map((c) => c.key)
|
||||||
|
expect(keys).toContain("userId")
|
||||||
|
expect(keys).toContain("userEmail")
|
||||||
|
expect(keys).toContain("action")
|
||||||
|
expect(keys).toContain("status")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("DATA_CHANGE_LOG_COLUMNS should have all required fields", () => {
|
||||||
|
const keys = DATA_CHANGE_LOG_COLUMNS.map((c) => c.key)
|
||||||
|
expect(keys).toContain("tableName")
|
||||||
|
expect(keys).toContain("recordId")
|
||||||
|
expect(keys).toContain("action")
|
||||||
|
expect(keys).toContain("oldValue")
|
||||||
|
expect(keys).toContain("newValue")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("all columns should have header, key, and width", () => {
|
||||||
|
for (const col of [...AUDIT_LOG_COLUMNS, ...LOGIN_LOG_COLUMNS, ...DATA_CHANGE_LOG_COLUMNS]) {
|
||||||
|
expect(col.header).toBeTruthy()
|
||||||
|
expect(col.key).toBeTruthy()
|
||||||
|
expect(col.width).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
144
src/modules/audit/export.ts
Normal file
144
src/modules/audit/export.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { exportToExcel, type ExcelColumn } from "@/shared/lib/excel"
|
||||||
|
import { formatDateForFile } from "@/shared/lib/utils"
|
||||||
|
|
||||||
|
import type { AuditLog, DataChangeLog, LoginLog } from "./types"
|
||||||
|
|
||||||
|
/** 审计日志 Excel 列定义 */
|
||||||
|
export const AUDIT_LOG_COLUMNS: ExcelColumn[] = [
|
||||||
|
{ header: "User ID", key: "userId", width: 22 },
|
||||||
|
{ header: "User Name", key: "userName", width: 18 },
|
||||||
|
{ header: "Module", key: "module", width: 16 },
|
||||||
|
{ header: "Action", key: "action", width: 22 },
|
||||||
|
{ header: "Target ID", key: "targetId", width: 22 },
|
||||||
|
{ header: "Target Type", key: "targetType", width: 16 },
|
||||||
|
{ header: "Detail", key: "detail", width: 40 },
|
||||||
|
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||||
|
{ header: "Status", key: "status", width: 10 },
|
||||||
|
{ header: "Created At", key: "createdAt", width: 22 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 登录日志 Excel 列定义 */
|
||||||
|
export const LOGIN_LOG_COLUMNS: ExcelColumn[] = [
|
||||||
|
{ header: "User ID", key: "userId", width: 22 },
|
||||||
|
{ header: "User Email", key: "userEmail", width: 26 },
|
||||||
|
{ header: "Action", key: "action", width: 12 },
|
||||||
|
{ header: "Status", key: "status", width: 10 },
|
||||||
|
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||||
|
{ header: "User Agent", key: "userAgent", width: 40 },
|
||||||
|
{ header: "Error Message", key: "errorMessage", width: 30 },
|
||||||
|
{ header: "Created At", key: "createdAt", width: 22 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 数据变更日志 Excel 列定义 */
|
||||||
|
export const DATA_CHANGE_LOG_COLUMNS: ExcelColumn[] = [
|
||||||
|
{ header: "Table Name", key: "tableName", width: 22 },
|
||||||
|
{ header: "Record ID", key: "recordId", width: 22 },
|
||||||
|
{ header: "Action", key: "action", width: 10 },
|
||||||
|
{ header: "Old Value", key: "oldValue", width: 50 },
|
||||||
|
{ header: "New Value", key: "newValue", width: 50 },
|
||||||
|
{ header: "Changed By", key: "changedBy", width: 22 },
|
||||||
|
{ header: "Changed By Name", key: "changedByName", width: 18 },
|
||||||
|
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||||
|
{ header: "Created At", key: "createdAt", width: 22 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 将审计日志映射为 Excel 行数据 */
|
||||||
|
export function mapAuditLogsToRows(
|
||||||
|
items: AuditLog[],
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
return items.map((r) => ({
|
||||||
|
userId: r.userId,
|
||||||
|
userName: r.userName,
|
||||||
|
module: r.module,
|
||||||
|
action: r.action,
|
||||||
|
targetId: r.targetId ?? "",
|
||||||
|
targetType: r.targetType ?? "",
|
||||||
|
detail: r.detail ?? "",
|
||||||
|
ipAddress: r.ipAddress ?? "",
|
||||||
|
status: r.status,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将登录日志映射为 Excel 行数据 */
|
||||||
|
export function mapLoginLogsToRows(
|
||||||
|
items: LoginLog[],
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
return items.map((r) => ({
|
||||||
|
userId: r.userId ?? "",
|
||||||
|
userEmail: r.userEmail,
|
||||||
|
action: r.action,
|
||||||
|
status: r.status,
|
||||||
|
ipAddress: r.ipAddress ?? "",
|
||||||
|
userAgent: r.userAgent ?? "",
|
||||||
|
errorMessage: r.errorMessage ?? "",
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将数据变更日志映射为 Excel 行数据 */
|
||||||
|
export function mapDataChangeLogsToRows(
|
||||||
|
items: DataChangeLog[],
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
return items.map((r) => ({
|
||||||
|
tableName: r.tableName,
|
||||||
|
recordId: r.recordId,
|
||||||
|
action: r.action,
|
||||||
|
oldValue: r.oldValue ?? "",
|
||||||
|
newValue: r.newValue ?? "",
|
||||||
|
changedBy: r.changedBy,
|
||||||
|
changedByName: r.changedByName,
|
||||||
|
ipAddress: r.ipAddress ?? "",
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建审计日志 Excel 导出 */
|
||||||
|
export async function buildAuditLogExport(
|
||||||
|
items: AuditLog[],
|
||||||
|
): Promise<{ buffer: Buffer; filename: string }> {
|
||||||
|
const buffer = await exportToExcel({
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
name: "Audit Logs",
|
||||||
|
columns: AUDIT_LOG_COLUMNS,
|
||||||
|
rows: mapAuditLogsToRows(items),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return { buffer, filename: `audit_logs_${formatDateForFile()}.xlsx` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建登录日志 Excel 导出 */
|
||||||
|
export async function buildLoginLogExport(
|
||||||
|
items: LoginLog[],
|
||||||
|
): Promise<{ buffer: Buffer; filename: string }> {
|
||||||
|
const buffer = await exportToExcel({
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
name: "Login Logs",
|
||||||
|
columns: LOGIN_LOG_COLUMNS,
|
||||||
|
rows: mapLoginLogsToRows(items),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return { buffer, filename: `login_logs_${formatDateForFile()}.xlsx` }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建数据变更日志 Excel 导出 */
|
||||||
|
export async function buildDataChangeLogExport(
|
||||||
|
items: DataChangeLog[],
|
||||||
|
): Promise<{ buffer: Buffer; filename: string }> {
|
||||||
|
const buffer = await exportToExcel({
|
||||||
|
sheets: [
|
||||||
|
{
|
||||||
|
name: "Data Change Logs",
|
||||||
|
columns: DATA_CHANGE_LOG_COLUMNS,
|
||||||
|
rows: mapDataChangeLogsToRows(items),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return { buffer, filename: `data_change_logs_${formatDateForFile()}.xlsx` }
|
||||||
|
}
|
||||||
27
src/modules/audit/hooks/use-log-pagination.ts
Normal file
27
src/modules/audit/hooks/use-log-pagination.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
|
import { useCallback } from "react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页 URL 状态管理 hook。
|
||||||
|
* 统一审计模块三个列表页的分页逻辑,消除三处重复的 handlePageChange。
|
||||||
|
*/
|
||||||
|
export function useLogPagination(): (page: number) => void {
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
return useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
if (newPage <= 1) {
|
||||||
|
params.delete("page")
|
||||||
|
} else {
|
||||||
|
params.set("page", String(newPage))
|
||||||
|
}
|
||||||
|
const query = params.toString()
|
||||||
|
router.push(query ? `?${query}` : "?")
|
||||||
|
},
|
||||||
|
[router, searchParams],
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/modules/audit/retention.test.ts
Normal file
92
src/modules/audit/retention.test.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest"
|
||||||
|
|
||||||
|
// Mock server-only and db dependencies so the module loads in jsdom
|
||||||
|
vi.mock("@/shared/db", () => ({
|
||||||
|
db: {
|
||||||
|
delete: vi.fn(),
|
||||||
|
select: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock("@/shared/db/schema", () => ({
|
||||||
|
auditLogs: { createdAt: "created_at" },
|
||||||
|
loginLogs: { createdAt: "created_at" },
|
||||||
|
dataChangeLogs: { createdAt: "created_at" },
|
||||||
|
}))
|
||||||
|
vi.mock("@/modules/settings/data-access-system-settings", () => ({
|
||||||
|
getSystemSetting: vi.fn(),
|
||||||
|
upsertSystemSetting: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
calculateRetentionThreshold,
|
||||||
|
isValidRetentionDays,
|
||||||
|
DEFAULT_RETENTION_DAYS,
|
||||||
|
MIN_RETENTION_DAYS,
|
||||||
|
MAX_RETENTION_DAYS,
|
||||||
|
} from "./retention"
|
||||||
|
|
||||||
|
describe("calculateRetentionThreshold", () => {
|
||||||
|
it("should return a date N days before the reference date", () => {
|
||||||
|
const now = new Date("2026-06-24T12:00:00Z")
|
||||||
|
const threshold = calculateRetentionThreshold(180, now)
|
||||||
|
const expected = new Date("2025-12-26T12:00:00Z")
|
||||||
|
expect(threshold.toISOString()).toBe(expected.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should return today minus retentionDays for 0 days", () => {
|
||||||
|
const now = new Date("2026-06-24T00:00:00Z")
|
||||||
|
const threshold = calculateRetentionThreshold(0, now)
|
||||||
|
expect(threshold.toISOString()).toBe(now.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should handle leap year correctly", () => {
|
||||||
|
const now = new Date("2024-03-01T00:00:00Z")
|
||||||
|
const threshold = calculateRetentionThreshold(1, now)
|
||||||
|
const expected = new Date("2024-02-29T00:00:00Z")
|
||||||
|
expect(threshold.toISOString()).toBe(expected.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should use current time when no reference date provided", () => {
|
||||||
|
const before = new Date()
|
||||||
|
const threshold = calculateRetentionThreshold(90)
|
||||||
|
const after = new Date()
|
||||||
|
const expectedMin = new Date(before)
|
||||||
|
expectedMin.setDate(expectedMin.getDate() - 90)
|
||||||
|
const expectedMax = new Date(after)
|
||||||
|
expectedMax.setDate(expectedMax.getDate() - 90)
|
||||||
|
expect(threshold.getTime()).toBeGreaterThanOrEqual(expectedMin.getTime())
|
||||||
|
expect(threshold.getTime()).toBeLessThanOrEqual(expectedMax.getTime())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("isValidRetentionDays", () => {
|
||||||
|
it("should accept values within the valid range", () => {
|
||||||
|
expect(isValidRetentionDays(MIN_RETENTION_DAYS)).toBe(true)
|
||||||
|
expect(isValidRetentionDays(90)).toBe(true)
|
||||||
|
expect(isValidRetentionDays(180)).toBe(true)
|
||||||
|
expect(isValidRetentionDays(365)).toBe(true)
|
||||||
|
expect(isValidRetentionDays(MAX_RETENTION_DAYS)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject values below the minimum", () => {
|
||||||
|
expect(isValidRetentionDays(0)).toBe(false)
|
||||||
|
expect(isValidRetentionDays(1)).toBe(false)
|
||||||
|
expect(isValidRetentionDays(MIN_RETENTION_DAYS - 1)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject values above the maximum", () => {
|
||||||
|
expect(isValidRetentionDays(MAX_RETENTION_DAYS + 1)).toBe(false)
|
||||||
|
expect(isValidRetentionDays(99999)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject non-integer values", () => {
|
||||||
|
expect(isValidRetentionDays(90.5)).toBe(false)
|
||||||
|
expect(isValidRetentionDays(100.1)).toBe(false)
|
||||||
|
expect(isValidRetentionDays(NaN)).toBe(false)
|
||||||
|
expect(isValidRetentionDays(Infinity)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should have DEFAULT_RETENTION_DAYS within valid range", () => {
|
||||||
|
expect(isValidRetentionDays(DEFAULT_RETENTION_DAYS)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
181
src/modules/audit/retention.ts
Normal file
181
src/modules/audit/retention.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import "server-only"
|
||||||
|
|
||||||
|
import { lt } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { db } from "@/shared/db"
|
||||||
|
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
||||||
|
import {
|
||||||
|
getSystemSetting,
|
||||||
|
upsertSystemSetting,
|
||||||
|
} from "@/modules/settings/data-access-system-settings"
|
||||||
|
import type { AuditRetentionConfig, PurgeResult } from "./types"
|
||||||
|
|
||||||
|
/** 默认保留天数 */
|
||||||
|
export const DEFAULT_RETENTION_DAYS = 180
|
||||||
|
|
||||||
|
/** audit-P2-5: 登录日志默认保留天数(1 年,比审计日志更长以支持安全取证) */
|
||||||
|
export const DEFAULT_LOGIN_LOG_RETENTION_DAYS = 365
|
||||||
|
|
||||||
|
/** 最小保留天数(防止误设为 0 导致全量删除) */
|
||||||
|
export const MIN_RETENTION_DAYS = 30
|
||||||
|
|
||||||
|
/** 最大保留天数 */
|
||||||
|
export const MAX_RETENTION_DAYS = 3650
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算保留阈值日期(早于此日期的日志将被清理)
|
||||||
|
*
|
||||||
|
* 纯函数,便于单测。
|
||||||
|
*/
|
||||||
|
export function calculateRetentionThreshold(retentionDays: number, now: Date = new Date()): Date {
|
||||||
|
const threshold = new Date(now)
|
||||||
|
threshold.setDate(threshold.getDate() - retentionDays)
|
||||||
|
return threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验保留天数是否在合法范围内
|
||||||
|
*
|
||||||
|
* 纯函数,便于单测。
|
||||||
|
*/
|
||||||
|
export function isValidRetentionDays(days: number): boolean {
|
||||||
|
return Number.isInteger(days) && days >= MIN_RETENTION_DAYS && days <= MAX_RETENTION_DAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审计日志保留策略配置
|
||||||
|
*
|
||||||
|
* 从 system_settings 表读取(category = "audit_retention")。
|
||||||
|
* 若未配置则返回默认值。
|
||||||
|
*
|
||||||
|
* audit-P2-5: 新增 loginLogRetentionDays 配置项(默认 365 天)。
|
||||||
|
*/
|
||||||
|
export async function getAuditRetentionConfig(): Promise<AuditRetentionConfig> {
|
||||||
|
try {
|
||||||
|
const [retentionDaysRow, loginLogRetentionDaysRow, autoCleanupRow] = await Promise.all([
|
||||||
|
getSystemSetting("audit_retention", "retentionDays"),
|
||||||
|
getSystemSetting("audit_retention", "loginLogRetentionDays"),
|
||||||
|
getSystemSetting("audit_retention", "autoCleanupEnabled"),
|
||||||
|
])
|
||||||
|
|
||||||
|
const retentionDays = retentionDaysRow
|
||||||
|
? Number(retentionDaysRow.value)
|
||||||
|
: DEFAULT_RETENTION_DAYS
|
||||||
|
|
||||||
|
const loginLogRetentionDays = loginLogRetentionDaysRow
|
||||||
|
? Number(loginLogRetentionDaysRow.value)
|
||||||
|
: DEFAULT_LOGIN_LOG_RETENTION_DAYS
|
||||||
|
|
||||||
|
const autoCleanupEnabled = autoCleanupRow
|
||||||
|
? autoCleanupRow.value === "true"
|
||||||
|
: false
|
||||||
|
|
||||||
|
return {
|
||||||
|
retentionDays: isValidRetentionDays(retentionDays) ? retentionDays : DEFAULT_RETENTION_DAYS,
|
||||||
|
loginLogRetentionDays: isValidRetentionDays(loginLogRetentionDays)
|
||||||
|
? loginLogRetentionDays
|
||||||
|
: DEFAULT_LOGIN_LOG_RETENTION_DAYS,
|
||||||
|
autoCleanupEnabled,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("getAuditRetentionConfig failed:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存审计日志保留策略配置
|
||||||
|
*
|
||||||
|
* audit-P2-5: 新增 loginLogRetentionDays 持久化。
|
||||||
|
*/
|
||||||
|
export async function saveAuditRetentionConfig(
|
||||||
|
config: AuditRetentionConfig,
|
||||||
|
updatedBy?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isValidRetentionDays(config.retentionDays)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid retentionDays: must be integer between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!isValidRetentionDays(config.loginLogRetentionDays)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid loginLogRetentionDays: must be integer between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all([
|
||||||
|
upsertSystemSetting({
|
||||||
|
category: "audit_retention",
|
||||||
|
key: "retentionDays",
|
||||||
|
value: String(config.retentionDays),
|
||||||
|
valueType: "number",
|
||||||
|
updatedBy,
|
||||||
|
}),
|
||||||
|
upsertSystemSetting({
|
||||||
|
category: "audit_retention",
|
||||||
|
key: "loginLogRetentionDays",
|
||||||
|
value: String(config.loginLogRetentionDays),
|
||||||
|
valueType: "number",
|
||||||
|
updatedBy,
|
||||||
|
}),
|
||||||
|
upsertSystemSetting({
|
||||||
|
category: "audit_retention",
|
||||||
|
key: "autoCleanupEnabled",
|
||||||
|
value: String(config.autoCleanupEnabled),
|
||||||
|
valueType: "boolean",
|
||||||
|
updatedBy,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
} catch (error) {
|
||||||
|
console.error("saveAuditRetentionConfig failed:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期的审计日志
|
||||||
|
*
|
||||||
|
* 删除三张日志表中早于保留阈值的所有记录。
|
||||||
|
* 返回各表删除的行数。
|
||||||
|
*
|
||||||
|
* audit-P2-5: login_logs 可使用更长的保留期(loginLogRetentionDays),
|
||||||
|
* 默认 365 天以支持安全取证;audit_logs 与 data_change_logs 使用 retentionDays。
|
||||||
|
*/
|
||||||
|
export async function purgeExpiredAuditLogs(
|
||||||
|
retentionDays: number,
|
||||||
|
loginLogRetentionDays?: number,
|
||||||
|
): Promise<PurgeResult> {
|
||||||
|
if (!isValidRetentionDays(retentionDays)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid retentionDays: must be integer between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveLoginRetention = loginLogRetentionDays ?? retentionDays
|
||||||
|
if (!isValidRetentionDays(effectiveLoginRetention)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid loginLogRetentionDays: must be integer between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditThreshold = calculateRetentionThreshold(retentionDays)
|
||||||
|
const loginThreshold = calculateRetentionThreshold(effectiveLoginRetention)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [auditResult, loginResult, dataChangeResult] = await Promise.all([
|
||||||
|
db.delete(auditLogs).where(lt(auditLogs.createdAt, auditThreshold)),
|
||||||
|
db.delete(loginLogs).where(lt(loginLogs.createdAt, loginThreshold)),
|
||||||
|
db.delete(dataChangeLogs).where(lt(dataChangeLogs.createdAt, auditThreshold)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditLogsDeleted: auditResult[0].affectedRows,
|
||||||
|
loginLogsDeleted: loginResult[0].affectedRows,
|
||||||
|
dataChangeLogsDeleted: dataChangeResult[0].affectedRows,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("purgeExpiredAuditLogs failed:", error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/modules/audit/services/admin-audit-service.ts
Normal file
87
src/modules/audit/services/admin-audit-service.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { AuditService } from "./audit-service"
|
||||||
|
import type {
|
||||||
|
AuditLog,
|
||||||
|
AuditOverviewStats,
|
||||||
|
AuditRetentionConfig,
|
||||||
|
AuditTrendPoint,
|
||||||
|
DataChangeActionStat,
|
||||||
|
DataChangeLog,
|
||||||
|
DataChangeStat,
|
||||||
|
LoginLog,
|
||||||
|
PaginatedResult,
|
||||||
|
PurgeResult,
|
||||||
|
} from "@/modules/audit/types"
|
||||||
|
import {
|
||||||
|
getAuditRetentionConfigAction,
|
||||||
|
saveAuditRetentionConfigAction,
|
||||||
|
purgeAuditLogsAction,
|
||||||
|
} from "../actions"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员审计服务实现(Client-safe)。
|
||||||
|
*
|
||||||
|
* 不 import server-only 的 data-access,而是委托 Server Actions 执行写操作。
|
||||||
|
* 适用于通过 `AuditServiceProvider` 注入到 Client Component 中。
|
||||||
|
*
|
||||||
|
* 查询方法抛出错误,因为客户端组件应通过 RSC props 接收查询数据,
|
||||||
|
* 而非直接调用查询接口。这强制执行正确的数据流:
|
||||||
|
* - 读:RSC 页面调 data-access → props 传入 Client Component
|
||||||
|
* - 写:Client Component → useAuditService() → Server Action
|
||||||
|
*
|
||||||
|
* 未来可新增 ComplianceAuditService(只读子集)等角色实现。
|
||||||
|
*/
|
||||||
|
export const adminAuditService: AuditService = {
|
||||||
|
// ── 查询方法:客户端不可用,应通过 RSC props 传入 ──
|
||||||
|
getAuditLogs: async (): Promise<PaginatedResult<AuditLog>> => {
|
||||||
|
throw new Error("getAuditLogs is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getLoginLogs: async (): Promise<PaginatedResult<LoginLog>> => {
|
||||||
|
throw new Error("getLoginLogs is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getDataChangeLogs: async (): Promise<PaginatedResult<DataChangeLog>> => {
|
||||||
|
throw new Error("getDataChangeLogs is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getAuditModuleOptions: async (): Promise<string[]> => {
|
||||||
|
throw new Error("getAuditModuleOptions is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getDataChangeStats: async (): Promise<DataChangeStat[]> => {
|
||||||
|
throw new Error("getDataChangeStats is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getDataChangeTableOptions: async (): Promise<string[]> => {
|
||||||
|
throw new Error("getDataChangeTableOptions is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getAuditOverviewStats: async (): Promise<AuditOverviewStats> => {
|
||||||
|
throw new Error("getAuditOverviewStats is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getAuditTrend: async (): Promise<AuditTrendPoint[]> => {
|
||||||
|
throw new Error("getAuditTrend is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
getDataChangeActionStats: async (): Promise<DataChangeActionStat[]> => {
|
||||||
|
throw new Error("getDataChangeActionStats is not available on client. Use RSC props.")
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 保留策略:通过 Server Action 委托(Client-safe) ──
|
||||||
|
getAuditRetentionConfig: async (): Promise<AuditRetentionConfig> => {
|
||||||
|
const res = await getAuditRetentionConfigAction()
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
throw new Error(res.message ?? "Failed to get audit retention config")
|
||||||
|
}
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
saveAuditRetentionConfig: async (config: AuditRetentionConfig): Promise<void> => {
|
||||||
|
const res = await saveAuditRetentionConfigAction(config)
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
throw new Error(res.message ?? "Failed to save audit retention config")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
purgeExpiredAuditLogs: async (
|
||||||
|
retentionDays: number,
|
||||||
|
loginLogRetentionDays?: number,
|
||||||
|
): Promise<PurgeResult> => {
|
||||||
|
const res = await purgeAuditLogsAction(retentionDays, loginLogRetentionDays)
|
||||||
|
if (!res.success || !res.data) {
|
||||||
|
throw new Error(res.message ?? "Failed to purge audit logs")
|
||||||
|
}
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
}
|
||||||
139
src/modules/audit/services/audit-service.tsx
Normal file
139
src/modules/audit/services/audit-service.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, type ReactNode } from "react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AuditLog,
|
||||||
|
AuditLogQueryParams,
|
||||||
|
AuditOverviewStats,
|
||||||
|
AuditRetentionConfig,
|
||||||
|
AuditTrendPoint,
|
||||||
|
DataChangeActionStat,
|
||||||
|
DataChangeLog,
|
||||||
|
DataChangeLogQueryParams,
|
||||||
|
DataChangeStat,
|
||||||
|
LoginLog,
|
||||||
|
LoginLogQueryParams,
|
||||||
|
PaginatedResult,
|
||||||
|
PurgeResult,
|
||||||
|
} from "@/modules/audit/types"
|
||||||
|
|
||||||
|
// ─── 数据服务接口 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计数据服务接口(抽象数据依赖)。
|
||||||
|
*
|
||||||
|
* 定义所有审计模块数据获取与操作的契约。
|
||||||
|
* 组件通过 `useAuditService()` 获取当前注入的实现,不直接 import data-access。
|
||||||
|
* 测试时可注入 mock 实现以隔离数据层。
|
||||||
|
*
|
||||||
|
* 角色差异通过不同实现隔离:
|
||||||
|
* - AdminAuditService:完整访问(默认实现,委托给 data-access)
|
||||||
|
* - 未来 ComplianceAuditService:只读子集
|
||||||
|
*/
|
||||||
|
export interface AuditService {
|
||||||
|
// ── 日志查询 ──
|
||||||
|
getAuditLogs(params?: AuditLogQueryParams): Promise<PaginatedResult<AuditLog>>
|
||||||
|
getLoginLogs(params?: LoginLogQueryParams): Promise<PaginatedResult<LoginLog>>
|
||||||
|
getDataChangeLogs(params?: DataChangeLogQueryParams): Promise<PaginatedResult<DataChangeLog>>
|
||||||
|
|
||||||
|
// ── 选项与统计 ──
|
||||||
|
getAuditModuleOptions(): Promise<string[]>
|
||||||
|
getDataChangeStats(): Promise<DataChangeStat[]>
|
||||||
|
getDataChangeTableOptions(): Promise<string[]>
|
||||||
|
|
||||||
|
// ── 概览仪表盘 ──
|
||||||
|
getAuditOverviewStats(): Promise<AuditOverviewStats>
|
||||||
|
getAuditTrend(days?: number): Promise<AuditTrendPoint[]>
|
||||||
|
getDataChangeActionStats(): Promise<DataChangeActionStat[]>
|
||||||
|
|
||||||
|
// ── 保留策略 ──
|
||||||
|
getAuditRetentionConfig(): Promise<AuditRetentionConfig>
|
||||||
|
saveAuditRetentionConfig(config: AuditRetentionConfig): Promise<void>
|
||||||
|
purgeExpiredAuditLogs(retentionDays: number, loginLogRetentionDays?: number): Promise<PurgeResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 监控埋点接口 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计监控埋点接口。
|
||||||
|
*
|
||||||
|
* 预留关键操作埋点,供后续接入实际监控 SDK(如 PostHog / Mixpanel)。
|
||||||
|
* 默认实现为空操作,生产环境通过 Provider 注入实际实现。
|
||||||
|
*/
|
||||||
|
export interface AuditAnalytics {
|
||||||
|
/** 日志查看 */
|
||||||
|
trackLogView(logType: "audit" | "login" | "dataChange"): void
|
||||||
|
/** 日志导出 */
|
||||||
|
trackExport(logType: "audit" | "login" | "dataChange", count: number): void
|
||||||
|
/** 保留策略变更 */
|
||||||
|
trackRetentionConfigChange(config: AuditRetentionConfig): void
|
||||||
|
/** 手动清理 */
|
||||||
|
trackPurge(result: PurgeResult): void
|
||||||
|
/** 概览页查看 */
|
||||||
|
trackOverviewView(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空操作实现(默认) */
|
||||||
|
const noopAnalytics: AuditAnalytics = {
|
||||||
|
trackLogView: () => {},
|
||||||
|
trackExport: () => {},
|
||||||
|
trackRetentionConfigChange: () => {},
|
||||||
|
trackPurge: () => {},
|
||||||
|
trackOverviewView: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── React Context 依赖注入 ────────────────────────────────
|
||||||
|
|
||||||
|
const AuditServiceContext = createContext<AuditService | null>(null)
|
||||||
|
const AuditAnalyticsContext = createContext<AuditAnalytics>(noopAnalytics)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审计服务 Provider(在页面层注入角色特定的实现)。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AuditServiceProvider service={adminAuditService}>
|
||||||
|
* <AuditOverviewView ... />
|
||||||
|
* </AuditServiceProvider>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function AuditServiceProvider({
|
||||||
|
service,
|
||||||
|
analytics,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
service: AuditService
|
||||||
|
analytics?: AuditAnalytics
|
||||||
|
children: ReactNode
|
||||||
|
}): ReactNode {
|
||||||
|
return (
|
||||||
|
<AuditServiceContext.Provider value={service}>
|
||||||
|
<AuditAnalyticsContext.Provider value={analytics ?? noopAnalytics}>
|
||||||
|
{children}
|
||||||
|
</AuditAnalyticsContext.Provider>
|
||||||
|
</AuditServiceContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前注入的审计数据服务。
|
||||||
|
*
|
||||||
|
* 必须在 `AuditServiceProvider` 内部使用。
|
||||||
|
*/
|
||||||
|
export function useAuditService(): AuditService {
|
||||||
|
const service = useContext(AuditServiceContext)
|
||||||
|
if (!service) {
|
||||||
|
throw new Error("useAuditService must be used within AuditServiceProvider")
|
||||||
|
}
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前注入的审计监控埋点接口。
|
||||||
|
*
|
||||||
|
* 未注入时返回空操作实现,安全调用。
|
||||||
|
*/
|
||||||
|
export function useAuditAnalytics(): AuditAnalytics {
|
||||||
|
return useContext(AuditAnalyticsContext)
|
||||||
|
}
|
||||||
75
src/modules/audit/services/mock-audit-service.ts
Normal file
75
src/modules/audit/services/mock-audit-service.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { AuditService } from "./audit-service"
|
||||||
|
import type {
|
||||||
|
AuditLog,
|
||||||
|
AuditOverviewStats,
|
||||||
|
AuditRetentionConfig,
|
||||||
|
AuditTrendPoint,
|
||||||
|
DataChangeActionStat,
|
||||||
|
DataChangeLog,
|
||||||
|
DataChangeStat,
|
||||||
|
LoginLog,
|
||||||
|
PaginatedResult,
|
||||||
|
PurgeResult,
|
||||||
|
} from "@/modules/audit/types"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空数据 Mock 实现(用于测试)。
|
||||||
|
*
|
||||||
|
* 所有方法返回空数据或默认值,不触发任何 DB 调用。
|
||||||
|
* 通过为每个方法添加显式返回类型标注,避免 `as` 类型断言。
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* <AuditServiceProvider service={mockAuditService}>
|
||||||
|
* <AuditOverviewView ... />
|
||||||
|
* </AuditServiceProvider>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const mockAuditService: AuditService = {
|
||||||
|
getAuditLogs: async (): Promise<PaginatedResult<AuditLog>> => ({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
totalPages: 0,
|
||||||
|
}),
|
||||||
|
getLoginLogs: async (): Promise<PaginatedResult<LoginLog>> => ({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
totalPages: 0,
|
||||||
|
}),
|
||||||
|
getDataChangeLogs: async (): Promise<PaginatedResult<DataChangeLog>> => ({
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
totalPages: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAuditModuleOptions: async (): Promise<string[]> => [],
|
||||||
|
getDataChangeStats: async (): Promise<DataChangeStat[]> => [],
|
||||||
|
getDataChangeTableOptions: async (): Promise<string[]> => [],
|
||||||
|
|
||||||
|
getAuditOverviewStats: async (): Promise<AuditOverviewStats> => ({
|
||||||
|
auditEventsToday: 0,
|
||||||
|
failedLoginsToday: 0,
|
||||||
|
dataChangesToday: 0,
|
||||||
|
totalAuditLogs: 0,
|
||||||
|
}),
|
||||||
|
getAuditTrend: async (): Promise<AuditTrendPoint[]> => [],
|
||||||
|
getDataChangeActionStats: async (): Promise<DataChangeActionStat[]> => [],
|
||||||
|
|
||||||
|
getAuditRetentionConfig: async (): Promise<AuditRetentionConfig> => ({
|
||||||
|
retentionDays: 180,
|
||||||
|
loginLogRetentionDays: 365,
|
||||||
|
autoCleanupEnabled: false,
|
||||||
|
}),
|
||||||
|
saveAuditRetentionConfig: async (): Promise<void> => undefined,
|
||||||
|
purgeExpiredAuditLogs: async (): Promise<PurgeResult> => ({
|
||||||
|
auditLogsDeleted: 0,
|
||||||
|
loginLogsDeleted: 0,
|
||||||
|
dataChangeLogsDeleted: 0,
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -115,3 +115,53 @@ export interface PaginatedResult<T> {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
totalPages: number
|
totalPages: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 审计概览统计(今日数据) */
|
||||||
|
export interface AuditOverviewStats {
|
||||||
|
/** 今日审计事件数 */
|
||||||
|
auditEventsToday: number
|
||||||
|
/** 今日失败登录数 */
|
||||||
|
failedLoginsToday: number
|
||||||
|
/** 今日数据变更数 */
|
||||||
|
dataChangesToday: number
|
||||||
|
/** 审计日志总数 */
|
||||||
|
totalAuditLogs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计活动趋势数据点 */
|
||||||
|
export interface AuditTrendPoint {
|
||||||
|
/** 日期(YYYY-MM-DD) */
|
||||||
|
date: string
|
||||||
|
/** 当日审计事件数 */
|
||||||
|
auditEvents: number
|
||||||
|
/** 当日登录事件数 */
|
||||||
|
loginEvents: number
|
||||||
|
/** 当日数据变更数 */
|
||||||
|
dataChanges: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 数据变更按动作统计 */
|
||||||
|
export interface DataChangeActionStat {
|
||||||
|
action: DataChangeAction
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审计日志保留策略配置 */
|
||||||
|
export interface AuditRetentionConfig {
|
||||||
|
/** 保留天数(90/180/365 等) */
|
||||||
|
retentionDays: number
|
||||||
|
/** 登录日志保留天数(audit-P2-5 新增:login_logs 默认 365 天,比审计日志更长以支持安全取证) */
|
||||||
|
loginLogRetentionDays: number
|
||||||
|
/** 是否启用自动清理 */
|
||||||
|
autoCleanupEnabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留策略清理结果 */
|
||||||
|
export interface PurgeResult {
|
||||||
|
/** 清理的审计日志数 */
|
||||||
|
auditLogsDeleted: number
|
||||||
|
/** 清理的登录日志数 */
|
||||||
|
loginLogsDeleted: number
|
||||||
|
/** 清理的数据变更日志数 */
|
||||||
|
dataChangeLogsDeleted: number
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user