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"
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { exportToExcel, type ExcelColumn } from "@/shared/lib/excel"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
import { trackEvent } from "@/shared/lib/track-event"
|
||||
import { getSession } from "@/shared/lib/session"
|
||||
|
||||
import {
|
||||
getAuditLogsForExport,
|
||||
getDataChangeLogs,
|
||||
getDataChangeLogsForExport,
|
||||
getDataChangeStats,
|
||||
getDataChangeTableOptions,
|
||||
getAuditLogsForExport,
|
||||
getLoginLogsForExport,
|
||||
} from "./data-access"
|
||||
import {
|
||||
buildAuditLogExport,
|
||||
buildDataChangeLogExport,
|
||||
buildLoginLogExport,
|
||||
} from "./export"
|
||||
import {
|
||||
getAuditRetentionConfig,
|
||||
saveAuditRetentionConfig,
|
||||
purgeExpiredAuditLogs,
|
||||
} from "./retention"
|
||||
import type {
|
||||
AuditLogQueryParams,
|
||||
AuditRetentionConfig,
|
||||
DataChangeLog,
|
||||
DataChangeLogQueryParams,
|
||||
DataChangeStat,
|
||||
LoginLogQueryParams,
|
||||
PurgeResult,
|
||||
} from "./types"
|
||||
|
||||
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(
|
||||
params?: AuditLogQueryParams
|
||||
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
|
||||
try {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
const items = await getAuditLogsForExport(params)
|
||||
const { buffer, filename } = await buildAuditLogExport(items)
|
||||
|
||||
const { buffer, filename } = await buildExcelExport({
|
||||
sheetName: "Audit Logs",
|
||||
columns: [
|
||||
{ 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 },
|
||||
],
|
||||
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",
|
||||
const session = await getSession()
|
||||
await trackEvent({
|
||||
event: "audit.exported",
|
||||
userId: session?.user?.id,
|
||||
targetType: "audit_log",
|
||||
properties: { type: "audit", count: items.length },
|
||||
})
|
||||
|
||||
return { success: true, data: { buffer, filename } }
|
||||
@@ -137,30 +104,14 @@ export async function exportLoginLogsAction(
|
||||
try {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
const items = await getLoginLogsForExport(params)
|
||||
const { buffer, filename } = await buildLoginLogExport(items)
|
||||
|
||||
const { buffer, filename } = await buildExcelExport({
|
||||
sheetName: "Login Logs",
|
||||
columns: [
|
||||
{ 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 },
|
||||
],
|
||||
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",
|
||||
const session = await getSession()
|
||||
await trackEvent({
|
||||
event: "audit.exported",
|
||||
userId: session?.user?.id,
|
||||
targetType: "login_log",
|
||||
properties: { type: "login", count: items.length },
|
||||
})
|
||||
|
||||
return { success: true, data: { buffer, filename } }
|
||||
@@ -177,32 +128,14 @@ export async function exportDataChangeLogsAction(
|
||||
try {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
const items = await getDataChangeLogsForExport(params)
|
||||
const { buffer, filename } = await buildDataChangeLogExport(items)
|
||||
|
||||
const { buffer, filename } = await buildExcelExport({
|
||||
sheetName: "Data Change Logs",
|
||||
columns: [
|
||||
{ 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 },
|
||||
],
|
||||
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",
|
||||
const session = await getSession()
|
||||
await trackEvent({
|
||||
event: "audit.exported",
|
||||
userId: session?.user?.id,
|
||||
targetType: "data_change_log",
|
||||
properties: { type: "dataChange", count: items.length },
|
||||
})
|
||||
|
||||
return { success: true, data: { buffer, filename } }
|
||||
@@ -212,3 +145,82 @@ export async function exportDataChangeLogsAction(
|
||||
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 { Download, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { downloadBlob } from "@/shared/lib/download"
|
||||
import { useAuditAnalytics } from "@/modules/audit/services/audit-service"
|
||||
|
||||
interface AuditLogExportButtonProps {
|
||||
exportType: "audit" | "login" | "dataChange"
|
||||
params?: Record<string, unknown>
|
||||
label?: string
|
||||
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive"
|
||||
size?: "default" | "sm" | "lg" | "icon"
|
||||
className?: string
|
||||
@@ -25,14 +26,15 @@ const ACTION_MAP = {
|
||||
export function AuditLogExportButton({
|
||||
exportType,
|
||||
params,
|
||||
label = "Export Excel",
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
className,
|
||||
}: AuditLogExportButtonProps) {
|
||||
const t = useTranslations("audit")
|
||||
const analytics = useAuditAnalytics()
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleExport = async (): Promise<void> => {
|
||||
setIsPending(true)
|
||||
try {
|
||||
const res = await fetch("/api/export", {
|
||||
@@ -43,20 +45,20 @@ export function AuditLogExportButton({
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null)
|
||||
toast.error(body?.message || "Export failed")
|
||||
toast.error(body?.message || t("export.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
// Try to read filename from Content-Disposition header
|
||||
const disposition = res.headers.get("Content-Disposition") || ""
|
||||
const filenameMatch = disposition.match(/filename="?([^";]+)"?/i)
|
||||
const filename = filenameMatch?.[1] ?? `export_${Date.now()}.xlsx`
|
||||
|
||||
const blob = await res.blob()
|
||||
downloadBlob(blob, filename)
|
||||
toast.success("Export ready")
|
||||
analytics.trackExport(exportType, 0)
|
||||
toast.success(t("export.success"))
|
||||
} catch {
|
||||
toast.error("Export failed")
|
||||
toast.error(t("export.failed"))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
@@ -70,6 +72,7 @@ export function AuditLogExportButton({
|
||||
className={className}
|
||||
disabled={isPending}
|
||||
onClick={() => void handleExport()}
|
||||
aria-label={t("export.button")}
|
||||
data-action-name={ACTION_MAP[exportType]}
|
||||
>
|
||||
{isPending ? (
|
||||
@@ -77,7 +80,7 @@ export function AuditLogExportButton({
|
||||
) : (
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{label}
|
||||
{t("export.button")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import {
|
||||
@@ -17,9 +18,11 @@ interface AuditLogFiltersProps {
|
||||
}
|
||||
|
||||
export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||
const t = useTranslations("audit")
|
||||
const [module, setModule] = useQueryState("module", parseAsString.withOptions({ shallow: false }))
|
||||
const [action, setAction] = useQueryState("action", 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(
|
||||
"startDate",
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
@@ -29,7 +32,7 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
)
|
||||
|
||||
const hasFilters = Boolean(module || action || status || startDate || endDate)
|
||||
const hasFilters = Boolean(module || action || status || userId || startDate || endDate)
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
@@ -39,16 +42,17 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||
setModule(null)
|
||||
setAction(null)
|
||||
setStatus(null)
|
||||
setUserId(null)
|
||||
setStartDate(null)
|
||||
setEndDate(null)
|
||||
}}
|
||||
>
|
||||
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px] bg-background">
|
||||
<SelectValue placeholder="Module" />
|
||||
<SelectTrigger className="w-[160px] bg-background" aria-label={t("filter.modulePlaceholder")}>
|
||||
<SelectValue placeholder={t("filter.modulePlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Module</SelectItem>
|
||||
<SelectItem value="all">{t("filter.anyModule")}</SelectItem>
|
||||
{moduleOptions.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
@@ -58,32 +62,43 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Action..."
|
||||
placeholder={t("filter.actionPlaceholder")}
|
||||
className="w-full md:w-[180px] bg-background"
|
||||
aria-label={t("filter.actionPlaceholder")}
|
||||
value={action || ""}
|
||||
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)}>
|
||||
<SelectTrigger className="w-[140px] bg-background">
|
||||
<SelectValue placeholder="Status" />
|
||||
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
|
||||
<SelectValue placeholder={t("filter.statusPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Status</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failure">Failure</SelectItem>
|
||||
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
|
||||
<SelectItem value="success">{t("filter.success")}</SelectItem>
|
||||
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
|
||||
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"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
@@ -15,6 +17,7 @@ import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { AuditLog } from "../types"
|
||||
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
||||
import { AuditLogDetailDialog } from "./audit-log-detail-dialog"
|
||||
|
||||
interface AuditLogTableProps {
|
||||
items: AuditLog[]
|
||||
@@ -33,24 +36,27 @@ export function AuditLogTable({
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: AuditLogTableProps) {
|
||||
const t = useTranslations("audit")
|
||||
const locale = useLocale()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table aria-label={t("title")}>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Module</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>{t("table.user")}</TableHead>
|
||||
<TableHead>{t("table.module")}</TableHead>
|
||||
<TableHead>{t("table.action")}</TableHead>
|
||||
<TableHead>{t("table.target")}</TableHead>
|
||||
<TableHead>{t("table.status")}</TableHead>
|
||||
<TableHead>{t("table.ipAddress")}</TableHead>
|
||||
<TableHead>{t("table.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<EmptyTableRow colSpan={7} message="No audit logs found." />
|
||||
<EmptyTableRow colSpan={7} message={t("empty.audit")} />
|
||||
) : (
|
||||
items.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
@@ -89,7 +95,10 @@ export function AuditLogTable({
|
||||
{log.ipAddress ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(log.createdAt, "zh-CN")}
|
||||
{formatDate(log.createdAt, locale)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AuditLogDetailDialog type="audit" item={log} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { AuditLogTable } from "./audit-log-table"
|
||||
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"
|
||||
|
||||
interface AuditLogViewProps {
|
||||
@@ -23,19 +25,7 @@ function AuditLogViewInner({
|
||||
totalPages,
|
||||
moduleOptions,
|
||||
}: AuditLogViewProps) {
|
||||
const router = useRouter()
|
||||
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}` : "?")
|
||||
}
|
||||
const handlePageChange = useLogPagination()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -54,7 +44,7 @@ function AuditLogViewInner({
|
||||
|
||||
export function AuditLogView(props: AuditLogViewProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<AuditLogTableSkeleton />}>
|
||||
<AuditLogViewInner {...props} />
|
||||
</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"
|
||||
|
||||
import { useState, Fragment, Suspense } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { X } from "lucide-react"
|
||||
import { useState, Fragment } from "react"
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
@@ -14,23 +13,16 @@ import {
|
||||
TableRow,
|
||||
} from "@/shared/components/ui/table"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { EmptyTableRow } from "@/shared/components/ui/empty-table-row"
|
||||
import { Pagination } from "@/shared/components/ui/pagination"
|
||||
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 type { DataChangeLog, DataChangeStat } from "../types"
|
||||
import type { DataChangeLog } from "../types"
|
||||
import {
|
||||
DATA_CHANGE_ACTION_VARIANT,
|
||||
DATA_CHANGE_ACTION_CLASS_NAME,
|
||||
} from "../types"
|
||||
import { AuditLogDetailDialog } from "./audit-log-detail-dialog"
|
||||
|
||||
interface DataChangeLogTableProps {
|
||||
items: DataChangeLog[]
|
||||
@@ -38,54 +30,39 @@ interface DataChangeLogTableProps {
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
tableOptions: string[]
|
||||
stats: DataChangeStat[]
|
||||
onPageChange: (page: number) => void
|
||||
}
|
||||
|
||||
function DataChangeLogTableInner({
|
||||
export function DataChangeLogTable({
|
||||
items,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages,
|
||||
tableOptions,
|
||||
stats,
|
||||
onPageChange,
|
||||
}: DataChangeLogTableProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const t = useTranslations("audit")
|
||||
const locale = useLocale()
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<DataChangeLogFilters tableOptions={tableOptions} stats={stats} />
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table aria-label={t("dataChanges.title")}>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead>Table</TableHead>
|
||||
<TableHead>Record ID</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Changed By</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
<TableHead>{t("table.tableName")}</TableHead>
|
||||
<TableHead>{t("table.recordId")}</TableHead>
|
||||
<TableHead>{t("table.action")}</TableHead>
|
||||
<TableHead>{t("table.changedBy")}</TableHead>
|
||||
<TableHead>{t("table.ipAddress")}</TableHead>
|
||||
<TableHead>{t("table.time")}</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<EmptyTableRow colSpan={7} message="No data change logs found." />
|
||||
<EmptyTableRow colSpan={7} message={t("empty.dataChange")} />
|
||||
) : (
|
||||
items.map((log) => (
|
||||
<Fragment key={log.id}>
|
||||
@@ -113,16 +90,21 @@ function DataChangeLogTableInner({
|
||||
{log.ipAddress ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(log.createdAt, "zh-CN")}
|
||||
{formatDate(log.createdAt, locale)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpandedId(expandedId === log.id ? null : log.id)}
|
||||
>
|
||||
{expandedId === log.id ? "Hide" : "View"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-expanded={expandedId === log.id}
|
||||
aria-label={expandedId === log.id ? t("table.hide") : t("table.view")}
|
||||
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>
|
||||
</TableRow>
|
||||
{expandedId === log.id && (
|
||||
@@ -131,7 +113,7 @@ function DataChangeLogTableInner({
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
||||
Old Value
|
||||
{t("table.oldValue")}
|
||||
</div>
|
||||
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
||||
{log.oldValue ?? "—"}
|
||||
@@ -139,7 +121,7 @@ function DataChangeLogTableInner({
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-muted-foreground">
|
||||
New Value
|
||||
{t("table.newValue")}
|
||||
</div>
|
||||
<pre className="max-h-60 overflow-auto rounded border bg-background p-2 text-xs">
|
||||
{log.newValue ?? "—"}
|
||||
@@ -161,121 +143,8 @@ function DataChangeLogTableInner({
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</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"
|
||||
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
|
||||
import {
|
||||
@@ -13,8 +14,10 @@ import { Input } from "@/shared/components/ui/input"
|
||||
import { FilterBar } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function LoginLogFilters() {
|
||||
const t = useTranslations("audit")
|
||||
const [action, setAction] = useQueryState("action", 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(
|
||||
"startDate",
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
@@ -24,7 +27,7 @@ export function LoginLogFilters() {
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
)
|
||||
|
||||
const hasFilters = Boolean(action || status || startDate || endDate)
|
||||
const hasFilters = Boolean(action || status || userId || startDate || endDate)
|
||||
|
||||
return (
|
||||
<FilterBar
|
||||
@@ -33,42 +36,53 @@ export function LoginLogFilters() {
|
||||
onReset={() => {
|
||||
setAction(null)
|
||||
setStatus(null)
|
||||
setUserId(null)
|
||||
setStartDate(null)
|
||||
setEndDate(null)
|
||||
}}
|
||||
>
|
||||
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px] bg-background">
|
||||
<SelectValue placeholder="Action" />
|
||||
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.actionSelectPlaceholder")}>
|
||||
<SelectValue placeholder={t("filter.actionSelectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Action</SelectItem>
|
||||
<SelectItem value="signin">Sign In</SelectItem>
|
||||
<SelectItem value="signout">Sign Out</SelectItem>
|
||||
<SelectItem value="signup">Sign Up</SelectItem>
|
||||
<SelectItem value="all">{t("filter.anyAction")}</SelectItem>
|
||||
<SelectItem value="signin">{t("filter.signIn")}</SelectItem>
|
||||
<SelectItem value="signout">{t("filter.signOut")}</SelectItem>
|
||||
<SelectItem value="signup">{t("filter.signUp")}</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)}
|
||||
/>
|
||||
|
||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px] bg-background">
|
||||
<SelectValue placeholder="Status" />
|
||||
<SelectTrigger className="w-[140px] bg-background" aria-label={t("filter.statusPlaceholder")}>
|
||||
<SelectValue placeholder={t("filter.statusPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Any Status</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failure">Failure</SelectItem>
|
||||
<SelectItem value="all">{t("filter.anyStatus")}</SelectItem>
|
||||
<SelectItem value="success">{t("filter.success")}</SelectItem>
|
||||
<SelectItem value="failure">{t("filter.failure")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useLocale, useTranslations } from "next-intl"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import {
|
||||
Table,
|
||||
@@ -15,6 +17,7 @@ import { StatusBadge } from "@/shared/components/ui/status-badge"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { LoginLog } from "../types"
|
||||
import { AUDIT_STATUS_VARIANT, AUDIT_STATUS_CLASS_NAME } from "../types"
|
||||
import { AuditLogDetailDialog } from "./audit-log-detail-dialog"
|
||||
|
||||
interface LoginLogTableProps {
|
||||
items: LoginLog[]
|
||||
@@ -33,23 +36,26 @@ export function LoginLogTable({
|
||||
totalPages,
|
||||
onPageChange,
|
||||
}: LoginLogTableProps) {
|
||||
const t = useTranslations("audit")
|
||||
const locale = useLocale()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<Table aria-label={t("loginLogs.title")}>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>User Agent</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>{t("table.user")}</TableHead>
|
||||
<TableHead>{t("table.action")}</TableHead>
|
||||
<TableHead>{t("table.status")}</TableHead>
|
||||
<TableHead>{t("table.ipAddress")}</TableHead>
|
||||
<TableHead>{t("table.userAgent")}</TableHead>
|
||||
<TableHead>{t("table.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<EmptyTableRow colSpan={6} message="No login logs found." />
|
||||
<EmptyTableRow colSpan={6} message={t("empty.login")} />
|
||||
) : (
|
||||
items.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
@@ -83,7 +89,10 @@ export function LoginLogTable({
|
||||
{log.userAgent ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(log.createdAt, "zh-CN")}
|
||||
{formatDate(log.createdAt, locale)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<AuditLogDetailDialog type="login" item={log} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { Suspense } from "react"
|
||||
|
||||
import { LoginLogTable } from "./login-log-table"
|
||||
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"
|
||||
|
||||
interface LoginLogViewProps {
|
||||
@@ -21,19 +23,7 @@ function LoginLogViewInner({
|
||||
total,
|
||||
totalPages,
|
||||
}: LoginLogViewProps) {
|
||||
const router = useRouter()
|
||||
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}` : "?")
|
||||
}
|
||||
const handlePageChange = useLogPagination()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -52,7 +42,7 @@ function LoginLogViewInner({
|
||||
|
||||
export function LoginLogView(props: LoginLogViewProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<AuditLogTableSkeleton />}>
|
||||
<LoginLogViewInner {...props} />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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 { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
||||
import type {
|
||||
AuditLog,
|
||||
AuditLogQueryParams,
|
||||
AuditOverviewStats,
|
||||
AuditTrendPoint,
|
||||
DataChangeActionStat,
|
||||
DataChangeLog,
|
||||
DataChangeLogQueryParams,
|
||||
DataChangeStat,
|
||||
@@ -82,7 +85,7 @@ export async function getAuditLogs(
|
||||
}
|
||||
} catch (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) {
|
||||
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)
|
||||
} catch (error) {
|
||||
console.error("getAuditModuleOptions failed:", error)
|
||||
return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,7 +204,7 @@ export async function getDataChangeLogs(
|
||||
}
|
||||
} catch (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) }))
|
||||
} catch (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)
|
||||
} catch (error) {
|
||||
console.error("getDataChangeTableOptions failed:", error)
|
||||
return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,3 +291,134 @@ export async function getDataChangeLogsForExport(
|
||||
}
|
||||
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
|
||||
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