From f16d04fe028963bbb10d9c12e2324a0060d4d29d Mon Sep 17 00:00:00 2001
From: Stev_Wang <304865932@qq.com>
Date: Wed, 4 Nov 2026 11:50:11 +0800
Subject: [PATCH] =?UTF-8?q?test(e2e):=20A4=20=E5=AD=97=E5=85=B8=E4=B8=8E?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=AB=AF=E5=88=B0=E7=AB=AF=E7=94=A8=E4=BE=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
背景:A4-QA-01 需要真实 API + Web + PG 的浏览器回归。\n改动:新增 e2e/a4-dicts-settings.spec.ts,覆盖三层合并 / 编码只读 / 树词表切换 / 类型与条目启停 / 命名预览 / 阈值二次确认 / 变更记录 / 权限三态;e2e/README 登记 A4 用例与 Semi 选择器口径。\n影响面:仅测试文件;A4_E2E=1 才执行,默认 skip。\n回滚:删除本文件即可。\n验证:A4_E2E=1 运行 7 passed(约 1.1m);eslint/prettier 通过。
---
e2e/README.md | 44 ++-
e2e/a4-dicts-settings.spec.ts | 678 ++++++++++++++++++++++++++++++++++
2 files changed, 716 insertions(+), 6 deletions(-)
create mode 100644 e2e/a4-dicts-settings.spec.ts
diff --git a/e2e/README.md b/e2e/README.md
index eeef29b..789bce6 100644
--- a/e2e/README.md
+++ b/e2e/README.md
@@ -2,12 +2,13 @@
## 现有用例
-| 文件 | 说明 | 默认是否执行 |
-| --------------------- | --------------------------------------------- | ----------------- |
-| `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` |
+| 文件 | 说明 | 默认是否执行 |
+| --------------------------- | ------------------------------------------------------------------------ | ----------------- |
+| `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` |
## A1 账号生命周期用例
@@ -77,3 +78,34 @@ A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list
- 可选覆盖:`A3_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A3_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
- 截图:`design/a3-ui-v1/impl/`(实施报告引用)。
+
+## A4 数据字典与系统配置用例(A4-QA-01)
+
+覆盖 7 条:
+
+1. `/system/dicts` 主从页:h1、左侧「字典类型」、右栏 `script_direction` 类型头、三层 chips(通用 5 + 父行业 2 + 本行业 1 = 生效 7)、默认行业「电商 / 服饰鞋包」、种草短视频(已覆盖)/ 直播切片 / 穿搭教程;树 ↔ 词表切换(`?view=tree|list`);行业作用域切全平台(生效 5)再切回。
+2. 条目:新增 SideSheet → 列表出现 → 编辑态编码 disabled 且值锁定 → Modal.confirm 停用 / 启用 → 行状态变化。
+3. 类型:新建(`a4e2e_` 唯一 code + 名称 + 分组 + 形态树)→ 左侧类型列表可见 → 编辑类型编码 / 形态只读。
+4. `/system/settings`:6 组导航、6 个占位符 chips、实时预览「电商-花间集-口播-20261104-01」、模板行内编辑与放弃恢复。
+5. 阈值:客户基础字段上限改 12 → 保存全部更改 → 二次确认 → toast / Prisma 断言版本 +1 / 变更记录抽屉含前后值与原因。
+6. 只读态:无 `dict:manage` / `system:configure` 账号访问两页 → `/403`。
+7. 未登录访问 `/system/dicts` → 重定向登录页。
+
+### 前置与运行
+
+1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。
+2. 用例自建临时超管 `qa4.dicts.e2e` 与只读账号 `qa4.dicts.ro`(Prisma 写入,结束后删除);字典类型 / 条目按 `a4e2e_` 前缀清理;系统配置保存后恢复原值并删除本次 history。
+
+```bash
+A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.spec.ts --reporter=list
+```
+
+- 可选覆盖:`A4_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A4_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。
+- 结果:7 passed(约 1.1m)。
+- 截图:不生成常驻截图;失败时 Playwright 默认 `only-on-failure` 落 `test-results/`。
+
+### 选择器校准(Semi)
+
+- Semi Select 选项可访问名含图标 aria-label(tick),精确匹配不到,使用 `.semi-select-option` 文本点击。
+- `Modal.confirm` 的确认 / 取消按钮 `aria-label` 固定为 `confirm` / `cancel`,不要按 `okText` 文本定位。
+- `RadioGroup` 隐藏 input 被 addon 拦截,点可见文本或 `.semi-radio-addon-buttonRadio`。
diff --git a/e2e/a4-dicts-settings.spec.ts b/e2e/a4-dicts-settings.spec.ts
new file mode 100644
index 0000000..998e347
--- /dev/null
+++ b/e2e/a4-dicts-settings.spec.ts
@@ -0,0 +1,678 @@
+///
+/**
+ * A4-QA-01 · 数据字典与系统配置端到端用例(真实 API + 真实 PG + 真实浏览器)
+ *
+ * 覆盖:
+ * ① /system/dicts:h1「数据字典」、左侧「字典类型」、右栏类型头、三层 chips、树 / 词表切换、行业作用域;
+ * ② 默认类型 script_direction + 行业视角「电商 / 服饰鞋包」→ 生效 7 项,并可见「种草短视频 / 直播切片 /
+ * 穿搭教程」与「已覆盖」标签;
+ * ③ 新增条目 → 列表出现 → 打开编辑时编码只读 → 停用 / 启用(Modal.confirm)→ 行状态变化;
+ * ④ 新建字典类型(编码前缀 a4e2e_)→ 类型列表可见 → 打开编辑验证编码只读;
+ * ⑤ /system/settings:h1「系统配置」、左侧 6 组、命名规则占位符 chips 与实时预览、模板行内编辑;
+ * ⑥ 修改「客户基础字段上限」为 12 → 保存全部更改 → 二次确认 → toast / 版本 +1 / 变更记录抽屉。
+ * ⑦ 无 dict:manage / system:configure 的账号访问 /system/dicts 与 /system/settings → 403;
+ * ⑧ 未登录访问 /system/dicts → 重定向登录页。
+ *
+ * 门控:仅 A4_E2E=1 时执行;默认 skip,不影响 smoke / CI。
+ * 前置:API 3000、Web 5173(`pnpm dev`,真实 PG + Redis,已 `pnpm db:init`)。
+ * 数据自建自清:临时账号 qa4.dicts.e2e(超管)+ qa4.dicts.ro(素材运营,无字典 / 配置权限),
+ * 用例结束删除;字典条目 / 类型按 `a4e2e_` 前缀清理;系统配置保存后通过 Prisma 恢复原值并删除本次历史行。
+ *
+ * 运行:A4_E2E=1 pnpm exec playwright test e2e/a4-dicts-settings.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.A4_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1';
+const WEB_BASE = process.env.A4_E2E_BASE_URL ?? 'http://127.0.0.1:5173';
+const ENABLED = process.env.A4_E2E === '1';
+
+const PASSWORD = 'lingxi2026';
+/** 与 a3 / a1 共用的 argon2id 哈希(明文 lingxi2026) */
+const PASSWORD_HASH =
+ '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
+const ADMIN_ACCOUNT = 'qa4.dicts.e2e';
+const READONLY_ACCOUNT = 'qa4.dicts.ro';
+
+/** 类型 / 条目编码前缀:用于自清理与识别异常中断遗留数据 */
+const TYPE_CODE_PREFIX = 'a4e2e_';
+const ITEM_CODE_PREFIX = 'a4e2e_item_';
+
+const SEED_BASE_FIELD_LIMIT = 10;
+const BASE_FIELD_KEY = 'limit.customer.base_field';
+const SAVE_REASON = '在系统配置页调整';
+
+const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+const prisma = new PrismaClient();
+
+let apiContext: APIRequestContext | null = null;
+let adminSession: QaSession | null = null;
+let readonlySession: QaSession | null = null;
+let bootstrapError: string | null = null;
+
+let baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
+let baseFieldBeforeVersion = 1;
+let baseFieldAfterVersion: number | null = null;
+
+/** 本次运行创建的字典数据(失败时 afterAll 兜底清理) */
+let createdItemId: string | null = null;
+let createdTypeCode: string | null = null;
+
+interface QaSession {
+ accessToken: string;
+ refreshToken: string;
+ user: { id: string; name: string; account: string; roleName?: string };
+ permissions: string[];
+ mustChangePassword: boolean;
+}
+
+test.skip(!ENABLED, 'A4_E2E=1 才运行数据字典 / 系统配置 e2e(依赖 API :3000 + web :5173 + PG)');
+
+test.use({ baseURL: WEB_BASE, viewport: { width: 1440, height: 900 } });
+
+// ---------------------------------------------------------------------------
+// 基础工具
+// ---------------------------------------------------------------------------
+
+function randomSuffix(): string {
+ return randomBytes(3).toString('hex');
+}
+
+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 requireReadonlySession(): QaSession {
+ if (readonlySession === null) {
+ throw new Error('只读账号会话未就绪');
+ }
+ return readonlySession;
+}
+
+/** 把登录态写入 localStorage(auth-store 的持久化 key) */
+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');
+ const roles = Array.isArray(me.roles) ? me.roles : [];
+ const firstRole = isRecord(roles[0]) ? roles[0] : null;
+ return {
+ accessToken,
+ refreshToken,
+ user: {
+ id: requireString(user, 'id', '/me.user'),
+ name: requireString(user, 'name', '/me.user'),
+ account: requireString(user, 'account', '/me.user'),
+ ...(firstRole === null ? {} : { roleName: requireString(firstRole, 'name', '/me.roles[0]') }),
+ },
+ permissions: stringArray(me.permissions),
+ mustChangePassword: me.mustChangePassword === true,
+ };
+}
+
+async function createUser(account: string, name: string, roleCode: string): Promise {
+ const [role, department] = await Promise.all([
+ prisma.role.findUniqueOrThrow({ where: { code: roleCode } }),
+ prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }),
+ ]);
+ await prisma.user.deleteMany({ where: { account } });
+ await prisma.user.create({
+ data: {
+ id: ulid(),
+ name,
+ account,
+ email: `${account}@lingxi.cn`,
+ phone: `139${randomBytes(4).readUInt32BE(0).toString().padStart(8, '0').slice(-8)}`,
+ passwordHash: PASSWORD_HASH,
+ departmentId: department.id,
+ mustChangePassword: false,
+ roles: { create: [{ roleId: role.id }] },
+ },
+ });
+}
+
+/** 清理异常中断运行遗留的字典数据(前缀匹配,避免影响 seed 与其他用例) */
+async function cleanupDictLeftovers(): Promise {
+ const staleTypes = await prisma.dictType.findMany({
+ where: { code: { startsWith: TYPE_CODE_PREFIX } },
+ select: { id: true },
+ });
+ if (staleTypes.length > 0) {
+ await prisma.dictItem.deleteMany({
+ where: { typeId: { in: staleTypes.map((row) => row.id) } },
+ });
+ await prisma.dictType.deleteMany({ where: { id: { in: staleTypes.map((row) => row.id) } } });
+ }
+ await prisma.dictItem.deleteMany({ where: { code: { startsWith: ITEM_CODE_PREFIX } } });
+}
+
+/** 读取并记录「客户基础字段上限」的原始值 / 版本;异常遗留值先恢复 seed 默认值 */
+async function prepareBaseFieldConfig(): Promise {
+ const row = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
+ const original = typeof row?.configValue === 'number' ? row.configValue : SEED_BASE_FIELD_LIMIT;
+ baseFieldOriginalValue = original;
+ baseFieldBeforeVersion = row?.version ?? 1;
+ if (original !== SEED_BASE_FIELD_LIMIT) {
+ // 上次运行可能异常中断;恢复 seed 默认值,保证本次「改成 12」一定产生变更。
+ await prisma.systemConfig.update({
+ where: { configKey: BASE_FIELD_KEY },
+ data: { configValue: SEED_BASE_FIELD_LIMIT },
+ });
+ baseFieldOriginalValue = SEED_BASE_FIELD_LIMIT;
+ }
+}
+
+interface DictTypeRow {
+ id: string;
+ code: string;
+}
+
+async function readDictTypeByCode(code: string): Promise {
+ return prisma.dictType.findUnique({ where: { code }, select: { id: true, code: true } });
+}
+
+// ---------------------------------------------------------------------------
+// 浏览器 / 页面辅助
+// ---------------------------------------------------------------------------
+
+async function openDicts(page: Page): Promise {
+ await injectSession(page, requireAdminSession());
+ await page.goto('/system/dicts');
+ await expect(page.getByRole('heading', { name: '数据字典' })).toBeVisible();
+ await expect(page.getByTestId('app-shell')).toBeVisible();
+}
+
+async function gotoSystem(page: Page, path: string): Promise {
+ await injectSession(page, requireAdminSession());
+ await page.goto(path);
+}
+
+function dictItemRow(page: Page, text: string) {
+ return page.locator('.lx-dict-table tbody tr', { hasText: text });
+}
+
+/**
+ * 选择 Semi Select 下拉项。
+ * 注意:Semi Select option 的可访问名会带上勾选图标的 aria-label(tick),
+ * `getByRole('option', { name, exact: true })` 匹配不到,这里统一按选项文本定位。
+ */
+async function pickSelectOption(page: Page, optionName: string): Promise {
+ 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 cleanupDictLeftovers();
+ await prepareBaseFieldConfig();
+ await createUser(ADMIN_ACCOUNT, 'A4 端到端超管', 'super_admin');
+ await createUser(READONLY_ACCOUNT, 'A4 端到端只读', 'material_operator');
+ adminSession = await loginViaApi(ADMIN_ACCOUNT);
+ readonlySession = await loginViaApi(READONLY_ACCOUNT);
+ expect(adminSession.permissions, '超管应含 dict:manage').toContain('dict:manage');
+ expect(adminSession.permissions, '超管应含 system:configure').toContain('system:configure');
+ expect(readonlySession.permissions, '素材运营不应含 dict:manage').not.toContain('dict:manage');
+ expect(readonlySession.permissions, '素材运营不应含 system:configure').not.toContain(
+ 'system:configure',
+ );
+ } catch (error) {
+ bootstrapError = error instanceof Error ? error.message : String(error);
+ }
+});
+
+test.afterAll(async () => {
+ if (!ENABLED) return;
+ try {
+ // 字典:先删条目再删类型(按本次记录 + 前缀双保险)
+ if (createdItemId !== null) {
+ await prisma.dictItem.deleteMany({ where: { id: createdItemId } });
+ }
+ if (createdTypeCode !== null) {
+ const type = await readDictTypeByCode(createdTypeCode);
+ if (type !== null) {
+ await prisma.dictItem.deleteMany({ where: { typeId: type.id } });
+ await prisma.dictType.deleteMany({ where: { id: type.id } });
+ }
+ }
+ await cleanupDictLeftovers();
+
+ // 系统配置:恢复原值 / 原版本,并删除本次产生的历史行
+ if (baseFieldAfterVersion !== null) {
+ await prisma.systemConfigVersion.deleteMany({
+ where: { configKey: BASE_FIELD_KEY, version: baseFieldAfterVersion },
+ });
+ }
+ const configRow = await prisma.systemConfig.findUnique({
+ where: { configKey: BASE_FIELD_KEY },
+ });
+ if (configRow !== null) {
+ await prisma.systemConfig.update({
+ where: { configKey: BASE_FIELD_KEY },
+ data: { configValue: baseFieldOriginalValue, version: baseFieldBeforeVersion },
+ });
+ }
+
+ await prisma.user.deleteMany({ where: { account: { in: [ADMIN_ACCOUNT, READONLY_ACCOUNT] } } });
+ } catch {
+ // 清理失败不覆盖真实断言结果;残留由下一次 beforeAll 按前缀清理
+ }
+ await apiContext?.dispose();
+ apiContext = null;
+ await prisma.$disconnect();
+});
+
+test.beforeEach(() => {
+ test.skip(bootstrapError !== null, bootstrapError ?? '测试环境未就绪');
+});
+
+// ---------------------------------------------------------------------------
+// ① / ② 字典主从页:默认类型、三层 chips、树表切换、行业作用域
+// ---------------------------------------------------------------------------
+
+test('数据字典:默认类型 / 三层 chips / 树词表切换 / 行业作用域', async ({ page }) => {
+ test.setTimeout(120_000);
+ await openDicts(page);
+
+ // 左侧类型栏与右栏类型头
+ await expect(page.getByRole('complementary', { name: '字典类型' })).toBeVisible();
+ await expect(page.locator('.lx-dict-list__title')).toHaveText('字典类型');
+ const detailHead = page.locator('.lx-dict-head');
+ await expect(detailHead.locator('.lx-dict-head__name b').first()).toHaveText('脚本方向');
+ await expect(detailHead.locator('.lx-dict-head__meta code').first()).toHaveText(
+ 'script_direction',
+ );
+
+ // 默认行业视角:电商 / 服饰鞋包
+ await expect(page.locator('.lx-dict-scope__select')).toContainText('电商 / 服饰鞋包');
+
+ // 三层 chips:通用 5 + 父行业 2 + 本行业 1 = 生效 7
+ const chips = page.locator('.lx-dict-scope__layers .lx-dict-layer-chip');
+ await expect(chips.filter({ hasText: '通用' })).toContainText('5');
+ await expect(chips.filter({ hasText: '父行业' })).toContainText('2');
+ await expect(chips.filter({ hasText: '本行业' })).toContainText('1');
+ await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('生效');
+ await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
+ await expect(page.locator('.lx-dict-viewrow__hint')).toContainText('生效 7 项');
+
+ // 词表视图:种短视频 / 直播切片 / 穿搭教程 + 「已覆盖」标签
+ await expect(page.locator('.lx-dict-table')).toBeVisible();
+ for (const name of ['种草短视频', '直播切片', '穿搭教程']) {
+ await expect(dictItemRow(page, name)).toBeVisible();
+ }
+ await expect(dictItemRow(page, '种草短视频').getByText('已覆盖')).toBeVisible();
+ await expect(dictItemRow(page, '直播切片').getByText('已覆盖')).toHaveCount(0);
+
+ // 树 / 词表切换:点击「树视图」→ URL ?view=tree 且渲染树
+ // Semi RadioGroup 的隐藏 input 被 addon 覆盖,直接点击可见的 addon 文本。
+ const viewSwitcher = page.locator('.lx-dict-viewrow');
+ await viewSwitcher
+ .getByText(/树视图/)
+ .first()
+ .click();
+ await expect(page).toHaveURL(/view=tree/);
+ await expect(page.locator('.lx-dict-tree')).toBeVisible();
+ await expect(page.locator('.lx-dict-table')).toHaveCount(0);
+ await viewSwitcher
+ .getByText(/词表视图/)
+ .first()
+ .click();
+ await expect(page).toHaveURL(/view=list/);
+ await expect(page.locator('.lx-dict-table')).toBeVisible();
+
+ // 行业作用域:切到全平台 → 只保留通用层(生效 5)
+ await page.locator('.lx-dict-scope__select').click();
+ await pickSelectOption(page, '全平台');
+ await expect(page).toHaveURL(/industry=all/);
+ await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('5');
+ await expect(page.locator('.lx-dict-layer-chip', { hasText: '父行业' })).toHaveCount(0);
+ await expect(dictItemRow(page, '直播切片')).toHaveCount(0);
+ await expect(dictItemRow(page, '口播')).toBeVisible();
+
+ // 切回电商 / 服饰鞋包 → 三层恢复为 7
+ await page.locator('.lx-dict-scope__select').click();
+ await pickSelectOption(page, '电商 / 服饰鞋包');
+ await expect(page.locator('.lx-dict-layer-chip.is-total')).toContainText('7');
+ await expect(dictItemRow(page, '直播切片')).toBeVisible();
+});
+
+// ---------------------------------------------------------------------------
+// ③ 条目:新增 → 编辑编码只读 → 停用 / 启用
+// ---------------------------------------------------------------------------
+
+test('数据字典:新增条目 → 编辑编码只读 → 停用 / 启用', async ({ page }) => {
+ test.setTimeout(150_000);
+ await openDicts(page);
+ await expect(page.locator('.lx-dict-table tbody tr').first()).toBeVisible();
+
+ const suffix = randomSuffix();
+ const itemCode = `${ITEM_CODE_PREFIX}${suffix}`;
+ const itemName = `A4E2E测试条目${suffix}`;
+
+ await page.getByRole('button', { name: '新增条目' }).first().click();
+ const sheet = page.locator('.semi-sidesheet');
+ await expect(sheet).toBeVisible();
+ await expect(sheet.locator('.semi-sidesheet-title')).toHaveText('新增条目');
+ await sheet.getByPlaceholder('如:口播').fill(itemName);
+ await sheet.getByPlaceholder('字母、数字、下划线或短横线').fill(itemCode);
+ await sheet.getByRole('button', { name: '保存', exact: true }).click();
+ await waitToast(page, '条目已创建');
+
+ const row = dictItemRow(page, itemCode);
+ await expect(row).toBeVisible({ timeout: 15_000 });
+ await expect(row).toContainText(itemName);
+ await expect(row.locator('.lx-dict-status--active')).toBeVisible();
+
+ // 记录 id,afterAll 兜底清理
+ const createdItem = await prisma.dictItem.findFirst({
+ where: { code: itemCode },
+ select: { id: true },
+ });
+ createdItemId = createdItem?.id ?? null;
+ expect(createdItemId, '新增条目应落库').not.toBeNull();
+
+ // 打开编辑:编码输入禁用且值不可改
+ await row.getByRole('button', { name: '编辑', exact: true }).click();
+ const editSheet = page.locator('.semi-sidesheet');
+ await expect(editSheet.locator('.semi-sidesheet-title')).toHaveText('编辑条目');
+ const codeInput = editSheet.getByPlaceholder('字母、数字、下划线或短横线');
+ await expect(codeInput).toBeDisabled();
+ await expect(codeInput).toHaveValue(itemCode);
+ await expect(editSheet.getByText('编码创建后不可修改')).toBeVisible();
+ await editSheet.getByRole('button', { name: '取消', exact: true }).click();
+ await expect(editSheet).toHaveCount(0);
+
+ // 停用条目:Modal.confirm 点「停用」(Semi 确认按钮的 aria-label 固定为 confirm)
+ await row.getByRole('button', { name: '停用', exact: true }).click();
+ const disableDialog = page.getByRole('dialog', { name: new RegExp(`停用条目「${itemName}」`) });
+ await expect(disableDialog).toBeVisible();
+ await disableDialog.getByRole('button', { name: 'confirm' }).click();
+ await waitToast(page, `已停用「${itemName}」`);
+ await expect(row.locator('.lx-dict-status--disabled')).toBeVisible();
+ await expect(row).toHaveClass(/is-disabled/);
+
+ // 启用条目:Modal.confirm 点「启用」
+ await row.getByRole('button', { name: '启用', exact: true }).click();
+ const enableDialog = page.getByRole('dialog', { name: new RegExp(`启用条目「${itemName}」`) });
+ await expect(enableDialog).toBeVisible();
+ await enableDialog.getByRole('button', { name: 'confirm' }).click();
+ await waitToast(page, `已启用「${itemName}」`);
+ await expect(row.locator('.lx-dict-status--active')).toBeVisible();
+ await expect(row).not.toHaveClass(/is-disabled/);
+});
+
+// ---------------------------------------------------------------------------
+// ④ 字典类型:新建 → 列表可见 → 编辑编码只读
+// ---------------------------------------------------------------------------
+
+test('数据字典:新建类型 → 列表可见 → 编辑类型编码只读', async ({ page }) => {
+ test.setTimeout(120_000);
+ await openDicts(page);
+
+ const suffix = randomSuffix();
+ const typeCode = `${TYPE_CODE_PREFIX}type_${suffix}`;
+ const typeName = `A4E2E测试类型${suffix}`;
+ createdTypeCode = typeCode;
+
+ await page.getByRole('button', { name: '新建字典类型' }).click();
+ const modal = page.getByRole('dialog', { name: '新建字典类型' });
+ await expect(modal).toBeVisible();
+ await modal.getByPlaceholder('如:投放位置').fill(typeName);
+ await modal.getByPlaceholder('小写字母、数字与下划线').fill(typeCode);
+
+ // 所属分组:质量与合规
+ const groupField = modal.locator('.lx-dict-field', { hasText: '所属分组' });
+ await groupField.locator('.semi-select').click();
+ await pickSelectOption(page, '质量与合规');
+ // 形态:树
+ const kindField = modal.locator('.lx-dict-field', { hasText: '形态' });
+ await kindField.locator('.semi-select').click();
+ await pickSelectOption(page, '树');
+ await expect(kindField.locator('.semi-select')).toContainText('树');
+
+ await modal.getByRole('button', { name: '保存', exact: true }).click();
+ await waitToast(page, '字典类型已创建');
+
+ // 保存后自动进入新类型详情;左侧类型列表可见
+ await expect(page).toHaveURL(new RegExp(`/system/dicts/${typeCode}`));
+ await expect(page.locator('.lx-dict-head__name b').first()).toHaveText(typeName);
+ const typeRow = page.locator('.lx-dict-type', { hasText: typeCode });
+ await expect(typeRow).toBeVisible({ timeout: 15_000 });
+ await expect(typeRow).toContainText(typeName);
+
+ // 编辑类型:编码只读、形态只读
+ await page.getByRole('button', { name: '编辑类型' }).click();
+ const editModal = page.getByRole('dialog', { name: '编辑字典类型' });
+ await expect(editModal).toBeVisible();
+ const codeField = editModal.locator('.lx-dict-field', { hasText: '类型编码' });
+ await expect(codeField.locator('input')).toBeDisabled();
+ await expect(codeField.locator('input')).toHaveValue(typeCode);
+ await expect(editModal.getByText('创建后不可修改').first()).toBeVisible();
+ const editKindField = editModal.locator('.lx-dict-field', { hasText: '形态' });
+ await expect(editKindField.locator('.semi-select-disabled')).toBeVisible();
+ await editModal.getByRole('button', { name: '取消', exact: true }).click();
+ await expect(editModal).toHaveCount(0);
+});
+
+// ---------------------------------------------------------------------------
+// ⑤ 系统配置:6 组、命名占位符与实时预览、模板行内编辑
+// ---------------------------------------------------------------------------
+
+test('系统配置:6 组导航 / 命名占位符 / 实时预览 / 模板编辑', async ({ page }) => {
+ test.setTimeout(120_000);
+ await gotoSystem(page, '/system/settings');
+ await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
+ await expect(page.getByTestId('app-shell')).toBeVisible();
+
+ // 左侧 6 组
+ await expect(page.getByRole('complementary', { name: '配置分组' })).toBeVisible();
+ const navItems = page.locator('.lx-settings-nav__item');
+ await expect(navItems).toHaveCount(6);
+ for (const groupName of [
+ '基础信息',
+ '命名规则',
+ '导出与归档',
+ '通知开关',
+ '阈值与上限',
+ '业务阈值',
+ ]) {
+ await expect(navItems.filter({ hasText: groupName })).toBeVisible();
+ }
+
+ // 命名规则:占位符 chips + 默认实时预览
+ await expect(page.locator('.lx-settings-naming-aside')).toBeVisible();
+ await expect(page.locator('.lx-settings-aside-head')).toContainText('命名占位符');
+ await expect(page.locator('.lx-settings-token')).toHaveCount(6);
+ await expect(page.locator('.lx-settings-preview__value')).toHaveText(
+ '电商-花间集-口播-20261104-01',
+ );
+ const originalPreview = await page.locator('.lx-settings-preview__value').innerText();
+
+ // 点击「编辑」改模板 → 预览实时变化 → 放弃更改恢复
+ const patternRow = page
+ .locator('.lx-settings-config.is-pattern')
+ .filter({ hasText: '脚本命名规则' });
+ await patternRow.getByRole('button', { name: '编辑', exact: true }).click();
+ const patternInput = page.getByLabel('命名规则');
+ await expect(patternInput).toBeVisible();
+ await patternInput.fill('{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}');
+ await expect(page.locator('.lx-settings-preview__value')).toHaveText(
+ '电商-花间集-口播-20261104-01-1080x1920',
+ );
+ await page
+ .locator('.lx-settings-form__foot')
+ .getByRole('button', { name: '放弃更改', exact: true })
+ .click();
+ await expect(page.locator('.lx-settings-preview__value')).toHaveText(originalPreview);
+ await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
+});
+
+// ---------------------------------------------------------------------------
+// ⑥ 阈值修改:保存 → 二次确认 → toast / 版本 +1 / 变更记录
+// ---------------------------------------------------------------------------
+
+test('系统配置:阈值修改 → 二次确认 → 版本 +1 → 变更记录', async ({ page }) => {
+ test.setTimeout(150_000);
+ await gotoSystem(page, '/system/settings');
+ await expect(page.getByRole('heading', { name: '系统配置' })).toBeVisible();
+
+ // 进入「阈值与上限」
+ await page.locator('.lx-settings-nav__item', { hasText: '阈值与上限' }).click();
+ await expect(page).toHaveURL(/group=limits/);
+ await expect(page.locator('.lx-settings-form__head h2')).toHaveText('阈值与上限');
+
+ const baseFieldRow = page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' });
+ await expect(baseFieldRow).toBeVisible();
+ await baseFieldRow.getByLabel('客户基础字段上限').fill('12');
+ await expect(page.locator('.lx-settings-headindicator')).toContainText('未保存 1 项');
+
+ await page
+ .locator('.lx-settings-form__foot')
+ .getByRole('button', { name: '保存全部更改', exact: true })
+ .click();
+
+ // 阈值类配置二次确认
+ const confirmDialog = page.getByRole('dialog', { name: /确认修改阈值与上限/ });
+ await expect(confirmDialog).toBeVisible();
+ await expect(confirmDialog.getByText('客户基础字段上限')).toBeVisible();
+ await expect(confirmDialog).toContainText('12');
+ await confirmDialog.getByRole('button', { name: 'confirm' }).click();
+
+ await waitToast(page, '配置已保存,版本已更新');
+ await expect(page.locator('.lx-settings-headindicator')).toContainText('所有修改已保存');
+
+ // 版本 +1(值来自 Prisma 当前行)
+ const saved = await prisma.systemConfig.findUnique({ where: { configKey: BASE_FIELD_KEY } });
+ expect(saved?.configValue, '配置值应为 12').toBe(12);
+ baseFieldAfterVersion = saved?.version ?? null;
+ expect(baseFieldAfterVersion, '配置版本应 +1').toBe(baseFieldBeforeVersion + 1);
+ await expect(
+ page.locator('.lx-settings-config').filter({ hasText: '客户基础字段上限' }),
+ ).toContainText(`v${String(baseFieldAfterVersion)}`);
+
+ // 变更记录抽屉:出现本次记录(版本 / 前后值 / 原因)
+ await page.getByRole('button', { name: '变更记录' }).click();
+ const historySheet = page.locator('.semi-sidesheet');
+ await expect(historySheet).toBeVisible();
+ await expect(historySheet.locator('.semi-sidesheet-title')).toHaveText('配置变更记录');
+ const historyItem = page
+ .locator('.lx-settings-history-item')
+ .filter({ hasText: '客户基础字段上限' })
+ .filter({ hasText: `v${String(baseFieldAfterVersion)}` });
+ await expect(historyItem).toBeVisible({ timeout: 15_000 });
+ await expect(historyItem).toContainText('12');
+ await expect(historyItem).toContainText(SAVE_REASON);
+});
+
+// ---------------------------------------------------------------------------
+// ⑦ 权限:无 dict:manage / system:configure 的账号 → 403
+// ---------------------------------------------------------------------------
+
+test('权限:无字典 / 配置权限的账号访问 A4 页面显示 403', async ({ page }) => {
+ test.setTimeout(90_000);
+ await injectSession(page, requireReadonlySession());
+
+ await page.goto('/system/dicts');
+ await expect(page).toHaveURL(/\/403/);
+ await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
+
+ await page.goto('/system/settings');
+ await expect(page).toHaveURL(/\/403/);
+ await expect(page.getByRole('heading', { name: '没有访问权限' })).toBeVisible();
+});
+
+// ---------------------------------------------------------------------------
+// ⑧ 未登录:访问 /system/dicts 重定向登录页
+// ---------------------------------------------------------------------------
+
+test('未登录访问 /system/dicts 重定向到登录页', async ({ page }) => {
+ await page.goto('/system/dicts');
+ await expect(page).toHaveURL(/\/login\?redirect=/);
+ await expect(page.getByRole('heading', { name: '登录灵犀' })).toBeVisible();
+});