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(/\/+$/, ''); return (configured ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
} }
/** API 根地址(无尾斜杠):供签名下载等需要直链 / 新窗口打开的接口复用,禁止在业务里重复硬编码 */
export function apiBaseUrl(): string {
return baseUrl();
}
function buildUrl(path: string): string { function buildUrl(path: string): string {
if (/^https?:\/\//.test(path)) { if (/^https?:\/\//.test(path)) {
return path; return path;
+11
View File
@@ -6,6 +6,7 @@
const USERS = '/users'; const USERS = '/users';
const DICTS = '/dicts'; const DICTS = '/dicts';
const SYSTEM_CONFIGS = '/system/configs'; const SYSTEM_CONFIGS = '/system/configs';
const AUDIT_LOGS = '/audit-logs';
export const ENDPOINTS = { export const ENDPOINTS = {
login: '/auth/login', login: '/auth/login',
@@ -64,4 +65,14 @@ export const ENDPOINTS = {
systemConfigs: SYSTEM_CONFIGS, systemConfigs: SYSTEM_CONFIGS,
systemConfigsEffective: `${SYSTEM_CONFIGS}/effective`, systemConfigsEffective: `${SYSTEM_CONFIGS}/effective`,
systemConfigsHistory: `${SYSTEM_CONFIGS}/history`, 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; } as const;
+8
View File
@@ -35,4 +35,12 @@ export const queryKeys = {
systemConfigs: ['system-configs'] as const, systemConfigs: ['system-configs'] as const,
systemConfigsEffective: ['system-configs', 'effective'] as const, systemConfigsEffective: ['system-configs', 'effective'] as const,
systemConfigHistory: (query: object) => ['system-configs', 'history', query] 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,
}; };
+155
View File
@@ -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>;
+5 -2
View File
@@ -11,7 +11,7 @@ import { PageBreadcrumbs } from './PageBreadcrumbs';
import { SideNav } from './SideNav'; import { SideNav } from './SideNav';
import { TopBar } from './TopBar'; import { TopBar } from './TopBar';
/** 用户管理、部门管理、数据字典、系统配置为「满高主从布局」,由页面自己控制内部滚动 */ /** 用户 / 部门 / 字典 / 系统配置 / 操作日志 / 运行日志为「满高布局」,由页面自己控制内部滚动 */
function isFlushRoute(pathname: string): boolean { function isFlushRoute(pathname: string): boolean {
return ( return (
pathname === ROUTES.system.users || pathname === ROUTES.system.users ||
@@ -19,7 +19,10 @@ function isFlushRoute(pathname: string): boolean {
pathname === ROUTES.system.departments || pathname === ROUTES.system.departments ||
pathname === ROUTES.system.dicts || pathname === ROUTES.system.dicts ||
pathname.startsWith(`${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
); );
} }
+6 -1
View File
@@ -1,7 +1,11 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Link } from 'react-router'; 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'; import { getBreadcrumbs, NAV_MODULES } from '@/navigation/nav-config';
export interface PageBreadcrumbsProps { export interface PageBreadcrumbsProps {
@@ -12,6 +16,7 @@ export function PageBreadcrumbs({ pathname }: PageBreadcrumbsProps): ReactNode {
const entries = const entries =
getUserSectionTrail(pathname) ?? getUserSectionTrail(pathname) ??
getRoleSectionTrail(pathname) ?? getRoleSectionTrail(pathname) ??
getAuditSectionTrail(pathname) ??
getBreadcrumbs(NAV_MODULES, pathname); getBreadcrumbs(NAV_MODULES, pathname);
return ( return (
<nav className="lx-breadcrumbs" aria-label="面包屑"> <nav className="lx-breadcrumbs" aria-label="面包屑">
+2
View File
@@ -12,6 +12,8 @@ import '@/styles/departments.css';
import '@/styles/client.css'; import '@/styles/client.css';
import '@/styles/dicts.css'; import '@/styles/dicts.css';
import '@/styles/settings.css'; import '@/styles/settings.css';
import '@/styles/audit.css';
import '@/styles/run-logs.css';
import { LocaleProvider } from '@douyinfe/semi-ui-19'; import { LocaleProvider } from '@douyinfe/semi-ui-19';
import { localeZhCN } from '@lingxi/ui'; import { localeZhCN } from '@lingxi/ui';
+14 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs'; import { getAuditSectionTrail, getRoleSectionTrail, getUserSectionTrail } from './breadcrumbs';
describe('面包屑子路由补全', () => { describe('面包屑子路由补全', () => {
it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => { it('角色新建页:工作台 / 管理中心 / 角色管理 / 新建角色', () => {
@@ -28,6 +28,19 @@ describe('面包屑子路由补全', () => {
expect(getRoleSectionTrail('/system/roles')).toBeNull(); 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('用户子路由补全不受影响', () => { it('用户子路由补全不受影响', () => {
expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([ expect(getUserSectionTrail('/system/users/new')?.map((entry) => entry.label)).toEqual([
'工作台', '工作台',
+13
View File
@@ -46,3 +46,16 @@ export function getUserSectionTrail(pathname: string): BreadcrumbEntry[] | null
} }
return 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 type { PermissionCode } from '@lingxi/shared';
import { IconServer } from '@lingxi/ui/icons';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { filterNavByPermissions, findActiveNav, getBreadcrumbs, NAV_MODULES } from './nav-config'; import { filterNavByPermissions, findActiveNav, getBreadcrumbs, NAV_MODULES } from './nav-config';
@@ -75,6 +76,34 @@ describe('导航配置', () => {
expect(findActiveNav(NAV_MODULES, '/system/dicts')?.item?.label).toBe('数据字典'); 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('按路径定位当前模块与菜单', () => { it('按路径定位当前模块与菜单', () => {
const active = findActiveNav(NAV_MODULES, '/material/quick-review'); const active = findActiveNav(NAV_MODULES, '/material/quick-review');
expect(active?.module.key).toBe('material'); expect(active?.module.key).toBe('material');
+11 -2
View File
@@ -17,6 +17,7 @@ import {
IconPlay, IconPlay,
IconRobot, IconRobot,
IconScript, IconScript,
IconServer,
IconSettings, IconSettings,
IconShield, IconShield,
IconStack, IconStack,
@@ -305,12 +306,20 @@ export const NAV_MODULES: readonly NavModule[] = [
}, },
{ {
key: 'audit', key: 'audit',
label: '审计日志', label: '操作日志',
path: '/system/audit-logs', path: '/system/audit-logs',
icon: IconView, icon: IconView,
permission: Permission.AUDIT_READ, permission: Permission.AUDIT_READ,
description: '关键操作留痕与导出记录', description: '关键操作留痕与导出记录',
}, },
{
key: 'runlogs',
label: '运行日志',
path: '/system/logs',
icon: IconServer,
permission: Permission.AUDIT_READ,
description: '日志平台入口、trace 检索与运行日志说明',
},
{ {
key: 'ai', key: 'ai',
label: '模型接入', label: '模型接入',
@@ -345,7 +354,7 @@ export const NAV_MODULES: readonly NavModule[] = [
}, },
{ {
key: 'customer-fields', key: 'customer-fields',
label: '客户字段', label: '字段管理',
path: '/system/customer-fields/approvals', path: '/system/customer-fields/approvals',
icon: IconTask, icon: IconTask,
permission: Permission.CUSTOMER_FIELD_APPROVE, 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>
);
}
+39
View File
@@ -84,6 +84,21 @@ const SettingsPage = lazy(async () => {
return { default: module.SettingsPage }; 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 { function withSkeleton(node: ReactNode): ReactNode {
return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>; return <Suspense fallback={<PageSkeleton rows={8} />}>{node}</Suspense>;
} }
@@ -238,6 +253,30 @@ export const router = createBrowserRouter([
</RequirePermission> </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', path: 'system/settings',
element: ( element: (
+10
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
auditLogDetailPath,
loginPath, loginPath,
roleDetailPath, roleDetailPath,
roleNewPath, 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('部门管理路由挂在管理中心下', () => { it('部门管理路由挂在管理中心下', () => {
expect(ROUTES.system.departments).toBe('/system/departments'); expect(ROUTES.system.departments).toBe('/system/departments');
}); });
+11
View File
@@ -24,6 +24,9 @@ export const ROUTES = {
customerFields: '/system/customer-fields/approvals', customerFields: '/system/customer-fields/approvals',
dicts: '/system/dicts', dicts: '/system/dicts',
dictDetail: '/system/dicts/:type', dictDetail: '/system/dicts/:type',
auditLogs: '/system/audit-logs',
auditLogDetail: '/system/audit-logs/:id',
runLogs: '/system/logs',
settings: '/system/settings', settings: '/system/settings',
}, },
unauthorized: '/401', unauthorized: '/401',
@@ -51,6 +54,9 @@ export type RoutePath =
| typeof ROUTES.system.customerFields | typeof ROUTES.system.customerFields
| typeof ROUTES.system.dicts | typeof ROUTES.system.dicts
| typeof ROUTES.system.dictDetail | typeof ROUTES.system.dictDetail
| typeof ROUTES.system.auditLogs
| typeof ROUTES.system.auditLogDetail
| typeof ROUTES.system.runLogs
| typeof ROUTES.system.settings | typeof ROUTES.system.settings
| typeof ROUTES.unauthorized | typeof ROUTES.unauthorized
| typeof ROUTES.forbidden | typeof ROUTES.forbidden
@@ -77,6 +83,11 @@ export function dictDetailPath(typeCode: string): string {
return `${ROUTES.system.dicts}/${encodeURIComponent(typeCode)}`; 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 { export function customerNewPath(): string {
return ROUTES.system.customerNew; return ROUTES.system.customerNew;
File diff suppressed because it is too large. Load diff
+187
View File
@@ -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;
}
}