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())
+123
View File
@@ -0,0 +1,123 @@
/**
* 相册域接口(对齐 @photo/contracts:albums/assets/members/categories/photos
* 与 /admin/users 账号前缀查询)
*
* - 列表端点均为游标分页(cursor + limit;nextCursor 为 null 表示到底)
* - 更新为 PATCH 部分语义;translations 数组全量替换
* - 204 端点(移除成员/删除分类/删除照片)无响应体
* - 资产直传 PUT 走 staging 预签名 URL(裸 fetch,不带会话头),见
* views/albums 内的上传组件
*/
import request from '@/utils/http'
/** 相册列表(管理视角:当前用户可见,创建时间倒序) */
export function fetchAlbumList(params: { cursor?: string; limit?: number }) {
return request.get<Api.Album.ListResponse>({ url: '/v1/albums', params })
}
/** 相册详情(完整配置 + 多语言内容 + 分类) */
export function fetchAlbum(albumId: string) {
return request.get<Api.Album.Detail>({ url: `/v1/albums/${albumId}` })
}
/** 创建相册(创建者自动成为 owner 成员) */
export function fetchCreateAlbum(data: Api.Album.CreatePayload) {
return request.post<Api.Album.Detail>({ url: '/v1/albums', data })
}
/** 更新相册(部分更新;translations 全量替换,须含 defaultLocale 条目) */
export function fetchUpdateAlbum(albumId: string, data: Api.Album.UpdatePayload) {
return request.patch<Api.Album.Detail>({ url: `/v1/albums/${albumId}`, data })
}
/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */
export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) {
return request.post<Api.Album.AssetInitResponse>({
url: `/v1/albums/${albumId}/assets`,
data
})
}
/** 完成资产上传核验(幂等;返回短期签名 URL,即预览/刷新入口) */
export function fetchCompleteAlbumAsset(albumId: string, assetId: string) {
return request.post<Api.Album.AssetCompleteResponse>({
url: `/v1/albums/${albumId}/assets/${assetId}/complete`
})
}
/** 成员列表(含 owner) */
export function fetchAlbumMembers(albumId: string, params: { cursor?: string; limit?: number }) {
return request.get<Api.Album.MemberListResponse>({
url: `/v1/albums/${albumId}/members`,
params
})
}
/** 添加成员(按用户 ID;重复添加 409 MEMBER_CONFLICT) */
export function fetchAddAlbumMember(albumId: string, userId: string) {
return request.post<Api.Album.Member>({
url: `/v1/albums/${albumId}/members`,
data: { userId, role: 'collaborator' }
})
}
/** 移除成员(owner 不可移除 403;目标不存在幂等 204) */
export function fetchRemoveAlbumMember(albumId: string, userId: string) {
return request.del<void>({ url: `/v1/albums/${albumId}/members/${userId}` })
}
/** 账号前缀查询(邀请协作者前置;≥2 字符,激活用户) */
export function fetchLookupUsers(accountPrefix: string, limit = 10) {
return request.get<{ items: Api.Album.UserLookupItem[] }>({
url: '/v1/admin/users',
params: { accountPrefix, limit },
showErrorMessage: false
})
}
/** 创建分类 */
export function fetchCreateCategory(albumId: string, data: Api.Album.CategoryCreatePayload) {
return request.post<Api.Album.Category>({
url: `/v1/albums/${albumId}/categories`,
data
})
}
/** 更新分类(translations 全量替换) */
export function fetchUpdateCategory(categoryId: string, data: Api.Album.CategoryUpdatePayload) {
return request.patch<Api.Album.Category>({ url: `/v1/categories/${categoryId}`, data })
}
/** 删除分类(204;分类下有照片时服务端 400 拒绝) */
export function fetchDeleteCategory(categoryId: string) {
return request.del<void>({ url: `/v1/categories/${categoryId}` })
}
/** 管理照片列表(全状态可见 + uploader 脱敏;uploadedAt 倒序游标) */
export function fetchAlbumPhotos(
albumId: string,
params: {
visibility?: Api.Album.PhotoVisibility
categoryId?: string
cursor?: string
limit?: number
}
) {
return request.get<Api.Album.AdminPhotoListResponse>({
url: `/v1/albums/${albumId}/photos`,
params
})
}
/** 调整照片可见性(仅 hidden ↔ published) */
export function fetchUpdatePhotoVisibility(photoId: string, visibility: 'hidden' | 'published') {
return request.patch<Api.Album.AdminPhoto>({
url: `/v1/photos/${photoId}`,
data: { visibility }
})
}
/** 软删除照片(204;deleted 为员工侧终态) */
export function fetchDeletePhoto(photoId: string) {
return request.del<void>({ url: `/v1/photos/${photoId}` })
}
+179 -3
View File
@@ -232,7 +232,8 @@
},
"albums": {
"title": "Albums",
"manage": "Album Management"
"manage": "Album Management",
"detail": "Album Detail"
}
},
"table": {
@@ -264,11 +265,186 @@
"headerBackground": "Header BG"
},
"albumsManage": {
"placeholder": "Album management is under construction (list/upload/member management arrive in later P1b tasks)"
"list": {
"title": "Album List",
"create": "New Album",
"loadMore": "Load More",
"noMore": "All loaded",
"empty": "No albums yet — click \"New Album\" to create one",
"loadFailed": "Failed to load albums",
"retry": "Retry",
"colTitle": "Title",
"colToken": "Public Token",
"colStatus": "Status",
"colSwitches": "Switches",
"colStartAt": "Start",
"colEndAt": "End",
"colActions": "Actions",
"copyToken": "Copy public token",
"copied": "Copied",
"statusActive": "Active",
"statusOffline": "Offline",
"switchUpload": "Upload",
"switchPublic": "Public",
"switchDownload": "Download",
"switchOriginal": "Original",
"on": "On",
"off": "Off",
"actionEdit": "Edit",
"actionMembers": "Members",
"actionDetail": "Detail",
"endAtUndecided": "TBD"
},
"form": {
"createTitle": "New Album",
"editTitle": "Edit Album",
"tabZh": "Chinese (zh-CN)",
"tabEn": "English (en)",
"fieldTitle": "Title",
"fieldDescription": "Description",
"fieldVenue": "Venue",
"fieldAddress": "Address",
"fieldDefaultLocale": "Default Locale",
"fieldStartAt": "Start Time",
"fieldEndAt": "End Time",
"fieldTimezone": "Timezone (IANA)",
"fieldSwitches": "Switches",
"switchUpload": "Allow uploads",
"switchPublic": "Publicly visible",
"switchDownload": "Allow download (browse)",
"switchOriginal": "Allow original download",
"translationsReplaceHint": "Saving fully replaces all translations: entries without a title will be removed, and the default locale entry is required.",
"createSuccess": "Album created",
"updateSuccess": "Album updated",
"submit": "Save",
"cancel": "Cancel",
"errors": {
"startAtRequired": "Start time is required",
"defaultLocaleMissing": "Title for the default locale ({locale}) is required",
"titleRequired": "Title is required",
"titleRequiredWithFields": "Title is required when other fields of this locale are filled",
"endAtNotAfterStartAt": "End time must be later than start time"
}
},
"detail": {
"back": "Back to List",
"edit": "Edit Album",
"basicInfo": "Basic Info",
"banner": "Banner",
"tabPhotos": "Photos",
"tabMembers": "Members",
"tabCategories": "Categories",
"loadFailed": "Failed to load album",
"retry": "Retry",
"fieldTitle": "Title",
"fieldToken": "Public Token",
"fieldStatus": "Status",
"fieldTimezone": "Timezone",
"fieldStartAt": "Start",
"fieldEndAt": "End",
"fieldCreatedAt": "Created",
"fieldUpdatedAt": "Updated"
},
"banner": {
"kind": "Asset Kind",
"kindBanner": "Banner",
"kindCover": "Cover",
"kindLogo": "Logo",
"locale": "Locale (optional)",
"localeNone": "Locale-independent",
"pick": "Choose Image",
"pickHint": "jpg / png / heic — uploaded directly to object storage, then verified",
"stageInitializing": "Initializing upload session…",
"stageUploading": "Uploading…",
"stageCompleting": "Verifying…",
"stageDone": "Effective",
"stageError": "Upload failed",
"reupload": "Upload Again",
"preview": "Preview (signed URL, valid until {expiresAt})",
"invalidType": "Only jpg / png / heic images are supported"
},
"members": {
"title": "Album Members",
"searchPlaceholder": "Type an account prefix (min 2 chars) to search",
"searching": "Searching…",
"noMatch": "No matching account",
"invite": "Invite",
"invited": "Member added",
"removed": "Member removed",
"removeConfirm": "Remove member \"{account}\"?",
"colAccount": "Account",
"colDisplayName": "Name",
"colRole": "Role",
"colActions": "Actions",
"roleOwner": "Owner",
"roleCollaborator": "Collaborator",
"remove": "Remove",
"empty": "No members"
},
"categories": {
"title": "Categories",
"create": "New Category",
"edit": "Edit Category",
"colSort": "Sort",
"colZh": "Chinese Name",
"colEn": "English Name",
"colActions": "Actions",
"fieldSort": "Sort Order (ascending)",
"fieldNameZh": "Chinese Name",
"fieldNameEn": "English Name",
"nameRequired": "At least one locale name is required",
"delete": "Delete",
"createSuccess": "Category created",
"updateSuccess": "Category updated",
"deleteConfirm": "Delete category \"{name}\"?",
"deleteSuccess": "Category deleted",
"empty": "No categories",
"submit": "Save",
"cancel": "Cancel"
},
"photos": {
"title": "Photo Management",
"filterVisibility": "Visibility",
"visibilityAll": "All",
"filterCategory": "Category",
"categoryAll": "All Categories",
"uncategorized": "Uncategorized",
"colState": "State",
"colVisibility": "Visibility",
"colSize": "Size",
"colUploader": "Uploader",
"colUploadedAt": "Uploaded",
"colActions": "Actions",
"statePendingUpload": "Pending Upload",
"stateUploaded": "Uploaded",
"stateProcessing": "Processing",
"stateReady": "Ready",
"stateProcessingFailed": "Processing Failed",
"visibilityUnpublished": "Unpublished",
"visibilityPublished": "Published",
"visibilityHidden": "Hidden",
"visibilityDeleted": "Deleted",
"hide": "Hide",
"restore": "Restore",
"delete": "Delete",
"hideConfirm": "Hide this photo? It becomes invisible to the public immediately.",
"restoreConfirm": "Restore this photo to public?",
"deleteConfirm": "Delete this photo? (soft delete, cannot be restored here)",
"hidden": "Photo hidden",
"restored": "Photo restored",
"deleted": "Photo deleted",
"empty": "No photos"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "Invalid account or password",
"AUTH_RATE_LIMITED": "Too many failed login attempts; try again later",
"VALIDATION_FAILED": "Request validation failed"
"VALIDATION_FAILED": "Request validation failed",
"MEMBER_CONFLICT": "This user is already a member",
"ALBUM_NOT_FOUND": "Album not found or not visible",
"PHOTO_NOT_FOUND": "Photo not found",
"UPLOAD_VERIFY_FAILED": "Upload verification failed, please retry",
"UPLOAD_SESSION_CONFLICT": "Upload session conflict, please start over",
"ALBUM_UPLOAD_DISABLED": "Upload is disabled for this album"
}
}
+179 -3
View File
@@ -232,7 +232,8 @@
},
"albums": {
"title": "相册管理",
"manage": "相册管理"
"manage": "相册管理",
"detail": "相册详情"
}
},
"table": {
@@ -264,11 +265,186 @@
"headerBackground": "表头背景"
},
"albumsManage": {
"placeholder": "相册管理功能建设中(P1b 后续任务接入列表/上传/成员管理)"
"list": {
"title": "相册列表",
"create": "新建相册",
"loadMore": "加载更多",
"noMore": "已全部加载",
"empty": "暂无相册,点击「新建相册」创建",
"loadFailed": "列表加载失败",
"retry": "重试",
"colTitle": "标题",
"colToken": "公开令牌",
"colStatus": "状态",
"colSwitches": "功能开关",
"colStartAt": "开始时间",
"colEndAt": "结束时间",
"colActions": "操作",
"copyToken": "复制公开令牌",
"copied": "已复制",
"statusActive": "活跃",
"statusOffline": "下线",
"switchUpload": "上传",
"switchPublic": "公开",
"switchDownload": "下载",
"switchOriginal": "原图",
"on": "开",
"off": "关",
"actionEdit": "编辑",
"actionMembers": "成员",
"actionDetail": "详情",
"endAtUndecided": "未定"
},
"form": {
"createTitle": "新建相册",
"editTitle": "编辑相册",
"tabZh": "中文(zh-CN)",
"tabEn": "英文(en)",
"fieldTitle": "标题",
"fieldDescription": "描述",
"fieldVenue": "场地名称",
"fieldAddress": "场地地址",
"fieldDefaultLocale": "默认语言",
"fieldStartAt": "开始时间",
"fieldEndAt": "结束时间",
"fieldTimezone": "时区(IANA)",
"fieldSwitches": "功能开关",
"switchUpload": "允许上传",
"switchPublic": "公开可见",
"switchDownload": "允许下载浏览版",
"switchOriginal": "允许下载原图",
"translationsReplaceHint": "保存将全量替换多语言内容:未填标题的语言条目会被移除,且必须保留默认语言条目。",
"createSuccess": "相册已创建",
"updateSuccess": "相册已更新",
"submit": "保存",
"cancel": "取消",
"errors": {
"startAtRequired": "开始时间必填",
"defaultLocaleMissing": "默认语言({locale})的标题必填",
"titleRequired": "标题必填",
"titleRequiredWithFields": "该语言已填写其他字段,标题也必填",
"endAtNotAfterStartAt": "结束时间必须晚于开始时间"
}
},
"detail": {
"back": "返回列表",
"edit": "编辑相册",
"basicInfo": "基础信息",
"banner": "宣传图(Banner)",
"tabPhotos": "照片管理",
"tabMembers": "成员管理",
"tabCategories": "分类管理",
"loadFailed": "相册加载失败",
"retry": "重试",
"fieldTitle": "标题",
"fieldToken": "公开令牌",
"fieldStatus": "状态",
"fieldTimezone": "时区",
"fieldStartAt": "开始时间",
"fieldEndAt": "结束时间",
"fieldCreatedAt": "创建时间",
"fieldUpdatedAt": "更新时间"
},
"banner": {
"kind": "资产类型",
"kindBanner": "宣传图(banner)",
"kindCover": "封面(cover)",
"kindLogo": "Logo",
"locale": "语言(可选)",
"localeNone": "语言无关",
"pick": "选择图片",
"pickHint": "jpg / png / heic,直传对象存储后核验生效",
"stageInitializing": "初始化上传会话…",
"stageUploading": "上传中…",
"stageCompleting": "核验中…",
"stageDone": "已生效",
"stageError": "上传失败",
"reupload": "重新上传",
"preview": "当前预览(签名 URL,{expiresAt} 前有效)",
"invalidType": "仅支持 jpg / png / heic 图片"
},
"members": {
"title": "相册成员",
"searchPlaceholder": "输入账号前缀(至少 2 字符)搜索",
"searching": "搜索中…",
"noMatch": "无匹配账号",
"invite": "邀请",
"invited": "成员已添加",
"removed": "成员已移除",
"removeConfirm": "确定移除成员「{account}」?",
"colAccount": "账号",
"colDisplayName": "姓名",
"colRole": "角色",
"colActions": "操作",
"roleOwner": "创建者",
"roleCollaborator": "协作者",
"remove": "移除",
"empty": "暂无成员"
},
"categories": {
"title": "分类管理",
"create": "新建分类",
"edit": "编辑分类",
"colSort": "排序",
"colZh": "中文名",
"colEn": "英文名",
"colActions": "操作",
"fieldSort": "排序序号(升序)",
"fieldNameZh": "中文名",
"fieldNameEn": "英文名",
"nameRequired": "至少填写一个语言的名称",
"delete": "删除",
"createSuccess": "分类已创建",
"updateSuccess": "分类已更新",
"deleteConfirm": "确定删除分类「{name}」?",
"deleteSuccess": "分类已删除",
"empty": "暂无分类",
"submit": "保存",
"cancel": "取消"
},
"photos": {
"title": "照片管理",
"filterVisibility": "可见性",
"visibilityAll": "全部",
"filterCategory": "分类",
"categoryAll": "全部分类",
"uncategorized": "未分类",
"colState": "处理状态",
"colVisibility": "可见性",
"colSize": "尺寸/大小",
"colUploader": "上传人",
"colUploadedAt": "上传时间",
"colActions": "操作",
"statePendingUpload": "待上传",
"stateUploaded": "已上传",
"stateProcessing": "处理中",
"stateReady": "就绪",
"stateProcessingFailed": "处理失败",
"visibilityUnpublished": "未发布",
"visibilityPublished": "已发布",
"visibilityHidden": "已隐藏",
"visibilityDeleted": "已删除",
"hide": "隐藏",
"restore": "恢复发布",
"delete": "删除",
"hideConfirm": "确定隐藏该照片?隐藏后公开端即刻不可见。",
"restoreConfirm": "确定恢复发布该照片?",
"deleteConfirm": "确定删除该照片?(软删除,管理端不可恢复)",
"hidden": "照片已隐藏",
"restored": "照片已恢复发布",
"deleted": "照片已删除",
"empty": "暂无照片"
}
},
"apiMsg": {
"AUTH_INVALID_CREDENTIALS": "账号或密码错误",
"AUTH_RATE_LIMITED": "登录失败次数过多,请稍后再试",
"VALIDATION_FAILED": "请求参数校验失败"
"VALIDATION_FAILED": "请求参数校验失败",
"MEMBER_CONFLICT": "该用户已是相册成员",
"ALBUM_NOT_FOUND": "相册不存在或不可见",
"PHOTO_NOT_FOUND": "照片不存在",
"UPLOAD_VERIFY_FAILED": "上传核验失败,请重试",
"UPLOAD_SESSION_CONFLICT": "上传会话状态冲突,请重新开始",
"ALBUM_UPLOAD_DISABLED": "该相册已关闭上传"
}
}
+14 -1
View File
@@ -1,5 +1,6 @@
/**
* 相册管理路由(P1b Task 9 唯一受保护业务页——占位 stub)
* 相册管理路由(P1b Task 10:列表 + 详情——详情含照片/成员/分类管理与
* banner 上传,isHide 不进侧边菜单,activePath 保持列表高亮)
*/
import { AppRouteRecord } from '@/types/router'
@@ -22,6 +23,18 @@ export const albumsRoutes: AppRouteRecord = {
keepAlive: false,
fixedTab: true
}
},
{
path: 'detail/:albumId',
name: 'AlbumsDetail',
component: '/albums/detail',
meta: {
title: 'menus.albums.detail',
icon: 'ri:gallery-line',
isHide: true,
keepAlive: false,
activePath: '/albums/manage'
}
}
]
}
+210
View File
@@ -0,0 +1,210 @@
/**
* 相册域接口类型(对齐 @photo/contracts openapi.yaml tags [albums][assets]
* [members][categories][photos] 与 /admin/users 查询)
*
* 与 api.d.ts 同用全局 Api 命名空间(declare namespace 自动合并)。
* 契约要点:
* - 列表一律游标分页(CursorPageMeta:nextCursor/hasMore 成对不变式)
* - translations 数组按 locale 组织;更新语义为「全量替换」
* - 管理端照片列表含全部可见性状态与 uploader 脱敏信息
*
* @module types/api/album
*/
declare namespace Api {
/** 相册域 */
namespace Album {
/** 支持语言(契约 Locale:zh-CN | en) */
type Locale = 'zh-CN' | 'en'
/** 相册多语言条目(创建/更新与响应共用) */
interface Translation {
locale: Locale
title: string
description?: string | null
venue?: string | null
address?: string | null
}
/** 相册列表条目(完整开关与多语言标题) */
interface Summary {
id: string
publicToken: string
ownerId: string
status: 'active' | 'offline'
defaultLocale: Locale
timezone: string
startAt: string
endAt: string | null
uploadEnabled: boolean
publicEnabled: boolean
downloadEnabled: boolean
originalDownloadEnabled: boolean
watermarkVersion: number
translations: Translation[]
createdAt: string
updatedAt: string
}
/** 分类(相册详情内嵌,按 sortOrder 升序) */
interface Category {
id: string
albumId: string
sortOrder: number
translations: CategoryTranslation[]
}
/** 分类多语言条目 */
interface CategoryTranslation {
locale: Locale
name: string
}
/** 相册详情 = 摘要 + 分类列表 */
interface Detail extends Summary {
categories: Category[]
}
/** 相册列表响应(游标分页) */
interface ListResponse {
items: Summary[]
nextCursor: string | null
hasMore: boolean
}
/** 创建相册请求(契约 AlbumCreateRequest;开关仅 download 两项) */
interface CreatePayload {
translations: Translation[]
defaultLocale: Locale
startAt: string
endAt?: string | null
timezone?: string
downloadEnabled?: boolean
originalDownloadEnabled?: boolean
}
/** 更新相册请求(部分更新;translations 全量替换;endAt null 清除) */
interface UpdatePayload {
translations?: Translation[]
defaultLocale?: Locale
startAt?: string
endAt?: string | null
timezone?: string
uploadEnabled?: boolean
publicEnabled?: boolean
downloadEnabled?: boolean
originalDownloadEnabled?: boolean
}
/** 相册资产类型 */
type AssetKind = 'banner' | 'cover' | 'logo'
/** 资产上传初始化请求 */
interface AssetInitPayload {
kind: AssetKind
locale?: Locale
filename: string
bytes: number
contentType: 'image/jpeg' | 'image/png' | 'image/heic'
}
/** staging 直传凭证(与照片直传同形) */
interface UploadCredential {
mode: 'presigned'
url: string
method: 'PUT'
headers: Record<string, string>
expiresAt: string
}
/** 资产上传初始化响应 */
interface AssetInitResponse {
assetId: string
objectKey: string
credential: UploadCredential
}
/** 资产完成核验响应(短期签名 URL) */
interface AssetCompleteResponse {
assetId: string
url: string
expiresAt: string
}
/** 相册成员 */
interface Member {
userId: string
account: string
displayName: string
role: 'owner' | 'collaborator'
status: 'active'
}
/** 成员列表响应(游标分页) */
interface MemberListResponse {
items: Member[]
nextCursor: string | null
hasMore: boolean
}
/** 账号前缀查询条目(脱敏) */
interface UserLookupItem {
id: string
account: string
displayName: string
}
/** 分类创建请求 */
interface CategoryCreatePayload {
sortOrder?: number
translations: CategoryTranslation[]
}
/** 分类更新请求(部分更新;translations 全量替换) */
interface CategoryUpdatePayload {
sortOrder?: number
translations?: CategoryTranslation[]
}
/** 照片可见性 */
type PhotoVisibility = 'unpublished' | 'published' | 'hidden' | 'deleted'
/** 照片处理状态 */
type PhotoProcessingState =
'pending_upload' | 'uploaded' | 'processing' | 'ready' | 'processing_failed'
/** 管理端照片记录 */
interface AdminPhoto {
photoId: string
albumId: string
categoryId: string | null
uploaderId: string
visibility: PhotoVisibility
processingState: PhotoProcessingState
bytes: number
width: number | null
height: number | null
shotAt: string | null
uploadedAt: string
publishedAt: string | null
likeCount: number
uploader: {
userId: string
account: string
displayName: string
}
}
/** 管理照片列表响应(游标分页) */
interface AdminPhotoListResponse {
items: AdminPhoto[]
nextCursor: string | null
hasMore: boolean
}
/** 照片可见性调整请求(仅 hidden ↔ published) */
interface PhotoVisibilityUpdatePayload {
visibility: 'hidden' | 'published'
}
}
}
+11
View File
@@ -57,10 +57,14 @@ declare module 'vue' {
BoxStyleSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default']
ColorSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default']
ContainerSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
@@ -76,10 +80,17 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPopover: typeof import('element-plus/es')['ElPopover']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElWatermark: typeof import('element-plus/es')['ElWatermark']
HorizontalSubmenu: typeof import('./../../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default']
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
toTranslationPayload,
validateTimeRange,
validateTranslations,
type TranslationDraft
} from './albumValidation'
const drafts = (
over: Partial<Record<'zh-CN' | 'en', Partial<TranslationDraft>>> = {}
): TranslationDraft[] => [
{ locale: 'zh-CN', title: '', description: null, venue: null, address: null, ...over['zh-CN'] },
{ locale: 'en', title: '', description: null, venue: null, address: null, ...over['en'] }
]
describe('validateTranslations 翻译覆盖校验', () => {
it('defaultLocale 条目缺失 title → defaultLocaleMissing', () => {
const errors = validateTranslations(drafts({ en: { title: 'Album' } }), 'zh-CN')
expect(errors).toContain('defaultLocaleMissing')
})
it('默认语言齐全 + 其他语言仅 title 缺省(无其他字段)→ 通过且不生成该条目', () => {
const errors = validateTranslations(drafts({ 'zh-CN': { title: '相册' } }), 'zh-CN')
expect(errors).toEqual([])
expect(toTranslationPayload(drafts({ 'zh-CN': { title: ' 相册 ' } }))).toEqual([
{ locale: 'zh-CN', title: '相册', description: null, venue: null, address: null }
])
})
it('title 空但 description/venue 有值 → titleRequiredWithFields', () => {
const errors = validateTranslations(
drafts({ 'zh-CN': { title: '相册' }, en: { venue: 'Grand Hotel' } }),
'zh-CN'
)
expect(errors).toEqual(['titleRequiredWithFields'])
})
})
describe('validateTimeRange 时间范围校验', () => {
it('endAt 早于或等于 startAt → 拒绝;严格晚于 → 通过;endAt 未定 → 放行', () => {
expect(validateTimeRange('2026-10-01T10:00:00Z', '2026-10-01T09:00:00Z')).toBe(false)
expect(validateTimeRange('2026-10-01T10:00:00Z', '2026-10-01T10:00:00Z')).toBe(false)
expect(validateTimeRange('2026-10-01T10:00:00Z', '2026-10-01T11:00:00Z')).toBe(true)
expect(validateTimeRange('2026-10-01T10:00:00Z', null)).toBe(true)
})
})
@@ -0,0 +1,74 @@
/**
* 相册表单校验(纯函数,镜像契约约束)
*
* 客户端先拦一层常见错误,服务端 VALIDATION_FAILED 仍是权威:
* - translations 必须包含 defaultLocale 对应条目且 title 非空(minLength 1)
* - 提交的每个翻译条目 title 均必填(未填 title 的语言不生成条目;
* 但「title 空而其他字段有值」视为漏填,给出可定位错误)
* - endAt 必须严格晚于 startAt(null 表示未定,放行)
*/
/** 校验错误码(i18n 键后缀,展示层映射 albumsManage.form.errors.*) */
export type AlbumFormErrorCode =
'defaultLocaleMissing' | 'titleRequired' | 'titleRequiredWithFields' | 'endAtNotAfterStartAt'
export interface TranslationDraft {
locale: string
title: string
description?: string | null
venue?: string | null
address?: string | null
}
const hasValue = (value: string | null | undefined): boolean => !!value && value.trim() !== ''
/**
* 校验翻译草稿覆盖:返回按序去重的错误码列表(空数组 = 通过)。
*
* @param drafts 表单内的翻译草稿(通常 zh-CN + en 两项)
* @param defaultLocale 默认语言
*/
export function validateTranslations(
drafts: TranslationDraft[],
defaultLocale: string
): AlbumFormErrorCode[] {
const errors: AlbumFormErrorCode[] = []
const defaultDraft = drafts.find((d) => d.locale === defaultLocale)
if (!defaultDraft || !hasValue(defaultDraft.title)) {
errors.push('defaultLocaleMissing')
}
for (const draft of drafts) {
const hasOtherFields = [draft.description, draft.venue, draft.address].some(hasValue)
if (!hasValue(draft.title)) {
if (draft.locale === defaultLocale || hasOtherFields) {
errors.push(hasOtherFields ? 'titleRequiredWithFields' : 'titleRequired')
}
}
}
return [...new Set(errors)]
}
/**
* 校验时间范围:endAt 必须严格晚于 startAt。
* 任意一端缺失(表单层必填校验另行处理)或 endAt 为 null(未定)时放行。
*/
export function validateTimeRange(startAt: string | null, endAt: string | null): boolean {
if (!startAt || !endAt) return true
return new Date(endAt).getTime() > new Date(startAt).getTime()
}
/** 从草稿提炼可提交的 translations:只保留 title 非空的语言条目 */
export function toTranslationPayload<T extends TranslationDraft>(drafts: T[]) {
return drafts
.filter((d) => hasValue(d.title))
.map((d) => ({
locale: d.locale,
title: d.title.trim(),
description: hasValue(d.description) ? (d.description as string).trim() : null,
venue: hasValue(d.venue) ? (d.venue as string).trim() : null,
address: hasValue(d.address) ? (d.address as string).trim() : null
}))
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import {
INITIAL_ASSET_UPLOAD_STATE,
nextAssetUploadState,
type AssetUploadEvent
} from './assetUpload'
const run = (events: AssetUploadEvent[]) =>
events.reduce(nextAssetUploadState, INITIAL_ASSET_UPLOAD_STATE)
describe('assetUpload 状态机', () => {
it('happy path:START→INIT_OK→PUT_OK→COMPLETE_OK 进入 done 并保留签名预览', () => {
const state = run([
{ type: 'START' },
{ type: 'INIT_OK' },
{ type: 'PUT_OK' },
{ type: 'COMPLETE_OK', url: 'http://minio/signed', expiresAt: '2026-10-01T00:00:00Z' }
])
expect(state.stage).toBe('done')
expect(state.previewUrl).toBe('http://minio/signed')
expect(state.previewExpiresAt).toBe('2026-10-01T00:00:00Z')
})
it('PUT 失败 → error 携带失败阶段,可重新 START', () => {
const state = run([
{ type: 'START' },
{ type: 'INIT_OK' },
{ type: 'FAIL', stage: 'put', message: 'network' }
])
expect(state.stage).toBe('error')
expect(state.failedStage).toBe('put')
expect(state.error).toBe('network')
expect(nextAssetUploadState(state, { type: 'START' }).stage).toBe('initializing')
})
it('迟到响应不污染新会话:RESET 后的 INIT_OK/PUT_OK 被忽略', () => {
const state = run([{ type: 'START' }, { type: 'INIT_OK' }])
const reset = nextAssetUploadState(state, { type: 'RESET' })
expect(reset.stage).toBe('idle')
// RESET 之前发出的迟到完成事件不得把 idle 推进到 done
expect(nextAssetUploadState(reset, { type: 'PUT_OK' }).stage).toBe('idle')
expect(
nextAssetUploadState(reset, { type: 'COMPLETE_OK', url: 'x', expiresAt: 'y' }).stage
).toBe('idle')
})
})
@@ -0,0 +1,78 @@
/**
* 资产预签名上传状态机(纯函数 reducer)
*
* 三段链路 init(POST /assets 预签名)→ PUT(staging 直传)→ complete
* (核验生效并返回签名 URL)。任何一步失败进入 error(携带阶段与原因,
* 可重新开始);complete 幂等,成功态保留签名 URL 与过期时间用于预览。
* 抽成纯 reducer 以便单测与 UI 解耦。
*/
export type AssetUploadStage =
'idle' | 'initializing' | 'uploading' | 'completing' | 'done' | 'error'
export interface AssetUploadState {
stage: AssetUploadStage
/** 失败发生的阶段(error 态定位重试入口) */
failedStage?: 'init' | 'put' | 'complete'
/** 错误消息(已本地化/服务端文案) */
error?: string
/** complete 返回的签名预览 URL */
previewUrl?: string
/** 签名 URL 过期时间(ISO) */
previewExpiresAt?: string
}
export type AssetUploadEvent =
| { type: 'START' }
| { type: 'INIT_OK' }
| { type: 'PUT_OK' }
| { type: 'COMPLETE_OK'; url: string; expiresAt: string }
| { type: 'FAIL'; stage: 'init' | 'put' | 'complete'; message?: string }
| { type: 'RESET' }
export const INITIAL_ASSET_UPLOAD_STATE: AssetUploadState = { stage: 'idle' }
/**
* 状态转移:非法转移(如 idle 收到 PUT_OK)保持原状——UI 侧的迟到响应
* 不得污染已重置的新会话。
*/
export function nextAssetUploadState(
state: AssetUploadState,
event: AssetUploadEvent
): AssetUploadState {
switch (event.type) {
case 'START':
if (state.stage === 'idle' || state.stage === 'done' || state.stage === 'error') {
return { stage: 'initializing' }
}
return state
case 'INIT_OK':
if (state.stage === 'initializing') return { stage: 'uploading' }
return state
case 'PUT_OK':
if (state.stage === 'uploading') return { stage: 'completing' }
return state
case 'COMPLETE_OK':
if (state.stage === 'completing') {
return {
stage: 'done',
previewUrl: event.url,
previewExpiresAt: event.expiresAt
}
}
return state
case 'FAIL':
if (
state.stage === 'initializing' ||
state.stage === 'uploading' ||
state.stage === 'completing'
) {
return { stage: 'error', failedStage: event.stage, error: event.message }
}
return state
case 'RESET':
return INITIAL_ASSET_UPLOAD_STATE
default:
return state
}
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { accumulatePage, firstPage, type CursorPage } from './cursorList'
const pageOf = <T>(items: T[], nextCursor: string | null): CursorPage<T> => ({
items,
nextCursor,
hasMore: nextCursor !== null
})
describe('cursorList 游标累积', () => {
it('首页整页替换并保留游标', () => {
const state = firstPage(pageOf([1, 2], 'c1'))
expect(state.items).toEqual([1, 2])
expect(state.nextCursor).toBe('c1')
expect(state.hasMore).toBe(true)
})
it('加载更多按序追加,nextCursor 为 null 即到底', () => {
let state = firstPage(pageOf([1, 2], 'c1'))
state = accumulatePage(state, pageOf([3], 'c2'))
expect(state.items).toEqual([1, 2, 3])
expect(state.hasMore).toBe(true)
state = accumulatePage(state, pageOf([4], null))
expect(state.items).toEqual([1, 2, 3, 4])
expect(state.hasMore).toBe(false)
})
it('keyOf 去重:跨页重复行不重复 append', () => {
let state = firstPage(pageOf([{ id: 'a' }, { id: 'b' }], 'c1'))
state = accumulatePage(state, pageOf([{ id: 'b' }, { id: 'c' }], null), (x) => x.id)
expect(state.items.map((x) => x.id)).toEqual(['a', 'b', 'c'])
})
})
+67
View File
@@ -0,0 +1,67 @@
/**
* 游标分页累积(纯函数)
*
* 契约列表端点(相册/成员/管理照片)一律 nextCursor 键集分页,而模板
* useTable 假设 offset 分页(current/size/total 并整页替换 data)——两者
* 语义不兼容。此处以「页内累积 + 加载更多」适配:每次把新页 append 到
* 已有行,nextCursor 为 null 即到底。保持纯函数以便单测。
*/
/** 契约游标页形状(CursorPageMeta + items) */
export interface CursorPage<T> {
items: T[]
nextCursor: string | null
hasMore: boolean
}
/** 累积状态:已加载行 + 下一页游标 */
export interface CursorState<T> {
items: T[]
nextCursor: string | null
hasMore: boolean
}
export const EMPTY_CURSOR_STATE: CursorState<never> = {
items: [],
nextCursor: null,
hasMore: false
}
/** 首页:整页替换(重置语义) */
export function firstPage<T>(page: CursorPage<T>): CursorState<T> {
return { items: [...page.items], nextCursor: page.nextCursor, hasMore: page.hasMore }
}
/**
* 追加一页(加载更多语义)
*
* @param state 当前累积状态
* @param page 新拉取的一页
* @param keyOf 行唯一键(可选):服务端排序键含 id,正常不会跨页重复;
* 传入时防御性去重(重复行以旧位置为准,不重复 append)
*/
export function accumulatePage<T>(
state: CursorState<T>,
page: CursorPage<T>,
keyOf?: (item: T) => string
): CursorState<T> {
if (!keyOf) {
return {
items: [...state.items, ...page.items],
nextCursor: page.nextCursor,
hasMore: page.hasMore
}
}
const seen = new Set(state.items.map(keyOf))
const appended = page.items.filter((item) => {
const key = keyOf(item)
if (seen.has(key)) return false
seen.add(key)
return true
})
return {
items: [...state.items, ...appended],
nextCursor: page.nextCursor,
hasMore: page.hasMore
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* 相册页展示格式化(无第三方日期库:Intl 够用且随语言环境走)
*/
/** ISO 时间 → 本地展示(yyyy-MM-dd HH:mm) */
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '-'
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
}
/** 字节数 → 人类可读 */
export function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) return '-'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
+3
View File
@@ -217,6 +217,9 @@ const api = {
post<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'POST' })
},
patch<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PATCH' })
},
put<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PUT' })
},
@@ -0,0 +1,86 @@
/**
* useCursorList —— 游标分页列表(页内累积 + 加载更多)
*
* 模板 useTable 按 offset 分页建模(current/size/total 整页替换),与契约
* 的 nextCursor 键集分页语义不兼容,此处做轻量适配:首次加载整页替换、
* 「加载更多」按 nextCursor 追加。loading/error/empty 状态遵循模板约定
* (v-loading + ElEmpty + 失败重试)。纯累积逻辑见 utils/albums/cursorList。
*
* 返回值建议在 setup 顶层解构使用(items/loading/error 为 ref,模板自动解包)。
*/
import { computed, ref, shallowRef } from 'vue'
import {
accumulatePage,
firstPage,
type CursorPage,
type CursorState
} from '@/utils/albums/cursorList'
export function useCursorList<T>(
fetchPage: (cursor?: string) => Promise<CursorPage<T>>,
keyOf?: (item: T) => string
) {
// shallowRef:状态整体替换(items 数组不深层响应,行内字段更新由调用方整行替换)
const state = shallowRef<CursorState<T>>({ items: [], nextCursor: null, hasMore: false })
const loading = ref(false)
const error = ref<Error | null>(null)
/** 首屏是否已加载过(区分「空数据」与「未加载」) */
const loaded = ref(false)
const items = computed(() => state.value.items)
const hasMore = computed(() => state.value.hasMore)
async function loadFirst(): Promise<void> {
loading.value = true
error.value = null
try {
state.value = firstPage(await fetchPage())
loaded.value = true
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
async function loadMore(): Promise<void> {
if (loading.value || !state.value.hasMore) return
const cursor = state.value.nextCursor ?? undefined
loading.value = true
error.value = null
try {
state.value = accumulatePage(state.value, await fetchPage(cursor), keyOf)
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
/** 行内整行替换(如可见性 PATCH 回包回显)。shallowRef 状态对数组原位
* splice 不触发响应,必须整体替换 state 才会重渲染。 */
function replaceItem(index: number, item: T): void {
const next = state.value.items.slice()
next.splice(index, 1, item)
state.value = { ...state.value, items: next }
}
/** 行内移除(如过滤条件下状态迁移后从当前页消失) */
function removeItem(index: number): void {
const next = state.value.items.slice()
next.splice(index, 1)
state.value = { ...state.value, items: next }
}
return {
items,
hasMore,
loading,
error,
loaded,
loadFirst,
loadMore,
replaceItem,
removeItem
}
}
@@ -0,0 +1,199 @@
<!-- Banner/封面/Logo 上传:assets init(预签名)→ PUT staging → complete 核验 →
签名 URL 预览。状态机见 utils/albums/assetUpload;PUT 用裸 fetch(预签名
URL 已含凭证,不带会话头),Content-Type 必须与 init 申明一致(核验比对)。 -->
<template>
<ElCard :header="t('albumsManage.detail.banner')" data-test="album-banner-card">
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-3">
<ElSelect v-model="kind" style="width: 200px" data-test="album-banner-kind">
<ElOption value="banner" :label="t('albumsManage.banner.kindBanner')" />
<ElOption value="cover" :label="t('albumsManage.banner.kindCover')" />
<ElOption value="logo" :label="t('albumsManage.banner.kindLogo')" />
</ElSelect>
<ElSelect v-model="locale" style="width: 200px" data-test="album-banner-locale">
<ElOption :value="''" :label="t('albumsManage.banner.localeNone')" />
<ElOption value="zh-CN" label="zh-CN" />
<ElOption value="en" label="en" />
</ElSelect>
<ElButton
type="primary"
:disabled="
uploadState.stage !== 'idle' &&
uploadState.stage !== 'done' &&
uploadState.stage !== 'error'
"
:loading="busy"
data-test="album-banner-pick"
@click="fileInput?.click()"
>
{{
uploadState.stage === 'done' || uploadState.stage === 'error'
? t('albumsManage.banner.reupload')
: t('albumsManage.banner.pick')
}}
</ElButton>
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/heic"
class="hidden"
data-test="album-banner-input"
@change="onFileChange"
/>
<span class="text-gray-400 text-sm">{{ t('albumsManage.banner.pickHint') }}</span>
</div>
<div v-if="uploadState.stage !== 'idle'" data-test="album-banner-status">
<ElTag :type="statusTagType" :data-test="`album-banner-status-${uploadState.stage}`">
{{ statusText }}
</ElTag>
<span v-if="uploadState.stage === 'error'" class="ml-2 text-red-500">
{{ uploadState.error }}
</span>
</div>
<!-- 最新生效预览:complete 返回的短期签名 URL(latest-effective 行) -->
<div
v-if="uploadState.stage === 'done' && uploadState.previewUrl"
data-test="album-banner-preview"
>
<img
:src="uploadState.previewUrl"
alt="banner"
class="max-h-40 rounded border border-gray-200"
data-test="album-banner-preview-img"
/>
<div class="text-gray-400 text-sm mt-1">
{{
t('albumsManage.banner.preview', {
expiresAt: formatDateTime(uploadState.previewExpiresAt)
})
}}
</div>
</div>
</div>
</ElCard>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { fetchCompleteAlbumAsset, fetchInitAlbumAsset } from '@/api/albums'
import {
INITIAL_ASSET_UPLOAD_STATE,
nextAssetUploadState,
type AssetUploadState
} from '@/utils/albums/assetUpload'
import { formatDateTime } from '@/utils/albums/format'
const props = defineProps<{
albumId: string
}>()
const { t } = useI18n()
const kind = ref<Api.Album.AssetKind>('banner')
const locale = ref<'' | Api.Album.Locale>('')
const fileInput = ref<HTMLInputElement | null>(null)
const uploadState = ref<AssetUploadState>({ ...INITIAL_ASSET_UPLOAD_STATE })
const busy = computed(() =>
['initializing', 'uploading', 'completing'].includes(uploadState.value.stage)
)
const statusTagType = computed(() => {
switch (uploadState.value.stage) {
case 'done':
return 'success'
case 'error':
return 'danger'
default:
return 'info'
}
})
const statusText = computed(() => {
switch (uploadState.value.stage) {
case 'initializing':
return t('albumsManage.banner.stageInitializing')
case 'uploading':
return t('albumsManage.banner.stageUploading')
case 'completing':
return t('albumsManage.banner.stageCompleting')
case 'done':
return t('albumsManage.banner.stageDone')
case 'error':
return t('albumsManage.banner.stageError')
default:
return ''
}
})
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/heic'])
async function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// 允许同一文件连续重传:change 处理完即复位
input.value = ''
if (!file) return
if (!CONTENT_TYPES.has(file.type)) {
uploadState.value = {
stage: 'error',
failedStage: 'init',
error: t('albumsManage.banner.invalidType')
}
return
}
await upload(file)
}
async function upload(file: File) {
const dispatch = (event: Parameters<typeof nextAssetUploadState>[1]) => {
uploadState.value = nextAssetUploadState(uploadState.value, event)
}
// ① init:预签名直传凭证(assetId 即一次性会话,失败重试重新 init)
dispatch({ type: 'START' })
let assetId: string
let credential: Api.Album.UploadCredential
try {
const initResponse = await fetchInitAlbumAsset(props.albumId, {
kind: kind.value,
locale: locale.value === '' ? undefined : locale.value,
filename: file.name,
bytes: file.size,
contentType: file.type as Api.Album.AssetInitPayload['contentType']
})
assetId = initResponse.assetId
credential = initResponse.credential
} catch (e) {
dispatch({ type: 'FAIL', stage: 'init', message: (e as Error).message })
return
}
// ② PUT staging:裸 fetch(预签名 URL 自带凭证;headers 必须原样携带)
dispatch({ type: 'INIT_OK' })
try {
const response = await fetch(credential.url, {
method: credential.method,
headers: credential.headers,
body: file
})
if (!response.ok) {
throw new Error(`PUT ${response.status}`)
}
} catch (e) {
dispatch({ type: 'FAIL', stage: 'put', message: (e as Error).message })
return
}
// ③ complete:核验生效并换取短期签名 URL(幂等)
dispatch({ type: 'PUT_OK' })
try {
const complete = await fetchCompleteAlbumAsset(props.albumId, assetId)
dispatch({ type: 'COMPLETE_OK', url: complete.url, expiresAt: complete.expiresAt })
} catch (e) {
dispatch({ type: 'FAIL', stage: 'complete', message: (e as Error).message })
}
}
</script>
@@ -0,0 +1,176 @@
<!-- 分类管理:列表 + 新建/编辑(sortOrder + zh/en 名称)+ 删除确认。
分类数据来自相册详情(album.categories,按 sortOrder 升序),变更后通知
父级重载详情。translations 全量替换语义(至少一个语言名称)。 -->
<template>
<div data-test="categories-panel">
<div class="mb-4">
<ElButton type="primary" data-test="category-create-btn" @click="openCreate">
{{ $t('albumsManage.categories.create') }}
</ElButton>
</div>
<ElTable :data="props.album.categories" data-test="category-list-table">
<ElTableColumn :label="$t('albumsManage.categories.colSort')" prop="sortOrder" width="100" />
<ElTableColumn :label="$t('albumsManage.categories.colZh')" min-width="160">
<template #default="{ row }">
<span data-test="category-name-zh">{{ nameOf(row, 'zh-CN') }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.categories.colEn')" min-width="160">
<template #default="{ row }">
<span data-test="category-name-en">{{ nameOf(row, 'en') }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.categories.colActions')" width="140">
<template #default="{ row }">
<ElButton link type="primary" data-test="category-edit-btn" @click="openEdit(row)">
{{ $t('albumsManage.categories.edit') }}
</ElButton>
<ElButton link type="danger" data-test="category-delete-btn" @click="remove(row)">
{{ $t('albumsManage.categories.delete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty :description="$t('albumsManage.categories.empty')" />
</template>
</ElTable>
<ElDialog
v-model="dialogVisible"
:title="editingId ? t('albumsManage.categories.edit') : t('albumsManage.categories.create')"
width="480px"
destroy-on-close
data-test="category-form-dialog"
>
<ElForm :model="form" label-width="150px">
<ElFormItem :label="t('albumsManage.categories.fieldSort')">
<ElInputNumber
v-model="form.sortOrder"
:min="0"
:max="9999"
data-test="category-form-sort"
/>
</ElFormItem>
<ElFormItem :label="t('albumsManage.categories.fieldNameZh')" required>
<ElInput v-model="form.nameZh" maxlength="128" data-test="category-form-name-zh" />
</ElFormItem>
<ElFormItem :label="t('albumsManage.categories.fieldNameEn')" required>
<ElInput v-model="form.nameEn" maxlength="128" data-test="category-form-name-en" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton data-test="category-form-cancel" @click="dialogVisible = false">
{{ t('albumsManage.categories.cancel') }}
</ElButton>
<ElButton
type="primary"
:loading="submitting"
data-test="category-form-submit"
@click="submit"
>
{{ t('albumsManage.categories.submit') }}
</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchCreateCategory, fetchDeleteCategory, fetchUpdateCategory } from '@/api/albums'
const props = defineProps<{
album: Api.Album.Detail
}>()
const emit = defineEmits<{
(e: 'changed'): void
}>()
const { t } = useI18n()
const dialogVisible = ref(false)
const submitting = ref(false)
const editingId = ref<string | null>(null)
const form = reactive({
sortOrder: 0,
nameZh: '',
nameEn: ''
})
function nameOf(category: Api.Album.Category, locale: Api.Album.Locale): string {
return category.translations.find((item) => item.locale === locale)?.name ?? '-'
}
function openCreate() {
editingId.value = null
form.sortOrder = 0
form.nameZh = ''
form.nameEn = ''
dialogVisible.value = true
}
function openEdit(category: Api.Album.Category) {
editingId.value = category.id
form.sortOrder = category.sortOrder
form.nameZh = nameOf(category, 'zh-CN') === '-' ? '' : nameOf(category, 'zh-CN')
form.nameEn = nameOf(category, 'en') === '-' ? '' : nameOf(category, 'en')
dialogVisible.value = true
}
/** 至少一个语言名称(契约 translations minItems 1 + name 必填) */
function buildTranslations(): Api.Album.CategoryTranslation[] | null {
const translations: Api.Album.CategoryTranslation[] = []
if (form.nameZh.trim()) translations.push({ locale: 'zh-CN', name: form.nameZh.trim() })
if (form.nameEn.trim()) translations.push({ locale: 'en', name: form.nameEn.trim() })
return translations.length > 0 ? translations : null
}
async function submit() {
const translations = buildTranslations()
if (!translations) {
ElMessage.error(t('albumsManage.categories.nameRequired'))
return
}
submitting.value = true
try {
if (editingId.value) {
await fetchUpdateCategory(editingId.value, { sortOrder: form.sortOrder, translations })
ElMessage.success(t('albumsManage.categories.updateSuccess'))
} else {
await fetchCreateCategory(props.album.id, { sortOrder: form.sortOrder, translations })
ElMessage.success(t('albumsManage.categories.createSuccess'))
}
dialogVisible.value = false
emit('changed')
} finally {
submitting.value = false
}
}
async function remove(category: Api.Album.Category) {
const fallbackName =
category.translations.find((item) => item.locale === props.album.defaultLocale)?.name ??
category.id
try {
await ElMessageBox.confirm(
t('albumsManage.categories.deleteConfirm', { name: fallbackName }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchDeleteCategory(category.id)
ElMessage.success(t('albumsManage.categories.deleteSuccess'))
emit('changed')
}
</script>
@@ -0,0 +1,174 @@
<!-- 成员管理:列表(角色标签)+ 账号前缀搜索邀请(lookupUsersByAccount,
≥2 字符)+ 移除确认。owner 不可移除(服务端 403,前端预置禁用)。 -->
<template>
<div data-test="members-panel">
<div class="flex flex-wrap items-center gap-2 mb-4">
<ElSelect
v-model="selectedUser"
filterable
remote
clearable
:remote-method="searchUsers"
:loading="searching"
:placeholder="t('albumsManage.members.searchPlaceholder')"
style="width: 320px"
data-test="member-search-select"
>
<ElOption
v-for="user in options"
:key="user.id"
:value="user.id"
:label="`${user.account}(${user.displayName})`"
:data-test="`member-option-${user.account}`"
/>
<template #empty>
<span class="px-4 py-2 text-gray-400 text-sm">
{{
query.length < 2
? t('albumsManage.members.searchPlaceholder')
: searching
? t('albumsManage.members.searching')
: t('albumsManage.members.noMatch')
}}
</span>
</template>
</ElSelect>
<ElButton
type="primary"
:disabled="!selectedUser"
:loading="inviting"
data-test="member-invite-btn"
@click="invite"
>
{{ t('albumsManage.members.invite') }}
</ElButton>
</div>
<ElAlert v-if="error && !items.length" type="error" :closable="false">
{{ error.message }}
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="member-list-table">
<ElTableColumn :label="t('albumsManage.members.colAccount')" prop="account" min-width="140" />
<ElTableColumn
:label="t('albumsManage.members.colDisplayName')"
prop="displayName"
min-width="140"
/>
<ElTableColumn :label="t('albumsManage.members.colRole')" width="120">
<template #default="{ row }">
<ElTag :type="row.role === 'owner' ? 'warning' : 'info'" data-test="member-role-tag">
{{
row.role === 'owner'
? t('albumsManage.members.roleOwner')
: t('albumsManage.members.roleCollaborator')
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.members.colActions')" width="100">
<template #default="{ row }">
<ElButton
v-if="row.role !== 'owner'"
link
type="danger"
data-test="member-remove-btn"
@click="remove(row)"
>
{{ t('albumsManage.members.remove') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.members.empty')" />
</template>
</ElTable>
<div v-if="items.length && hasMore" class="mt-3 text-center">
<ElButton :loading="loading" data-test="member-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
fetchAddAlbumMember,
fetchAlbumMembers,
fetchLookupUsers,
fetchRemoveAlbumMember
} from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
const props = defineProps<{
album: Api.Album.Detail
}>()
const { t } = useI18n()
const { items, hasMore, loading, error, loadFirst, loadMore } = useCursorList<Api.Album.Member>(
(cursor?: string) => fetchAlbumMembers(props.album.id, { cursor, limit: 20 }),
(item) => item.userId
)
// ── 账号前缀搜索(≥2 字符才发起;短前缀清空候选) ──
const query = ref('')
const options = ref<Api.Album.UserLookupItem[]>([])
const searching = ref(false)
const selectedUser = ref('')
const inviting = ref(false)
async function searchUsers(prefix: string) {
query.value = prefix
options.value = []
if (prefix.trim().length < 2) return
searching.value = true
try {
const result = await fetchLookupUsers(prefix.trim())
options.value = result.items
} finally {
searching.value = false
}
}
async function invite() {
if (!selectedUser.value) return
inviting.value = true
try {
await fetchAddAlbumMember(props.album.id, selectedUser.value)
ElMessage.success(t('albumsManage.members.invited'))
selectedUser.value = ''
options.value = []
await loadFirst()
} finally {
inviting.value = false
}
}
async function remove(member: Api.Album.Member) {
try {
await ElMessageBox.confirm(
t('albumsManage.members.removeConfirm', { account: member.account }),
t('common.tips'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return // 用户取消
}
await fetchRemoveAlbumMember(props.album.id, member.userId)
ElMessage.success(t('albumsManage.members.removed'))
await loadFirst()
}
onMounted(() => {
loadFirst()
})
</script>
@@ -0,0 +1,248 @@
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
单条操作:隐藏(published→hidden)/恢复发布(hidden→published)/软删除;
批量隐藏属 P4a,此处仅单条。操作结果按条回显(行内状态即时更新 + 消息)。 -->
<template>
<div data-test="photos-panel">
<div class="flex flex-wrap items-center gap-3 mb-4">
<ElSelect
v-model="visibilityFilter"
clearable
:placeholder="t('albumsManage.photos.filterVisibility')"
style="width: 160px"
data-test="photo-visibility-filter"
@change="reload"
>
<ElOption value="unpublished" :label="t('albumsManage.photos.visibilityUnpublished')" />
<ElOption value="published" :label="t('albumsManage.photos.visibilityPublished')" />
<ElOption value="hidden" :label="t('albumsManage.photos.visibilityHidden')" />
<ElOption value="deleted" :label="t('albumsManage.photos.visibilityDeleted')" />
</ElSelect>
<ElSelect
v-model="categoryFilter"
clearable
:placeholder="t('albumsManage.photos.filterCategory')"
style="width: 200px"
data-test="photo-category-filter"
@change="reload"
>
<ElOption
v-for="category in props.album.categories"
:key="category.id"
:value="category.id"
:label="categoryLabel(category)"
/>
</ElSelect>
</div>
<ElAlert v-if="error && !items.length" type="error" :closable="false">
{{ error.message }}
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="photo-list-table">
<ElTableColumn :label="t('albumsManage.photos.colState')" width="120">
<template #default="{ row }">
<ElTag :type="stateTagType(row.processingState)" data-test="photo-state-tag">
{{ t(`albumsManage.photos.state${pascal(row.processingState)}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colVisibility')" width="110">
<template #default="{ row }">
<ElTag
:type="visibilityTagType(row.visibility)"
:effect="row.visibility === 'deleted' ? 'plain' : 'light'"
data-test="photo-visibility-tag"
>
{{ t(`albumsManage.photos.visibility${pascal(row.visibility)}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colSize')" min-width="150">
<template #default="{ row }">
<span class="text-gray-500">
{{ row.width && row.height ? `${row.width}×${row.height}` : '—' }} ·
{{ formatBytes(row.bytes) }}
</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colUploader')" min-width="140">
<template #default="{ row }">
<span>{{ row.uploader.displayName || row.uploader.account }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colUploadedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.uploadedAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="t('albumsManage.photos.colActions')" width="200" fixed="right">
<template #default="{ row, $index }">
<ElButton
v-if="row.visibility === 'published'"
link
type="warning"
data-test="photo-hide-btn"
@click="setVisibility(row, $index, 'hidden')"
>
{{ t('albumsManage.photos.hide') }}
</ElButton>
<ElButton
v-if="row.visibility === 'hidden'"
link
type="success"
data-test="photo-restore-btn"
@click="setVisibility(row, $index, 'published')"
>
{{ t('albumsManage.photos.restore') }}
</ElButton>
<ElButton
v-if="row.visibility !== 'deleted'"
link
type="danger"
data-test="photo-delete-btn"
@click="softDelete(row, $index)"
>
{{ t('albumsManage.photos.delete') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.photos.empty')" />
</template>
</ElTable>
<div v-if="items.length && hasMore" class="mt-3 text-center">
<ElButton :loading="loading" data-test="photo-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { fetchAlbumPhotos, fetchDeletePhoto, fetchUpdatePhotoVisibility } from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatBytes, formatDateTime } from '@/utils/albums/format'
const props = defineProps<{
album: Api.Album.Detail
}>()
const { t } = useI18n()
const visibilityFilter = ref<Api.Album.PhotoVisibility | ''>('')
const categoryFilter = ref('')
const list = useCursorList<Api.Album.AdminPhoto>(
(cursor?: string) =>
fetchAlbumPhotos(props.album.id, {
visibility: visibilityFilter.value === '' ? undefined : visibilityFilter.value,
categoryId: categoryFilter.value === '' ? undefined : categoryFilter.value,
cursor,
limit: 20
}),
(item) => item.photoId
)
const { items, hasMore, loading, error, loadFirst, loadMore } = list
function reload() {
loadFirst()
}
onMounted(() => {
loadFirst()
})
/** processing_failed → ProcessingFailed(拼 i18n 键) */
function pascal(value: string): string {
return value
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('')
}
function stateTagType(state: Api.Album.PhotoProcessingState) {
switch (state) {
case 'ready':
return 'success'
case 'processing':
return 'warning'
case 'processing_failed':
return 'danger'
default:
return 'info'
}
}
function visibilityTagType(visibility: Api.Album.PhotoVisibility) {
switch (visibility) {
case 'published':
return 'success'
case 'hidden':
return 'warning'
case 'deleted':
return 'danger'
default:
return 'info'
}
}
function categoryLabel(category: Api.Album.Category): string {
const preferred = category.translations.find(
(item) => item.locale === props.album.defaultLocale
)
return preferred?.name ?? category.translations[0]?.name ?? category.id
}
async function confirmBox(message: string): Promise<boolean> {
try {
await ElMessageBox.confirm(message, t('common.tips'), {
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
})
return true
} catch {
return false
}
}
/** 隐藏/恢复:PATCH 回包按行回显(状态机仅允许 hidden ↔ published)。
* shallowRef 列表对原位 splice 不响应,统一走 replaceItem/removeItem。 */
async function setVisibility(
row: Api.Album.AdminPhoto,
index: number,
target: 'hidden' | 'published'
) {
const message =
target === 'hidden'
? t('albumsManage.photos.hideConfirm')
: t('albumsManage.photos.restoreConfirm')
if (!(await confirmBox(message))) return
const updated = await fetchUpdatePhotoVisibility(row.photoId, target)
// 过滤条件下回显:当前过滤与目标态不符时从列表移除,否则原位更新
if (visibilityFilter.value !== '' && visibilityFilter.value !== updated.visibility) {
list.removeItem(index)
} else {
list.replaceItem(index, { ...row, ...updated })
}
ElMessage.success(
target === 'hidden' ? t('albumsManage.photos.hidden') : t('albumsManage.photos.restored')
)
}
/** 软删除:204 无回包,本地标记 deleted(deleted 为员工侧终态) */
async function softDelete(row: Api.Album.AdminPhoto, index: number) {
if (!(await confirmBox(t('albumsManage.photos.deleteConfirm')))) return
await fetchDeletePhoto(row.photoId)
if (visibilityFilter.value !== '' && visibilityFilter.value !== 'deleted') {
list.removeItem(index)
} else {
list.replaceItem(index, { ...row, visibility: 'deleted' as const })
}
ElMessage.success(t('albumsManage.photos.deleted'))
}
</script>
@@ -0,0 +1,206 @@
<!-- 相册详情:基础信息 + banner 上传 + 照片/成员/分类管理页签(P1b Task 10)。
路由 /albums/detail/:albumId(isHide,不进菜单)。 -->
<template>
<div class="p-5">
<ElAlert v-if="error" type="error" :closable="false" data-test="album-detail-error">
<div class="flex items-center gap-2">
<span>{{ $t('albumsManage.detail.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="album-detail-retry" @click="load">
{{ $t('albumsManage.detail.retry') }}
</ElButton>
</div>
</ElAlert>
<ElCard v-else v-loading="loading" :data-test="'album-detail-card'">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ElButton link @click="router.back()" data-test="album-detail-back">
<ElIcon><ArrowLeft /></ElIcon>
</ElButton>
<span class="font-medium">{{ titleOf(album) }}</span>
<ElTag size="small" type="info">{{ album?.defaultLocale }}</ElTag>
</div>
<ElButton type="primary" plain data-test="album-detail-edit" @click="editVisible = true">
{{ $t('albumsManage.detail.edit') }}
</ElButton>
</div>
</template>
<ElDescriptions
v-if="album"
:column="3"
border
:title="$t('albumsManage.detail.basicInfo')"
data-test="album-detail-desc"
>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldTitle')">
{{ titleOf(album) }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldToken')">
<span class="flex items-center gap-1">
<span data-test="album-detail-token">{{ album.publicToken }}</span>
<ElButton link type="primary" size="small" @click="copyToken(album.publicToken)">
<ElIcon><CopyDocument /></ElIcon>
</ElButton>
</span>
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldStatus')">
<ElTag :type="album.status === 'active' ? 'success' : 'info'">
{{
album.status === 'active'
? $t('albumsManage.list.statusActive')
: $t('albumsManage.list.statusOffline')
}}
</ElTag>
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.list.colSwitches')">
<span class="flex flex-wrap gap-1">
<ElTag
v-for="sw in switchTags(album)"
:key="sw.key"
size="small"
:type="sw.on ? 'success' : 'info'"
:effect="sw.on ? 'light' : 'plain'"
>
{{ sw.label }} {{ sw.on ? $t('albumsManage.list.on') : $t('albumsManage.list.off') }}
</ElTag>
</span>
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldTimezone')">
{{ album.timezone }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldStartAt')">
{{ formatDateTime(album.startAt) }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldEndAt')">
{{ album.endAt ? formatDateTime(album.endAt) : $t('albumsManage.list.endAtUndecided') }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldCreatedAt')">
{{ formatDateTime(album.createdAt) }}
</ElDescriptionsItem>
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldUpdatedAt')">
{{ formatDateTime(album.updatedAt) }}
</ElDescriptionsItem>
</ElDescriptions>
<div v-if="album" class="mt-5">
<AlbumBannerUpload :album-id="albumId" />
</div>
<ElTabs v-if="album" v-model="activeTab" class="mt-4" data-test="album-detail-tabs">
<!-- 页签头由 ElTabs 自动生成(pane 上的 attr 只落在隐藏面板上):
data-test 经 #label 插槽挂到头部文案,供 e2e 点击 -->
<ElTabPane name="photos">
<template #label>
<span data-test="album-photos-tab">{{ $t('albumsManage.detail.tabPhotos') }}</span>
</template>
<AlbumPhotos :album="album" />
</ElTabPane>
<ElTabPane name="members">
<template #label>
<span data-test="album-members-tab">{{ $t('albumsManage.detail.tabMembers') }}</span>
</template>
<AlbumMembers :album="album" />
</ElTabPane>
<ElTabPane name="categories">
<template #label>
<span data-test="album-categories-tab">
{{ $t('albumsManage.detail.tabCategories') }}
</span>
</template>
<AlbumCategories :album="album" @changed="load" />
</ElTabPane>
</ElTabs>
</ElCard>
<AlbumFormDialog v-if="album" v-model="editVisible" :album="album" @saved="onAlbumSaved" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, CopyDocument } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import AlbumFormDialog from '@/views/albums/manage/AlbumFormDialog.vue'
import AlbumBannerUpload from './AlbumBannerUpload.vue'
import AlbumMembers from './AlbumMembers.vue'
import AlbumCategories from './AlbumCategories.vue'
import AlbumPhotos from './AlbumPhotos.vue'
import { fetchAlbum } from '@/api/albums'
import { formatDateTime } from '@/utils/albums/format'
defineOptions({ name: 'AlbumsDetail' })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const albumId = computed(() => String(route.params.albumId ?? ''))
const album = ref<Api.Album.Detail | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const editVisible = ref(false)
const activeTab = ref('photos')
async function load() {
if (!albumId.value) return
loading.value = true
error.value = null
try {
album.value = await fetchAlbum(albumId.value)
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
function onAlbumSaved(saved: Api.Album.Detail) {
album.value = saved
}
function titleOf(target: Api.Album.Detail | null): string {
if (!target) return ''
const preferred = target.translations.find((item) => item.locale === target.defaultLocale)
return preferred?.title ?? target.translations[0]?.title ?? target.id
}
function switchTags(target: Api.Album.Detail) {
return [
{ key: 'upload', on: target.uploadEnabled, label: t('albumsManage.list.switchUpload') },
{ key: 'public', on: target.publicEnabled, label: t('albumsManage.list.switchPublic') },
{ key: 'download', on: target.downloadEnabled, label: t('albumsManage.list.switchDownload') },
{
key: 'original',
on: target.originalDownloadEnabled,
label: t('albumsManage.list.switchOriginal')
}
]
}
async function copyToken(token: string) {
await navigator.clipboard.writeText(token)
ElMessage.success(t('albumsManage.list.copied'))
}
/** 列表「成员」入口带 #members 锚点:直达成员页签 */
watch(
() => route.hash,
(hash) => {
if (hash === '#members') activeTab.value = 'members'
},
{ immediate: true }
)
watch(albumId, () => {
if (route.name === 'AlbumsDetail') load()
})
onMounted(() => {
load()
})
</script>
@@ -0,0 +1,294 @@
<!-- 创建/编辑相册对话框:zh-CN/en 翻译分页签 + 契约镜像校验(utils/albums/albumValidation)。
编辑为 PATCH 语义;translations 全量替换(提示 i18n)。 -->
<template>
<ElDialog
v-model="visible"
:title="isEdit ? t('albumsManage.form.editTitle') : t('albumsManage.form.createTitle')"
width="720px"
destroy-on-close
data-test="album-form-dialog"
@closed="resetForm"
>
<ElForm :model="form" label-width="130px">
<ElFormItem :label="t('albumsManage.form.fieldDefaultLocale')">
<ElRadioGroup v-model="form.defaultLocale" data-test="album-form-default-locale">
<ElRadioButton value="zh-CN">zh-CN</ElRadioButton>
<ElRadioButton value="en">en</ElRadioButton>
</ElRadioGroup>
</ElFormItem>
<ElTabs v-model="activeTab">
<ElTabPane :label="t('albumsManage.form.tabZh')" name="zh-CN">
<TranslationFields v-model="drafts['zh-CN']" :locale="'zh-CN'" />
</ElTabPane>
<ElTabPane :label="t('albumsManage.form.tabEn')" name="en">
<TranslationFields v-model="drafts['en']" :locale="'en'" />
</ElTabPane>
</ElTabs>
<ElFormItem
:label="t('albumsManage.form.fieldStartAt')"
required
:error="fieldErrors.startAt"
>
<!-- ElDatePicker 的透传 attr 被 ElTooltip 吞掉,data-test 挂不到 DOM:
外层普通 div 承载选择器,测试经 div 内 input 定位 -->
<div data-test="album-form-start-at">
<ElDatePicker
v-model="form.startAt"
type="datetime"
:placeholder="t('albumsManage.form.fieldStartAt')"
/>
</div>
</ElFormItem>
<ElFormItem :label="t('albumsManage.form.fieldEndAt')" :error="fieldErrors.endAt">
<div data-test="album-form-end-at">
<ElDatePicker
v-model="form.endAt"
type="datetime"
clearable
:placeholder="t('albumsManage.form.fieldEndAt')"
/>
</div>
</ElFormItem>
<ElFormItem :label="t('albumsManage.form.fieldTimezone')">
<ElInput
v-model="form.timezone"
data-test="album-form-timezone"
placeholder="Asia/Shanghai"
/>
</ElFormItem>
<ElFormItem :label="t('albumsManage.form.fieldSwitches')">
<div class="flex flex-col gap-2" data-test="album-form-switches">
<template v-if="isEdit">
<ElSwitch
v-model="form.uploadEnabled"
:active-text="t('albumsManage.form.switchUpload')"
data-test="album-form-switch-upload"
/>
<ElSwitch
v-model="form.publicEnabled"
:active-text="t('albumsManage.form.switchPublic')"
data-test="album-form-switch-public"
/>
</template>
<ElSwitch
v-model="form.downloadEnabled"
:active-text="t('albumsManage.form.switchDownload')"
data-test="album-form-switch-download"
/>
<ElSwitch
v-model="form.originalDownloadEnabled"
:active-text="t('albumsManage.form.switchOriginal')"
data-test="album-form-switch-original"
/>
</div>
</ElFormItem>
<ElAlert
v-if="isEdit"
type="warning"
:closable="false"
:title="t('albumsManage.form.translationsReplaceHint')"
data-test="album-form-replace-hint"
/>
</ElForm>
<template #footer>
<ElButton data-test="album-form-cancel" @click="visible = false">
{{ t('albumsManage.form.cancel') }}
</ElButton>
<ElButton type="primary" :loading="submitting" data-test="album-form-submit" @click="submit">
{{ t('albumsManage.form.submit') }}
</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import TranslationFields from './TranslationFields.vue'
import { fetchCreateAlbum, fetchUpdateAlbum } from '@/api/albums'
import {
toTranslationPayload,
validateTimeRange,
validateTranslations,
type TranslationDraft
} from '@/utils/albums/albumValidation'
/** 翻译草稿形状(两语言固定键,模板按键取值) */
type DraftMap = Record<'zh-CN' | 'en', TranslationDraft>
const props = defineProps<{
/** 编辑目标(null = 创建) */
album: Api.Album.Summary | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'saved', album: Api.Album.Detail): void
}>()
const visible = defineModel<boolean>({ default: false })
const { t } = useI18n()
const isEdit = computed(() => props.album !== null)
const emptyDraft = (locale: 'zh-CN' | 'en'): TranslationDraft => ({
locale,
title: '',
description: '',
venue: '',
address: ''
})
const activeTab = ref<'zh-CN' | 'en'>('zh-CN')
const drafts = reactive<DraftMap>({
'zh-CN': emptyDraft('zh-CN'),
en: emptyDraft('en')
})
const form = reactive({
defaultLocale: 'zh-CN' as Api.Album.Locale,
startAt: null as Date | null,
endAt: null as Date | null,
timezone: 'Asia/Shanghai',
uploadEnabled: true,
publicEnabled: true,
downloadEnabled: true,
originalDownloadEnabled: false
})
const fieldErrors = reactive<{ startAt: string; endAt: string }>({ startAt: '', endAt: '' })
const submitting = ref(false)
/** 打开时装载编辑目标(或重置为创建默认值) */
watch(visible, (open) => {
if (!open) return
resetForm()
const album = props.album
if (!album) return
form.defaultLocale = album.defaultLocale
form.startAt = new Date(album.startAt)
form.endAt = album.endAt ? new Date(album.endAt) : null
form.timezone = album.timezone
form.uploadEnabled = album.uploadEnabled
form.publicEnabled = album.publicEnabled
form.downloadEnabled = album.downloadEnabled
form.originalDownloadEnabled = album.originalDownloadEnabled
for (const locale of ['zh-CN', 'en'] as const) {
const translation = album.translations.find((item) => item.locale === locale)
drafts[locale] = {
...emptyDraft(locale),
title: translation?.title ?? '',
description: translation?.description ?? '',
venue: translation?.venue ?? '',
address: translation?.address ?? ''
}
}
})
function resetForm() {
activeTab.value = 'zh-CN'
drafts['zh-CN'] = emptyDraft('zh-CN')
drafts.en = emptyDraft('en')
Object.assign(form, {
defaultLocale: 'zh-CN',
startAt: null,
endAt: null,
timezone: 'Asia/Shanghai',
uploadEnabled: true,
publicEnabled: true,
downloadEnabled: true,
originalDownloadEnabled: false
})
fieldErrors.startAt = ''
fieldErrors.endAt = ''
}
/** 提交前校验(镜像契约:默认语言覆盖 + endAt>startAt) */
function validate(): boolean {
fieldErrors.startAt = ''
fieldErrors.endAt = ''
if (!form.startAt) {
fieldErrors.startAt = t('albumsManage.form.errors.startAtRequired')
return false
}
const translationErrors = validateTranslations([drafts['zh-CN'], drafts.en], form.defaultLocale)
if (translationErrors.length > 0) {
const code = translationErrors[0]
// 定位到出错的页签并切换,便于用户直接修补
const other =
code === 'titleRequiredWithFields' &&
!drafts.en.title.trim() &&
drafts['zh-CN'].title.trim()
? 'en'
: null
activeTab.value = other ?? form.defaultLocale
ElMessage.error(
code === 'defaultLocaleMissing'
? t('albumsManage.form.errors.defaultLocaleMissing', { locale: form.defaultLocale })
: t(`albumsManage.form.errors.${code}`)
)
return false
}
const startIso = form.startAt.toISOString()
const endIso = form.endAt ? form.endAt.toISOString() : null
if (!validateTimeRange(startIso, endIso)) {
fieldErrors.endAt = t('albumsManage.form.errors.endAtNotAfterStartAt')
return false
}
return true
}
async function submit() {
if (!validate()) return
submitting.value = true
try {
const translations = toTranslationPayload([
drafts['zh-CN'],
drafts.en
]) as Api.Album.Translation[]
const endAt = form.endAt ? form.endAt.toISOString() : null
let saved: Api.Album.Detail
if (props.album) {
// 编辑:PATCH 全量可编辑字段(开关独立修改;translations 全量替换)
saved = await fetchUpdateAlbum(props.album.id, {
translations,
defaultLocale: form.defaultLocale,
startAt: form.startAt!.toISOString(),
endAt,
timezone: form.timezone.trim() || 'Asia/Shanghai',
uploadEnabled: form.uploadEnabled,
publicEnabled: form.publicEnabled,
downloadEnabled: form.downloadEnabled,
originalDownloadEnabled: form.originalDownloadEnabled
})
} else {
// 创建:契约仅 download 两开关(upload/public 服务端固定初始 true)
saved = await fetchCreateAlbum({
translations,
defaultLocale: form.defaultLocale,
startAt: form.startAt!.toISOString(),
endAt,
timezone: form.timezone.trim() || 'Asia/Shanghai',
downloadEnabled: form.downloadEnabled,
originalDownloadEnabled: form.originalDownloadEnabled
})
}
ElMessage.success(
isEdit.value ? t('albumsManage.form.updateSuccess') : t('albumsManage.form.createSuccess')
)
emit('saved', saved)
visible.value = false
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,53 @@
<!-- 单语言翻译字段组(标题/描述/场地/地址;标题为契约必填项) -->
<template>
<div class="flex flex-col gap-0" :data-test="`album-form-translation-${locale}`">
<ElFormItem :label="t('albumsManage.form.fieldTitle')" required>
<ElInput
v-model="model.title"
:placeholder="t('albumsManage.form.fieldTitle')"
:data-test="`album-form-title-${locale}`"
maxlength="255"
/>
</ElFormItem>
<ElFormItem :label="t('albumsManage.form.fieldDescription')">
<ElInput
v-model="model.description"
type="textarea"
:rows="2"
:placeholder="t('albumsManage.form.fieldDescription')"
:data-test="`album-form-description-${locale}`"
/>
</ElFormItem>
<ElFormItem :label="t('albumsManage.form.fieldVenue')">
<ElInput
v-model="model.venue"
:placeholder="t('albumsManage.form.fieldVenue')"
:data-test="`album-form-venue-${locale}`"
maxlength="255"
/>
</ElFormItem>
<ElFormItem :label="t('albumsManage.form.fieldAddress')">
<ElInput
v-model="model.address"
:placeholder="t('albumsManage.form.fieldAddress')"
:data-test="`album-form-address-${locale}`"
maxlength="255"
/>
</ElFormItem>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { TranslationDraft } from '@/utils/albums/albumValidation'
defineOptions({ name: 'AlbumTranslationFields' })
defineProps<{
locale: Api.Album.Locale
}>()
const model = defineModel<TranslationDraft>({ required: true })
const { t } = useI18n()
</script>
+192 -4
View File
@@ -1,15 +1,203 @@
<!-- 相册管理(P1b Task 9 占位 stub:登录后落地的受保护页面,后续任务填充列表/详情) -->
<!-- 相册列表(P1b Task 10):游标分页「页内累积 + 加载更多」(模板 useTable 按
offset 建模,与契约 nextCursor 键集分页不兼容,见 composables/useCursorList) -->
<template>
<div class="p-5">
<ElCard>
<ElCard data-test="album-list-card">
<template #header>
<span>{{ $t('menus.albums.manage') }}</span>
<div class="flex items-center justify-between">
<span>{{ $t('albumsManage.list.title') }}</span>
<ElButton type="primary" data-test="album-create-btn" @click="openCreate">
{{ $t('albumsManage.list.create') }}
</ElButton>
</div>
</template>
<ElEmpty :description="$t('albumsManage.placeholder')" />
<ElAlert
v-if="error && !items.length"
type="error"
:closable="false"
data-test="album-list-error"
>
<div class="flex items-center gap-2">
<span>{{ $t('albumsManage.list.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="album-list-retry" @click="loadFirst()">
{{ $t('albumsManage.list.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="album-list-table">
<ElTableColumn :label="$t('albumsManage.list.colTitle')" min-width="220">
<template #default="{ row }">
<div class="flex items-center gap-2">
<span class="font-medium">{{ titleOf(row) }}</span>
<ElTag size="small" type="info">{{ row.defaultLocale }}</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colToken')" min-width="200">
<template #default="{ row }">
<div class="flex items-center gap-1">
<span class="text-gray-500" data-test="album-token">{{ row.publicToken }}</span>
<ElButton
link
type="primary"
size="small"
:title="$t('albumsManage.list.copyToken')"
@click="copyToken(row.publicToken)"
>
<ElIcon><CopyDocument /></ElIcon>
</ElButton>
</div>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colStatus')" width="90">
<template #default="{ row }">
<ElTag :type="row.status === 'active' ? 'success' : 'info'">
{{
row.status === 'active'
? $t('albumsManage.list.statusActive')
: $t('albumsManage.list.statusOffline')
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colSwitches')" width="240">
<template #default="{ row }">
<div class="flex flex-wrap gap-1" data-test="album-switch-tags">
<ElTag
v-for="sw in switchTags(row)"
:key="sw.key"
:type="sw.on ? 'success' : 'info'"
:effect="sw.on ? 'light' : 'plain'"
size="small"
:data-test="`album-switch-${sw.key}`"
>
{{ sw.label }}
{{ sw.on ? $t('albumsManage.list.on') : $t('albumsManage.list.off') }}
</ElTag>
</div>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colStartAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.startAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colEndAt')" width="160">
<template #default="{ row }">
<span>{{
row.endAt ? formatDateTime(row.endAt) : $t('albumsManage.list.endAtUndecided')
}}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('albumsManage.list.colActions')" width="200" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" data-test="album-edit-btn" @click="openEdit(row)">
{{ $t('albumsManage.list.actionEdit') }}
</ElButton>
<ElButton link type="primary" data-test="album-members-btn" @click="gotoMembers(row)">
{{ $t('albumsManage.list.actionMembers') }}
</ElButton>
<ElButton link type="primary" data-test="album-detail-btn" @click="gotoDetail(row)">
{{ $t('albumsManage.list.actionDetail') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('albumsManage.list.empty')" />
</template>
</ElTable>
<div v-if="items.length" class="mt-4 text-center">
<ElButton v-if="hasMore" :loading="loading" data-test="album-load-more" @click="loadMore()">
{{ $t('albumsManage.list.loadMore') }}
</ElButton>
<span v-else class="text-gray-400 text-sm">{{ $t('albumsManage.list.noMore') }}</span>
</div>
</ElCard>
<AlbumFormDialog v-model="formVisible" :album="editing" @saved="onSaved" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { CopyDocument } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import AlbumFormDialog from './AlbumFormDialog.vue'
import { fetchAlbumList } from '@/api/albums'
import { useCursorList } from '@/views/albums/composables/useCursorList'
import { formatDateTime } from '@/utils/albums/format'
defineOptions({ name: 'AlbumsManage' })
const router = useRouter()
const { t } = useI18n()
const PAGE_SIZE = 20
const { items, hasMore, loading, error, loadFirst, loadMore } = useCursorList<Api.Album.Summary>(
(cursor?: string) => fetchAlbumList({ cursor, limit: PAGE_SIZE }),
(item) => item.id
)
/** 列表标题按 defaultLocale 解析(回退第一条翻译) */
function titleOf(album: Api.Album.Summary): string {
const preferred = album.translations.find((t) => t.locale === album.defaultLocale)
return preferred?.title ?? album.translations[0]?.title ?? album.id
}
/** 四开关 → 标签展示(开=success、关=plain info) */
function switchTags(album: Api.Album.Summary) {
return [
{ key: 'upload', on: album.uploadEnabled, label: t('albumsManage.list.switchUpload') },
{ key: 'public', on: album.publicEnabled, label: t('albumsManage.list.switchPublic') },
{ key: 'download', on: album.downloadEnabled, label: t('albumsManage.list.switchDownload') },
{
key: 'original',
on: album.originalDownloadEnabled,
label: t('albumsManage.list.switchOriginal')
}
]
}
async function copyToken(token: string) {
await navigator.clipboard.writeText(token)
ElMessage.success(t('albumsManage.list.copied'))
}
// ── 创建 / 编辑 ──
const formVisible = ref(false)
const editing = ref<Api.Album.Summary | null>(null)
function openCreate() {
editing.value = null
formVisible.value = true
}
function openEdit(album: Api.Album.Summary) {
editing.value = album
formVisible.value = true
}
function onSaved() {
loadFirst()
}
// ── 跳转 ──
function gotoDetail(album: Api.Album.Summary) {
router.push({ name: 'AlbumsDetail', params: { albumId: album.id } })
}
function gotoMembers(album: Api.Album.Summary) {
router.push({ name: 'AlbumsDetail', params: { albumId: album.id }, hash: '#members' })
}
onMounted(() => {
loadFirst()
})
</script>