test(e2e): A4 字典与配置端到端用例

背景: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 通过。
This commit is contained in:
Stev_Wang committed 2026-11-04 11:50:11 +08:00
1 parent 3b0a99d91f
commit f16d04fe02
2 files changed
+711 -1

No files matched your search

+33 -1
View File
@@ -3,11 +3,12 @@
## 现有用例 ## 现有用例
| 文件 | 说明 | 默认是否执行 | | 文件 | 说明 | 默认是否执行 |
| --------------------- | --------------------------------------------- | ----------------- | | --------------------------- | ------------------------------------------------------------------------ | ----------------- |
| `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | | `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 |
| `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | | `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` |
| `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` | | `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` |
| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` | | `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` |
| `a4-dicts-settings.spec.ts` | A4-QA-01 数据字典 / 三层合并 / 编码只读 / 系统配置 / 阈值确认 / 变更记录 | 否,需 `A4_E2E=1` |
## A1 账号生命周期用例 ## 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`)。 - 可选覆盖:`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/`(实施报告引用)。 - 截图:`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`。
+678
View File
@@ -0,0 +1,678 @@
/// <reference lib="dom" />
/**
* 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<string>(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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) {
throw new Error(`${label} 不是 JSON 对象`);
}
return value;
}
function requireString(source: Record<string, unknown>, 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<void> {
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<QaSession> {
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<void> {
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<void> {
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<void> {
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<DictTypeRow | null> {
return prisma.dictType.findUnique({ where: { code }, select: { id: true, code: true } });
}
// ---------------------------------------------------------------------------
// 浏览器 / 页面辅助
// ---------------------------------------------------------------------------
async function openDicts(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
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();
});