feat(admin): album management pages with members and categories

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 12:33:46 +08:00
parent a6cc190751
commit 50c59ca9d4
27 changed files with 2977 additions and 16 deletions
+119
View File
@@ -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()
})
+37 -2
View File
@@ -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 }
})
}
+3 -3
View File
@@ -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 }) => {
+107
View File
@@ -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())