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,
|
||||
};
|
||||
Reference in new issue
Block a user