diff --git a/client/README.md b/client/README.md index 471ad770..bc8d50a0 100644 --- a/client/README.md +++ b/client/README.md @@ -29,17 +29,25 @@ npm run dev ``` ## 4. 鉴权与会话说明 -- Access Token 仅保存在内存,不写入 localStorage/sessionStorage。 +- Access Token 仅保存在内存,不写入 `localStorage/sessionStorage`。 - Refresh Token 由后端通过 HttpOnly Cookie 维护。 - 前端在 401 时自动调用 `/api/auth/refresh` 并重试一次请求。 - 登录与刷新请求都会附带 `x-device-fingerprint`。 -## 5. 风格资源复用策略(锁定) +## 5. 菜单与权限(当前壳层) +- 仪表盘:登录即可访问。 +- 我的报销:`expense.view_own` 或 `expense.view_all`。 +- 待我处理:`expense.approve`。 +- 票据中心:`invoice.upload` / `invoice.view_own` / `invoice.manage`。 +- 审计工作台:`system.audit.view`(finance_mgr/admin)。 +- 个人设置:登录即可访问。 + +## 6. 风格资源复用策略(锁定) - 视觉 Token 来源:`/home/shunli/Projects/hyb-web`。 - 采用“复制到 client + 单向同步”,不做跨仓软链与自动联动。 - 如需同步风格,请记录来源 commit 并通过评审。 -## 6. 可用脚本 +## 7. 可用脚本 ```bash npm run dev npm run typecheck diff --git a/client/src/api/audit.ts b/client/src/api/audit.ts new file mode 100644 index 00000000..e5fa99f3 --- /dev/null +++ b/client/src/api/audit.ts @@ -0,0 +1,50 @@ +import { apiRequestData } from "./http"; + +export type AuditLogItem = { + id: string; + requestId: string; + actorId: string; + actorName: string; + actorEmail: string; + action: string; + targetType: string; + targetId: string | null; + ipAddress: string | null; + userAgent: string | null; + extra: Record; + createdAt: string; +}; + +export type AuditLogListResponse = { + page: number; + pageSize: number; + total: number; + items: AuditLogItem[]; +}; + +export type AuditLogQuery = { + page?: number; + pageSize?: number; + actorId?: string; + action?: string; + targetType?: string; + dateFrom?: string; + dateTo?: string; +}; + +function toQueryString(query: AuditLogQuery) { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value == null || value === "") { + continue; + } + params.set(key, String(value)); + } + + const encoded = params.toString(); + return encoded ? `?${encoded}` : ""; +} + +export async function fetchAuditLogs(query: AuditLogQuery) { + return apiRequestData(`/api/audit-logs${toQueryString(query)}`); +} diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 127e58ce..ee128f14 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -39,6 +39,7 @@ const ROLE_PERMISSIONS: Record = { "expense.view_all", "expense.finance.override_duplicate", "system.settings", + "system.audit.view", ], administration: ["expense.proxy_submit", "expense.view_own"], hr: ["system.users", "expense.view_all"], diff --git a/client/src/components/navigation/Sidebar.tsx b/client/src/components/navigation/Sidebar.tsx index bc799091..4f7c7fd0 100644 --- a/client/src/components/navigation/Sidebar.tsx +++ b/client/src/components/navigation/Sidebar.tsx @@ -25,6 +25,11 @@ const NAV_ITEMS: NavItem[] = [ label: "票据中心", anyOf: ["invoice.upload", "invoice.view_own", "invoice.manage"], }, + { + to: "/audit-logs", + label: "审计工作台", + anyOf: ["system.audit.view"], + }, { to: "/settings/profile", label: "个人设置" }, ]; diff --git a/client/src/pages/AuditLogsPage.tsx b/client/src/pages/AuditLogsPage.tsx new file mode 100644 index 00000000..524dd3d4 --- /dev/null +++ b/client/src/pages/AuditLogsPage.tsx @@ -0,0 +1,186 @@ +import { useEffect, useMemo, useState } from "react"; +import { isApiError } from "../api/types"; +import { fetchAuditLogs, type AuditLogListResponse } from "../api/audit"; +import { PageHeader } from "../components/common/PageHeader"; + +const DEFAULT_PAGE_SIZE = 20; + +export function AuditLogsPage() { + const [page, setPage] = useState(1); + const [actionFilter, setActionFilter] = useState(""); + const [targetTypeFilter, setTargetTypeFilter] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [data, setData] = useState(null); + + const totalPages = useMemo(() => { + if (!data) { + return 1; + } + + return Math.max(1, Math.ceil(data.total / data.pageSize)); + }, [data]); + + useEffect(() => { + let cancelled = false; + + async function load() { + setLoading(true); + setError(""); + + try { + const response = await fetchAuditLogs({ + page, + pageSize: DEFAULT_PAGE_SIZE, + action: actionFilter || undefined, + targetType: targetTypeFilter || undefined, + }); + + if (!cancelled) { + setData(response); + } + } catch (err) { + if (!cancelled) { + if (isApiError(err)) { + setError(`${err.code}: ${err.message}`); + } else { + setError("加载审计日志失败,请稍后重试"); + } + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + void load(); + + return () => { + cancelled = true; + }; + }, [actionFilter, page, targetTypeFilter]); + + return ( +
+ + +
+
+ + + + +
+ 结果统计 +
+ 共 {data?.total ?? 0} 条 +
+
+
+ + {error ? ( +

+ {error} +

+ ) : null} + +
+ + + + + + + + + + + + {loading ? ( + + + + ) : null} + + {!loading && data?.items?.length ? ( + data.items.map((item) => ( + + + + + + + + )) + ) : null} + + {!loading && !(data?.items?.length ?? 0) ? ( + + + + ) : null} + +
时间ActionActorTargetRequest ID
+ 加载中... +
{new Date(item.createdAt).toLocaleString()}{item.action} +
{item.actorName || item.actorId}
+
{item.actorEmail}
+
+ {item.targetType} + {item.targetId ? `/${item.targetId}` : ""} + {item.requestId}
+ 暂无数据 +
+
+ +
+ + 第 {page} / {totalPages} 页 + + +
+ + +
+
+
+
+ ); +} diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index c26a3e70..f2cd2ad3 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AppShellLayout } from "../layouts/AppShellLayout"; import { AuthLayout } from "../layouts/AuthLayout"; +import { AuditLogsPage } from "../pages/AuditLogsPage"; import { DashboardPage } from "../pages/DashboardPage"; import { ForbiddenPage } from "../pages/ForbiddenPage"; import { InvoicePoolPage } from "../pages/InvoicePoolPage"; @@ -63,6 +64,15 @@ export function AppRouter() { } /> + + + + } + /> + } />