/// /** * 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(); });