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:
SpecialX
2026-07-03 10:24:26 +08:00
parent 048fc1c386
commit c935597803
29 changed files with 2344 additions and 356 deletions

View File

@@ -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" }
}
}

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

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

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

View File

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

View File

@@ -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)}
/>

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

View File

@@ -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>
))

View File

@@ -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>
)

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

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

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

View File

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

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

View File

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

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

View File

@@ -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)}
/>

View File

@@ -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>
))

View File

@@ -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>
)

View File

@@ -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
}
}

View 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
View 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` }
}

View 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],
)
}

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

View 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
}
}

View 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
},
}

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

View 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,
}),
}

View File

@@ -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
}