// 相册治理增量 e2e(P4a Task 9,真实后端 + compose 基础设施):批量隐藏 // (多选 2 → toast 成功汇总 + 行原位已隐藏 + cn 公开列表仅剩 1)→ 负责人交接 // (前缀搜索 staff → API 断言 ownerId 变更)→ 水印(配置保存 → 任务受理 → // fixture 直驱 worker 核心到 done → UI 轮询见已完成 + 公开 browse URL 指向 // /watermark.jpg)→ 用量(阈值调小触发告警 → 上调解除)→ 分享二维码 // (canvas 非空 + URL 复制)。选择器一律 data-test;文案断言仅用稳定短语。 import { expect, test, type Page } from '@playwright/test' import { adminCredentials, loginAsAdmin, seedFixture } from './helpers' test.use({ testIdAttribute: 'data-test' }) test.beforeAll(() => { // 幂等种子 staff 账号(交接目标;密码占位不可登录,仅用于查询与交接) seedFixture('staff', 'staff-e2e', 'Staff E2E') }) /** UI 创建相册(zh+en 标题),返回标题后缀(同 albums.spec 模式) */ async function createAlbumViaUi(page: Page): Promise { const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) await page.getByTestId('album-create-btn').click() // UI06:创建改整页表单路由 await expect(page.getByTestId('album-form-page')).toBeVisible() await page.getByTestId('album-form-title-zh-CN').fill(`E2E治理相册 ${suffix}`) await page.getByRole('tab', { name: '英文' }).click() await page.getByTestId('album-form-title-en').fill(`E2E Gov Album ${suffix}`) await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01') // UI06:日期粒度 await page.keyboard.press('Enter') await page.getByTestId('album-form-submit').click() // 保存成功后跳相册详情;返回列表做断言 await expect(page.getByTestId('album-form-page')).toBeHidden() await page.goBack() // UI06:列表默认封面卡片网格;表格断言前先切换 await page.getByTestId('album-view-table').click() await expect( page.getByTestId('album-list-table').getByText(`E2E治理相册 ${suffix}`) ).toBeVisible() return suffix } /** 列表内定位目标相册行 */ function albumRow(page: Page, suffix: string) { return page.getByTestId('album-list-table').locator('tr', { hasText: `E2E治理相册 ${suffix}` }) } /** 进入详情页,返回 albumId */ async function openAlbumDetail(page: Page, suffix: string): Promise { await albumRow(page, suffix).getByTestId('album-detail-btn').click() await expect(page.getByTestId('album-summary-cover')).toBeVisible() const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? '' expect(albumId).not.toBe('') return albumId } /** API 登录(UI 之外的断言用):返回 Bearer 会话令牌 */ async function apiLogin(page: Page): Promise { const { account, password } = adminCredentials() const res = await page.request.post('/v1/auth/password', { data: { account, password } }) expect(res.ok()).toBeTruthy() const body = (await res.json()) as { sessionToken: string } return body.sessionToken } /** cn 公开列表条数(批量隐藏的观众侧断言) */ async function publicPhotoCount(page: Page, token: string): Promise { const res = await page.request.get(`/v1/public/albums/${token}/photos`) expect(res.ok()).toBeTruthy() const body = (await res.json()) as { items: unknown[] } return body.items.length } /** ElSwitch 幂等设到目标态(is-checked 类为准;已同态不点击)。 */ async function setSwitchState(page: Page, testId: string, on: boolean): Promise { const isOn = await page.getByTestId(testId).evaluate((el) => el.classList.contains('is-checked')) if (isOn !== on) { await page.getByTestId(testId).click() } } test('批量隐藏:多选 2 → 汇总 toast + 行原位已隐藏 + 公开列表仅剩 1', async ({ page }) => { await loginAsAdmin(page) const suffix = await createAlbumViaUi(page) const albumId = await openAlbumDetail(page, suffix) seedFixture('gov-photos', albumId, adminCredentials().account, '3') await page.reload() // UI06:照片默认网格视图;表格断言前先切换 await page.getByTestId('photo-view-table').click() await expect(page.getByTestId('photo-list-table')).toBeVisible() const rows = page.getByTestId('photo-list-table').locator('tbody tr') await expect(rows).toHaveCount(3) for (const row of await rows.all()) { await expect(row.getByTestId('photo-visibility-tag')).toHaveText('已发布') } // 勾选前两行 → 批量按钮亮起(计数 2)→ 确认 await rows.nth(0).locator('.el-checkbox').click() await rows.nth(1).locator('.el-checkbox').click() const batchBtn = page.getByTestId('photo-batch-hide-btn') await expect(batchBtn).toBeEnabled() await expect(batchBtn).toContainText('2') await batchBtn.click() await page.getByRole('button', { name: '确定' }).click() // 汇总 toast(全部成功分支)+ 两行原位已隐藏、一行保持已发布 await expect(page.locator('.el-message').filter({ hasText: '成功 2 张' })).toBeVisible() await expect(rows.nth(0).getByTestId('photo-visibility-tag')).toHaveText('已隐藏') await expect(rows.nth(1).getByTestId('photo-visibility-tag')).toHaveText('已隐藏') await expect(rows.nth(2).getByTestId('photo-visibility-tag')).toHaveText('已发布') // 观众侧:公开列表 3 → 1 const token = await page.getByTestId('album-detail-token').textContent() expect(await publicPhotoCount(page, token ?? '')).toBe(1) }) test('负责人交接:搜索 staff → 交接 → ownerId 变更(API + 详情回显)', async ({ page }) => { await loginAsAdmin(page) const suffix = await createAlbumViaUi(page) const albumId = await openAlbumDetail(page, suffix) const sessionToken = await apiLogin(page) // 交接前 owner 是 admin 自己 const before = (await ( await page.request.get(`/v1/albums/${albumId}`, { headers: { Authorization: `Bearer ${sessionToken}` } }) ).json()) as { ownerId: string } const staffId = ( ( (await ( await page.request.get('/v1/admin/users', { params: { accountPrefix: 'staff-e2e' }, headers: { Authorization: `Bearer ${sessionToken}` } }) ).json()) as { items: { id: string }[] } ).items[0] ?? { id: '' } ).id expect(staffId).not.toBe('') expect(before.ownerId).not.toBe(staffId) // 交接对话框:前缀搜索(≥2 字符)→ 选中 → 保留协作者开关默认开 → 提交 await page.getByTestId('album-detail-transfer').click() await expect(page.getByTestId('transfer-dialog')).toBeVisible() const targetSelect = page.getByTestId('transfer-target-select') await targetSelect.click() await targetSelect.locator('input').fill('staff-e2e') await page.getByTestId('transfer-option-staff-e2e').click() await expect(page.getByTestId('transfer-keep-switch')).toHaveClass(/is-checked/) await page.getByTestId('transfer-submit').click() await expect(page.getByTestId('transfer-dialog')).toBeHidden() await expect(page.locator('.el-message').filter({ hasText: '负责人已交接' })).toBeVisible() // 详情回显 + API 权威断言 await expect(page.getByTestId('album-detail-owner-id')).toHaveText(staffId) const after = (await ( await page.request.get(`/v1/albums/${albumId}`, { headers: { Authorization: `Bearer ${sessionToken}` } }) ).json()) as { ownerId: string } expect(after.ownerId).toBe(staffId) }) test('水印:配置保存 → 任务受理 → 直驱到已完成 → 公开 browse URL 指向水印图', async ({ page }) => { await loginAsAdmin(page) const suffix = await createAlbumViaUi(page) const albumId = await openAlbumDetail(page, suffix) const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim() // 真实对象照片先行(任务 total=3,非空相册 autoDone 分支不触发) seedFixture('wm-photos', albumId, adminCredentials().account, '3') await page.getByTestId('album-watermark-tab').click() await expect(page.getByTestId('watermark-panel')).toBeVisible() await setSwitchState(page, 'watermark-enabled-switch', true) await page.getByTestId('watermark-text-content').fill(`E2E水印 ${suffix}`) // 保存 → 202 受理(v1)→ 任务行进入列表(pending/running) await page.getByTestId('watermark-save-btn').click() await expect(page.locator('.el-message').filter({ hasText: 'v1' })).toBeVisible() const stateTag = page.getByTestId('watermark-jobs-table').locator('[data-state]') await expect(stateTag.first()).toBeVisible() await expect(stateTag.first()).toHaveText(/等待中|进行中/) // e2e 无常驻 worker:fixture 直驱 worker dist 服务核心把任务跑到终态 // (页面进程独立,轮询不受 execSync 阻塞影响) seedFixture('watermark-run', albumId) // UI 3s 轮询翻到已完成;配置卡回显开启 await expect(stateTag.first()).toHaveText('已完成') await expect(page.getByTestId('watermark-current-tag')).toBeVisible() // 公开侧:album.watermarkEnabled + watermark 行存在 → browse 现签水印键 // (SignedUrlResponse 形状:{url, expiresAt}) const res = await page.request.get(`/v1/public/albums/${token}/photos`) expect(res.ok()).toBeTruthy() const body = (await res.json()) as { items: { browse: { url: string } }[] } expect(body.items.length).toBe(3) for (const item of body.items) { expect(item.browse.url).toContain('/watermark.jpg') } }) test('用量:阈值调小触发告警 → 上调解除;合计与相册表可见', async ({ page }) => { await loginAsAdmin(page) // 独立数据:API 建相册 + 大字节照片(2MB > 0.00001GB 阈值),与 UI 顺序解耦 const sessionToken = await apiLogin(page) const created = (await ( await page.request.post('/v1/albums', { headers: { Authorization: `Bearer ${sessionToken}` }, data: { defaultLocale: 'zh-CN', startAt: '2026-10-01T10:00:00.000Z', translations: [{ locale: 'zh-CN', title: `E2E用量相册 ${Date.now().toString(36)}` }] } }) ).json()) as { id: string } seedFixture('gov-photos', created.id, adminCredentials().account, '2', '2097152') await page.goto('/#/system/usage') await expect(page.getByTestId('usage-totals')).toBeVisible() await expect(page.getByTestId('usage-albums-table')).toBeVisible() // 合计已加载(种子照片计入,非空占位) await expect(page.getByTestId('usage-total-photos')).not.toHaveText('-') // 阈值调小(perAlbum 0.00001 GB ≈ 10KB < 2MB)→ 即刻评估出未解决告警。 // 开关状态感知:共享开发库可能残留上次运行的已存阈值(开关已开)—— // 幂等设到目标态而非盲目点击。 await page.getByTestId('usage-thresholds-btn').click() await expect(page.getByTestId('thresholds-dialog')).toBeVisible() await setSwitchState(page, 'thresholds-per-album-switch', true) await page.getByTestId('thresholds-per-album-input').locator('input').fill('0.00001') await page.keyboard.press('Enter') await page.getByTestId('thresholds-submit').click() await expect(page.getByTestId('thresholds-dialog')).toBeHidden() await expect( page.getByTestId('usage-alerts-table').locator('.alert-row-active').first() ).toBeVisible() await expect( page .getByTestId('usage-alerts-table') .locator('.alert-row-active') .first() .getByTestId('alert-state-tag') ).toHaveText('未解决') // 上调到 100GB(storage 保持关闭=显式清除,避免历史阈值残留干扰)→ 解除 await page.getByTestId('usage-thresholds-btn').click() await expect(page.getByTestId('thresholds-dialog')).toBeVisible() await setSwitchState(page, 'thresholds-per-album-switch', true) await setSwitchState(page, 'thresholds-storage-switch', false) await page.getByTestId('thresholds-per-album-input').locator('input').fill('100') await page.keyboard.press('Enter') await page.getByTestId('thresholds-submit').click() await expect(page.getByTestId('thresholds-dialog')).toBeHidden() await expect(page.getByTestId('usage-alerts-table').locator('.alert-row-active')).toHaveCount(0) await expect( page.getByTestId('usage-alerts-table').locator('.alert-row-resolved').first() ).toBeVisible() }) test('分享二维码:canvas 非空 + viewer URL + 复制链接', async ({ page, context }) => { await loginAsAdmin(page) const suffix = await createAlbumViaUi(page) await openAlbumDetail(page, suffix) const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim() await page.getByTestId('album-share-tab').click() await expect(page.getByTestId('share-panel')).toBeVisible() // URL = VITE_VIEWER_BASE + /a/{token} const urlText = ((await page.getByTestId('share-url').textContent()) ?? '').trim() expect(urlText).toBe(`http://127.0.0.1:5175/a/${token}`) // canvas 已绘制(空白 canvas 的 dataURL 约几十字节,绘制后远大于 100) const dataUrlLength = await page .getByTestId('share-qr-canvas') .evaluate((canvas: HTMLCanvasElement) => canvas.toDataURL().length) expect(dataUrlLength).toBeGreaterThan(100) // 复制 → toast + 剪贴板内容一致(授 chromium 剪贴板权限) await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.getByTestId('share-copy-btn').click() await expect(page.locator('.el-message').filter({ hasText: '已复制' })).toBeVisible() const clipped = await page.evaluate(() => navigator.clipboard.readText()) expect(clipped).toBe(urlText) })