diff --git a/e2e/README.md b/e2e/README.md
index 789bce6..02256dc 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -2,13 +2,14 @@
## 现有用例
-| 文件 | 说明 | 默认是否执行 |
-| --------------------------- | ------------------------------------------------------------------------ | ----------------- |
-| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
-| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
-| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
-| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` |
-| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` |
+| 文件 | 说明 | 默认是否执行 |
+| --------------------------- | ------------------------------------------------------------------------- | ----------------- |
+| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
+| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
+| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
+| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` |
+| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` |
+| `a5-audit-logs.spec.ts` | A5-QA-01 操作日志列表 / 筛选 / 详情 diff / 导出闭环 / 权限三态 / 运行日志 | 否,需 `A5_E2E=1` |
## A1 账号生命周期用例
@@ -109,3 +110,27 @@ A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
- Semi Select 选项可访问名含图标 aria-label(tick),精确匹配不到,使用 `.semi-select-option` 文本点击。
- `Modal.confirm` 的确认 / 取消按钮 `aria-label` 固定为 `confirm` / `cancel`,不要按 `okText` 文本定位。
- `RadioGroup` 隐藏 input 被 addon 拦截,点可见文本或 `.semi-radio-addon-buttonRadio`。
+
+## A5 审计与日志用例(A5-QA-01)
+
+覆盖 5 条:
+
+1. `/system/audit-logs` 列表可见:h1「操作日志」、副标题、查看留痕提示条(`audit.log.view`)、表格有数据、导出按钮(超管)。
+2. 筛选:动作分类「权限与角色」→ URL `?category=permission` 且结果变化;「重置」恢复默认。
+3. 详情:点击「修改角色权限」行 → `/system/audit-logs/{id}`;变更对比含「权限点」;元信息含 trace_id / IP / 请求 / 耗时;切换「原始 JSON」可看到完整记录(occurred_at / actor / action / target / before / after);只读说明存在。
+4. 导出闭环:打开 560 抽屉 → 创建导出任务 → 轮询到「导出完成」→ 导出历史出现「已完成」任务 → API 断言 `download_token` 存在且签名下载返回 200 CSV → 关闭抽屉并重置筛选后,列表出现 `audit.export` 记录(导出自身留痕)。
+5. 权限三态:无 `audit:read` 账号访问 `/system/audit-logs` → `/403`;仅有 `audit:read`(自定义角色,无 `audit:export`)→ 页面显示「只读 · 无 audit:export 权限」且不渲染导出按钮。
+6. 运行日志:`audit.logs_platform_url` 置空 → 空态 + 「前往系统配置」;写入示例地址后刷新 → 外链与平台入口卡存在;结束后还原配置。
+
+### 前置与运行
+
+1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。
+2. 用例自建临时账号 `qa5.audit.e2e`(超管)、`qa5.audit.viewer`(仅 `audit:read`)、`qa5.audit.ro`(素材运营,无 `audit:read`)与自定义角色 `a5e2e_audit_viewer`;结束后删除账号 / 角色并还原 `audit.logs_platform_url`。
+3. ⚠️ 审计表 append-only:用例产生的登录 / 查看 / 导出 / 下载审计记录**不删除**,属预期留痕。
+
+```bash
+A5_E2E=1 pnpm exec playwright test e2e/a5-audit-logs.spec.ts --reporter=list
+```
+
+- 可选覆盖:`A5_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A5_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
+- 用例不点击外网链接;运行日志只断言外链 href 存在。
diff --git a/e2e/a5-audit-logs.spec.ts b/e2e/a5-audit-logs.spec.ts
new file mode 100644
index 0000000..7dfbc93
--- /dev/null
+++ b/e2e/a5-audit-logs.spec.ts
@@ -0,0 +1,484 @@
+///
+/**
+ * A5-QA-01 · 审计与日志端到端用例(真实 API + 真实 PG + 真实浏览器)
+ *
+ * 覆盖:
+ * ① 超管登录 → /system/audit-logs 列表可见(查看留痕提示条 / 日志列表);
+ * ② 筛选:动作分类「权限与角色」+ 近 7 天 → 结果变化;重置恢复;
+ * ③ 打开详情:变更对比 / 原始 JSON / trace_id / IP / 请求信息;
+ * ④ 导出:创建任务 → 轮询完成 → 导出历史可下载(API 断言 download_token 存在);
+ * ⑤ 列表新增 audit.export 记录(导出本身留痕);
+ * ⑥ 无 audit:read 账号访问 → /403;有 audit:read 无 audit:export → 不渲染导出按钮;
+ * ⑦ 运行日志:配置为空 → 空态;写入日志平台地址后 → 外链存在,结束后还原配置。
+ *
+ * 门控:仅 A5_E2E=1 时执行;默认 skip,不影响 smoke / CI。
+ * 前置:API 3000、Web 5173(`pnpm dev`,真实 PG + Redis,已 `pnpm db:init`)。
+ * 数据自建自清:临时账号 qa5.audit.e2e(超管)/ qa5.audit.ro(无 audit:read)/
+ * qa5.audit.viewer(仅 audit:read),自定义角色 A5E2E-审计只读,用例结束删除并还原系统配置。
+ * ⚠️ 审计表 append-only,用例产生的审计记录(登录 / 查看 / 导出 / 下载)不删除,属预期留痕。
+ *
+ * 运行:A5_E2E=1 pnpm exec playwright test e2e/a5-audit-logs.spec.ts --reporter=list
+ */
+import { randomBytes } from 'node:crypto';
+
+import { type APIRequestContext, expect, type Page, request, test } from '@playwright/test';
+import { PrismaClient } from '@prisma/client';
+
+const API_BASE = process.env.A5_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
+const WEB_BASE = process.env.A5_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
+const ENABLED = process.env.A5_E2E === '1';
+
+const PASSWORD = 'lingxi2026';
+/** 与 a1 / a3 / a4 共用(明文 lingxi2026) */
+const PASSWORD_HASH =
+ '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
+
+const ADMIN_ACCOUNT = 'qa5.audit.e2e';
+const NO_AUDIT_ACCOUNT = 'qa5.audit.ro';
+const VIEWER_ACCOUNT = 'qa5.audit.viewer';
+const VIEWER_ROLE_CODE = 'a5e2e_audit_viewer';
+const VIEWER_ROLE_ID = '01HZZZZZZZZZZZZZZZZZZZA5E2';
+const PLATFORM_URL_KEY = 'audit.logs_platform_url';
+const PLATFORM_URL_SAMPLE = 'https://logs.lingxi.example/grafana';
+
+const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+const prisma = new PrismaClient();
+
+let apiContext: APIRequestContext | null = null;
+let adminSession: QaSession | null = null;
+let viewerSession: QaSession | null = null;
+let noAuditSession: QaSession | null = null;
+let bootstrapError: string | null = null;
+let platformUrlOriginalValue = '';
+
+interface QaSession {
+ accessToken: string;
+ refreshToken: string;
+ user: { id: string; name: string; account: string };
+ permissions: string[];
+ mustChangePassword: boolean;
+}
+
+test.skip(!ENABLED, 'A5_E2E=1 才运行审计与日志 e2e(依赖 API :3000 + web :5173 + PG)');
+
+test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
+
+// ---------------------------------------------------------------------------
+// 基础工具
+// ---------------------------------------------------------------------------
+
+function ulid(): string {
+ let time = Date.now();
+ const timeChars = new Array(10);
+ for (let index = 9; index >= 0; index -= 1) {
+ timeChars[index] = ENCODING[time % 32] ?? '0';
+ time = Math.floor(time / 32);
+ }
+ const random = randomBytes(16);
+ return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function requireRecord(value: unknown, label: string): Record {
+ if (!isRecord(value)) {
+ throw new Error(`${label} 不是 JSON 对象`);
+ }
+ return value;
+}
+
+function requireString(source: Record, key: string, label: string): string {
+ const value = source[key];
+ if (typeof value !== 'string' || value.length === 0) {
+ throw new Error(`${label}.${key} 缺失或不是字符串`);
+ }
+ return value;
+}
+
+function stringArray(value: unknown): string[] {
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === 'string')
+ : [];
+}
+
+function requireApi(): APIRequestContext {
+ if (apiContext === null) throw new Error('APIRequestContext 未初始化');
+ return apiContext;
+}
+
+function requireAdminSession(): QaSession {
+ if (adminSession === null) throw new Error('超管会话未就绪');
+ return adminSession;
+}
+
+function requireViewerSession(): QaSession {
+ if (viewerSession === null) throw new Error('审计只读会话未就绪');
+ return viewerSession;
+}
+
+function requireNoAuditSession(): QaSession {
+ if (noAuditSession === null) throw new Error('无审计权限会话未就绪');
+ return noAuditSession;
+}
+
+async function injectSession(page: Page, session: QaSession): Promise {
+ const persisted = {
+ accessToken: session.accessToken,
+ refreshToken: session.refreshToken,
+ user: session.user,
+ permissions: session.permissions,
+ mustChangePassword: session.mustChangePassword,
+ };
+ await page.addInitScript((value: unknown) => {
+ window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value));
+ }, persisted);
+}
+
+async function loginViaApi(account: string): Promise {
+ const context = requireApi();
+ const login = await context.post(`${API_BASE}/auth/login`, {
+ data: { account, password: PASSWORD },
+ });
+ expect(login.status(), `POST /auth/login ${account}`).toBe(200);
+ const body = requireRecord((await login.json()) as unknown, '登录响应');
+ const accessToken = requireString(body, 'accessToken', '登录响应');
+ const refreshToken = requireString(body, 'refreshToken', '登录响应');
+ const meResponse = await context.get(`${API_BASE}/me`, {
+ headers: { authorization: `Bearer ${accessToken}` },
+ });
+ expect(meResponse.status(), `GET /me ${account}`).toBe(200);
+ const me = requireRecord((await meResponse.json()) as unknown, '/me 响应');
+ const user = requireRecord(me.user, '/me.user');
+ return {
+ accessToken,
+ refreshToken,
+ user: {
+ id: requireString(user, 'id', '/me.user'),
+ name: requireString(user, 'name', '/me.user'),
+ account: requireString(user, 'account', '/me.user'),
+ },
+ permissions: stringArray(me.permissions),
+ mustChangePassword: me.mustChangePassword === true,
+ };
+}
+
+async function createUser(
+ account: string,
+ name: string,
+ roleCodes: readonly string[],
+): Promise {
+ const [department, roles] = await Promise.all([
+ prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
+ prisma.role.findMany({ where: { code: { in: [...roleCodes] } }, select: { id: true } }),
+ ]);
+ await prisma.user.deleteMany({ where: { account } });
+ await prisma.user.create({
+ data: {
+ id: ulid(),
+ name,
+ account,
+ email: `${account}@lingxi.cn`,
+ phone: `138${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`,
+ passwordHash: PASSWORD_HASH,
+ departmentId: department.id,
+ mustChangePassword: false,
+ roles: { create: roles.map((role) => ({ roleId: role.id })) },
+ },
+ });
+}
+
+/** 自定义「仅 audit:read」角色:验证无 audit:export 时不渲染导出入口 */
+async function createAuditViewerRole(): Promise {
+ await prisma.role.deleteMany({ where: { code: VIEWER_ROLE_CODE } });
+ await prisma.role.create({
+ data: {
+ id: VIEWER_ROLE_ID,
+ code: VIEWER_ROLE_CODE,
+ name: 'A5 端到端审计只读',
+ description: 'e2e 临时角色:仅审计查看权限',
+ isSystem: false,
+ status: 'ACTIVE',
+ permissions: { create: [{ permissionCode: 'audit:read' }] },
+ dataScopes: { create: [{ id: ulid(), scopeType: 'all' }] },
+ },
+ });
+}
+
+async function preparePlatformUrl(): Promise {
+ const row = await prisma.systemConfig.findUnique({ where: { configKey: PLATFORM_URL_KEY } });
+ platformUrlOriginalValue = typeof row?.configValue === 'string' ? row.configValue : '';
+}
+
+async function setPlatformUrl(value: string): Promise {
+ await prisma.systemConfig.update({
+ where: { configKey: PLATFORM_URL_KEY },
+ data: { configValue: value },
+ });
+}
+
+async function restorePlatformUrl(): Promise {
+ await prisma.systemConfig.update({
+ where: { configKey: PLATFORM_URL_KEY },
+ data: { configValue: platformUrlOriginalValue },
+ });
+}
+
+async function gotoWithSession(page: Page, session: QaSession, path: string): Promise {
+ await injectSession(page, session);
+ await page.goto(path);
+}
+
+async function pickSelectOption(
+ page: Page,
+ triggerText: string,
+ optionName: string,
+): Promise {
+ await page.locator('.lx-audit-filters .semi-select', { hasText: triggerText }).first().click();
+ const option = page.locator('.semi-select-option').filter({ hasText: optionName }).first();
+ await expect(option).toBeVisible({ timeout: 10_000 });
+ await option.click();
+}
+
+async function waitToast(page: Page, text: string | RegExp): Promise {
+ await expect(page.locator('[role="alert"]', { hasText: text }).first()).toBeVisible({
+ timeout: 15_000,
+ });
+}
+
+// ---------------------------------------------------------------------------
+// 全局编排
+// ---------------------------------------------------------------------------
+
+test.beforeAll(async () => {
+ if (!ENABLED) return;
+ apiContext = await request.newContext();
+ try {
+ const web = await apiContext.get(`${WEB_BASE}/`);
+ expect(web.status(), `web 未就绪:请在 ${WEB_BASE} 启动前端`).toBe(200);
+ await preparePlatformUrl();
+ await createAuditViewerRole();
+ await createUser(ADMIN_ACCOUNT, 'A5 端到端超管', ['super_admin']);
+ await createUser(VIEWER_ACCOUNT, 'A5 端到端审计只读', [VIEWER_ROLE_CODE]);
+ await createUser(NO_AUDIT_ACCOUNT, 'A5 端到端无权限', ['material_operator']);
+ adminSession = await loginViaApi(ADMIN_ACCOUNT);
+ viewerSession = await loginViaApi(VIEWER_ACCOUNT);
+ noAuditSession = await loginViaApi(NO_AUDIT_ACCOUNT);
+ expect(adminSession.permissions, '超管应含 audit:read').toContain('audit:read');
+ expect(adminSession.permissions, '超管应含 audit:export').toContain('audit:export');
+ expect(viewerSession.permissions, '只读账号应含 audit:read').toContain('audit:read');
+ expect(viewerSession.permissions, '只读账号不应含 audit:export').not.toContain('audit:export');
+ expect(noAuditSession.permissions, '素材运营不应含 audit:read').not.toContain('audit:read');
+ } catch (error) {
+ bootstrapError = error instanceof Error ? error.message : String(error);
+ }
+});
+
+test.afterAll(async () => {
+ if (!ENABLED) return;
+ try {
+ await restorePlatformUrl();
+ await prisma.user.deleteMany({
+ where: { account: { in: [ADMIN_ACCOUNT, VIEWER_ACCOUNT, NO_AUDIT_ACCOUNT] } },
+ });
+ await prisma.role.deleteMany({ where: { code: VIEWER_ROLE_CODE } });
+ } catch {
+ // 清理失败不覆盖真实断言结果;残留由下一次 beforeAll 按账号 / 角色前缀清理
+ }
+ await apiContext?.dispose();
+ apiContext = null;
+ await prisma.$disconnect();
+});
+
+test.beforeEach(() => {
+ test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
+});
+
+// ---------------------------------------------------------------------------
+// ① / ② 列表与筛选
+// ---------------------------------------------------------------------------
+
+test('操作日志:列表可见 / 查看留痕提示 / 筛选变化 / 重置', async ({ page }) => {
+ test.setTimeout(90_000);
+ await gotoWithSession(page, requireAdminSession(), '/system/audit-logs');
+
+ await expect(page.getByRole('heading', { name: '操作日志', level: 1 })).toBeVisible();
+ await expect(page.getByText('关键操作留痕 · 只读 · append-only')).toBeVisible();
+ await expect(page.getByText('audit.log.view').first()).toBeVisible();
+
+ const table = page.locator('.lx-audit-table');
+ await expect(table).toBeVisible();
+ await expect(table.locator('tbody tr').first()).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole('button', { name: '导出日志' })).toBeVisible();
+
+ // 动作分类筛选:权限与角色
+ const beforeCount = await table.locator('tbody tr').count();
+ await pickSelectOption(page, '全部分类', '登录与会话');
+ // 等待 Select 浮层关闭后再提交,避免点击被浮层吞掉(半组件异步关闭)
+ await page.waitForTimeout(300);
+ await page.getByRole('button', { name: '查询' }).click();
+ await expect(page).toHaveURL(/category=auth/);
+ await expect(table.locator('tbody tr').first()).toContainText('identity.', {
+ timeout: 15_000,
+ });
+ const filteredCount = await table.locator('tbody tr').count();
+ expect(filteredCount).toBeGreaterThan(0);
+ expect(filteredCount).toBeLessThanOrEqual(beforeCount);
+
+ // 重置恢复默认筛选
+ await page.getByRole('button', { name: '重置' }).click();
+ await expect(page).not.toHaveURL(/category=permission/);
+ await expect(table.locator('tbody tr').first()).toBeVisible({ timeout: 15_000 });
+});
+
+// ---------------------------------------------------------------------------
+// ③ 详情:diff / 原始 JSON / trace
+// ---------------------------------------------------------------------------
+
+test('日志详情:变更对比 / 原始 JSON / trace_id / 请求信息', async ({ page }) => {
+ test.setTimeout(90_000);
+ await gotoWithSession(page, requireAdminSession(), '/system/audit-logs');
+
+ // 审计表为 append-only,历史演示数据会被测试与真实操作记录挤出首屏;
+ // 这里点击当前列表第一条,验证详情页对任意审计记录都能打开与展示。
+ const row = page.locator('.lx-audit-table tbody tr').first();
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ await row.click();
+
+ await expect(page).toHaveURL(/\/system\/audit-logs\/[0-9A-Z]{26}/);
+ await expect(page.getByRole('heading', { name: '日志详情', level: 1 })).toBeVisible();
+ await expect(page.getByText('只读 · append-only').first()).toBeVisible();
+ await expect(page.getByText('变更对比')).toBeVisible();
+ // 有 diff 时展示字段表;登录 / 查看类记录没有字段级变更时展示空态,两者均可
+ const diffTable = page.locator('.lx-audit-diff-table');
+ const diffEmpty = page.getByText('没有可展示的字段变更');
+ await expect(diffTable.or(diffEmpty).first()).toBeVisible({ timeout: 10_000 });
+ // 元信息:trace_id / IP / 请求 / 耗时
+ await expect(page.locator('.lx-audit-hero__meta')).toContainText('trace_id');
+ await expect(page.locator('.lx-audit-hero__meta')).toContainText('IP 地址');
+ await expect(page.locator('.lx-audit-hero__meta')).toContainText('请求');
+ await expect(page.locator('.lx-audit-hero__meta')).toContainText('耗时');
+
+ // 原始 JSON:完整记录
+ await page.getByRole('tab', { name: '原始 JSON' }).click();
+ const raw = page.locator('.lx-audit-raw__code');
+ await expect(raw).toBeVisible();
+ await expect(raw).toContainText('"occurred_at"');
+ await expect(raw).toContainText('"actor"');
+ await expect(raw).toContainText('"action"');
+ await expect(raw).toContainText('"target"');
+ await expect(raw).toContainText('"before"');
+ await expect(raw).toContainText('"after"');
+
+ // 只读说明与相关记录卡
+ await expect(page.getByText('只读说明')).toBeVisible();
+ await expect(page.getByText('审计表 append-only', { exact: false })).toBeVisible();
+});
+
+// ---------------------------------------------------------------------------
+// ④ / ⑤ 导出:创建 → 轮询 → 历史 → 下载 token;列表新增 audit.export
+// ---------------------------------------------------------------------------
+
+test('导出:创建任务 / 轮询完成 / 导出历史 / 下载 token / 列表留痕', async ({ page }) => {
+ test.setTimeout(150_000);
+ await gotoWithSession(page, requireAdminSession(), '/system/audit-logs');
+
+ await page.getByRole('button', { name: '导出日志' }).click();
+ const sheet = page.locator('.semi-sidesheet');
+ await expect(sheet).toBeVisible();
+ await expect(sheet.getByText('导出操作日志')).toBeVisible();
+ await expect(sheet.getByText('导出范围(跟随当前筛选)')).toBeVisible();
+ await expect(sheet.getByText('CSV(只读)')).toBeVisible();
+
+ await sheet.getByRole('button', { name: '创建导出任务' }).click();
+ await waitToast(page, /导出任务已创建/);
+
+ // 轮询进度直到终态(后端 runner 生成本地 CSV)
+ const successTitle = sheet.getByText('导出完成');
+ const failTitle = sheet.getByText('导出失败');
+ await expect(successTitle.or(failTitle)).toBeVisible({ timeout: 60_000 });
+ await expect(failTitle).toHaveCount(0);
+ await expect(sheet.getByRole('button', { name: '下载文件' })).toBeVisible();
+
+ // 导出历史:任务存在
+ await sheet.getByRole('tab', { name: /导出历史/ }).click();
+ await expect(sheet.locator('.lx-audit-history-item').first()).toBeVisible({ timeout: 15_000 });
+ await expect(sheet.locator('.lx-audit-history-item').first()).toContainText('已完成');
+ await expect(
+ sheet.locator('.lx-audit-history-item').first().getByRole('button', { name: '下载' }),
+ ).toBeVisible();
+
+ // API 断言 download_token 存在,且签名下载返回 200 CSV
+ const context = requireApi();
+ const session = requireAdminSession();
+ const exportsResponse = await context.get(`${API_BASE}/audit-logs/exports?limit=5`, {
+ headers: { authorization: `Bearer ${session.accessToken}` },
+ });
+ expect(exportsResponse.status()).toBe(200);
+ const exportsBody = requireRecord((await exportsResponse.json()) as unknown, '导出历史响应');
+ const items = Array.isArray(exportsBody.items) ? exportsBody.items : [];
+ expect(items.length).toBeGreaterThan(0);
+ const latest = requireRecord(items[0], '导出任务');
+ expect(latest.status).toBe('succeeded');
+ const token = requireString(latest, 'download_token', '导出任务');
+ const jobId = requireString(latest, 'id', '导出任务');
+ const download = await context.get(
+ `${API_BASE}/audit-logs/exports/${jobId}/download?token=${encodeURIComponent(token)}`,
+ );
+ expect(download.status(), '签名下载应返回 200').toBe(200);
+ expect(download.headers()['content-type'] ?? '').toContain('csv');
+
+ // 关闭抽屉,重置筛选,列表出现 audit.export 记录(导出自身留痕)
+ await sheet.getByRole('button', { name: '关闭' }).first().click();
+ await page.getByRole('button', { name: '重置' }).click();
+ const exportRow = page.locator('.lx-audit-table tbody tr', { hasText: '导出审计日志' }).first();
+ await expect(exportRow).toBeVisible({ timeout: 20_000 });
+ await expect(exportRow).toContainText('audit.export');
+});
+
+// ---------------------------------------------------------------------------
+// ⑥ 权限三态:无 audit:read → 403;有 audit:read 无 audit:export → 无导出入口
+// ---------------------------------------------------------------------------
+
+test('权限:无 audit:read 访问 403;无 audit:export 不渲染导出按钮', async ({ page }) => {
+ test.setTimeout(90_000);
+
+ await gotoWithSession(page, requireNoAuditSession(), '/system/audit-logs');
+ await expect(page).toHaveURL(/\/403/);
+ await expect(page.getByText('你的账号没有该页面的权限点', { exact: false })).toBeVisible({
+ timeout: 15_000,
+ });
+
+ await gotoWithSession(page, requireViewerSession(), '/system/audit-logs');
+ await expect(page.getByRole('heading', { name: '操作日志', level: 1 })).toBeVisible();
+ await expect(page.getByText('只读 · 无 audit:export 权限')).toBeVisible();
+ await expect(page.getByRole('button', { name: '导出日志' })).toHaveCount(0);
+ await expect(page.locator('.lx-audit-table tbody tr').first()).toBeVisible({ timeout: 15_000 });
+});
+
+// ---------------------------------------------------------------------------
+// ⑦ 运行日志:未配置空态 / 配置后外链
+// ---------------------------------------------------------------------------
+
+test('运行日志:未配置空态 → 配置地址后外链存在', async ({ page }) => {
+ test.setTimeout(90_000);
+ // 先置空,验证完整空态
+ await setPlatformUrl('');
+ await gotoWithSession(page, requireAdminSession(), '/system/logs');
+ await expect(page.getByRole('heading', { name: '运行日志', level: 1 })).toBeVisible();
+ await expect(page.getByText('尚未配置日志平台')).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole('link', { name: '前往系统配置' })).toBeVisible();
+
+ // 再写入真实地址,验证外链与入口卡
+ await setPlatformUrl(PLATFORM_URL_SAMPLE);
+ await page.reload();
+ await expect(page.getByText('日志平台接入')).toBeVisible({ timeout: 15_000 });
+ const externalLink = page.getByRole('link', { name: PLATFORM_URL_SAMPLE });
+ await expect(externalLink).toBeVisible();
+ await expect(externalLink).toHaveAttribute('href', PLATFORM_URL_SAMPLE);
+ await expect(page.getByRole('button', { name: '打开日志平台' }).first()).toBeVisible();
+ await expect(page.getByText('trace 检索').first()).toBeVisible();
+ await expect(page.getByText('服务状态').first()).toBeVisible();
+ await expect(page.getByText('最近错误').first()).toBeVisible();
+});