feat(web): A5 操作日志与运行日志页

按用户选定实现操作日志列表 / 详情(方案 A)与运行日志(方案 C);多条件筛选、真实游标分页、字段级 diff / 完整原始 JSON、560 导出抽屉(轮询 / 历史 / 签名下载)、只读与权限控制;侧栏「审计日志」更名「操作日志」、「客户字段」更名「字段管理」并新增「运行日志」;1024 关键列适配。
This commit is contained in:
Stev_Wang committed 2026-11-04 16:27:29 +08:00
1 parent 6bdda6ec62
commit a5933a286e
30 files changed
+4867 -6

No files matched your search

+255
View File
@@ -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();
});
});
+109
View File
@@ -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)}`;
}
+5
View File
@@ -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;
+11
View File
@@ -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;
+8
View File
@@ -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,
};