feat(web): A5 操作日志与运行日志页
按用户选定实现操作日志列表 / 详情(方案 A)与运行日志(方案 C);多条件筛选、真实游标分页、字段级 diff / 完整原始 JSON、560 导出抽屉(轮询 / 历史 / 签名下载)、只读与权限控制;侧栏「审计日志」更名「操作日志」、「客户字段」更名「字段管理」并新增「运行日志」;1024 关键列适配。
This commit is contained in:
1 parent
6bdda6ec62
commit
a5933a286e
30 files changed
+4867
-6
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, string | number | undefined>): 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<AuditLogListQuery> = {},
|
||||
): Promise<AuditLogListResponse> {
|
||||
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<AuditLogActionsResponse> {
|
||||
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<AuditLogDetail> {
|
||||
const payload: unknown = await request(ENDPOINTS.auditLog(logId));
|
||||
return auditLogDetailSchema.parse(payload);
|
||||
}
|
||||
|
||||
/** 创建异步导出任务(导出本身会写审计,由后端拦截器完成) */
|
||||
export async function createAuditExport(
|
||||
input: AuditExportCreateInput,
|
||||
): Promise<AuditExportCreateResponse> {
|
||||
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<AuditExportListQuery> = {},
|
||||
): Promise<AuditExportListResponse> {
|
||||
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<AuditExportJob> {
|
||||
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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<InfiniteData<AuditLogListResponse>> {
|
||||
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<AuditLogListQuery>,
|
||||
options: { enabled?: boolean } = {},
|
||||
): UseQueryResult<AuditLogListResponse> {
|
||||
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<AuditLogDetail> {
|
||||
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<AuditLogActionsResponse> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.auditActions,
|
||||
queryFn: fetchAuditActions,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 我的导出历史 */
|
||||
export function useAuditExportsQuery(
|
||||
options: { enabled?: boolean } = {},
|
||||
): UseQueryResult<AuditExportListResponse> {
|
||||
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<AuditExportJob> {
|
||||
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<AuditExportListQuery>;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<nav className="lx-breadcrumbs" aria-label="面包屑">
|
||||
|
||||
@@ -12,6 +12,8 @@ import '@/styles/departments.css';
|
||||
import '@/styles/client.css';
|
||||
import '@/styles/dicts.css';
|
||||
import '@/styles/settings.css';
|
||||
import '@/styles/audit.css';
|
||||
import '@/styles/run-logs.css';
|
||||
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||
import { localeZhCN } from '@lingxi/ui';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
|
||||
import { getAuditSectionTrail, getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
|
||||
|
||||
describe('面包屑子路由补全', () => {
|
||||
it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => {
|
||||
@@ -28,6 +28,19 @@ describe('面包屑子路由补全', () => {
|
||||
expect(getRoleSectionTrail('/system/roles')).toBeNull();
|
||||
});
|
||||
|
||||
it('操作日志详情:管理中心 / 操作日志 / 日志详情', () => {
|
||||
const trail = getAuditSectionTrail('/system/audit-logs/01HZZZZZZZZZZZZZZZZZZZZZZZ');
|
||||
expect(trail?.map((entry) => entry.label)).toEqual([
|
||||
'工作台',
|
||||
'管理中心',
|
||||
'操作日志',
|
||||
'日志详情',
|
||||
]);
|
||||
expect(trail?.[2]?.path).toBe('/system/audit-logs');
|
||||
expect(getAuditSectionTrail('/system/audit-logs')).toBeNull();
|
||||
expect(getAuditSectionTrail('/system/logs')).toBeNull();
|
||||
});
|
||||
|
||||
it('用户子路由补全不受影响', () => {
|
||||
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
|
||||
'工作台',
|
||||
|
||||
@@ -46,3 +46,16 @@ export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 操作日志详情不在 nav-config 菜单项里,这里补齐:管理中心 / 操作日志 / 日志详情 */
|
||||
export function getAuditSectionTrail(pathname: string): BreadcrumbEntry[] | null {
|
||||
if (/^\/system\/audit-logs\/[^/]+$/.test(pathname)) {
|
||||
return [
|
||||
{ key: 'home', label: '工作台', path: ROUTES.home },
|
||||
{ key: 'system', label: '管理中心' },
|
||||
{ key: 'audit', label: '操作日志', path: ROUTES.system.auditLogs },
|
||||
{ key: 'audit-detail', label: '日志详情' },
|
||||
];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PermissionCode } from '@lingxi/shared';
|
||||
import { IconServer } from '@lingxi/ui/icons';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { filterNavByPermissions, findActiveNav, getBreadcrumbs, NAV_MODULES } from './nav-config';
|
||||
@@ -75,6 +76,34 @@ describe('导航配置', () => {
|
||||
expect(findActiveNav(NAV_MODULES, '/system/dicts')?.item?.label).toBe('数据字典');
|
||||
});
|
||||
|
||||
it('A5:操作日志 / 运行日志 / 字段管理命名、权限与归属', () => {
|
||||
const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? [];
|
||||
const auditItem = systemItems.find((item) => item.key === 'audit');
|
||||
const runlogsItem = systemItems.find((item) => item.key === 'runlogs');
|
||||
const customerFieldsItem = systemItems.find((item) => item.key === 'customer-fields');
|
||||
|
||||
expect(auditItem?.label).toBe('操作日志');
|
||||
expect(auditItem?.path).toBe('/system/audit-logs');
|
||||
expect(auditItem?.permission).toBe('audit:read');
|
||||
expect(runlogsItem?.label).toBe('运行日志');
|
||||
expect(runlogsItem?.path).toBe('/system/logs');
|
||||
expect(runlogsItem?.permission).toBe('audit:read');
|
||||
expect(runlogsItem?.icon).toBe(IconServer);
|
||||
expect(customerFieldsItem?.label).toBe('字段管理');
|
||||
|
||||
// 运行日志排在操作日志之后
|
||||
const auditIndex = systemItems.findIndex((item) => item.key === 'audit');
|
||||
const runlogsIndex = systemItems.findIndex((item) => item.key === 'runlogs');
|
||||
expect(runlogsIndex).toBe(auditIndex + 1);
|
||||
|
||||
expect(findActiveNav(NAV_MODULES, '/system/logs')?.item?.label).toBe('运行日志');
|
||||
expect(findActiveNav(NAV_MODULES, '/system/logs')?.module.key).toBe('system');
|
||||
expect(findActiveNav(NAV_MODULES, '/system/audit-logs')?.item?.label).toBe('操作日志');
|
||||
expect(
|
||||
findActiveNav(NAV_MODULES, '/system/audit-logs/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.item?.label,
|
||||
).toBe('操作日志');
|
||||
});
|
||||
|
||||
it('按路径定位当前模块与菜单', () => {
|
||||
const active = findActiveNav(NAV_MODULES, '/material/quick-review');
|
||||
expect(active?.module.key).toBe('material');
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
IconPlay,
|
||||
IconRobot,
|
||||
IconScript,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconShield,
|
||||
IconStack,
|
||||
@@ -305,12 +306,20 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
},
|
||||
{
|
||||
key: 'audit',
|
||||
label: '审计日志',
|
||||
label: '操作日志',
|
||||
path: '/system/audit-logs',
|
||||
icon: IconView,
|
||||
permission: Permission.AUDIT_READ,
|
||||
description: '关键操作留痕与导出记录',
|
||||
},
|
||||
{
|
||||
key: 'runlogs',
|
||||
label: '运行日志',
|
||||
path: '/system/logs',
|
||||
icon: IconServer,
|
||||
permission: Permission.AUDIT_READ,
|
||||
description: '日志平台入口、trace 检索与运行日志说明',
|
||||
},
|
||||
{
|
||||
key: 'ai',
|
||||
label: '模型接入',
|
||||
@@ -345,7 +354,7 @@ export const NAV_MODULES: readonly NavModule[] = [
|
||||
},
|
||||
{
|
||||
key: 'customer-fields',
|
||||
label: '客户字段',
|
||||
label: '字段管理',
|
||||
path: '/system/customer-fields/approvals',
|
||||
icon: IconTask,
|
||||
permission: Permission.CUSTOMER_FIELD_APPROVE,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable max-lines-per-function -- 详情 diff 视图包含表格 / 原始 JSON 双视图与失败态,集中便于保持展示口径 */
|
||||
import type { AuditLogDetail } from '@lingxi/shared';
|
||||
import { PageEmpty } from '@lingxi/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
AUDIT_DIFF_KIND_META,
|
||||
auditDiffFieldLabel,
|
||||
formatAuditDiffValue,
|
||||
formatAuditRawJson,
|
||||
} from './audit-display';
|
||||
|
||||
type DiffView = 'diff' | 'raw';
|
||||
|
||||
export interface AuditDiffViewProps {
|
||||
detail: AuditLogDetail;
|
||||
}
|
||||
|
||||
/** 详情主体:变更对比(默认)/ 完整原始 JSON 切换 */
|
||||
export function AuditDiffView({ detail }: AuditDiffViewProps): ReactNode {
|
||||
const [view, setView] = useState<DiffView>('diff');
|
||||
const failed = detail.result === 'failure';
|
||||
const diff = detail.diff;
|
||||
|
||||
return (
|
||||
<section className="lx-audit-detail-card lx-audit-diff" aria-label="变更对比">
|
||||
<div className="lx-audit-detail-card__head">
|
||||
<b>变更对比</b>
|
||||
<span className="lx-audit-detail-card__count">
|
||||
{diff.length === 0 ? '无字段变更' : `${String(diff.length)} 个字段变更`}
|
||||
</span>
|
||||
<div className="lx-audit-seg" role="tablist" aria-label="变更展示方式">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'diff'}
|
||||
className={`lx-audit-seg__item${view === 'diff' ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setView('diff');
|
||||
}}
|
||||
>
|
||||
对比视图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === 'raw'}
|
||||
className={`lx-audit-seg__item${view === 'raw' ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setView('raw');
|
||||
}}
|
||||
>
|
||||
原始 JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{failed ? (
|
||||
<div className="lx-audit-fail-box" role="alert">
|
||||
<b>操作失败 · 变更未生效</b>
|
||||
<span>
|
||||
{detail.error_code === null ? '' : `错误码 ${detail.error_code} · `}
|
||||
{detail.error_message ?? '服务端未返回错误信息'}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view === 'diff' ? (
|
||||
diff.length === 0 ? (
|
||||
<PageEmpty
|
||||
title="没有可展示的字段变更"
|
||||
description="该记录未产生字段级前后值差异,可切换到「原始 JSON」查看完整记录。"
|
||||
/>
|
||||
) : (
|
||||
<div className="lx-audit-diff__wrap">
|
||||
<table className="lx-audit-diff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">变更字段</th>
|
||||
<th scope="col">变更前</th>
|
||||
<th scope="col">变更后</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{diff.map((row) => {
|
||||
const meta = AUDIT_DIFF_KIND_META[row.kind];
|
||||
const rejected = failed && row.kind !== 'removed';
|
||||
return (
|
||||
<tr key={`${row.kind}-${row.path}`} className={`is-${meta.tone}`}>
|
||||
<td>
|
||||
<span className={`lx-audit-tag lx-audit-tag--${meta.tone}`}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<b>{auditDiffFieldLabel(row)}</b>
|
||||
{auditDiffFieldLabel(row) === row.path ? null : <code>{row.path}</code>}
|
||||
</td>
|
||||
<td className="lx-audit-diff__before">
|
||||
{row.kind === 'added' ? (
|
||||
<span className="lx-audit-muted">—</span>
|
||||
) : (
|
||||
formatAuditDiffValue(row.before)
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={
|
||||
rejected
|
||||
? 'lx-audit-diff__after lx-audit-diff__after--rejected'
|
||||
: 'lx-audit-diff__after'
|
||||
}
|
||||
>
|
||||
{row.kind === 'removed' ? (
|
||||
<span className="lx-audit-muted">—</span>
|
||||
) : (
|
||||
formatAuditDiffValue(row.after)
|
||||
)}
|
||||
{rejected ? <em className="lx-audit-diff__rejected">未生效</em> : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="lx-audit-raw">
|
||||
<div className="lx-audit-raw__head">
|
||||
完整审计记录(原始 JSON)· 含 id / 操作人 / 动作 / 对象 / 请求 / 前后值
|
||||
</div>
|
||||
<pre className="lx-audit-raw__code">{formatAuditRawJson(detail)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.reason === null || detail.reason === '' ? null : (
|
||||
<div className="lx-audit-reason">
|
||||
<span>变更原因</span>
|
||||
<p>{detail.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
/* eslint-disable max-lines-per-function, complexity -- 导出抽屉编排创建 / 进度轮询 / 历史与下载,集中便于追踪状态流转 */
|
||||
import { Button, Progress, SideSheet } from '@douyinfe/semi-ui-19';
|
||||
import { type AuditExportJob, type AuditLogFilter, systemConfigDefinition } from '@lingxi/shared';
|
||||
import { ErrorState, notifyError, notifySuccess, PageEmpty } from '@lingxi/ui';
|
||||
import { IconDownload, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { buildAuditExportDownloadUrl } from '@/api/audit';
|
||||
import {
|
||||
useAuditExportQuery,
|
||||
useAuditExportsQuery,
|
||||
useCreateAuditExportMutation,
|
||||
} from '@/hooks/queries/use-audit';
|
||||
import { useEffectiveConfigsQuery } from '@/hooks/queries/use-configs';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
import {
|
||||
AUDIT_EXPORT_STATUS_META,
|
||||
auditExportPercent,
|
||||
auditExportRowsText,
|
||||
canDownloadAuditExport,
|
||||
formatAuditDateTime,
|
||||
formatFileSize,
|
||||
formatRowCount,
|
||||
isAuditExportExpired,
|
||||
summarizeAuditFilterRange,
|
||||
} from './audit-display';
|
||||
import {
|
||||
AUDIT_RESULT_OPTIONS,
|
||||
type AuditFilterDraft,
|
||||
describeAuditTimeRange,
|
||||
toAuditLogFilter,
|
||||
} from './audit-filter-utils';
|
||||
import type { AuditFilterOption } from './AuditFilters';
|
||||
|
||||
const EXPORT_MAX_ROWS_KEY = 'audit.export_max_rows';
|
||||
const EXPORT_RETENTION_KEY = 'audit.retention_months';
|
||||
const EXPORT_TTL_KEY = 'platform.export.signed_url_ttl_seconds';
|
||||
|
||||
const EXPORT_FIELDS = ['时间', '操作人', '操作', '对象', '客户', '结果', 'IP', 'trace_id'] as const;
|
||||
|
||||
export interface AuditExportSheetProps {
|
||||
visible: boolean;
|
||||
/** 当前已应用的筛选(导出范围跟随列表当前筛选) */
|
||||
draft: AuditFilterDraft;
|
||||
categoryOptions: readonly AuditFilterOption[];
|
||||
customerOptions: readonly AuditFilterOption[];
|
||||
loadedCount: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ExportTab = 'create' | 'history';
|
||||
|
||||
function definitionNumber(key: string, fallback: number): number {
|
||||
const definition = systemConfigDefinition(key);
|
||||
return typeof definition?.defaultValue === 'number' ? definition.defaultValue : fallback;
|
||||
}
|
||||
|
||||
function statusTag(job: AuditExportJob): ReactNode {
|
||||
const meta = AUDIT_EXPORT_STATUS_META[job.status];
|
||||
return <span className={`lx-audit-tag lx-audit-tag--${meta.tone}`}>{meta.label}</span>;
|
||||
}
|
||||
|
||||
function downloadExport(job: AuditExportJob): void {
|
||||
if (job.download_token === null || job.download_token === '') {
|
||||
notifyError('下载链接不可用,请刷新后重试');
|
||||
return;
|
||||
}
|
||||
window.open(buildAuditExportDownloadUrl(job.id, job.download_token), '_blank', 'noopener');
|
||||
notifySuccess('已打开下载链接,下载动作会写入操作日志');
|
||||
}
|
||||
|
||||
function Fact({ label, value }: { label: string; value: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div className="lx-audit-fact">
|
||||
<span>{label}</span>
|
||||
<b>{value}</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 导出范围摘要(跟随列表当前筛选,不做「预计条数」这类无法真实获知的推算) */
|
||||
function scopeRows(
|
||||
draft: AuditFilterDraft,
|
||||
categoryLabel: string,
|
||||
customerLabel: string,
|
||||
): { label: string; value: string }[] {
|
||||
const time = describeAuditTimeRange(draft, new Date());
|
||||
const resultLabel =
|
||||
AUDIT_RESULT_OPTIONS.find((option) => option.value === draft.result)?.label ?? '全部结果';
|
||||
return [
|
||||
{ label: '时间范围', value: `${time.label}(${time.range})` },
|
||||
{ label: '动作分类', value: categoryLabel },
|
||||
{ label: '操作人', value: draft.actor.trim() === '' ? '全部操作人' : draft.actor.trim() },
|
||||
{ label: '客户', value: customerLabel },
|
||||
{ label: '结果', value: resultLabel },
|
||||
{ label: '关键字', value: draft.keyword.trim() === '' ? '未设置' : draft.keyword.trim() },
|
||||
];
|
||||
}
|
||||
|
||||
interface ExportProgressProps {
|
||||
job: AuditExportJob;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function ExportProgress({ job, onRetry }: ExportProgressProps): ReactNode {
|
||||
const now = new Date();
|
||||
const expired = isAuditExportExpired(job, now);
|
||||
if (job.status === 'queued' || job.status === 'running') {
|
||||
const percent = auditExportPercent(job);
|
||||
return (
|
||||
<div className="lx-audit-export-progress">
|
||||
<div className="lx-audit-export-progress__head">
|
||||
{statusTag(job)}
|
||||
<b>{job.status === 'queued' ? '任务排队中' : '正在生成 CSV 文件'}</b>
|
||||
<span className="lx-audit-export-progress__pct">{percent}%</span>
|
||||
</div>
|
||||
<Progress percent={percent} showInfo={false} stroke="var(--lx-color-brand)" />
|
||||
<div className="lx-audit-export-progress__meta">
|
||||
<span>{auditExportRowsText(job)}</span>
|
||||
<span>{job.truncated ? '已达单次上限,文件会被截断' : '可关闭窗口,任务在后台继续'}</span>
|
||||
</div>
|
||||
<div className="lx-audit-facts">
|
||||
<Fact label="任务号" value={<code>{job.id}</code>} />
|
||||
<Fact label="创建时间" value={formatAuditDateTime(job.requested_at)} />
|
||||
<Fact label="文件格式" value="CSV(只读)" />
|
||||
<Fact label="执行状态" value={AUDIT_EXPORT_STATUS_META[job.status].label} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (job.status === 'succeeded') {
|
||||
return (
|
||||
<div className="lx-audit-export-result">
|
||||
<div className="lx-audit-export-success">
|
||||
<b>导出完成</b>
|
||||
<span>
|
||||
{job.id} · {formatRowCount(job.row_count)} 条 · CSV
|
||||
</span>
|
||||
</div>
|
||||
<div className="lx-audit-facts">
|
||||
<Fact label="文件大小" value={formatFileSize(job.file_size)} />
|
||||
<Fact label="完成时间" value={formatAuditDateTime(job.finished_at)} />
|
||||
<Fact label="过期时间" value={formatAuditDateTime(job.expires_at)} />
|
||||
<Fact label="下载次数" value={`${String(job.download_count)} 次`} />
|
||||
<Fact label="实际条数" value={formatRowCount(job.row_count)} />
|
||||
<Fact label="是否截断" value={job.truncated ? '已截断(达单次上限)' : '未截断'} />
|
||||
</div>
|
||||
{expired ? (
|
||||
<div className="lx-audit-export-warn">
|
||||
下载链接已过期。请点击「重新导出」生成新任务;过期任务仍保留在导出历史中。
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="lx-audit-export-result">
|
||||
<div className="lx-audit-export-fail">
|
||||
<b>导出失败</b>
|
||||
<span>{job.error_message ?? '后台生成失败,请重新导出'}</span>
|
||||
</div>
|
||||
<div className="lx-audit-facts">
|
||||
<Fact label="任务号" value={<code>{job.id}</code>} />
|
||||
<Fact label="失败时间" value={formatAuditDateTime(job.finished_at)} />
|
||||
<Fact label="筛选范围" value={summarizeAuditFilterRange(job.filters)} />
|
||||
<Fact label="重试" value="重新导出会生成新的任务号" />
|
||||
</div>
|
||||
<Button icon={<IconRefresh size={16} />} onClick={onRetry} className="lx-audit-export-retry">
|
||||
重新导出
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 操作日志导出抽屉(方案 A:右侧 560,创建导出 / 导出历史两个 Tab) */
|
||||
export function AuditExportSheet({
|
||||
visible,
|
||||
draft,
|
||||
categoryOptions,
|
||||
customerOptions,
|
||||
loadedCount,
|
||||
onClose,
|
||||
}: AuditExportSheetProps): ReactNode {
|
||||
const [tab, setTab] = useState<ExportTab>('create');
|
||||
const [activeExportId, setActiveExportId] = useState<string | null>(null);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const userName = useAuthStore((state) => state.user?.name ?? '—');
|
||||
|
||||
const effective = useEffectiveConfigsQuery();
|
||||
const maxRowsConfig = effective.data?.[EXPORT_MAX_ROWS_KEY];
|
||||
const maxRows =
|
||||
typeof maxRowsConfig === 'number'
|
||||
? maxRowsConfig
|
||||
: definitionNumber(EXPORT_MAX_ROWS_KEY, 50000);
|
||||
const retentionDefinition = systemConfigDefinition(EXPORT_RETENTION_KEY);
|
||||
const retentionMonths =
|
||||
typeof retentionDefinition?.min === 'number' ? retentionDefinition.min : 12;
|
||||
const ttlDefinition = systemConfigDefinition(EXPORT_TTL_KEY);
|
||||
const ttlSeconds =
|
||||
typeof ttlDefinition?.defaultValue === 'number' ? ttlDefinition.defaultValue : 900;
|
||||
|
||||
const createExport = useCreateAuditExportMutation();
|
||||
const jobQuery = useAuditExportQuery(activeExportId, { enabled: visible });
|
||||
const job = jobQuery.data ?? null;
|
||||
const history = useAuditExportsQuery({ enabled: visible && tab === 'history' });
|
||||
|
||||
const categoryLabel =
|
||||
categoryOptions.find((option) => option.value === draft.category)?.label ?? '全部分类';
|
||||
const customerLabel =
|
||||
customerOptions.find((option) => option.value === draft.customerId)?.label ?? '全部客户';
|
||||
const scope = useMemo(
|
||||
() => scopeRows(draft, categoryLabel, customerLabel),
|
||||
[draft, categoryLabel, customerLabel],
|
||||
);
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
setSubmitError(null);
|
||||
const filters: AuditLogFilter = toAuditLogFilter(draft, new Date());
|
||||
try {
|
||||
const result = await createExport.mutateAsync({ format: 'csv', filters });
|
||||
setActiveExportId(result.id);
|
||||
setTab('create');
|
||||
notifySuccess('导出任务已创建,导出动作已写入操作日志');
|
||||
} catch (cause) {
|
||||
const message = cause instanceof Error ? cause.message : '创建导出任务失败';
|
||||
setSubmitError(message);
|
||||
notifyError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const retry = (): void => {
|
||||
void handleCreate();
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const canDownload = job !== null && canDownloadAuditExport(job, now);
|
||||
|
||||
const footer =
|
||||
tab === 'history' ? (
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
) : job === null ? (
|
||||
<>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
loading={createExport.isPending}
|
||||
onClick={() => {
|
||||
void handleCreate();
|
||||
}}
|
||||
>
|
||||
创建导出任务
|
||||
</Button>
|
||||
</>
|
||||
) : job.status === 'queued' || job.status === 'running' ? (
|
||||
<>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setTab('history');
|
||||
}}
|
||||
>
|
||||
查看导出历史
|
||||
</Button>
|
||||
</>
|
||||
) : job.status === 'succeeded' ? (
|
||||
<>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconDownload size={16} />}
|
||||
disabled={!canDownload}
|
||||
onClick={() => {
|
||||
downloadExport(job);
|
||||
}}
|
||||
>
|
||||
{canDownload ? '下载文件' : '链接已过期'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconRefresh size={16} />}
|
||||
loading={createExport.isPending}
|
||||
onClick={retry}
|
||||
>
|
||||
重新导出
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
visible={visible}
|
||||
width={560}
|
||||
title="导出操作日志"
|
||||
closeOnEsc
|
||||
maskClosable={false}
|
||||
onCancel={onClose}
|
||||
footer={<div className="lx-audit-export-footer">{footer}</div>}
|
||||
>
|
||||
<div className="lx-audit-export">
|
||||
<p className="lx-audit-export__sub">
|
||||
当前筛选已加载 {loadedCount} 条 · CSV · 审计记录只读,实际导出条数以服务端筛选为准
|
||||
</p>
|
||||
|
||||
<div className="lx-audit-tabs" role="tablist" aria-label="导出">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'create'}
|
||||
className={`lx-audit-tab${tab === 'create' ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setTab('create');
|
||||
}}
|
||||
>
|
||||
创建导出
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'history'}
|
||||
className={`lx-audit-tab${tab === 'history' ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setTab('history');
|
||||
}}
|
||||
>
|
||||
导出历史
|
||||
<i className="lx-audit-tab__count">
|
||||
{history.data === undefined ? '—' : String(history.data.items.length)}
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'create' ? (
|
||||
<div role="tabpanel" className="lx-audit-export__panel">
|
||||
<div className="lx-audit-export__label">导出范围(跟随当前筛选)</div>
|
||||
<div className="lx-audit-scope">
|
||||
{scope.map((row) => (
|
||||
<div key={row.label} className="lx-audit-scope__row">
|
||||
<span>{row.label}</span>
|
||||
<b>{row.value}</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="lx-audit-facts">
|
||||
<Fact label="文件格式" value="CSV(只读)" />
|
||||
<Fact label="单次上限" value={`${formatRowCount(maxRows)} 行`} />
|
||||
<Fact label="文件有效期" value={`约 ${String(Math.round(ttlSeconds / 60))} 分钟`} />
|
||||
<Fact label="审计保留期" value={`≥ ${String(retentionMonths)} 个月`} />
|
||||
</div>
|
||||
|
||||
{job === null ? (
|
||||
<>
|
||||
<div className="lx-audit-export__label">导出字段(CSV 列)</div>
|
||||
<div className="lx-audit-chips">
|
||||
{EXPORT_FIELDS.map((field) => (
|
||||
<span key={field} className="lx-audit-chip">
|
||||
{field}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="lx-audit-export-flow">
|
||||
<div className="lx-audit-export-flow__step">
|
||||
<i>1</i>
|
||||
<span>创建任务并写入操作日志(audit.export)</span>
|
||||
</div>
|
||||
<div className="lx-audit-export-flow__step">
|
||||
<i>2</i>
|
||||
<span>后台生成 CSV,通常数秒内完成</span>
|
||||
</div>
|
||||
<div className="lx-audit-export-flow__step">
|
||||
<i>3</i>
|
||||
<span>短时效链接内下载,下载再次留痕</span>
|
||||
</div>
|
||||
</div>
|
||||
{submitError === null ? null : (
|
||||
<div className="lx-audit-export-error">{submitError}</div>
|
||||
)}
|
||||
<div className="lx-audit-export-note">
|
||||
CSV 为只读格式;超过单次上限会截断并在任务上标记 truncated。文件有效期以任务完成时
|
||||
返回的过期时间为准,审计记录按系统配置保留至少 {retentionMonths}{' '}
|
||||
个月,导出与下载都会写入 操作日志。
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ExportProgress job={job} onRetry={retry} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div role="tabpanel" className="lx-audit-export__panel">
|
||||
<div className="lx-audit-history__toolbar">
|
||||
<span>
|
||||
{history.data === undefined
|
||||
? '加载中…'
|
||||
: `共 ${String(history.data.items.length)} 个任务`}
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconRefresh size={16} />}
|
||||
loading={history.isFetching}
|
||||
onClick={() => {
|
||||
void history.refetch();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
{history.isPending ? <p className="lx-audit-muted">正在加载导出历史…</p> : null}
|
||||
{history.error === null ? null : (
|
||||
<ErrorState
|
||||
title="导出历史加载失败"
|
||||
message={history.error.message}
|
||||
onRetry={() => {
|
||||
void history.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!history.isPending && history.error === null && history.data.items.length === 0 ? (
|
||||
<PageEmpty
|
||||
title="还没有导出任务"
|
||||
description="创建导出后会在这里看到任务号、条数、状态与下载入口。"
|
||||
/>
|
||||
) : null}
|
||||
{!history.isPending && history.error === null
|
||||
? history.data.items.map((item) => (
|
||||
<div key={item.id} className="lx-audit-history-item">
|
||||
<div className="lx-audit-history-item__main">
|
||||
<b>
|
||||
{statusTag(item)} <code>{item.id}</code>
|
||||
</b>
|
||||
<span>
|
||||
{formatAuditDateTime(item.requested_at)} ·{' '}
|
||||
{formatRowCount(item.total_rows ?? item.row_count)} 条 · {userName}
|
||||
</span>
|
||||
<em>
|
||||
{summarizeAuditFilterRange(
|
||||
item.filters,
|
||||
(category) =>
|
||||
categoryOptions.find((option) => option.value === category)?.label ??
|
||||
category,
|
||||
)}
|
||||
</em>
|
||||
</div>
|
||||
<div className="lx-audit-history-item__side">
|
||||
{item.status === 'succeeded' ? (
|
||||
canDownloadAuditExport(item, new Date()) ? (
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconDownload size={14} />}
|
||||
onClick={() => {
|
||||
downloadExport(item);
|
||||
}}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
) : (
|
||||
<span className="lx-audit-muted">链接已过期</span>
|
||||
)
|
||||
) : item.status === 'failed' ? (
|
||||
<span className="lx-audit-history-item__fail">
|
||||
{item.error_message ?? '生成失败'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="lx-audit-muted">{auditExportPercent(item)}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* eslint-disable max-lines-per-function -- 筛选卡两行布局与自定义时间区间集中在一处 */
|
||||
import { Button, DatePicker, Input, RadioGroup, Select } from '@douyinfe/semi-ui-19';
|
||||
import { PageSkeleton } from '@lingxi/ui';
|
||||
import { IconRefresh, IconSearch } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
AUDIT_RESULT_OPTIONS,
|
||||
AUDIT_TIME_PRESETS,
|
||||
type AuditFilterDraft,
|
||||
type AuditResultFilter,
|
||||
describeAuditTimeRange,
|
||||
formatDateInput,
|
||||
parseDateInput,
|
||||
} from './audit-filter-utils';
|
||||
|
||||
export interface AuditFilterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface AuditFiltersProps {
|
||||
draft: AuditFilterDraft;
|
||||
categoryOptions: readonly AuditFilterOption[];
|
||||
customerOptions: readonly AuditFilterOption[];
|
||||
loadingCategories?: boolean;
|
||||
loadingCustomers?: boolean;
|
||||
searching?: boolean;
|
||||
onChange: (draft: AuditFilterDraft) => void;
|
||||
onApply: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/** 操作日志筛选卡(方案 A:两行;第一行关键字 / 分类 / 操作人 / 客户 + 查询,第二行时间与结果) */
|
||||
export function AuditFilters({
|
||||
draft,
|
||||
categoryOptions,
|
||||
customerOptions,
|
||||
loadingCategories = false,
|
||||
loadingCustomers = false,
|
||||
searching = false,
|
||||
onChange,
|
||||
onApply,
|
||||
onReset,
|
||||
}: AuditFiltersProps): ReactNode {
|
||||
const customRange = useMemo<Date[] | undefined>(() => {
|
||||
if (draft.timePreset !== 'custom') return undefined;
|
||||
const from = parseDateInput(draft.customFrom);
|
||||
const to = parseDateInput(draft.customTo);
|
||||
if (from === null || to === null) return undefined;
|
||||
return [from, to];
|
||||
}, [draft.customFrom, draft.customTo, draft.timePreset]);
|
||||
|
||||
const timeHint = describeAuditTimeRange(draft, new Date());
|
||||
|
||||
const patch = (updates: Partial<AuditFilterDraft>): void => {
|
||||
onChange({ ...draft, ...updates });
|
||||
};
|
||||
|
||||
if (loadingCategories && categoryOptions.length === 0) {
|
||||
return (
|
||||
<section className="lx-audit-filters" aria-label="日志筛选">
|
||||
<PageSkeleton rows={2} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="lx-audit-filters"
|
||||
aria-label="日志筛选"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onApply();
|
||||
}}
|
||||
>
|
||||
<div className="lx-audit-filters__row">
|
||||
<label className="lx-audit-field lx-audit-field--keyword">
|
||||
<span>关键字</span>
|
||||
<Input
|
||||
value={draft.keyword}
|
||||
prefix={<IconSearch size={16} />}
|
||||
placeholder="操作人 / 对象 / trace_id / IP"
|
||||
showClear
|
||||
maxLength={100}
|
||||
onChange={(value) => {
|
||||
patch({ keyword: value });
|
||||
}}
|
||||
onEnterPress={onApply}
|
||||
/>
|
||||
</label>
|
||||
<label className="lx-audit-field">
|
||||
<span>动作分类</span>
|
||||
<Select
|
||||
value={draft.category}
|
||||
optionList={[{ value: '', label: '全部分类' }, ...categoryOptions]}
|
||||
loading={loadingCategories}
|
||||
showClear
|
||||
onChange={(value) => {
|
||||
patch({ category: typeof value === 'string' ? value : '' });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="lx-audit-field">
|
||||
<span>操作人</span>
|
||||
<Input
|
||||
value={draft.actor}
|
||||
placeholder="姓名 / 账号"
|
||||
showClear
|
||||
maxLength={64}
|
||||
onChange={(value) => {
|
||||
patch({ actor: value });
|
||||
}}
|
||||
onEnterPress={onApply}
|
||||
/>
|
||||
</label>
|
||||
<label className="lx-audit-field">
|
||||
<span>客户</span>
|
||||
<Select
|
||||
value={draft.customerId}
|
||||
optionList={[{ value: '', label: '全部客户' }, ...customerOptions]}
|
||||
loading={loadingCustomers}
|
||||
showClear
|
||||
filter
|
||||
onChange={(value) => {
|
||||
patch({ customerId: typeof value === 'string' ? value : '' });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="lx-audit-filters__actions">
|
||||
<Button icon={<IconRefresh size={16} />} onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
<Button htmlType="submit" theme="solid" type="primary" icon={<IconSearch size={16} />}>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lx-audit-filters__sub">
|
||||
<span className="lx-audit-filters__label">时间</span>
|
||||
<RadioGroup
|
||||
type="button"
|
||||
buttonSize="small"
|
||||
value={draft.timePreset}
|
||||
options={AUDIT_TIME_PRESETS.map((preset) => ({
|
||||
value: preset.value,
|
||||
label: preset.label,
|
||||
}))}
|
||||
onChange={(event) => {
|
||||
const next = String(event.target.value);
|
||||
const preset = AUDIT_TIME_PRESETS.find((item) => item.value === next);
|
||||
if (preset !== undefined) patch({ timePreset: preset.value });
|
||||
}}
|
||||
/>
|
||||
{draft.timePreset === 'custom' ? (
|
||||
<DatePicker
|
||||
className="lx-audit-filters__date"
|
||||
type="dateRange"
|
||||
value={customRange}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
onChange={(value) => {
|
||||
if (!Array.isArray(value)) {
|
||||
patch({ customFrom: '', customTo: '' });
|
||||
return;
|
||||
}
|
||||
const [start, end] = value;
|
||||
if (!(start instanceof Date) || !(end instanceof Date)) return;
|
||||
patch({ customFrom: formatDateInput(start), customTo: formatDateInput(end) });
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className="lx-audit-filters__sep" aria-hidden="true" />
|
||||
<Select
|
||||
className="lx-audit-filters__result"
|
||||
value={draft.result}
|
||||
optionList={AUDIT_RESULT_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}))}
|
||||
onChange={(value) => {
|
||||
const next = typeof value === 'string' ? value : 'all';
|
||||
const option: AuditResultFilter | undefined = AUDIT_RESULT_OPTIONS.find(
|
||||
(item) => item.value === next,
|
||||
)?.value;
|
||||
patch({ result: option ?? 'all' });
|
||||
}}
|
||||
/>
|
||||
<span className="lx-audit-filters__hint">
|
||||
{timeHint.label} · {timeHint.range}
|
||||
{searching ? ' · 查询中…' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/* eslint-disable max-lines-per-function -- 详情页编排摘要 / 上下文 / 相关记录查询,集中便于追踪数据流 */
|
||||
import { Button } from '@douyinfe/semi-ui-19';
|
||||
import type { AuditLogDetail } from '@lingxi/shared';
|
||||
import { ErrorState, notifyError, notifySuccess, PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconBack, IconCopy, IconLock } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { useAuditLogDetailQuery, useAuditLogsQuery } from '@/hooks/queries/use-audit';
|
||||
import { auditLogDetailPath, ROUTES } from '@/routes';
|
||||
|
||||
import { copyToClipboard } from './audit-clipboard';
|
||||
import {
|
||||
AUDIT_RESULT_LABEL,
|
||||
auditTargetTypeLabel,
|
||||
buildRelatedAuditQuery,
|
||||
formatAuditDateTime,
|
||||
formatDuration,
|
||||
} from './audit-display';
|
||||
import { AuditDiffView } from './AuditDiffView';
|
||||
|
||||
const RELATED_LIMIT = 5;
|
||||
|
||||
function copyLink(): void {
|
||||
void copyToClipboard(window.location.href).then((ok) => {
|
||||
if (ok) notifySuccess('已复制当前日志链接');
|
||||
else notifyError('复制失败,请从地址栏复制');
|
||||
});
|
||||
}
|
||||
|
||||
function copyTrace(traceId: string | null): void {
|
||||
if (traceId === null || traceId === '') {
|
||||
notifyError('该记录没有 trace_id');
|
||||
return;
|
||||
}
|
||||
void copyToClipboard(traceId).then((ok) => {
|
||||
if (ok) notifySuccess('已复制 trace_id');
|
||||
else notifyError('复制失败,请手动选择文本');
|
||||
});
|
||||
}
|
||||
|
||||
function DetailHeader(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<header className="lx-audit-page__header lx-audit-detail__header">
|
||||
<div className="lx-audit-detail__title">
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconBack size={18} />}
|
||||
aria-label="返回操作日志列表"
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.system.auditLogs);
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<h1>日志详情</h1>
|
||||
<p>审计记录不可修改或删除,仅用于追溯</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-audit-page__actions">
|
||||
<span className="lx-audit-readonly">
|
||||
<IconLock size={14} />
|
||||
只读 · append-only
|
||||
</span>
|
||||
<Button icon={<IconCopy size={16} />} onClick={copyLink}>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailHero({ detail }: { detail: AuditLogDetail }): ReactNode {
|
||||
return (
|
||||
<section className="lx-audit-hero" aria-label="日志摘要">
|
||||
<div className="lx-audit-hero__title">
|
||||
<span className={`lx-audit-tag lx-audit-tag--category`}>{detail.category_label}</span>
|
||||
<span className={`lx-audit-tag lx-audit-tag--${detail.result}`}>
|
||||
{AUDIT_RESULT_LABEL[detail.result]}
|
||||
</span>
|
||||
<b>{detail.action_label}</b>
|
||||
<code>{detail.action_code}</code>
|
||||
<span className="lx-audit-hero__time">{formatAuditDateTime(detail.occurred_at)}</span>
|
||||
</div>
|
||||
<div className="lx-audit-hero__grid">
|
||||
<div>
|
||||
<span>操作人</span>
|
||||
<b>{detail.actor_name ?? '系统'}</b>
|
||||
<em>{detail.actor_account ?? '—'}</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>对象</span>
|
||||
<b>{detail.target_name ?? '—'}</b>
|
||||
<em>
|
||||
{auditTargetTypeLabel(detail.target_type)}
|
||||
{detail.target_id === null ? '' : ` · ${detail.target_id}`}
|
||||
</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>客户</span>
|
||||
<b>{detail.customer_name ?? '平台级(无客户)'}</b>
|
||||
<em>{detail.customer_id ?? '—'}</em>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-audit-hero__meta">
|
||||
<div>
|
||||
<span>trace_id</span>
|
||||
<b className="lx-audit-mono">{detail.trace_id ?? '—'}</b>
|
||||
<Button
|
||||
theme="borderless"
|
||||
size="small"
|
||||
icon={<IconCopy size={14} />}
|
||||
aria-label="复制 trace_id"
|
||||
disabled={detail.trace_id === null}
|
||||
onClick={() => {
|
||||
copyTrace(detail.trace_id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span>IP 地址</span>
|
||||
<b className="lx-audit-mono">{detail.ip ?? '—'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>请求</span>
|
||||
<b className="lx-audit-mono">
|
||||
{detail.http_method ?? '—'} {detail.http_path ?? '—'}
|
||||
</b>
|
||||
</div>
|
||||
<div>
|
||||
<span>耗时</span>
|
||||
<b>{formatDuration(detail.duration_ms)}</b>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailAside({ detail }: { detail: AuditLogDetail }): ReactNode {
|
||||
const relatedFilter = useMemo(() => buildRelatedAuditQuery(detail), [detail]);
|
||||
const related = useAuditLogsQuery(
|
||||
relatedFilter === null ? { limit: RELATED_LIMIT } : { ...relatedFilter, limit: RELATED_LIMIT },
|
||||
{ enabled: relatedFilter !== null },
|
||||
);
|
||||
const relatedItems = (related.data?.items ?? [])
|
||||
.filter((item) => item.id !== detail.id)
|
||||
.slice(0, RELATED_LIMIT);
|
||||
|
||||
return (
|
||||
<aside className="lx-audit-detail__aside">
|
||||
<section className="lx-audit-detail-card">
|
||||
<div className="lx-audit-detail-card__head">
|
||||
<b>上下文</b>
|
||||
<span className="lx-audit-detail-card__count">请求与来源</span>
|
||||
</div>
|
||||
<dl className="lx-audit-kv">
|
||||
<dt>对象类型</dt>
|
||||
<dd>{auditTargetTypeLabel(detail.target_type)}</dd>
|
||||
<dt>对象 ID</dt>
|
||||
<dd className="lx-audit-mono">{detail.target_id ?? '—'}</dd>
|
||||
<dt>客户</dt>
|
||||
<dd>{detail.customer_name ?? '平台级(无客户)'}</dd>
|
||||
<dt>动作分类</dt>
|
||||
<dd>{detail.category_label}</dd>
|
||||
<dt>请求来源</dt>
|
||||
<dd className="lx-audit-mono">
|
||||
{detail.http_method ?? '—'} {detail.http_path ?? '—'}
|
||||
</dd>
|
||||
<dt>结果</dt>
|
||||
<dd>
|
||||
<span className={`lx-audit-tag lx-audit-tag--${detail.result}`}>
|
||||
{AUDIT_RESULT_LABEL[detail.result]}
|
||||
</span>
|
||||
</dd>
|
||||
<dt>trace_id</dt>
|
||||
<dd className="lx-audit-mono">{detail.trace_id ?? '—'}</dd>
|
||||
<dt>User-Agent</dt>
|
||||
<dd>{detail.user_agent ?? '—'}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="lx-audit-detail-card">
|
||||
<div className="lx-audit-detail-card__head">
|
||||
<b>只读说明</b>
|
||||
<IconLock size={14} />
|
||||
</div>
|
||||
<p className="lx-audit-detail-note">
|
||||
审计表 append-only,只允许
|
||||
INSERT;系统不提供修改与删除入口。操作日志用于业务追责,运行日志请前往
|
||||
<Link to={ROUTES.system.runLogs} className="lx-audit-link">
|
||||
运行日志
|
||||
</Link>
|
||||
。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{relatedItems.length === 0 ? null : (
|
||||
<section className="lx-audit-detail-card">
|
||||
<div className="lx-audit-detail-card__head">
|
||||
<b>相关记录</b>
|
||||
<span className="lx-audit-detail-card__count">同对象 / 同操作人</span>
|
||||
</div>
|
||||
<ul className="lx-audit-related">
|
||||
{relatedItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link to={auditLogDetailPath(item.id)} className="lx-audit-related__link">
|
||||
<b>{item.action_label}</b>
|
||||
<span>{formatAuditDateTime(item.occurred_at)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/** 操作日志详情(方案 A:左 2/3 变更对比 + 原始 JSON,右 1/3 上下文 / 只读说明 / 相关记录) */
|
||||
export function AuditLogDetailPage(): ReactNode {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const detailQuery = useAuditLogDetailQuery(id ?? null);
|
||||
const error = detailQuery.error;
|
||||
|
||||
if (detailQuery.isPending) {
|
||||
return (
|
||||
<div className="lx-audit-page">
|
||||
<DetailHeader />
|
||||
<PageSkeleton rows={10} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error !== null) {
|
||||
const notFound = error instanceof ApiError && error.code === 40401;
|
||||
return (
|
||||
<div className="lx-audit-page">
|
||||
<DetailHeader />
|
||||
<section className="lx-audit-state" aria-label={notFound ? '日志不存在' : '日志加载失败'}>
|
||||
{notFound ? (
|
||||
<PageEmpty
|
||||
title="日志不存在或已被归档"
|
||||
description="该审计记录可能不存在,或已按保留策略归档。请返回列表重新检索。"
|
||||
action={
|
||||
<Button
|
||||
onClick={() => {
|
||||
void navigate(ROUTES.system.auditLogs);
|
||||
}}
|
||||
>
|
||||
返回操作日志
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ErrorState
|
||||
title="日志详情加载失败"
|
||||
message={error.message}
|
||||
traceId={error instanceof ApiError ? error.traceId : undefined}
|
||||
onRetry={() => {
|
||||
void detailQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
|
||||
return (
|
||||
<div className="lx-audit-page lx-audit-detail">
|
||||
<DetailHeader />
|
||||
<DetailHero detail={detail} />
|
||||
<div className="lx-audit-detail__body">
|
||||
<div className="lx-audit-detail__main">
|
||||
<AuditDiffView detail={detail} />
|
||||
</div>
|
||||
<DetailAside detail={detail} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/* eslint-disable max-lines-per-function -- 表格列定义集中便于对照接口字段 */
|
||||
import { Button, Table } from '@douyinfe/semi-ui-19';
|
||||
import type { ColumnProps } from '@douyinfe/semi-ui-19/lib/es/table/interface';
|
||||
import type { AuditLogItem } from '@lingxi/shared';
|
||||
import { notifyError, notifySuccess, PageEmpty } from '@lingxi/ui';
|
||||
import { IconCheck, IconCopy, IconRefresh } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { copyToClipboard } from './audit-clipboard';
|
||||
import {
|
||||
AUDIT_RESULT_LABEL,
|
||||
auditTargetTypeLabel,
|
||||
formatAuditDate,
|
||||
formatAuditTime,
|
||||
truncateMiddle,
|
||||
} from './audit-display';
|
||||
|
||||
export interface AuditLogTableProps {
|
||||
items: readonly AuditLogItem[];
|
||||
loading: boolean;
|
||||
refreshing?: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
/** 追加分页失败时的提示(保留已有数据) */
|
||||
errorMessage?: string | null;
|
||||
onOpen: (logId: string) => void;
|
||||
onLoadMore: () => void;
|
||||
onRetryMore: () => void;
|
||||
onRefresh: () => void;
|
||||
onClearFilters: () => void;
|
||||
}
|
||||
|
||||
function copyTrace(traceId: string | null): void {
|
||||
if (traceId === null || traceId === '') {
|
||||
notifyError('该记录没有 trace_id');
|
||||
return;
|
||||
}
|
||||
void copyToClipboard(traceId).then((ok) => {
|
||||
if (ok) notifySuccess('已复制 trace_id');
|
||||
else notifyError('复制失败,请手动选择文本');
|
||||
});
|
||||
}
|
||||
|
||||
function emptyState(onClearFilters: () => void): ReactNode {
|
||||
return (
|
||||
<PageEmpty
|
||||
title="没有符合条件的日志"
|
||||
description="当前筛选条件下没有记录,可放宽时间范围或清除筛选后重试。"
|
||||
action={
|
||||
<Button onClick={onClearFilters} icon={<IconRefresh size={16} />}>
|
||||
清除筛选
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const COMPACT_COLUMN_WIDTHS: Readonly<Record<string, number>> = {
|
||||
occurred_at: 100,
|
||||
actor_name: 110,
|
||||
action_label: 140,
|
||||
target_name: 170,
|
||||
customer_name: 90,
|
||||
result: 90,
|
||||
ip: 96,
|
||||
trace_id: 140,
|
||||
id: 60,
|
||||
};
|
||||
|
||||
/** 1024:隐藏 IP / trace,并收窄关键列,保证时间 / 操作 / 对象 / 客户 / 结果同屏 */
|
||||
function useNarrowTable(): boolean {
|
||||
const query = '(max-width: 1100px)';
|
||||
const [narrow, setNarrow] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(query).matches,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const media = window.matchMedia(query);
|
||||
const onChange = (event: MediaQueryListEvent): void => {
|
||||
setNarrow(event.matches);
|
||||
};
|
||||
media.addEventListener('change', onChange);
|
||||
return () => {
|
||||
media.removeEventListener('change', onChange);
|
||||
};
|
||||
}, []);
|
||||
return narrow;
|
||||
}
|
||||
|
||||
/** 操作日志列表表格(方案 A:全宽表格 + 游标「加载更多」) */
|
||||
export function AuditLogTable({
|
||||
items,
|
||||
loading,
|
||||
refreshing = false,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
errorMessage = null,
|
||||
onOpen,
|
||||
onLoadMore,
|
||||
onRetryMore,
|
||||
onRefresh,
|
||||
onClearFilters,
|
||||
}: AuditLogTableProps): ReactNode {
|
||||
const narrow = useNarrowTable();
|
||||
const columns: ColumnProps<AuditLogItem>[] = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'occurred_at',
|
||||
width: 120,
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode => (
|
||||
<span className="lx-audit-time">
|
||||
<b>{formatAuditTime(record.occurred_at)}</b>
|
||||
<em>{formatAuditDate(record.occurred_at)}</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'actor_name',
|
||||
width: 130,
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode => (
|
||||
<span className="lx-audit-actor">
|
||||
<b>{record.actor_name ?? '系统'}</b>
|
||||
<em>{record.actor_account ?? '—'}</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action_label',
|
||||
width: 150,
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode => (
|
||||
<span className="lx-audit-action">
|
||||
<b>{record.action_label}</b>
|
||||
<em>{record.action_code}</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '对象',
|
||||
dataIndex: 'target_name',
|
||||
width: 200,
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode =>
|
||||
record.target_name === null && record.target_id === null ? (
|
||||
<span className="lx-audit-muted">—</span>
|
||||
) : (
|
||||
<span className="lx-audit-target">
|
||||
<b>{record.target_name ?? '未命名对象'}</b>
|
||||
<em>
|
||||
{auditTargetTypeLabel(record.target_type)}
|
||||
{record.target_id === null ? '' : ` · ${record.target_id}`}
|
||||
</em>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '客户',
|
||||
dataIndex: 'customer_name',
|
||||
width: 100,
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode =>
|
||||
record.customer_name === null ? (
|
||||
<span className="lx-audit-muted">—</span>
|
||||
) : (
|
||||
<span className="lx-audit-customer">{record.customer_name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
width: 110,
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode => (
|
||||
<span className="lx-audit-result">
|
||||
<span className={`lx-audit-tag lx-audit-tag--${record.result}`}>
|
||||
{AUDIT_RESULT_LABEL[record.result]}
|
||||
</span>
|
||||
{record.result === 'failure' && record.error_message !== null ? (
|
||||
<em title={record.error_message}>{record.error_message}</em>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'IP',
|
||||
dataIndex: 'ip',
|
||||
width: 112,
|
||||
className: 'lx-audit-col-ip',
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode => (
|
||||
<span className="lx-audit-mono">{record.ip ?? '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'trace_id',
|
||||
dataIndex: 'trace_id',
|
||||
width: 140,
|
||||
className: 'lx-audit-col-trace',
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode => (
|
||||
<span className="lx-audit-trace">
|
||||
<code title={record.trace_id ?? ''}>{truncateMiddle(record.trace_id, 18)}</code>
|
||||
<Button
|
||||
theme="borderless"
|
||||
size="small"
|
||||
icon={<IconCopy size={14} />}
|
||||
aria-label="复制 trace_id"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyTrace(record.trace_id);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '查看',
|
||||
dataIndex: 'id',
|
||||
width: 72,
|
||||
fixed: 'right',
|
||||
render: (_value: unknown, record: AuditLogItem): ReactNode => (
|
||||
<button
|
||||
type="button"
|
||||
className="lx-audit-link"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpen(record.id);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const visibleColumns = columns
|
||||
.filter((column) => !narrow || (column.dataIndex !== 'ip' && column.dataIndex !== 'trace_id'))
|
||||
.map((column) =>
|
||||
narrow
|
||||
? {
|
||||
...column,
|
||||
width: COMPACT_COLUMN_WIDTHS[String(column.dataIndex)] ?? column.width,
|
||||
}
|
||||
: column,
|
||||
);
|
||||
|
||||
const pagerText =
|
||||
items.length === 0
|
||||
? '暂无已加载记录'
|
||||
: hasNextPage
|
||||
? `已加载 ${String(items.length)} 条 · 还有更多`
|
||||
: `已加载 ${String(items.length)} 条 · 当前筛选结果已全部加载`;
|
||||
|
||||
return (
|
||||
<section className="lx-audit-table-card" aria-label="操作日志列表">
|
||||
<div className="lx-audit-table-card__head">
|
||||
<b>日志列表</b>
|
||||
<span className="lx-audit-table-card__count">{pagerText}</span>
|
||||
<div className="lx-audit-table-card__tools">
|
||||
<Button
|
||||
theme="borderless"
|
||||
icon={<IconRefresh size={16} />}
|
||||
loading={refreshing}
|
||||
aria-label="刷新日志"
|
||||
onClick={onRefresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lx-audit-table-wrap">
|
||||
<Table<AuditLogItem>
|
||||
className="lx-audit-table"
|
||||
columns={visibleColumns}
|
||||
dataSource={[...items]}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
size="middle"
|
||||
scroll={{ x: narrow ? 760 : 1180 }}
|
||||
empty={emptyState(onClearFilters)}
|
||||
onRow={(record) =>
|
||||
record === undefined
|
||||
? {}
|
||||
: {
|
||||
className: 'lx-audit-row',
|
||||
tabIndex: 0,
|
||||
onKeyDown: (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpen(record.id);
|
||||
}
|
||||
},
|
||||
onClick: () => {
|
||||
onOpen(record.id);
|
||||
},
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="lx-audit-pager">
|
||||
<span>
|
||||
游标分页 · 按操作时间倒序
|
||||
{errorMessage === null ? null : (
|
||||
<em className="lx-audit-pager__error"> · {errorMessage}</em>
|
||||
)}
|
||||
</span>
|
||||
{hasNextPage ? (
|
||||
<Button
|
||||
size="small"
|
||||
loading={isFetchingNextPage}
|
||||
onClick={errorMessage === null ? onLoadMore : onRetryMore}
|
||||
>
|
||||
加载更多
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="small" disabled icon={<IconCheck size={14} />}>
|
||||
已全部加载
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/* eslint-disable max-lines-per-function -- 页面编排筛选、游标列表与导出抽屉,集中便于追踪数据流 */
|
||||
import { Button, Modal } from '@douyinfe/semi-ui-19';
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { ErrorState } from '@lingxi/ui';
|
||||
import { IconAlert, IconDownload, IconLock } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
|
||||
import { ApiError } from '@/api/client';
|
||||
import { useAuditActionsQuery, useAuditLogsInfiniteQuery } from '@/hooks/queries/use-audit';
|
||||
import { useCustomersQuery } from '@/hooks/queries/use-client';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
import { auditLogDetailPath } from '@/routes';
|
||||
|
||||
import {
|
||||
type AuditFilterDraft,
|
||||
createDefaultAuditFilter,
|
||||
parseAuditFilters,
|
||||
serializeAuditFilters,
|
||||
toAuditLogFilter,
|
||||
} from './audit-filter-utils';
|
||||
import { AuditExportSheet } from './AuditExportSheet';
|
||||
import { type AuditFilterOption, AuditFilters } from './AuditFilters';
|
||||
import { AuditLogTable } from './AuditLogTable';
|
||||
|
||||
const CUSTOMER_OPTION_LIMIT = 100;
|
||||
|
||||
function showPermissionHelp(): void {
|
||||
Modal.info({
|
||||
title: '操作日志权限说明',
|
||||
content: (
|
||||
<div className="lx-audit-help">
|
||||
<p>
|
||||
<b>audit:read</b>:查看操作日志列表与详情,属特殊权限,默认不授予;缺少时无法进入本页。
|
||||
</p>
|
||||
<p>
|
||||
<b>audit:export</b>:创建异步导出任务,需在角色中单独授予;无此权限时不显示导出入口。
|
||||
</p>
|
||||
<p>审计日志 append-only,系统不提供修改 / 删除入口;查看与导出行为本身也会被记录。</p>
|
||||
</div>
|
||||
),
|
||||
okText: '知道了',
|
||||
});
|
||||
}
|
||||
|
||||
/** 操作日志(用户选定方案 A:页头 + 筛选卡 + 全宽表格 + 游标分页 + 560 导出抽屉) */
|
||||
export function AuditLogsPage(): ReactNode {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const canExport = usePermission(Permission.AUDIT_EXPORT);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
|
||||
const searchKey = searchParams.toString();
|
||||
const applied = useMemo(() => parseAuditFilters(new URLSearchParams(searchKey)), [searchKey]);
|
||||
/** 草稿跟随 URL:URL 变化时以 URL 为准,用户编辑时写入当前 key,避免在 effect 里同步 state */
|
||||
const [draftState, setDraftState] = useState<{ key: string; draft: AuditFilterDraft }>(() => ({
|
||||
key: searchKey,
|
||||
draft: parseAuditFilters(new URLSearchParams(searchKey)),
|
||||
}));
|
||||
const draft = draftState.key === searchKey ? draftState.draft : applied;
|
||||
const setDraft = (next: AuditFilterDraft): void => {
|
||||
setDraftState({ key: searchKey, draft: next });
|
||||
};
|
||||
|
||||
/** 只在筛选条件变化时换算时间,避免每次渲染 now 变化导致 queryKey 抖动 */
|
||||
const queryFilter = useMemo(() => toAuditLogFilter(applied, new Date()), [applied]);
|
||||
const logs = useAuditLogsInfiniteQuery(queryFilter);
|
||||
const actions = useAuditActionsQuery();
|
||||
const customers = useCustomersQuery({ limit: CUSTOMER_OPTION_LIMIT });
|
||||
|
||||
const items = useMemo(
|
||||
() => (logs.data === undefined ? [] : logs.data.pages.flatMap((page) => page.items)),
|
||||
[logs.data],
|
||||
);
|
||||
const categoryOptions = useMemo<AuditFilterOption[]>(
|
||||
() =>
|
||||
(actions.data?.categories ?? []).map((category) => ({
|
||||
value: category.key,
|
||||
label: category.label,
|
||||
})),
|
||||
[actions.data],
|
||||
);
|
||||
const customerOptions = useMemo<AuditFilterOption[]>(
|
||||
() =>
|
||||
(customers.data?.items ?? []).map((customer) => ({
|
||||
value: customer.id,
|
||||
label: customer.name,
|
||||
})),
|
||||
[customers.data],
|
||||
);
|
||||
|
||||
const applyFilters = (): void => {
|
||||
setSearchParams(serializeAuditFilters(draft), { replace: false });
|
||||
};
|
||||
const resetFilters = (): void => {
|
||||
setDraftState({ key: searchKey, draft: createDefaultAuditFilter() });
|
||||
setSearchParams(new URLSearchParams(), { replace: false });
|
||||
};
|
||||
const openDetail = (logId: string): void => {
|
||||
void navigate(auditLogDetailPath(logId));
|
||||
};
|
||||
|
||||
const pageError = logs.error;
|
||||
const showError = pageError !== null && items.length === 0;
|
||||
const moreError = logs.isFetchNextPageError ? (pageError?.message ?? '加载更多失败') : null;
|
||||
|
||||
return (
|
||||
<div className="lx-audit-page">
|
||||
<header className="lx-audit-page__header">
|
||||
<div>
|
||||
<h1>操作日志</h1>
|
||||
<p>关键操作留痕 · 只读 · append-only</p>
|
||||
</div>
|
||||
<div className="lx-audit-page__actions">
|
||||
{canExport ? (
|
||||
<>
|
||||
<span className="lx-audit-readonly">
|
||||
<IconLock size={14} />
|
||||
只读
|
||||
</span>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
icon={<IconDownload size={16} />}
|
||||
onClick={() => {
|
||||
setExportOpen(true);
|
||||
}}
|
||||
>
|
||||
导出日志
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span
|
||||
className="lx-audit-readonly"
|
||||
title="当前账号没有 audit:export 权限,无法创建导出任务"
|
||||
>
|
||||
<IconLock size={14} />
|
||||
只读 · 无 audit:export 权限
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="lx-audit-note" role="note">
|
||||
<IconAlert size={16} />
|
||||
<span>
|
||||
<b>查看留痕</b> ·
|
||||
查看操作日志本身也会被记录(audit.log.view),用于追溯谁在何时查看了哪些记录。
|
||||
{canExport ? '' : '导出需在角色中单独授予 audit:export 权限。'}
|
||||
</span>
|
||||
<button type="button" className="lx-audit-link" onClick={showPermissionHelp}>
|
||||
权限说明
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AuditFilters
|
||||
draft={draft}
|
||||
categoryOptions={categoryOptions}
|
||||
customerOptions={customerOptions}
|
||||
loadingCategories={actions.isPending}
|
||||
loadingCustomers={customers.isPending}
|
||||
searching={logs.isFetching}
|
||||
onChange={setDraft}
|
||||
onApply={applyFilters}
|
||||
onReset={resetFilters}
|
||||
/>
|
||||
|
||||
{showError ? (
|
||||
<section className="lx-audit-state" aria-label="操作日志加载失败">
|
||||
<ErrorState
|
||||
title="操作日志加载失败"
|
||||
message={pageError.message}
|
||||
traceId={pageError instanceof ApiError ? pageError.traceId : undefined}
|
||||
onRetry={() => {
|
||||
void logs.refetch();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<AuditLogTable
|
||||
items={items}
|
||||
loading={logs.isPending}
|
||||
refreshing={logs.isFetching && !logs.isFetchingNextPage}
|
||||
isFetchingNextPage={logs.isFetchingNextPage}
|
||||
hasNextPage={logs.hasNextPage}
|
||||
errorMessage={moreError}
|
||||
onOpen={openDetail}
|
||||
onLoadMore={() => {
|
||||
void logs.fetchNextPage();
|
||||
}}
|
||||
onRetryMore={() => {
|
||||
void logs.fetchNextPage();
|
||||
}}
|
||||
onRefresh={() => {
|
||||
void logs.refetch();
|
||||
}}
|
||||
onClearFilters={resetFilters}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canExport ? (
|
||||
<AuditExportSheet
|
||||
visible={exportOpen}
|
||||
draft={applied}
|
||||
categoryOptions={categoryOptions}
|
||||
customerOptions={customerOptions}
|
||||
loadedCount={items.length}
|
||||
onClose={() => {
|
||||
setExportOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 复制到剪贴板:非安全上下文 / 浏览器不支持时返回 false,由调用方提示手动复制。
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (text.length === 0) return false;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { AuditExportJob, AuditLogDetail } from '@lingxi/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AUDIT_DIFF_KIND_META,
|
||||
auditDiffFieldLabel,
|
||||
auditExportPercent,
|
||||
auditExportRowsText,
|
||||
auditTargetTypeLabel,
|
||||
buildAuditRawRecord,
|
||||
buildRelatedAuditQuery,
|
||||
canDownloadAuditExport,
|
||||
formatAuditDateTime,
|
||||
formatAuditDiffValue,
|
||||
formatDuration,
|
||||
formatFileSize,
|
||||
formatRowCount,
|
||||
isAuditExportExpired,
|
||||
summarizeAuditFilterRange,
|
||||
truncateMiddle,
|
||||
} from './audit-display';
|
||||
|
||||
const NOW = '2026-11-04T05:32:00.000Z';
|
||||
|
||||
const detail: AuditLogDetail = {
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZ1',
|
||||
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: 2,
|
||||
has_before: true,
|
||||
has_after: true,
|
||||
user_agent: 'Chrome 130 / Windows 11',
|
||||
reason: '开放图片合成权限',
|
||||
before: { permissions: ['material:review'] },
|
||||
after: { permissions: ['material:render'] },
|
||||
changes: null,
|
||||
diff: [
|
||||
{ path: 'permissions', label: null, before: 'material:review', after: null, kind: 'removed' },
|
||||
{ path: 'permissions', label: null, before: null, after: 'material:render', kind: 'added' },
|
||||
{ path: 'description', label: null, before: '旧描述', after: '新描述', kind: 'changed' },
|
||||
],
|
||||
};
|
||||
|
||||
function job(patch: Partial<AuditExportJob> = {}): AuditExportJob {
|
||||
return {
|
||||
id: '01HZZZZZZZZZZZZZZZZZZZZZZ9',
|
||||
status: 'succeeded',
|
||||
format: 'csv',
|
||||
filters: { result: 'failure' },
|
||||
total_rows: 120,
|
||||
row_count: 120,
|
||||
processed_rows: 120,
|
||||
truncated: false,
|
||||
progress: 100,
|
||||
file_size: 2_204_160,
|
||||
error_message: null,
|
||||
requested_at: NOW,
|
||||
started_at: NOW,
|
||||
finished_at: NOW,
|
||||
expires_at: '2026-11-04T05:47:00.000Z',
|
||||
download_count: 0,
|
||||
last_downloaded_at: null,
|
||||
download_token: 'signed.token',
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('操作日志展示纯函数', () => {
|
||||
it('时间 / 时长 / 大小 / 行数格式化', () => {
|
||||
expect(formatAuditDateTime(NOW)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
expect(formatDuration(74)).toBe('74 ms');
|
||||
expect(formatDuration(1500)).toBe('1.50 s');
|
||||
expect(formatDuration(null)).toBe('—');
|
||||
expect(formatFileSize(2048)).toBe('2.0 KB');
|
||||
expect(formatFileSize(2_204_160)).toBe('2.1 MB');
|
||||
expect(formatFileSize(null)).toBe('—');
|
||||
expect(formatRowCount(12000)).toBe('12,000');
|
||||
expect(formatRowCount(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('对象类型中文名与未知类型回退', () => {
|
||||
expect(auditTargetTypeLabel('role')).toBe('角色');
|
||||
expect(auditTargetTypeLabel('unknown_type')).toBe('unknown_type');
|
||||
expect(auditTargetTypeLabel(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('trace_id 截断保留首尾', () => {
|
||||
expect(truncateMiddle('01JCH9V4TR4CE00000000005', 18)).toHaveLength(18);
|
||||
expect(truncateMiddle('short', 18)).toBe('short');
|
||||
expect(truncateMiddle(null)).toBe('—');
|
||||
});
|
||||
|
||||
it('diff 值格式化覆盖 null / 数组 / 对象 / 空串', () => {
|
||||
expect(formatAuditDiffValue(null)).toBe('—');
|
||||
expect(formatAuditDiffValue('')).toBe('(空)');
|
||||
expect(formatAuditDiffValue(['a', 'b'])).toBe('a、b');
|
||||
expect(formatAuditDiffValue([])).toBe('(空)');
|
||||
expect(formatAuditDiffValue({ a: 1 })).toBe('{"a":1}');
|
||||
expect(formatAuditDiffValue(10)).toBe('10');
|
||||
expect(formatAuditDiffValue(false)).toBe('false');
|
||||
});
|
||||
|
||||
it('diff 字段中文名优先 label,其次路径映射,最后原路径', () => {
|
||||
expect(auditDiffFieldLabel({ ...detail.diff[0]!, label: '权限点' })).toBe('权限点');
|
||||
expect(auditDiffFieldLabel(detail.diff[0]!)).toBe('权限点');
|
||||
expect(auditDiffFieldLabel({ ...detail.diff[2]! })).toBe('描述');
|
||||
expect(auditDiffFieldLabel({ ...detail.diff[0]!, path: 'unknown.path' })).toBe('unknown.path');
|
||||
expect(AUDIT_DIFF_KIND_META.added.label).toBe('新增');
|
||||
expect(AUDIT_DIFF_KIND_META.removed.tone).toBe('removed');
|
||||
});
|
||||
|
||||
it('原始 JSON 含完整记录(不止 before / after)', () => {
|
||||
const raw = buildAuditRawRecord(detail);
|
||||
expect(Object.keys(raw)).toEqual([
|
||||
'id',
|
||||
'occurred_at',
|
||||
'actor',
|
||||
'action',
|
||||
'target',
|
||||
'customer',
|
||||
'result',
|
||||
'request',
|
||||
'ip',
|
||||
'user_agent',
|
||||
'trace_id',
|
||||
'reason',
|
||||
'error',
|
||||
'before',
|
||||
'after',
|
||||
]);
|
||||
expect(raw.actor.account).toBe('jiang.lan');
|
||||
expect(raw.action.label).toBe('修改角色权限');
|
||||
expect(raw.request.method).toBe('PATCH');
|
||||
expect(raw.error).toBeNull();
|
||||
});
|
||||
|
||||
it('相关记录查询:优先同对象,其次同操作人', () => {
|
||||
expect(buildRelatedAuditQuery(detail)).toEqual({
|
||||
target_type: 'role',
|
||||
target_id: 'role_material_ops',
|
||||
});
|
||||
const actorOnly = { ...detail, target_id: null };
|
||||
expect(buildRelatedAuditQuery(actorOnly)).toEqual({ actor: detail.actor_id });
|
||||
const none = { ...detail, target_id: null, actor_id: null };
|
||||
expect(buildRelatedAuditQuery(none)).toBeNull();
|
||||
});
|
||||
|
||||
it('导出进度直接使用服务端 progress 并夹在 0–100', () => {
|
||||
expect(auditExportPercent({ progress: 62 })).toBe(62);
|
||||
expect(auditExportPercent({ progress: 0 })).toBe(0);
|
||||
expect(auditExportPercent({ progress: 130 })).toBe(100);
|
||||
expect(auditExportRowsText({ processed_rows: 40, total_rows: 120 })).toBe('40 / 120 条');
|
||||
expect(auditExportRowsText({ processed_rows: null, total_rows: null })).toBe('0 / — 条');
|
||||
});
|
||||
|
||||
it('过期 / 可下载判断基于 expires_at 与 token', () => {
|
||||
const fresh = job();
|
||||
expect(isAuditExportExpired(fresh, new Date('2026-11-04T05:40:00.000Z'))).toBe(false);
|
||||
expect(canDownloadAuditExport(fresh, new Date('2026-11-04T05:40:00.000Z'))).toBe(true);
|
||||
expect(isAuditExportExpired(fresh, new Date('2026-11-04T05:50:00.000Z'))).toBe(true);
|
||||
expect(canDownloadAuditExport(fresh, new Date('2026-11-04T05:50:00.000Z'))).toBe(false);
|
||||
expect(canDownloadAuditExport(job({ download_token: null }), new Date(NOW))).toBe(false);
|
||||
expect(canDownloadAuditExport(job({ status: 'failed' }), new Date(NOW))).toBe(false);
|
||||
});
|
||||
|
||||
it('导出历史筛选摘要包含范围 / 分类 / 结果 / 关键字', () => {
|
||||
const summary = summarizeAuditFilterRange(
|
||||
{
|
||||
from: '2026-10-29T00:00:00.000Z',
|
||||
to: '2026-11-04T13:32:00.000Z',
|
||||
category: 'permission',
|
||||
result: 'failure',
|
||||
keyword: '素材',
|
||||
},
|
||||
(category) => (category === 'permission' ? '权限与角色' : category),
|
||||
);
|
||||
expect(summary).toContain('权限与角色');
|
||||
expect(summary).toContain('失败');
|
||||
expect(summary).toContain('关键字:素材');
|
||||
expect(summarizeAuditFilterRange({})).toBe('全部时间');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* 操作日志展示层纯函数:时间 / 大小 / diff / 原始 JSON / 导出进度。
|
||||
* 与接口字段一一对应,不做任何推测性计算(导出进度直接用服务端 progress)。
|
||||
*/
|
||||
import type {
|
||||
AuditDiffKind,
|
||||
AuditExportJob,
|
||||
AuditExportStatus,
|
||||
AuditLogDetail,
|
||||
AuditLogFilter,
|
||||
AuditResult,
|
||||
} from '@lingxi/shared';
|
||||
|
||||
export const AUDIT_RESULT_LABEL: Record<AuditResult, string> = {
|
||||
success: '成功',
|
||||
failure: '失败',
|
||||
};
|
||||
|
||||
/** 对象类型中文名(与 shared AUDIT_TARGET_TYPES 对齐;未登记类型回退原值) */
|
||||
export const AUDIT_TARGET_TYPE_LABEL: Readonly<Record<string, string>> = {
|
||||
role: '角色',
|
||||
user: '用户',
|
||||
customer: '客户',
|
||||
customer_field: '客户字段',
|
||||
customer_group: '客户组',
|
||||
department: '部门',
|
||||
industry: '行业',
|
||||
dict_item: '字典条目',
|
||||
dict_type: '字典类型',
|
||||
system_config: '系统配置',
|
||||
audit_export: '导出任务',
|
||||
audit_log: '操作日志',
|
||||
asset: '资产',
|
||||
task: '任务',
|
||||
};
|
||||
|
||||
export function auditTargetTypeLabel(type: string | null): string {
|
||||
if (type === null || type === '') return '—';
|
||||
return AUDIT_TARGET_TYPE_LABEL[type] ?? type;
|
||||
}
|
||||
|
||||
const DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
function toDate(value: string | null | undefined): Date | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** ISO 时间 → `YYYY-MM-DD HH:mm:ss` */
|
||||
export function formatAuditDateTime(value: string | null | undefined): string {
|
||||
const date = toDate(value);
|
||||
return date === null ? '—' : DATE_TIME_FORMATTER.format(date).replaceAll('/', '-');
|
||||
}
|
||||
|
||||
/** ISO 时间 → `HH:mm:ss`(列表「时间」列主行) */
|
||||
export function formatAuditTime(value: string | null | undefined): string {
|
||||
const date = toDate(value);
|
||||
return date === null ? '—' : TIME_FORMATTER.format(date);
|
||||
}
|
||||
|
||||
/** ISO 时间 → `YYYY-MM-DD`(列表「时间」列副行) */
|
||||
export function formatAuditDate(value: string | null | undefined): string {
|
||||
const date = toDate(value);
|
||||
return date === null ? '—' : DATE_FORMATTER.format(date).replaceAll('/', '-');
|
||||
}
|
||||
|
||||
/** 耗时:< 1s 用 ms,≥ 1s 用 s(保留两位) */
|
||||
export function formatDuration(durationMs: number | null | undefined): string {
|
||||
if (durationMs === null || durationMs === undefined || durationMs < 0) return '—';
|
||||
if (durationMs < 1000) return `${String(durationMs)} ms`;
|
||||
return `${(durationMs / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
/** 文件大小:B / KB / MB */
|
||||
export function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined || bytes < 0) return '—';
|
||||
if (bytes < 1024) return `${String(bytes)} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** 行数:千分位;未知返回 — */
|
||||
export function formatRowCount(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/** 截断长文本(trace_id / IP 等列展示用) */
|
||||
export function truncateMiddle(value: string | null | undefined, max = 16): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (value.length <= max) return value;
|
||||
const head = Math.ceil((max - 1) / 2);
|
||||
const tail = Math.floor((max - 1) / 2);
|
||||
return `${value.slice(0, head)}…${value.slice(value.length - tail)}`;
|
||||
}
|
||||
|
||||
export type AuditDiffItem = AuditLogDetail['diff'][number];
|
||||
|
||||
export const AUDIT_DIFF_KIND_META: Record<AuditDiffKind, { label: string; tone: string }> = {
|
||||
added: { label: '新增', tone: 'added' },
|
||||
removed: { label: '删除', tone: 'removed' },
|
||||
changed: { label: '修改', tone: 'changed' },
|
||||
};
|
||||
|
||||
/** 常见字段路径 → 中文名(接口不返回 label 时的兜底,未知路径原样展示) */
|
||||
export const AUDIT_DIFF_FIELD_LABELS: Readonly<Record<string, string>> = {
|
||||
name: '名称',
|
||||
displayName: '显示名称',
|
||||
description: '描述',
|
||||
code: '编码',
|
||||
status: '状态',
|
||||
remark: '备注',
|
||||
group: '分组',
|
||||
groupCode: '分组编码',
|
||||
sortOrder: '排序',
|
||||
permissions: '权限点',
|
||||
permissionCodes: '权限点',
|
||||
roleIds: '关联角色',
|
||||
ownerUserId: '负责人',
|
||||
customerIds: '关联客户',
|
||||
dataScope: '数据范围',
|
||||
dataScopeType: '数据范围',
|
||||
value: '配置值',
|
||||
};
|
||||
|
||||
export function auditDiffFieldLabel(row: AuditDiffItem): string {
|
||||
return row.label ?? AUDIT_DIFF_FIELD_LABELS[row.path] ?? row.path;
|
||||
}
|
||||
|
||||
/** diff 值 → 可读字符串;数组按顿号连接,对象用 JSON 文本 */
|
||||
export function formatAuditDiffValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
if (typeof value === 'string') return value === '' ? '(空)' : value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '(空)';
|
||||
if (value.every((item): item is string => typeof item === 'string')) {
|
||||
return value.join('、');
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export interface AuditRawRecord {
|
||||
id: string;
|
||||
occurred_at: string;
|
||||
actor: { id: string | null; name: string | null; account: string | null };
|
||||
action: { code: string; label: string; category: string; category_label: string };
|
||||
target: { type: string | null; id: string | null; name: string | null };
|
||||
customer: { id: string | null; name: string | null };
|
||||
result: AuditResult;
|
||||
request: { method: string | null; path: string | null; duration_ms: number | null };
|
||||
ip: string | null;
|
||||
user_agent: string | null;
|
||||
trace_id: string | null;
|
||||
reason: string | null;
|
||||
error: { code: string | null; message: string | null } | null;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
}
|
||||
|
||||
/** 详情「原始 JSON」:展示完整审计记录,不止 before / after */
|
||||
export function buildAuditRawRecord(detail: AuditLogDetail): AuditRawRecord {
|
||||
return {
|
||||
id: detail.id,
|
||||
occurred_at: detail.occurred_at,
|
||||
actor: { id: detail.actor_id, name: detail.actor_name, account: detail.actor_account },
|
||||
action: {
|
||||
code: detail.action_code,
|
||||
label: detail.action_label,
|
||||
category: detail.category,
|
||||
category_label: detail.category_label,
|
||||
},
|
||||
target: { type: detail.target_type, id: detail.target_id, name: detail.target_name },
|
||||
customer: { id: detail.customer_id, name: detail.customer_name },
|
||||
result: detail.result,
|
||||
request: {
|
||||
method: detail.http_method,
|
||||
path: detail.http_path,
|
||||
duration_ms: detail.duration_ms,
|
||||
},
|
||||
ip: detail.ip,
|
||||
user_agent: detail.user_agent,
|
||||
trace_id: detail.trace_id,
|
||||
reason: detail.reason,
|
||||
error:
|
||||
detail.error_code === null && detail.error_message === null
|
||||
? null
|
||||
: { code: detail.error_code, message: detail.error_message },
|
||||
before: detail.before,
|
||||
after: detail.after,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatAuditRawJson(detail: AuditLogDetail): string {
|
||||
return JSON.stringify(buildAuditRawRecord(detail), null, 2);
|
||||
}
|
||||
|
||||
/** 详情页「相关记录」查询:优先同对象,其次同操作人;都没有则不查 */
|
||||
export function buildRelatedAuditQuery(detail: AuditLogDetail): Partial<AuditLogFilter> | null {
|
||||
if (detail.target_id !== null && detail.target_id !== '') {
|
||||
return {
|
||||
...(detail.target_type === null ? {} : { target_type: detail.target_type }),
|
||||
target_id: detail.target_id,
|
||||
};
|
||||
}
|
||||
if (detail.actor_id !== null && detail.actor_id !== '') {
|
||||
return { actor: detail.actor_id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const AUDIT_EXPORT_STATUS_META: Record<AuditExportStatus, { label: string; tone: string }> =
|
||||
{
|
||||
queued: { label: '排队中', tone: 'queued' },
|
||||
running: { label: '生成中', tone: 'running' },
|
||||
succeeded: { label: '已完成', tone: 'succeeded' },
|
||||
failed: { label: '失败', tone: 'failed' },
|
||||
};
|
||||
|
||||
/** 导出进度百分比:直接使用服务端 progress,限制在 0–100 */
|
||||
export function auditExportPercent(job: Pick<AuditExportJob, 'progress'>): number {
|
||||
return Math.min(100, Math.max(0, Math.round(job.progress)));
|
||||
}
|
||||
|
||||
/** 已处理 / 总行数文案 */
|
||||
export function auditExportRowsText(
|
||||
job: Pick<AuditExportJob, 'processed_rows' | 'total_rows'>,
|
||||
): string {
|
||||
const processed = formatRowCount(job.processed_rows ?? 0);
|
||||
return `${processed} / ${formatRowCount(job.total_rows)} 条`;
|
||||
}
|
||||
|
||||
/** 签名链接是否已过期(过期后不再渲染下载入口) */
|
||||
export function isAuditExportExpired(job: AuditExportJob, now: Date): boolean {
|
||||
if (job.expires_at === null) return false;
|
||||
const expiresAt = new Date(job.expires_at);
|
||||
return !Number.isNaN(expiresAt.getTime()) && expiresAt.getTime() <= now.getTime();
|
||||
}
|
||||
|
||||
/** 可下载:成功 + 有 token + 未过期 */
|
||||
export function canDownloadAuditExport(job: AuditExportJob, now: Date): boolean {
|
||||
return (
|
||||
job.status === 'succeeded' &&
|
||||
job.download_token !== null &&
|
||||
job.download_token !== '' &&
|
||||
!isAuditExportExpired(job, now)
|
||||
);
|
||||
}
|
||||
|
||||
/** 筛选 → 导出历史里的范围摘要(分类使用调用方传入的 label 映射) */
|
||||
export function summarizeAuditFilterRange(
|
||||
filters: AuditLogFilter,
|
||||
categoryLabel?: (category: string) => string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (filters.from !== undefined || filters.to !== undefined) {
|
||||
const from = filters.from === undefined ? '不限' : formatAuditDate(filters.from);
|
||||
const to = filters.to === undefined ? '不限' : formatAuditDate(filters.to);
|
||||
parts.push(`${from} 至 ${to}`);
|
||||
} else {
|
||||
parts.push('全部时间');
|
||||
}
|
||||
if (filters.category !== undefined) {
|
||||
parts.push(categoryLabel?.(filters.category) ?? filters.category);
|
||||
}
|
||||
if (filters.result !== undefined) {
|
||||
parts.push(AUDIT_RESULT_LABEL[filters.result]);
|
||||
}
|
||||
if (filters.keyword !== undefined) parts.push(`关键字:${filters.keyword}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
auditFiltersEqual,
|
||||
createDefaultAuditFilter,
|
||||
DEFAULT_AUDIT_TIME_PRESET,
|
||||
describeAuditTimeRange,
|
||||
formatDateInput,
|
||||
parseAuditFilters,
|
||||
resolveAuditTimeRange,
|
||||
serializeAuditFilters,
|
||||
toAuditLogFilter,
|
||||
} from './audit-filter-utils';
|
||||
|
||||
/** 固定「现在」:本地时区 2026-11-04 13:32:00,避免依赖运行环境时区 */
|
||||
const NOW = new Date(2026, 10, 4, 13, 32, 0);
|
||||
|
||||
function localStart(year: number, month: number, day: number): string {
|
||||
return new Date(year, month, day, 0, 0, 0, 0).toISOString();
|
||||
}
|
||||
|
||||
function localEnd(year: number, month: number, day: number): string {
|
||||
return new Date(year, month, day, 23, 59, 59, 999).toISOString();
|
||||
}
|
||||
|
||||
describe('操作日志筛选纯函数', () => {
|
||||
it('默认时间范围为近 7 天', () => {
|
||||
expect(createDefaultAuditFilter().timePreset).toBe(DEFAULT_AUDIT_TIME_PRESET);
|
||||
});
|
||||
|
||||
it('今天:从本日 00:00 到当天结束', () => {
|
||||
const range = resolveAuditTimeRange(
|
||||
{ ...createDefaultAuditFilter(), timePreset: 'today' },
|
||||
NOW,
|
||||
);
|
||||
expect(range.from).toBe(localStart(2026, 10, 4));
|
||||
expect(range.to).toBe(localEnd(2026, 10, 4));
|
||||
});
|
||||
|
||||
it('近 7 天:从 6 天前 00:00 到当天结束', () => {
|
||||
const range = resolveAuditTimeRange({ ...createDefaultAuditFilter(), timePreset: '7d' }, NOW);
|
||||
expect(range.from).toBe(localStart(2026, 9, 29));
|
||||
expect(range.to).toBe(localEnd(2026, 10, 4));
|
||||
});
|
||||
|
||||
it('近 30 天:从 29 天前 00:00 到当天结束', () => {
|
||||
const range = resolveAuditTimeRange({ ...createDefaultAuditFilter(), timePreset: '30d' }, NOW);
|
||||
expect(range.from).toBe(localStart(2026, 9, 6));
|
||||
expect(range.to).toBe(localEnd(2026, 10, 4));
|
||||
});
|
||||
|
||||
it('自定义:from 取当天开始、to 取当天结束', () => {
|
||||
const range = resolveAuditTimeRange(
|
||||
{
|
||||
...createDefaultAuditFilter(),
|
||||
timePreset: 'custom',
|
||||
customFrom: '2026-10-01',
|
||||
customTo: '2026-10-07',
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(range.from).toBe(localStart(2026, 9, 1));
|
||||
expect(range.to).toBe(localEnd(2026, 9, 7));
|
||||
});
|
||||
|
||||
it('自定义缺日期时该端不限制', () => {
|
||||
const range = resolveAuditTimeRange(
|
||||
{ ...createDefaultAuditFilter(), timePreset: 'custom', customFrom: '2026-10-01' },
|
||||
NOW,
|
||||
);
|
||||
expect(range.from).toBe(localStart(2026, 9, 1));
|
||||
expect(range.to).toBeNull();
|
||||
});
|
||||
|
||||
it('关键字 / 操作人 trim,空值剔除,分类只接受白名单', () => {
|
||||
const filter = toAuditLogFilter(
|
||||
{
|
||||
...createDefaultAuditFilter(),
|
||||
keyword: ' 素材 ',
|
||||
actor: ' 江岚 ',
|
||||
category: 'permission',
|
||||
customerId: '01HZZZZZZZZZZZZZZZZZZZZZZ2',
|
||||
result: 'failure',
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
expect(filter.keyword).toBe('素材');
|
||||
expect(filter.actor).toBe('江岚');
|
||||
expect(filter.category).toBe('permission');
|
||||
expect(filter.customer_id).toBe('01HZZZZZZZZZZZZZZZZZZZZZZ2');
|
||||
expect(filter.result).toBe('failure');
|
||||
expect(filter.from).toBe(localStart(2026, 9, 29));
|
||||
|
||||
const empty = toAuditLogFilter(
|
||||
{ ...createDefaultAuditFilter(), keyword: ' ', actor: '', category: 'not-a-category' },
|
||||
NOW,
|
||||
);
|
||||
expect(empty.keyword).toBeUndefined();
|
||||
expect(empty.actor).toBeUndefined();
|
||||
expect(empty.category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('URL search params 往返:非默认值才序列化', () => {
|
||||
const draft = {
|
||||
...createDefaultAuditFilter(),
|
||||
keyword: ' 素材 ',
|
||||
category: 'permission',
|
||||
actor: '江岚',
|
||||
customerId: '01HZZZZZZZZZZZZZZZZZZZZZZ2',
|
||||
result: 'failure' as const,
|
||||
};
|
||||
const params = serializeAuditFilters(draft);
|
||||
expect(params.get('keyword')).toBe('素材');
|
||||
expect(params.get('time')).toBeNull();
|
||||
|
||||
const parsed = parseAuditFilters(params);
|
||||
expect(parsed.keyword).toBe('素材');
|
||||
expect(parsed.category).toBe('permission');
|
||||
expect(parsed.result).toBe('failure');
|
||||
expect(parsed.timePreset).toBe('7d');
|
||||
expect(auditFiltersEqual(parsed, draft)).toBe(true);
|
||||
expect(serializeAuditFilters(createDefaultAuditFilter()).toString()).toBe('');
|
||||
});
|
||||
|
||||
it('自定义时间写入 from / to 并可还原', () => {
|
||||
const draft = {
|
||||
...createDefaultAuditFilter(),
|
||||
timePreset: 'custom' as const,
|
||||
customFrom: '2026-10-01',
|
||||
customTo: '2026-10-07',
|
||||
};
|
||||
const params = serializeAuditFilters(draft);
|
||||
expect(params.get('time')).toBe('custom');
|
||||
expect(params.get('from')).toBe('2026-10-01');
|
||||
expect(params.get('to')).toBe('2026-10-07');
|
||||
const parsed = parseAuditFilters(params);
|
||||
expect(parsed.timePreset).toBe('custom');
|
||||
expect(parsed.customFrom).toBe('2026-10-01');
|
||||
});
|
||||
|
||||
it('非法 time / result 参数回退默认值', () => {
|
||||
const params = new URLSearchParams({ time: 'bad', result: 'unknown', category: 'auth' });
|
||||
const parsed = parseAuditFilters(params);
|
||||
expect(parsed.timePreset).toBe('7d');
|
||||
expect(parsed.result).toBe('all');
|
||||
expect(parsed.category).toBe('auth');
|
||||
});
|
||||
|
||||
it('时间范围说明用于导出抽屉', () => {
|
||||
const today = describeAuditTimeRange(
|
||||
{ ...createDefaultAuditFilter(), timePreset: 'today' },
|
||||
NOW,
|
||||
);
|
||||
expect(today.label).toBe('今天');
|
||||
expect(today.range).toBe('2026-11-04 至 2026-11-04');
|
||||
|
||||
const customEmpty = describeAuditTimeRange(
|
||||
{ ...createDefaultAuditFilter(), timePreset: 'custom' },
|
||||
NOW,
|
||||
);
|
||||
expect(customEmpty.range).toBe('未选择起止日期');
|
||||
});
|
||||
|
||||
it('formatDateInput 输出本地 YYYY-MM-DD', () => {
|
||||
expect(formatDateInput(new Date(2026, 0, 9))).toBe('2026-01-09');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 操作日志筛选 → API query 的纯函数(可单测,不依赖 React)。
|
||||
*
|
||||
* 口径:
|
||||
* - 时间预设「今天 / 近 7 天 / 近 30 天」在每次查询时按本地时区换算为 from / to ISO;
|
||||
* - 「自定义」用日期区间,from 取当天 00:00:00、to 取当天 23:59:59.999;
|
||||
* - 关键字 trim 后为空则剔除;URL search params 与界面草稿一一对应,刷新可还原。
|
||||
*/
|
||||
import { AUDIT_CATEGORY_KEYS, type AuditLogFilter } from '@lingxi/shared';
|
||||
|
||||
export type AuditResultFilter = 'all' | 'success' | 'failure';
|
||||
export type AuditTimePreset = 'today' | '7d' | '30d' | 'custom';
|
||||
|
||||
export const AUDIT_TIME_PRESETS: readonly { value: AuditTimePreset; label: string }[] = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: '7d', label: '近 7 天' },
|
||||
{ value: '30d', label: '近 30 天' },
|
||||
{ value: 'custom', label: '自定义' },
|
||||
];
|
||||
|
||||
export const DEFAULT_AUDIT_TIME_PRESET: AuditTimePreset = '7d';
|
||||
|
||||
export const AUDIT_RESULT_OPTIONS: readonly { value: AuditResultFilter; label: string }[] = [
|
||||
{ value: 'all', label: '全部结果' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'failure', label: '失败' },
|
||||
];
|
||||
|
||||
/** 筛选草稿(UI 状态;与 URL search params 同构) */
|
||||
export interface AuditFilterDraft {
|
||||
keyword: string;
|
||||
/** 动作分类;空串 = 全部分类 */
|
||||
category: string;
|
||||
/** 操作人:姓名 / 账号关键字 */
|
||||
actor: string;
|
||||
/** 客户 id;空串 = 全部客户 */
|
||||
customerId: string;
|
||||
result: AuditResultFilter;
|
||||
timePreset: AuditTimePreset;
|
||||
/** 自定义起始日期(YYYY-MM-DD,本地时区) */
|
||||
customFrom: string;
|
||||
/** 自定义结束日期(YYYY-MM-DD,本地时区) */
|
||||
customTo: string;
|
||||
}
|
||||
|
||||
export interface AuditTimeRange {
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
}
|
||||
|
||||
export function createDefaultAuditFilter(): AuditFilterDraft {
|
||||
return {
|
||||
keyword: '',
|
||||
category: '',
|
||||
actor: '',
|
||||
customerId: '',
|
||||
result: 'all',
|
||||
timePreset: DEFAULT_AUDIT_TIME_PRESET,
|
||||
customFrom: '',
|
||||
customTo: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function isAuditTimePreset(value: string): value is AuditTimePreset {
|
||||
return value === 'today' || value === '7d' || value === '30d' || value === 'custom';
|
||||
}
|
||||
|
||||
export function isAuditResultFilter(value: string): value is AuditResultFilter {
|
||||
return value === 'all' || value === 'success' || value === 'failure';
|
||||
}
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
function endOfDay(date: Date): Date {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
function shiftDays(date: Date, days: number): Date {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → 本地当天 00:00;非法输入返回 null */
|
||||
export function parseDateInput(value: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const date = new Date(`${value}T00:00:00`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
/** Date → `YYYY-MM-DD`(本地时区) */
|
||||
export function formatDateInput(date: Date): string {
|
||||
const year = String(date.getFullYear());
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
/** 时间预设 / 自定义区间 → from / to ISO(自定义缺任意一端时不限制该端) */
|
||||
export function resolveAuditTimeRange(draft: AuditFilterDraft, now: Date): AuditTimeRange {
|
||||
switch (draft.timePreset) {
|
||||
case 'today':
|
||||
return { from: startOfDay(now).toISOString(), to: endOfDay(now).toISOString() };
|
||||
case '7d':
|
||||
return {
|
||||
from: startOfDay(shiftDays(now, -6)).toISOString(),
|
||||
to: endOfDay(now).toISOString(),
|
||||
};
|
||||
case '30d':
|
||||
return {
|
||||
from: startOfDay(shiftDays(now, -29)).toISOString(),
|
||||
to: endOfDay(now).toISOString(),
|
||||
};
|
||||
case 'custom': {
|
||||
const from = parseDateInput(draft.customFrom);
|
||||
const to = parseDateInput(draft.customTo);
|
||||
return {
|
||||
from: from === null ? null : startOfDay(from).toISOString(),
|
||||
to: to === null ? null : endOfDay(to).toISOString(),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { from: null, to: null };
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿 → 后端筛选(snake_case,空值剔除) */
|
||||
export function toAuditLogFilter(draft: AuditFilterDraft, now: Date): AuditLogFilter {
|
||||
const filter: AuditLogFilter = {};
|
||||
const keyword = draft.keyword.trim();
|
||||
const actor = draft.actor.trim();
|
||||
const range = resolveAuditTimeRange(draft, now);
|
||||
|
||||
if (keyword !== '') filter.keyword = keyword;
|
||||
const category = AUDIT_CATEGORY_KEYS.find((key) => key === draft.category);
|
||||
if (category !== undefined) filter.category = category;
|
||||
if (actor !== '') filter.actor = actor;
|
||||
if (draft.customerId !== '') filter.customer_id = draft.customerId;
|
||||
if (draft.result !== 'all') filter.result = draft.result;
|
||||
if (range.from !== null) filter.from = range.from;
|
||||
if (range.to !== null) filter.to = range.to;
|
||||
return filter;
|
||||
}
|
||||
|
||||
/** 草稿 → URLSearchParams(仅序列化非默认值,便于分享 / 刷新还原) */
|
||||
export function serializeAuditFilters(draft: AuditFilterDraft): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
if (draft.keyword.trim() !== '') params.set('keyword', draft.keyword.trim());
|
||||
if (draft.category !== '') params.set('category', draft.category);
|
||||
if (draft.actor.trim() !== '') params.set('actor', draft.actor.trim());
|
||||
if (draft.customerId !== '') params.set('customer', draft.customerId);
|
||||
if (draft.result !== 'all') params.set('result', draft.result);
|
||||
if (draft.timePreset !== DEFAULT_AUDIT_TIME_PRESET) params.set('time', draft.timePreset);
|
||||
if (draft.timePreset === 'custom') {
|
||||
if (draft.customFrom !== '') params.set('from', draft.customFrom);
|
||||
if (draft.customTo !== '') params.set('to', draft.customTo);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/** URLSearchParams → 草稿(缺省时间范围为近 7 天) */
|
||||
export function parseAuditFilters(params: URLSearchParams): AuditFilterDraft {
|
||||
const timeRaw = params.get('time') ?? '';
|
||||
const resultRaw = params.get('result') ?? '';
|
||||
const category = params.get('category') ?? '';
|
||||
return {
|
||||
keyword: params.get('keyword') ?? '',
|
||||
category,
|
||||
actor: params.get('actor') ?? '',
|
||||
customerId: params.get('customer') ?? '',
|
||||
result: isAuditResultFilter(resultRaw) ? resultRaw : 'all',
|
||||
timePreset: isAuditTimePreset(timeRaw) ? timeRaw : DEFAULT_AUDIT_TIME_PRESET,
|
||||
customFrom: params.get('from') ?? '',
|
||||
customTo: params.get('to') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** 时间范围说明:用于导出抽屉的「本次导出范围」 */
|
||||
export function describeAuditTimeRange(
|
||||
draft: AuditFilterDraft,
|
||||
now: Date,
|
||||
): { label: string; range: string } {
|
||||
const preset = AUDIT_TIME_PRESETS.find((item) => item.value === draft.timePreset);
|
||||
const label = preset?.label ?? '近 7 天';
|
||||
const resolved = resolveAuditTimeRange(draft, now);
|
||||
if (resolved.from === null && resolved.to === null) {
|
||||
return { label, range: draft.timePreset === 'custom' ? '未选择起止日期' : '不限时间' };
|
||||
}
|
||||
const from = resolved.from === null ? '不限' : formatDateInput(new Date(resolved.from));
|
||||
const to = resolved.to === null ? '不限' : formatDateInput(new Date(resolved.to));
|
||||
return { label, range: `${from} 至 ${to}` };
|
||||
}
|
||||
|
||||
/** 两个草稿是否等价(用于避免重复查询 / 无效 URL 写入) */
|
||||
export function auditFiltersEqual(left: AuditFilterDraft, right: AuditFilterDraft): boolean {
|
||||
return (
|
||||
left.keyword.trim() === right.keyword.trim() &&
|
||||
left.category === right.category &&
|
||||
left.actor.trim() === right.actor.trim() &&
|
||||
left.customerId === right.customerId &&
|
||||
left.result === right.result &&
|
||||
left.timePreset === right.timePreset &&
|
||||
left.customFrom === right.customFrom &&
|
||||
left.customTo === right.customTo
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/* eslint-disable max-lines-per-function -- 运行日志页由平台入口 / trace / 空态卡片组成,集中便于保持文案口径 */
|
||||
import { Button, Input } from '@douyinfe/semi-ui-19';
|
||||
import { Permission } from '@lingxi/shared';
|
||||
import { ErrorState, notifyError, notifySuccess, PageEmpty, PageSkeleton } from '@lingxi/ui';
|
||||
import { IconExport, IconLock, IconSearch, IconServer } from '@lingxi/ui/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useEffectiveConfigsQuery } from '@/hooks/queries/use-configs';
|
||||
import { usePermission } from '@/hooks/use-permission';
|
||||
import { ROUTES } from '@/routes';
|
||||
|
||||
import { copyToClipboard } from '../audit-logs/audit-clipboard';
|
||||
|
||||
const PLATFORM_URL_KEY = 'audit.logs_platform_url';
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return /^https?:\/\/\S+$/.test(value);
|
||||
}
|
||||
|
||||
function openUrl(url: string): void {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
function PlatformCard({ url }: { url: string }): ReactNode {
|
||||
return (
|
||||
<section className="lx-runlog-card" aria-label="日志平台接入">
|
||||
<div className="lx-runlog-card__head">
|
||||
<b>日志平台接入</b>
|
||||
<span className="lx-audit-tag lx-audit-tag--success">已接入</span>
|
||||
<span className="lx-runlog-card__sub">运行日志集中检索</span>
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconExport size={14} />}
|
||||
onClick={() => {
|
||||
openUrl(url);
|
||||
}}
|
||||
>
|
||||
打开日志平台
|
||||
</Button>
|
||||
</div>
|
||||
<div className="lx-runlog-platform">
|
||||
<span className="lx-runlog-platform__icon" aria-hidden="true">
|
||||
<IconServer size={22} />
|
||||
</span>
|
||||
<div className="lx-runlog-platform__main">
|
||||
<b>日志平台</b>
|
||||
<a className="lx-audit-link lx-audit-mono" href={url} target="_blank" rel="noopener">
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
openUrl(url);
|
||||
}}
|
||||
>
|
||||
在平台中检索
|
||||
</Button>
|
||||
</div>
|
||||
<div className="lx-runlog-flow">
|
||||
<div className="lx-runlog-flow__step">
|
||||
<i>1</i>
|
||||
<span>从操作日志复制 trace_id</span>
|
||||
</div>
|
||||
<span className="lx-runlog-flow__arrow" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
<div className="lx-runlog-flow__step">
|
||||
<i>2</i>
|
||||
<span>在日志平台粘贴检索</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lx-runlog-note">
|
||||
平台地址由「系统配置 › 审计与日志 › 运行日志平台地址」维护;运行日志不写入业务库。
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TraceCard({ url }: { url: string }): ReactNode {
|
||||
const [traceId, setTraceId] = useState('');
|
||||
const trimmed = traceId.trim();
|
||||
|
||||
return (
|
||||
<section className="lx-runlog-card" aria-label="trace 检索">
|
||||
<div className="lx-runlog-card__head">
|
||||
<b>trace 检索</b>
|
||||
<span className="lx-runlog-card__sub">复制 trace_id 到日志平台查询</span>
|
||||
</div>
|
||||
<div className="lx-runlog-trace">
|
||||
<Input
|
||||
value={traceId}
|
||||
prefix={<IconSearch size={16} />}
|
||||
placeholder="输入或粘贴 trace_id"
|
||||
showClear
|
||||
maxLength={128}
|
||||
onChange={setTraceId}
|
||||
/>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="primary"
|
||||
disabled={trimmed === ''}
|
||||
onClick={() => {
|
||||
void copyToClipboard(trimmed).then((ok) => {
|
||||
if (ok) notifySuccess('已复制 trace_id,请到日志平台粘贴检索');
|
||||
else notifyError('复制失败,请手动选择文本');
|
||||
});
|
||||
}}
|
||||
>
|
||||
复制 trace_id
|
||||
</Button>
|
||||
<Button
|
||||
icon={<IconExport size={16} />}
|
||||
onClick={() => {
|
||||
openUrl(url);
|
||||
}}
|
||||
>
|
||||
打开日志平台
|
||||
</Button>
|
||||
</div>
|
||||
<div className="lx-runlog-trace__hint">
|
||||
操作日志详情中的 trace_id 与后端日志一致:复制后到日志平台按 trace_id
|
||||
检索即可查看完整链路。本页不保存检索历史。
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCard({ title, description }: { title: string; description: string }): ReactNode {
|
||||
return (
|
||||
<section className="lx-runlog-card lx-runlog-card--empty" aria-label={title}>
|
||||
<div className="lx-runlog-card__head">
|
||||
<b>{title}</b>
|
||||
</div>
|
||||
<PageEmpty title={title} description={description} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** 运行日志(用户选定方案 C:平台入口 / trace 检索 / 服务状态 / 最近错误;真实数据只有平台地址) */
|
||||
export function RunLogsPage(): ReactNode {
|
||||
const effective = useEffectiveConfigsQuery();
|
||||
const canConfigure = usePermission(Permission.SYSTEM_CONFIGURE);
|
||||
const rawUrl = effective.data?.[PLATFORM_URL_KEY];
|
||||
const url = typeof rawUrl === 'string' ? rawUrl.trim() : '';
|
||||
const configured = isHttpUrl(url);
|
||||
|
||||
return (
|
||||
<div className="lx-runlog-page">
|
||||
<header className="lx-audit-page__header">
|
||||
<div>
|
||||
<h1>运行日志</h1>
|
||||
<p>与操作日志分离 · 只读观测入口</p>
|
||||
</div>
|
||||
<div className="lx-audit-page__actions">
|
||||
<span className="lx-audit-readonly">
|
||||
<IconLock size={14} />
|
||||
只读
|
||||
</span>
|
||||
{configured ? (
|
||||
<Button
|
||||
icon={<IconExport size={16} />}
|
||||
onClick={() => {
|
||||
openUrl(url);
|
||||
}}
|
||||
>
|
||||
打开日志平台
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="lx-audit-note" role="note">
|
||||
<IconServer size={16} />
|
||||
<span>
|
||||
<b>运行日志不写入业务库</b>:本页只提供日志平台入口与 trace 检索指引;业务追责请查看
|
||||
append-only 的操作日志。
|
||||
</span>
|
||||
<Link to={ROUTES.system.auditLogs} className="lx-audit-link">
|
||||
前往操作日志
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{effective.isPending ? (
|
||||
<div className="lx-runlog-grid">
|
||||
<PageSkeleton rows={6} />
|
||||
<PageSkeleton rows={6} />
|
||||
</div>
|
||||
) : effective.error === null ? null : (
|
||||
<section className="lx-audit-state" aria-label="运行日志配置加载失败">
|
||||
<ErrorState
|
||||
title="日志平台配置加载失败"
|
||||
message={effective.error.message}
|
||||
onRetry={() => {
|
||||
void effective.refetch();
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{effective.isPending || effective.error !== null ? null : configured ? (
|
||||
<div className="lx-runlog-grid">
|
||||
<PlatformCard url={url} />
|
||||
<EmptyCard
|
||||
title="服务状态"
|
||||
description="服务状态与日志级别统计由日志平台提供;本页不伪造运行数据,请打开平台查看。"
|
||||
/>
|
||||
<TraceCard url={url} />
|
||||
<EmptyCard
|
||||
title="最近错误"
|
||||
description="接入日志平台后在此查看最近错误;错误日志与上下文请到日志平台按级别 / trace_id 检索。"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<section className="lx-runlog-unconfigured" aria-label="尚未配置日志平台">
|
||||
<PageEmpty
|
||||
title="尚未配置日志平台"
|
||||
description="到系统配置填写运行日志平台地址后,这里会展示平台入口与 trace 检索;运行日志不写入业务库。"
|
||||
action={
|
||||
canConfigure ? (
|
||||
<Link to={ROUTES.system.settings} className="lx-audit-link">
|
||||
前往系统配置
|
||||
</Link>
|
||||
) : (
|
||||
<span className="lx-audit-muted">请联系平台管理员在系统配置中填写日志平台地址</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,21 @@ const SettingsPage = lazy(async () => {
|
||||
return { default: module.SettingsPage };
|
||||
});
|
||||
|
||||
const AuditLogsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/audit-logs/AuditLogsPage');
|
||||
return { default: module.AuditLogsPage };
|
||||
});
|
||||
|
||||
const AuditLogDetailPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/audit-logs/AuditLogDetailPage');
|
||||
return { default: module.AuditLogDetailPage };
|
||||
});
|
||||
|
||||
const RunLogsPage = lazy(async () => {
|
||||
const module = await import('@/pages/system/run-logs/RunLogsPage');
|
||||
return { default: module.RunLogsPage };
|
||||
});
|
||||
|
||||
function withSkeleton(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
|
||||
}
|
||||
@@ -238,6 +253,30 @@ export const router = createBrowserRouter([
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/audit-logs',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.AUDIT_READ}>
|
||||
{withSkeleton(<AuditLogsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/audit-logs/:id',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.AUDIT_READ}>
|
||||
{withSkeleton(<AuditLogDetailPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/logs',
|
||||
element: (
|
||||
<RequirePermission permission={Permission.AUDIT_READ}>
|
||||
{withSkeleton(<RunLogsPage />)}
|
||||
</RequirePermission>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'system/settings',
|
||||
element: (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
auditLogDetailPath,
|
||||
loginPath,
|
||||
roleDetailPath,
|
||||
roleNewPath,
|
||||
@@ -31,6 +32,15 @@ describe('路由常量与登录回跳', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('A5 审计路由:列表 / 详情 / 运行日志', () => {
|
||||
expect(ROUTES.system.auditLogs).toBe('/system/audit-logs');
|
||||
expect(ROUTES.system.auditLogDetail).toBe('/system/audit-logs/:id');
|
||||
expect(ROUTES.system.runLogs).toBe('/system/logs');
|
||||
expect(auditLogDetailPath('01HZZZZZZZZZZZZZZZZZZZZZZZ')).toBe(
|
||||
'/system/audit-logs/01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||
);
|
||||
});
|
||||
|
||||
it('部门管理路由挂在管理中心下', () => {
|
||||
expect(ROUTES.system.departments).toBe('/system/departments');
|
||||
});
|
||||
|
||||
@@ -24,6 +24,9 @@ export const ROUTES = {
|
||||
customerFields: '/system/customer-fields/approvals',
|
||||
dicts: '/system/dicts',
|
||||
dictDetail: '/system/dicts/:type',
|
||||
auditLogs: '/system/audit-logs',
|
||||
auditLogDetail: '/system/audit-logs/:id',
|
||||
runLogs: '/system/logs',
|
||||
settings: '/system/settings',
|
||||
},
|
||||
unauthorized: '/401',
|
||||
@@ -51,6 +54,9 @@ export type RoutePath =
|
||||
| typeof ROUTES.system.customerFields
|
||||
| typeof ROUTES.system.dicts
|
||||
| typeof ROUTES.system.dictDetail
|
||||
| typeof ROUTES.system.auditLogs
|
||||
| typeof ROUTES.system.auditLogDetail
|
||||
| typeof ROUTES.system.runLogs
|
||||
| typeof ROUTES.system.settings
|
||||
| typeof ROUTES.unauthorized
|
||||
| typeof ROUTES.forbidden
|
||||
@@ -77,6 +83,11 @@ export function dictDetailPath(typeCode: string): string {
|
||||
return `${ROUTES.system.dicts}/${encodeURIComponent(typeCode)}`;
|
||||
}
|
||||
|
||||
/** 操作日志详情深链:`/system/audit-logs/{id}` */
|
||||
export function auditLogDetailPath(logId: string): string {
|
||||
return `${ROUTES.system.auditLogs}/${encodeURIComponent(logId)}`;
|
||||
}
|
||||
|
||||
/** 新建客户(四步向导) */
|
||||
export function customerNewPath(): string {
|
||||
return ROUTES.system.customerNew;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
/* A5 运行日志(用户选定方案 C:平台入口 / trace 检索 / 服务状态 / 最近错误) */
|
||||
|
||||
.lx-runlog-page {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--lx-space-4);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lx-runlog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
gap: var(--lx-space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.lx-runlog-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-lg);
|
||||
background: var(--lx-color-bg-panel);
|
||||
box-shadow: var(--lx-shadow-panel);
|
||||
}
|
||||
|
||||
.lx-runlog-card__head {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--lx-color-border);
|
||||
}
|
||||
|
||||
.lx-runlog-card__head b {
|
||||
font-size: var(--lx-font-size-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lx-runlog-card__head .semi-button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.lx-runlog-card__sub {
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
}
|
||||
|
||||
.lx-runlog-card--empty {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.lx-runlog-card--empty .lx-empty {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lx-runlog-platform {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.lx-runlog-platform__icon {
|
||||
display: inline-flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--lx-radius-md);
|
||||
background: var(--lx-color-brand-soft);
|
||||
color: var(--lx-color-brand);
|
||||
}
|
||||
|
||||
.lx-runlog-platform__main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lx-runlog-platform__main b {
|
||||
font-size: var(--lx-font-size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lx-runlog-platform__main a {
|
||||
overflow: hidden;
|
||||
color: var(--lx-color-brand);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lx-runlog-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px 14px;
|
||||
}
|
||||
|
||||
.lx-runlog-flow__step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-md);
|
||||
color: var(--lx-color-text-secondary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
}
|
||||
|
||||
.lx-runlog-flow__step i {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--lx-color-brand-soft);
|
||||
color: var(--lx-color-brand);
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.lx-runlog-flow__arrow {
|
||||
color: var(--lx-color-text-tertiary);
|
||||
}
|
||||
|
||||
.lx-runlog-note {
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid var(--lx-color-border);
|
||||
background: var(--lx-color-bg-subtle);
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
}
|
||||
|
||||
.lx-runlog-trace {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.lx-runlog-trace .semi-input-wrapper {
|
||||
min-width: 220px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.lx-runlog-trace__hint {
|
||||
padding: 0 16px 14px;
|
||||
color: var(--lx-color-text-tertiary);
|
||||
font-size: var(--lx-font-size-xs);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.lx-runlog-unconfigured {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 320px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
border: 1px solid var(--lx-color-border);
|
||||
border-radius: var(--lx-radius-lg);
|
||||
background: var(--lx-color-bg-panel);
|
||||
box-shadow: var(--lx-shadow-panel);
|
||||
}
|
||||
|
||||
@media (width <= 1200px) {
|
||||
.lx-runlog-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.lx-runlog-card--empty {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user