Files
Edu/apps/admin-portal/src/app/admin/audit-logs/page.tsx
SpecialX 7cf9aec20e feat(admin-portal): 完成参考项目差距闭环 - 4 批次补齐 + Vitest 测试
差距分析闭环(workline §7-§8):
- P0:审计子模块 3 页 + 学校组织 7 页
- P1:系统设置 4 Card + 公告管理 + 邀请码管理
- P2:文件管理 + AI 配置
- P6:5 测试文件 / 69 用例全过

共享基础设施:
- permissions +11 权限点 +14 路由
- view-models +20 接口
- i18n +100 key
- graphql-client +25 operation
- fixtures/handlers +15 mock +20 handler

质量:typecheck + lint 零错误,vitest 69/69 通过,arch.db 已更新
2026-07-13 13:05:29 +08:00

162 lines
5.3 KiB
TypeScript

"use client";
import { type ReactNode, useState, useEffect } from "react";
import {
useAuditLogs,
useAuditLogFilter,
exportAuditLogsCsv,
} from "@/hooks/use-audit-logs";
import { useToast } from "@/providers/toast-provider";
import { AuditLogsTabs } from "@/components/audit-logs-tabs";
import {
PageHeader,
PaperCard,
Input,
Select,
Button,
LoadingState,
ErrorState,
EmptyState,
Table,
TableRow,
TableCell,
Pagination,
} from "@/components/ui";
import { t } from "@/lib/i18n";
export default function AuditLogsPage(): ReactNode {
const { filter, setPage, setSearch, setAction } = useAuditLogFilter();
const { data, loading, error } = useAuditLogs(filter);
const { show } = useToast();
const [searchInput, setSearchInput] = useState("");
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput), 300);
return () => clearTimeout(timer);
}, [searchInput, setSearch]);
const handleExport = () => {
if (!data || data.items.length === 0) {
show("warning", "暂无数据可导出");
return;
}
exportAuditLogsCsv(data.items);
show("success", "已导出 CSV");
};
return (
<div style={{ padding: 32, maxWidth: 1200, margin: "0 auto" }}>
<PageHeader
title={t("admin.auditLogs.title")}
description="系统所有操作的审计记录"
actions={
<Button variant="secondary" onClick={handleExport}>
{t("admin.auditLogs.exportCsv")}
</Button>
}
/>
<AuditLogsTabs />
<PaperCard className="p-4 mb-4">
<div className="flex gap-3 items-center">
<Input
type="search"
placeholder={t("admin.common.search")}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
style={{ width: 240 }}
/>
<Select
value={filter.action ?? ""}
onChange={(e) => setAction(e.target.value)}
>
<option value="">{t("admin.common.all")}</option>
<option value="USER_LOGIN"></option>
<option value="USER_CREATE"></option>
<option value="USER_UPDATE"></option>
<option value="USER_TOGGLE_STATUS"></option>
<option value="ROLE_CREATE"></option>
<option value="ROLE_UPDATE"></option>
<option value="VIEWPORT_UPDATE"></option>
<option value="SYSTEM_SETTINGS_UPDATE"></option>
<option value="ABNORMAL_LOGIN_BLOCKED"></option>
</Select>
</div>
</PaperCard>
<PaperCard className="p-4">
{loading && <LoadingState />}
{error && <ErrorState message={error.message} />}
{!loading && !error && data && (
<>
{data.items.length === 0 ? (
<EmptyState message={t("admin.common.empty")} />
) : (
<>
<Table
headers={[
t("admin.auditLogs.time"),
t("admin.auditLogs.actor"),
t("admin.auditLogs.action"),
t("admin.auditLogs.resource"),
t("admin.auditLogs.resourceId"),
t("admin.auditLogs.ip"),
t("admin.auditLogs.traceId"),
]}
>
{data.items.map((log) => (
<TableRow key={log.id}>
<TableCell style={{ whiteSpace: "nowrap" }}>
{new Date(log.occurredAt).toLocaleString("zh-CN")}
</TableCell>
<TableCell>{log.actorName}</TableCell>
<TableCell>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
}}
>
{log.action}
</span>
</TableCell>
<TableCell>{log.resourceType}</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.resourceId}
</TableCell>
<TableCell
style={{ fontFamily: "var(--font-mono)", fontSize: 11 }}
>
{log.ip}
</TableCell>
<TableCell
style={{
fontFamily: "var(--font-mono)",
fontSize: 11,
color: "var(--color-ink-muted)",
}}
>
{log.traceId ?? "—"}
</TableCell>
</TableRow>
))}
</Table>
<Pagination
page={data.page}
pageSize={data.pageSize}
total={data.total}
hasNext={data.hasNext}
onPageChange={setPage}
/>
</>
)}
</>
)}
</PaperCard>
</div>
);
}