feat(admin): album management pages with members and categories
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// 相册管理 e2e(真实后端 + compose 基础设施):登录 → 创建(zh+en)→ 列表可见 →
|
||||
// 编辑开关 → 标签更新;详情页:成员邀请(账号前缀搜索种子 staff)→ 分类创建 →
|
||||
// banner 直传(init→PUT→complete)预览 → 管理照片列表(种子照片状态)→ 隐藏。
|
||||
// 选择器一律 data-test(test.use testIdAttribute),与文案解耦。
|
||||
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 标题),返回标题后缀 */
|
||||
async function createAlbumViaUi(page: Page): Promise<string> {
|
||||
const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||
await page.getByTestId('album-create-btn').click()
|
||||
await expect(page.getByTestId('album-form-dialog')).toBeVisible()
|
||||
await page.getByTestId('album-form-title-zh-CN').fill(`E2E相册 ${suffix}`)
|
||||
// 非激活页签的内容被隐藏(v-show):先切到英文页签再填写
|
||||
await page.getByRole('tab', { name: '英文' }).click()
|
||||
await page.getByTestId('album-form-title-en').fill(`E2E Album ${suffix}`)
|
||||
// ElDatePicker 透传 attr 被 ElTooltip 吞掉:组件里用普通 div 挂 data-test,这里进 div 找 input
|
||||
await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.getByTestId('album-form-submit').click()
|
||||
await expect(page.getByTestId('album-form-dialog')).toBeHidden()
|
||||
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}` })
|
||||
}
|
||||
|
||||
test('创建相册(zh+en)→ 列表可见 → 关闭上传开关 → 标签更新', async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
const suffix = await createAlbumViaUi(page)
|
||||
|
||||
const row = albumRow(page, suffix)
|
||||
await row.getByTestId('album-edit-btn').click()
|
||||
await expect(page.getByTestId('album-form-dialog')).toBeVisible()
|
||||
await page.getByTestId('album-form-switch-upload').click()
|
||||
await page.getByTestId('album-form-submit').click()
|
||||
await expect(page.getByTestId('album-form-dialog')).toBeHidden()
|
||||
|
||||
await expect(row.getByTestId('album-switch-upload')).toContainText('关')
|
||||
})
|
||||
|
||||
test('详情:邀请成员 → 建分类 → 传 banner → 照片管理(隐藏)', async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
const suffix = await createAlbumViaUi(page)
|
||||
|
||||
// ── 进入详情 ──
|
||||
await albumRow(page, suffix).getByTestId('album-detail-btn').click()
|
||||
await expect(page.getByTestId('album-detail-card')).toBeVisible()
|
||||
const albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''
|
||||
expect(albumId).not.toBe('')
|
||||
|
||||
// ── 成员:前缀搜索种子 staff 账号并邀请 ──
|
||||
await page.getByTestId('album-members-tab').click()
|
||||
const searchSelect = page.getByTestId('member-search-select')
|
||||
await searchSelect.click()
|
||||
await searchSelect.locator('input').fill('staff-e2e')
|
||||
await page.getByTestId('member-option-staff-e2e').click()
|
||||
await page.getByTestId('member-invite-btn').click()
|
||||
await expect(page.getByTestId('member-list-table').getByText('staff-e2e')).toBeVisible()
|
||||
// owner 徽标(admin 自己)可见
|
||||
await expect(
|
||||
page.getByTestId('member-list-table').getByTestId('member-role-tag').first()
|
||||
).toBeVisible()
|
||||
|
||||
// ── 分类:zh/en 名称 ──
|
||||
await page.getByTestId('album-categories-tab').click()
|
||||
await page.getByTestId('category-create-btn').click()
|
||||
await page.getByTestId('category-form-name-zh').fill(`E2E分类 ${suffix}`)
|
||||
await page.getByTestId('category-form-name-en').fill(`E2E Category ${suffix}`)
|
||||
await page.getByTestId('category-form-submit').click()
|
||||
await expect(page.getByTestId('category-form-dialog')).toBeHidden()
|
||||
await expect(page.getByTestId('category-list-table').getByText(`E2E分类 ${suffix}`)).toBeVisible()
|
||||
|
||||
// ── banner:真实小 jpeg 走 file chooser → init/PUT/complete → 预览 ──
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await page.getByTestId('album-banner-pick').click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles({
|
||||
name: 'banner-e2e.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
buffer: Buffer.from(
|
||||
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwcJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPDs0NDT/wAALCAABAAEBAREA/8QAFAABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AmgA//9k=',
|
||||
'base64'
|
||||
)
|
||||
})
|
||||
await expect(page.getByTestId('album-banner-status-done')).toBeVisible({ timeout: 20_000 })
|
||||
await expect(page.getByTestId('album-banner-preview-img')).toBeVisible()
|
||||
|
||||
// ── 照片管理:直种两种状态 → 列表可见 → 隐藏已发布照片 ──
|
||||
seedFixture('photos', albumId, adminCredentials().account)
|
||||
await page.reload()
|
||||
await expect(page.getByTestId('photo-list-table')).toBeVisible()
|
||||
|
||||
// 用处理状态标签(就绪)锚定行:隐藏后行的可见性文案变化,锚点保持稳定
|
||||
const publishedRow = page
|
||||
.getByTestId('photo-list-table')
|
||||
.locator('tr')
|
||||
.filter({ has: page.getByTestId('photo-state-tag').filter({ hasText: '就绪' }) })
|
||||
.first()
|
||||
await expect(publishedRow.getByTestId('photo-state-tag')).toHaveText('就绪')
|
||||
await expect(publishedRow.getByTestId('photo-visibility-tag')).toHaveText('已发布')
|
||||
|
||||
await publishedRow.getByTestId('photo-hide-btn').click()
|
||||
await page.getByRole('button', { name: '确定' }).click()
|
||||
await expect(publishedRow.getByTestId('photo-visibility-tag')).toHaveText('已隐藏')
|
||||
|
||||
// 处理失败状态标签也在列(种子第二行)
|
||||
await expect(page.getByTestId('photo-list-table').getByText('处理失败')).toBeVisible()
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
// e2e 辅助:从仓库根 .env 读种子管理员凭据(global-setup 已跑过幂等 seed)。
|
||||
// e2e 辅助:根 .env 凭据/环境读取、UI 登录、DB 直种夹具(seed-fixture.cjs)。
|
||||
import { execSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
@@ -10,7 +13,7 @@ export interface AdminCredentials {
|
||||
password: string
|
||||
}
|
||||
|
||||
export function adminCredentials(): AdminCredentials {
|
||||
function rootEnv(): Map<string, string> {
|
||||
const envPath = join(repoRoot, '.env')
|
||||
if (!existsSync(envPath)) {
|
||||
throw new Error(`[admin e2e] 仓库根 .env 不存在(${envPath})`)
|
||||
@@ -20,7 +23,39 @@ export function adminCredentials(): AdminCredentials {
|
||||
const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim())
|
||||
if (match) vars.set(match[1], match[2])
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
export function adminCredentials(): AdminCredentials {
|
||||
const vars = rootEnv()
|
||||
const account = vars.get('ADMIN_ACCOUNT') ?? 'admin'
|
||||
const password = vars.get('ADMIN_PASSWORD') ?? 'admin-secret-change-me'
|
||||
return { account, password }
|
||||
}
|
||||
|
||||
/** UI 登录(真实后端):填账号密码 → 落到 /albums/manage */
|
||||
export async function loginAsAdmin(page: Page): Promise<void> {
|
||||
const { account, password } = adminCredentials()
|
||||
await page.goto('/#/auth/login')
|
||||
await page.getByPlaceholder('请输入账号').fill(account)
|
||||
await page.getByPlaceholder('请输入密码', { exact: false }).fill(password)
|
||||
await page.getByRole('button', { name: '登录' }).click()
|
||||
await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 })
|
||||
}
|
||||
|
||||
/** 直种夹具(seed-fixture.cjs):staff 账号 / 相册种子照片(幂等) */
|
||||
export function seedFixture(mode: 'staff' | 'photos', ...args: string[]): void {
|
||||
const vars = rootEnv()
|
||||
const databaseUrl = vars.get('DATABASE_URL_CN')
|
||||
if (!databaseUrl) {
|
||||
throw new Error('[admin e2e] 根 .env 缺少 DATABASE_URL_CN')
|
||||
}
|
||||
// @photo/db-cn 绝对路径:apps/admin 不依赖该包,借 services/api 的 node_modules
|
||||
const dbCn = join(repoRoot, 'services', 'api', 'node_modules', '@photo', 'db-cn')
|
||||
execSync(`node seed-fixture.cjs ${mode} ${args.join(' ')}`, {
|
||||
cwd: fileURLToPath(new URL('.', import.meta.url)),
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
env: { ...process.env, PHOTO_DB_CN: dbCn, DATABASE_URL_CN: databaseUrl }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ test('错误密码 → 本地化错误提示(AUTH_INVALID_CREDENTIALS)', asy
|
||||
await expect(page).toHaveURL(/\/auth\/login/)
|
||||
})
|
||||
|
||||
test('正确登录 → 落到相册管理占位页', async ({ page }) => {
|
||||
test('正确登录 → 落到相册管理列表页', async ({ page }) => {
|
||||
const { account, password } = adminCredentials()
|
||||
await page.goto('/#/auth/login')
|
||||
await page.getByPlaceholder('请输入账号').fill(account)
|
||||
@@ -39,7 +39,7 @@ test('正确登录 → 落到相册管理占位页', async ({ page }) => {
|
||||
// 首页 '/' 重定向到菜单第一个有效路径(/albums/manage)
|
||||
await expect(page).toHaveURL(/\/albums\/manage/)
|
||||
await expect(page.getByText('相册管理').first()).toBeVisible()
|
||||
await expect(page.getByText('相册管理功能建设中').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '新建相册' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('登录后刷新页面 → 会话令牌持久,受保护路由仍可达', async ({ page }) => {
|
||||
@@ -57,7 +57,7 @@ test('登录后刷新页面 → 会话令牌持久,受保护路由仍可达',
|
||||
// 刷新后不弹回登录页:动态路由重建,受保护页仍加载
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/\/albums\/manage/)
|
||||
await expect(page.getByText('相册管理功能建设中').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '新建相册' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('用户菜单登出 → 尽力吊销会话并回到登录页', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// e2e 数据种子夹具(Node 直跑,Playwright 用例内按需调用)。
|
||||
// 为什么不用 API 造数:目标照片需要 published/processing_failed 等终态,
|
||||
// 真实链路要 worker 处理流水线;e2e 只验证管理页展示与操作,直插 DB 行
|
||||
// (uploadedAt 置位即可入管理列表,见 services/api listAlbumPhotos 过滤条件)。
|
||||
// 依赖经 PHOTO_DB_CN 环境变量注入 @photo/db-cn 绝对路径(apps/admin 不依赖
|
||||
// 该包,node 从文件位置解析不到;services/api 的 node_modules 里有现成链接)。
|
||||
// 用法:
|
||||
// node seed-fixture.cjs staff <account> <displayName>
|
||||
// node seed-fixture.cjs photos <albumId> <uploaderAccount>
|
||||
// 幂等:账号存在即跳过;照片按 objectKey upsert(同相册重复种不重复建行)。
|
||||
const db_cn = process.env.PHOTO_DB_CN
|
||||
const databaseUrl = process.env.DATABASE_URL_CN
|
||||
if (!db_cn || !databaseUrl) {
|
||||
console.error('seed-fixture: PHOTO_DB_CN and DATABASE_URL_CN env vars are required')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// CommonJS(node 直跑,无构建步骤):动态 require 经环境变量注入的包路径
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { PrismaClient } = require(db_cn)
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } })
|
||||
|
||||
async function seedStaff(account, displayName) {
|
||||
const existing = await prisma.users.findUnique({ where: { account } })
|
||||
if (existing) {
|
||||
console.log(`seed-fixture: staff "${account}" exists, skipping`)
|
||||
return
|
||||
}
|
||||
// passwordHash 占位(不可登录):e2e 只经账号查询邀请该用户,从不登录
|
||||
await prisma.users.create({
|
||||
data: {
|
||||
account,
|
||||
passwordHash: 'seeded-no-login',
|
||||
displayName: displayName || account,
|
||||
role: 'staff',
|
||||
credentialStatus: 'active'
|
||||
}
|
||||
})
|
||||
console.log(`seed-fixture: staff "${account}" created`)
|
||||
}
|
||||
|
||||
async function seedPhotos(albumId, uploaderAccount) {
|
||||
const uploader = await prisma.users.findUnique({ where: { account: uploaderAccount } })
|
||||
if (!uploader) {
|
||||
throw new Error(`seed-fixture: uploader "${uploaderAccount}" not found`)
|
||||
}
|
||||
const now = new Date()
|
||||
const rows = [
|
||||
{
|
||||
suffix: 'published',
|
||||
visibility: 'published',
|
||||
processingState: 'ready',
|
||||
width: 1200,
|
||||
height: 800,
|
||||
publishedAt: now,
|
||||
errorCode: null
|
||||
},
|
||||
{
|
||||
suffix: 'failed',
|
||||
visibility: 'unpublished',
|
||||
processingState: 'processing_failed',
|
||||
width: null,
|
||||
height: null,
|
||||
publishedAt: null,
|
||||
errorCode: 'DECODE_FAILED'
|
||||
}
|
||||
]
|
||||
for (const row of rows) {
|
||||
const objectKey = `e2e-seed/albums/${albumId}/${row.suffix}.jpg`
|
||||
await prisma.photos.upsert({
|
||||
where: { objectKey },
|
||||
update: {},
|
||||
create: {
|
||||
albumId,
|
||||
uploaderId: uploader.id,
|
||||
objectKey,
|
||||
bytes: 2048,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
uploadedAt: now,
|
||||
publishedAt: row.publishedAt,
|
||||
processingState: row.processingState,
|
||||
errorCode: row.errorCode,
|
||||
visibility: row.visibility
|
||||
}
|
||||
})
|
||||
}
|
||||
console.log(`seed-fixture: 2 photos seeded for album ${albumId}`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [mode, argA, argB] = process.argv.slice(2)
|
||||
if (mode === 'staff') {
|
||||
await seedStaff(argA, argB)
|
||||
} else if (mode === 'photos') {
|
||||
await seedPhotos(argA, argB)
|
||||
} else {
|
||||
throw new Error(`seed-fixture: unknown mode "${mode}"`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => prisma.$disconnect())
|
||||
Reference in New Issue
Block a user