From a5933a286e35bad34928cffe2d13db423bae56ee Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Wed, 4 Nov 2026 16:27:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20A5=20=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=97=A5=E5=BF=97=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户选定实现操作日志列表 / 详情(方案 A)与运行日志(方案 C);多条件筛选、真实游标分页、字段级 diff / 完整原始 JSON、560 导出抽屉(轮询 / 历史 / 签名下载)、只读与权限控制;侧栏「审计日志」更名「操作日志」、「客户字段」更名「字段管理」并新增「运行日志」;1024 关键列适配。 --- apps/web/src/api/audit.spec.ts | 255 ++++ apps/web/src/api/audit.ts | 109 ++ apps/web/src/api/client.ts | 5 + apps/web/src/api/endpoints.ts | 11 + apps/web/src/api/query-keys.ts | 8 + apps/web/src/hooks/queries/use-audit.ts | 155 +++ apps/web/src/layout/AppLayout.tsx | 7 +- apps/web/src/layout/PageBreadcrumbs.tsx | 7 +- apps/web/src/main.tsx | 2 + apps/web/src/navigation/breadcrumbs.spec.ts | 15 +- apps/web/src/navigation/breadcrumbs.ts | 13 + apps/web/src/navigation/nav-config.spec.ts | 29 + apps/web/src/navigation/nav-config.ts | 13 +- .../pages/system/audit-logs/AuditDiffView.tsx | 143 ++ .../system/audit-logs/AuditExportSheet.tsx | 487 +++++++ .../pages/system/audit-logs/AuditFilters.tsx | 197 +++ .../system/audit-logs/AuditLogDetailPage.tsx | 286 ++++ .../pages/system/audit-logs/AuditLogTable.tsx | 321 +++++ .../pages/system/audit-logs/AuditLogsPage.tsx | 216 +++ .../system/audit-logs/audit-clipboard.ts | 12 + .../system/audit-logs/audit-display.spec.ts | 201 +++ .../pages/system/audit-logs/audit-display.ts | 295 ++++ .../audit-logs/audit-filter-utils.spec.ts | 167 +++ .../system/audit-logs/audit-filter-utils.ts | 208 +++ .../src/pages/system/run-logs/RunLogsPage.tsx | 238 ++++ apps/web/src/router.tsx | 39 + apps/web/src/routes.spec.ts | 10 + apps/web/src/routes.ts | 11 + apps/web/src/styles/audit.css | 1226 +++++++++++++++++ apps/web/src/styles/run-logs.css | 187 +++ 30 files changed, 4867 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/api/audit.spec.ts create mode 100644 apps/web/src/api/audit.ts create mode 100644 apps/web/src/hooks/queries/use-audit.ts create mode 100644 apps/web/src/pages/system/audit-logs/AuditDiffView.tsx create mode 100644 apps/web/src/pages/system/audit-logs/AuditExportSheet.tsx create mode 100644 apps/web/src/pages/system/audit-logs/AuditFilters.tsx create mode 100644 apps/web/src/pages/system/audit-logs/AuditLogDetailPage.tsx create mode 100644 apps/web/src/pages/system/audit-logs/AuditLogTable.tsx create mode 100644 apps/web/src/pages/system/audit-logs/AuditLogsPage.tsx create mode 100644 apps/web/src/pages/system/audit-logs/audit-clipboard.ts create mode 100644 apps/web/src/pages/system/audit-logs/audit-display.spec.ts create mode 100644 apps/web/src/pages/system/audit-logs/audit-display.ts create mode 100644 apps/web/src/pages/system/audit-logs/audit-filter-utils.spec.ts create mode 100644 apps/web/src/pages/system/audit-logs/audit-filter-utils.ts create mode 100644 apps/web/src/pages/system/run-logs/RunLogsPage.tsx create mode 100644 apps/web/src/styles/audit.css create mode 100644 apps/web/src/styles/run-logs.css diff --git a/apps/web/src/api/audit.spec.ts b/apps/web/src/api/audit.spec.ts new file mode 100644 index 0000000..5f35bf9 --- /dev/null +++ b/apps/web/src/api/audit.spec.ts @@ -0,0 +1,255 @@ +import type { + AuditExportJob, + AuditLogActionsResponse, + AuditLogDetail, + AuditLogItem, +} from '@lingxi/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { + buildAuditExportDownloadUrl, + createAuditExport, + fetchAuditActions, + fetchAuditExport, + fetchAuditExports, + fetchAuditLogDetail, + fetchAuditLogs, +} from './audit'; +import { setUnauthorizedHandler } from './client'; + +const NOW = '2026-11-04T10:00:00.000Z'; +const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const CUSTOMER_ID = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(status === 204 ? null : JSON.stringify(body), { + status, + headers: status === 204 ? undefined : { 'Content-Type': 'application/json' }, + }); + +const item: AuditLogItem = { + id: ID, + occurred_at: NOW, + actor_id: '01HZZZZZZZZZZZZZZZZZZZZZZ3', + actor_name: '江岚', + actor_account: 'jiang.lan', + action_code: 'identity.role.update', + action_label: '修改角色权限', + category: 'permission', + category_label: '权限与角色', + target_type: 'role', + target_id: 'role_material_ops', + target_name: '素材运营', + customer_id: null, + customer_name: null, + result: 'success', + http_method: 'PATCH', + http_path: '/api/v1/roles/role_material_ops', + trace_id: '01JCH9V4TR4CE00000000005', + ip: '10.12.0.18', + duration_ms: 74, + error_code: null, + error_message: null, + change_count: 1, + has_before: true, + has_after: true, +}; + +const detail: AuditLogDetail = { + ...item, + customer_id: CUSTOMER_ID, + customer_name: '花间集', + user_agent: 'Chrome 130 / Windows 11', + reason: '开放图片合成权限', + before: { permissions: ['material:review'] }, + after: { permissions: ['material:render'] }, + changes: null, + diff: [ + { + path: 'permissions', + label: '权限点', + before: 'material:review', + after: null, + kind: 'removed', + }, + { path: 'permissions', label: '权限点', before: null, after: 'material:render', kind: 'added' }, + ], +}; + +const actions: AuditLogActionsResponse = { + items: [ + { + code: 'identity.role.update', + label: '修改角色权限', + category: 'permission', + category_label: '权限与角色', + target_type: 'role', + sensitive: false, + }, + ], + categories: [{ key: 'permission', label: '权限与角色' }], +}; + +const exportJob: AuditExportJob = { + id: ID, + status: 'succeeded', + format: 'csv', + filters: { result: 'failure' }, + total_rows: 120, + row_count: 120, + processed_rows: 120, + truncated: false, + progress: 100, + file_size: 2048, + error_message: null, + requested_at: NOW, + started_at: NOW, + finished_at: NOW, + expires_at: NOW, + download_count: 0, + last_downloaded_at: null, + download_token: 'signed.token', +}; + +function lastCall(): { url: string; init: RequestInit | undefined } { + const fetchMock = vi.mocked(globalThis.fetch); + const call = fetchMock.mock.calls.at(-1); + const input = call?.[0]; + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? ''); + return { url, init: call?.[1] }; +} + +function lastBody(): unknown { + const body = lastCall().init?.body; + return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined; +} + +beforeEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + useAuthStore.getState().clearSession(); + setUnauthorizedHandler(null); + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: '01HZZZZZZZZZZZZZZZZZZZZZZ3', name: '江岚', account: 'jiang.lan' }, + permissions: ['audit:read', 'audit:export'], + }); +}); + +describe('审计日志接口封装', () => { + it('列表 query 使用 snake_case,并剔除空值', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + jsonResponse({ items: [item], next_cursor: 'cursor-2' }), + ); + + const response = await fetchAuditLogs({ + keyword: '素材', + category: 'permission', + actor: '江岚', + customer_id: CUSTOMER_ID, + action_code: 'identity.role.update', + target_type: 'role', + target_id: 'role_material_ops', + result: 'failure', + from: '2026-10-29T00:00:00.000Z', + to: '2026-11-04T13:32:00.000Z', + cursor: 'cursor-1', + limit: 20, + }); + + expect(response.items).toHaveLength(1); + expect(response.next_cursor).toBe('cursor-2'); + const url = lastCall().url; + expect(url).toContain('/audit-logs?'); + for (const key of [ + 'keyword=', + 'category=permission', + 'actor=', + 'customer_id=', + 'action_code=', + 'target_type=role', + 'target_id=role_material_ops', + 'result=failure', + 'from=', + 'to=', + 'cursor=cursor-1', + 'limit=20', + ]) { + expect(url).toContain(key); + } + expect(url).not.toContain('undefined'); + }); + + it('列表默认 limit=20,空筛选不会带出空参数', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + jsonResponse({ items: [], next_cursor: null }), + ); + await fetchAuditLogs({}); + const url = lastCall().url; + expect(url).toContain('limit=20'); + expect(url).not.toContain('keyword='); + expect(url).not.toContain('category='); + }); + + it('详情响应经 Zod parse,diff 保留前后值', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(detail)); + const result = await fetchAuditLogDetail(ID); + expect(lastCall().url).toContain(`/audit-logs/${ID}`); + expect(result.diff).toHaveLength(2); + expect(result.diff[0]?.kind).toBe('removed'); + expect(result.user_agent).toBe('Chrome 130 / Windows 11'); + }); + + it('动作目录返回分类与动作中文名', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse(actions)); + const result = await fetchAuditActions(); + expect(lastCall().url).toContain('/audit-logs/actions'); + expect(result.categories[0]?.label).toBe('权限与角色'); + expect(result.items[0]?.label).toBe('修改角色权限'); + }); + + it('创建导出:body 为 { format, filters }(filters snake_case)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse({ id: ID, status: 'queued' })); + const result = await createAuditExport({ + format: 'csv', + filters: { result: 'failure', from: '2026-10-29T00:00:00.000Z' }, + }); + expect(result.status).toBe('queued'); + expect(lastCall().init?.method).toBe('POST'); + expect(lastCall().url).toContain('/audit-logs/export'); + expect(lastBody()).toEqual({ + format: 'csv', + filters: { result: 'failure', from: '2026-10-29T00:00:00.000Z' }, + }); + }); + + it('导出历史与单任务进度', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [exportJob], next_cursor: null })) + .mockResolvedValueOnce(jsonResponse(exportJob)); + + const history = await fetchAuditExports({ limit: 20 }); + expect(history.items[0]?.status).toBe('succeeded'); + expect(lastCall().url).toContain('/audit-logs/exports?'); + expect(lastCall().url).toContain('limit=20'); + + const job = await fetchAuditExport(ID); + expect(lastCall().url).toContain(`/audit-logs/exports/${ID}`); + expect(job.download_token).toBe('signed.token'); + }); + + it('下载地址 = API base + path + 编码后的 token', () => { + const url = buildAuditExportDownloadUrl(ID, 'a+b/c='); + expect(url).toContain('/api/v1/audit-logs/exports/'); + expect(url).toContain(`/exports/${ID}/download?token=`); + expect(url).toContain('a%2Bb%2Fc%3D'); + }); + + it('响应不符合契约时抛 Zod 错误', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(jsonResponse({ items: [{ id: 1 }] })); + await expect(fetchAuditLogs({})).rejects.toThrow(); + }); +}); diff --git a/apps/web/src/api/audit.ts b/apps/web/src/api/audit.ts new file mode 100644 index 0000000..8f297b5 --- /dev/null +++ b/apps/web/src/api/audit.ts @@ -0,0 +1,109 @@ +/** + * A5 审计日志接口(唯一出处)。 + * 依据:packages/shared/src/schemas/audit.ts、PRD R-05、工程规范 08。 + * + * 约定:query 用 snake_case(C-01);导出创建 body 为 `{ format, filters }`,filters 内同样 snake_case。 + * 所有响应 / 入参都用 shared Zod schema parse,契约漂移在请求层直接暴露。 + */ +import { + type AuditExportCreateInput, + type AuditExportCreateResponse, + auditExportCreateResponseSchema, + auditExportCreateSchema, + type AuditExportJob, + auditExportJobSchema, + type AuditExportListQuery, + auditExportListQuerySchema, + type AuditExportListResponse, + auditExportListResponseSchema, + type AuditLogActionsResponse, + auditLogActionsResponseSchema, + type AuditLogDetail, + auditLogDetailSchema, + type AuditLogListQuery, + auditLogListQuerySchema, + type AuditLogListResponse, + auditLogListResponseSchema, +} from '@lingxi/shared'; + +import { apiBaseUrl, request } from './client'; +import { ENDPOINTS } from './endpoints'; + +function searchParams(entries: Record): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(entries)) { + if (value !== undefined && value !== '') params.set(key, String(value)); + } + const search = params.toString(); + return search === '' ? '' : `?${search}`; +} + +/** 审计日志列表:多条件 + 游标分页(query 全部 snake_case) */ +export async function fetchAuditLogs( + query: Partial = {}, +): Promise { + const parsed = auditLogListQuerySchema.parse(query); + const payload: unknown = await request( + `${ENDPOINTS.auditLogs}${searchParams({ + actor: parsed.actor, + customer_id: parsed.customer_id, + action_code: parsed.action_code, + category: parsed.category, + target_type: parsed.target_type, + target_id: parsed.target_id, + result: parsed.result, + from: parsed.from, + to: parsed.to, + keyword: parsed.keyword, + cursor: parsed.cursor, + limit: parsed.limit, + })}`, + ); + return auditLogListResponseSchema.parse(payload); +} + +/** 动作目录(筛选下拉 + 中文名 / 分类映射) */ +export async function fetchAuditActions(): Promise { + const payload: unknown = await request(ENDPOINTS.auditLogActions); + return auditLogActionsResponseSchema.parse(payload); +} + +/** 日志详情:含 before / after / changes / diff / trace / ip / ua */ +export async function fetchAuditLogDetail(logId: string): Promise { + const payload: unknown = await request(ENDPOINTS.auditLog(logId)); + return auditLogDetailSchema.parse(payload); +} + +/** 创建异步导出任务(导出本身会写审计,由后端拦截器完成) */ +export async function createAuditExport( + input: AuditExportCreateInput, +): Promise { + const body = auditExportCreateSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.auditExport, { method: 'POST', body }); + return auditExportCreateResponseSchema.parse(payload); +} + +/** 我的导出任务(按发起时间倒序,游标分页) */ +export async function fetchAuditExports( + query: Partial = {}, +): Promise { + const parsed = auditExportListQuerySchema.parse(query); + const payload: unknown = await request( + `${ENDPOINTS.auditExports}${searchParams({ + cursor: parsed.cursor, + limit: parsed.limit, + })}`, + ); + return auditExportListResponseSchema.parse(payload); +} + +/** 单个导出任务进度(download_token 仅成功后返回) */ +export async function fetchAuditExport(exportId: string): Promise { + const payload: unknown = await request(ENDPOINTS.auditExportJob(exportId)); + return auditExportJobSchema.parse(payload); +} + +/** 短时效签名下载地址:API base + path + token(token 只出现在 URL,不塞进 JSON) */ +export function buildAuditExportDownloadUrl(exportId: string, token: string): string { + return `${apiBaseUrl()}${ENDPOINTS.auditExportDownload(exportId)}?token=${encodeURIComponent(token)}`; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 66b01d0..867c3d5 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -51,6 +51,11 @@ function baseUrl(): string { return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); } +/** API 根地址(无尾斜杠):供签名下载等需要直链 / 新窗口打开的接口复用,禁止在业务里重复硬编码 */ +export function apiBaseUrl(): string { + return baseUrl(); +} + function buildUrl(path: string): string { if (/^https?:\/\//.test(path)) { return path; diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts index 43bc4f6..84b2c20 100644 --- a/apps/web/src/api/endpoints.ts +++ b/apps/web/src/api/endpoints.ts @@ -6,6 +6,7 @@ const USERS = '/users'; const DICTS = '/dicts'; const SYSTEM_CONFIGS = '/system/configs'; +const AUDIT_LOGS = '/audit-logs'; export const ENDPOINTS = { login: '/auth/login', @@ -64,4 +65,14 @@ export const ENDPOINTS = { systemConfigs: SYSTEM_CONFIGS, systemConfigsEffective: `${SYSTEM_CONFIGS}/effective`, systemConfigsHistory: `${SYSTEM_CONFIGS}/history`, + // A5 audit:列表 / 动作目录 / 详情 / 异步导出 + auditLogs: AUDIT_LOGS, + auditLog: (logId: string): string => `${AUDIT_LOGS}/${encodeURIComponent(logId)}`, + auditLogActions: `${AUDIT_LOGS}/actions`, + auditExport: `${AUDIT_LOGS}/export`, + auditExports: `${AUDIT_LOGS}/exports`, + auditExportJob: (exportId: string): string => + `${AUDIT_LOGS}/exports/${encodeURIComponent(exportId)}`, + auditExportDownload: (exportId: string): string => + `${AUDIT_LOGS}/exports/${encodeURIComponent(exportId)}/download`, } as const; diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index 7b1b217..3d6583f 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -35,4 +35,12 @@ export const queryKeys = { systemConfigs: ['system-configs'] as const, systemConfigsEffective: ['system-configs', 'effective'] as const, systemConfigHistory: (query: object) => ['system-configs', 'history', query] as const, + // A5 audit + auditLogs: ['audit-logs'] as const, + auditLogList: (query: object) => ['audit-logs', 'list', query] as const, + auditLog: (logId: string) => ['audit-logs', 'detail', logId] as const, + auditActions: ['audit-logs', 'actions'] as const, + auditExports: ['audit-exports'] as const, + auditExportList: (query: object) => ['audit-exports', 'list', query] as const, + auditExport: (exportId: string) => ['audit-exports', 'detail', exportId] as const, }; diff --git a/apps/web/src/hooks/queries/use-audit.ts b/apps/web/src/hooks/queries/use-audit.ts new file mode 100644 index 0000000..1de688f --- /dev/null +++ b/apps/web/src/hooks/queries/use-audit.ts @@ -0,0 +1,155 @@ +/** + * A5 审计日志查询与写操作(TanStack Query 唯一入口)。 + * + * - 列表用 `useInfiniteQuery` 做真实游标累加,「加载更多」追加下一页; + * - 详情请求本身会写 `audit.log.view`(后端拦截器),前端只负责读取; + * - 导出为异步任务:创建后失效审计列表,让「导出本身留痕」的新记录可见; + * - 导出进度按 status 轮询(queued / running → 1.5s),成功 / 失败即停止。 + */ +import type { + AuditExportCreateInput, + AuditExportCreateResponse, + AuditExportJob, + AuditExportListQuery, + AuditExportListResponse, + AuditLogActionsResponse, + AuditLogDetail, + AuditLogFilter, + AuditLogListQuery, + AuditLogListResponse, +} from '@lingxi/shared'; +import { + type InfiniteData, + keepPreviousData, + useInfiniteQuery, + type UseInfiniteQueryResult, + useMutation, + type UseMutationResult, + useQuery, + useQueryClient, + type UseQueryResult, +} from '@tanstack/react-query'; + +import { + createAuditExport, + fetchAuditActions, + fetchAuditExport, + fetchAuditExports, + fetchAuditLogDetail, + fetchAuditLogs, +} from '@/api/audit'; +import { ApiError } from '@/api/client'; +import { queryKeys } from '@/api/query-keys'; + +/** 审计列表每页条数(与后端默认上限 100 对齐,取 20 兼顾首屏) */ +export const AUDIT_LOG_PAGE_SIZE = 20; +/** 导出任务列表每页条数 */ +export const AUDIT_EXPORT_PAGE_SIZE = 20; +/** 导出进行中轮询间隔 */ +const EXPORT_POLL_INTERVAL_MS = 1500; + +/** 游标分页列表:下一页靠后端返回的 next_cursor,不猜页码 */ +export function useAuditLogsInfiniteQuery( + filter: AuditLogFilter, +): UseInfiniteQueryResult> { + return useInfiniteQuery({ + queryKey: queryKeys.auditLogList({ ...filter, limit: AUDIT_LOG_PAGE_SIZE }), + queryFn: ({ pageParam }) => + fetchAuditLogs({ + ...filter, + limit: AUDIT_LOG_PAGE_SIZE, + ...(pageParam === '' ? {} : { cursor: pageParam }), + }), + initialPageParam: '', + getNextPageParam: (lastPage) => lastPage.next_cursor, + staleTime: 10_000, + }); +} + +/** 单页列表(详情页「相关记录」等小范围读取) */ +export function useAuditLogsQuery( + query: Partial, + options: { enabled?: boolean } = {}, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.auditLogList(query), + queryFn: () => fetchAuditLogs(query), + enabled: options.enabled ?? true, + placeholderData: keepPreviousData, + staleTime: 10_000, + }); +} + +/** 详情:40401 不做无意义重试,避免「重试 → loading → 又失败」的抖动 */ +export function useAuditLogDetailQuery(logId: string | null): UseQueryResult { + return useQuery({ + queryKey: queryKeys.auditLog(logId ?? ''), + queryFn: () => fetchAuditLogDetail(logId ?? ''), + enabled: logId !== null && logId !== '', + staleTime: 10_000, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.code === 40401) return false; + return failureCount < 2; + }, + }); +} + +/** 动作目录:中文名 / 分类,筛选下拉与标签映射共用 */ +export function useAuditActionsQuery(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.auditActions, + queryFn: fetchAuditActions, + staleTime: 5 * 60_000, + }); +} + +/** 我的导出历史 */ +export function useAuditExportsQuery( + options: { enabled?: boolean } = {}, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.auditExportList({ limit: AUDIT_EXPORT_PAGE_SIZE }), + queryFn: () => fetchAuditExports({ limit: AUDIT_EXPORT_PAGE_SIZE }), + enabled: options.enabled ?? true, + staleTime: 5_000, + }); +} + +/** 单个导出任务:进行中按 1.5s 轮询,终态自动停止 */ +export function useAuditExportQuery( + exportId: string | null, + options: { enabled?: boolean } = {}, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.auditExport(exportId ?? ''), + queryFn: () => fetchAuditExport(exportId ?? ''), + enabled: (options.enabled ?? true) && exportId !== null && exportId !== '', + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === 'queued' || status === 'running' ? EXPORT_POLL_INTERVAL_MS : false; + }, + staleTime: 0, + retry: 1, + }); +} + +/** 创建导出:成功后失效审计列表与导出历史(导出动作自身会生成 audit.export 记录) */ +export function useCreateAuditExportMutation(): UseMutationResult< + AuditExportCreateResponse, + Error, + AuditExportCreateInput +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createAuditExport, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.auditLogs }), + queryClient.invalidateQueries({ queryKey: queryKeys.auditExports }), + ]); + }, + }); +} + +/** 导出历史查询参数(供调用方在需要时扩展) */ +export type AuditExportHistoryQuery = Partial; diff --git a/apps/web/src/layout/AppLayout.tsx b/apps/web/src/layout/AppLayout.tsx index ed9ad57..fef02a4 100644 --- a/apps/web/src/layout/AppLayout.tsx +++ b/apps/web/src/layout/AppLayout.tsx @@ -11,7 +11,7 @@ import { PageBreadcrumbs } from './PageBreadcrumbs'; import { SideNav } from './SideNav'; import { TopBar } from './TopBar'; -/** 用户管理、部门管理、数据字典、系统配置为「满高主从布局」,由页面自己控制内部滚动 */ +/** 用户 / 部门 / 字典 / 系统配置 / 操作日志 / 运行日志为「满高布局」,由页面自己控制内部滚动 */ function isFlushRoute(pathname: string): boolean { return ( pathname === ROUTES.system.users || @@ -19,7 +19,10 @@ function isFlushRoute(pathname: string): boolean { pathname === ROUTES.system.departments || pathname === ROUTES.system.dicts || pathname.startsWith(`${ROUTES.system.dicts}/`) || - pathname === ROUTES.system.settings + pathname === ROUTES.system.settings || + pathname === ROUTES.system.auditLogs || + pathname.startsWith(`${ROUTES.system.auditLogs}/`) || + pathname === ROUTES.system.runLogs ); } diff --git a/apps/web/src/layout/PageBreadcrumbs.tsx b/apps/web/src/layout/PageBreadcrumbs.tsx index 0fa34a7..969b4a2 100644 --- a/apps/web/src/layout/PageBreadcrumbs.tsx +++ b/apps/web/src/layout/PageBreadcrumbs.tsx @@ -1,7 +1,11 @@ import type { ReactNode } from 'react'; import { Link } from 'react-router'; -import { getRoleSectionTrail, getUserSectionTrail } from '@/navigation/breadcrumbs'; +import { + getAuditSectionTrail, + getRoleSectionTrail, + getUserSectionTrail, +} from '@/navigation/breadcrumbs'; import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config'; export interface PageBreadcrumbsProps { @@ -12,6 +16,7 @@ export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode { const entries = getUserSectionTrail(pathname) ?? getRoleSectionTrail(pathname) ?? + getAuditSectionTrail(pathname) ?? getBreadcrumbs(NAV_MODULES, pathname); return (