From 50c59ca9d47edff38beed0d7649067eadc4f848a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 12:33:46 +0800 Subject: [PATCH] feat(admin): album management pages with members and categories Co-Authored-By: Claude Code --- apps/admin/e2e/albums.spec.ts | 119 +++++++ apps/admin/e2e/helpers.ts | 39 ++- apps/admin/e2e/login.spec.ts | 6 +- apps/admin/e2e/seed-fixture.cjs | 107 +++++++ apps/admin/src/api/albums.ts | 123 ++++++++ apps/admin/src/locales/langs/en.json | 182 ++++++++++- apps/admin/src/locales/langs/zh.json | 182 ++++++++++- apps/admin/src/router/modules/albums.ts | 15 +- apps/admin/src/types/api/album.d.ts | 210 +++++++++++++ apps/admin/src/types/import/components.d.ts | 11 + .../src/utils/albums/albumValidation.spec.ts | 46 +++ .../admin/src/utils/albums/albumValidation.ts | 74 +++++ .../src/utils/albums/assetUpload.spec.ts | 46 +++ apps/admin/src/utils/albums/assetUpload.ts | 78 +++++ .../admin/src/utils/albums/cursorList.spec.ts | 33 ++ apps/admin/src/utils/albums/cursorList.ts | 67 ++++ apps/admin/src/utils/albums/format.ts | 20 ++ apps/admin/src/utils/http/index.ts | 3 + .../views/albums/composables/useCursorList.ts | 86 +++++ .../views/albums/detail/AlbumBannerUpload.vue | 199 ++++++++++++ .../views/albums/detail/AlbumCategories.vue | 176 +++++++++++ .../src/views/albums/detail/AlbumMembers.vue | 174 +++++++++++ .../src/views/albums/detail/AlbumPhotos.vue | 248 +++++++++++++++ apps/admin/src/views/albums/detail/index.vue | 206 ++++++++++++ .../views/albums/manage/AlbumFormDialog.vue | 294 ++++++++++++++++++ .../views/albums/manage/TranslationFields.vue | 53 ++++ apps/admin/src/views/albums/manage/index.vue | 196 +++++++++++- 27 files changed, 2977 insertions(+), 16 deletions(-) create mode 100644 apps/admin/e2e/albums.spec.ts create mode 100644 apps/admin/e2e/seed-fixture.cjs create mode 100644 apps/admin/src/api/albums.ts create mode 100644 apps/admin/src/types/api/album.d.ts create mode 100644 apps/admin/src/utils/albums/albumValidation.spec.ts create mode 100644 apps/admin/src/utils/albums/albumValidation.ts create mode 100644 apps/admin/src/utils/albums/assetUpload.spec.ts create mode 100644 apps/admin/src/utils/albums/assetUpload.ts create mode 100644 apps/admin/src/utils/albums/cursorList.spec.ts create mode 100644 apps/admin/src/utils/albums/cursorList.ts create mode 100644 apps/admin/src/utils/albums/format.ts create mode 100644 apps/admin/src/views/albums/composables/useCursorList.ts create mode 100644 apps/admin/src/views/albums/detail/AlbumBannerUpload.vue create mode 100644 apps/admin/src/views/albums/detail/AlbumCategories.vue create mode 100644 apps/admin/src/views/albums/detail/AlbumMembers.vue create mode 100644 apps/admin/src/views/albums/detail/AlbumPhotos.vue create mode 100644 apps/admin/src/views/albums/detail/index.vue create mode 100644 apps/admin/src/views/albums/manage/AlbumFormDialog.vue create mode 100644 apps/admin/src/views/albums/manage/TranslationFields.vue diff --git a/apps/admin/e2e/albums.spec.ts b/apps/admin/e2e/albums.spec.ts new file mode 100644 index 0000000..62c5a83 --- /dev/null +++ b/apps/admin/e2e/albums.spec.ts @@ -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 { + 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() +}) diff --git a/apps/admin/e2e/helpers.ts b/apps/admin/e2e/helpers.ts index cc9a7ac..57b886b 100644 --- a/apps/admin/e2e/helpers.ts +++ b/apps/admin/e2e/helpers.ts @@ -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 { 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 { + 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 } + }) +} diff --git a/apps/admin/e2e/login.spec.ts b/apps/admin/e2e/login.spec.ts index 58627bb..ba1d79a 100644 --- a/apps/admin/e2e/login.spec.ts +++ b/apps/admin/e2e/login.spec.ts @@ -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 }) => { diff --git a/apps/admin/e2e/seed-fixture.cjs b/apps/admin/e2e/seed-fixture.cjs new file mode 100644 index 0000000..315d570 --- /dev/null +++ b/apps/admin/e2e/seed-fixture.cjs @@ -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 +// node seed-fixture.cjs photos +// 幂等:账号存在即跳过;照片按 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()) diff --git a/apps/admin/src/api/albums.ts b/apps/admin/src/api/albums.ts new file mode 100644 index 0000000..3249be5 --- /dev/null +++ b/apps/admin/src/api/albums.ts @@ -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({ url: '/v1/albums', params }) +} + +/** 相册详情(完整配置 + 多语言内容 + 分类) */ +export function fetchAlbum(albumId: string) { + return request.get({ url: `/v1/albums/${albumId}` }) +} + +/** 创建相册(创建者自动成为 owner 成员) */ +export function fetchCreateAlbum(data: Api.Album.CreatePayload) { + return request.post({ url: '/v1/albums', data }) +} + +/** 更新相册(部分更新;translations 全量替换,须含 defaultLocale 条目) */ +export function fetchUpdateAlbum(albumId: string, data: Api.Album.UpdatePayload) { + return request.patch({ url: `/v1/albums/${albumId}`, data }) +} + +/** 初始化资产上传(banner/cover/logo;返回 staging 直传凭证) */ +export function fetchInitAlbumAsset(albumId: string, data: Api.Album.AssetInitPayload) { + return request.post({ + url: `/v1/albums/${albumId}/assets`, + data + }) +} + +/** 完成资产上传核验(幂等;返回短期签名 URL,即预览/刷新入口) */ +export function fetchCompleteAlbumAsset(albumId: string, assetId: string) { + return request.post({ + url: `/v1/albums/${albumId}/assets/${assetId}/complete` + }) +} + +/** 成员列表(含 owner) */ +export function fetchAlbumMembers(albumId: string, params: { cursor?: string; limit?: number }) { + return request.get({ + url: `/v1/albums/${albumId}/members`, + params + }) +} + +/** 添加成员(按用户 ID;重复添加 409 MEMBER_CONFLICT) */ +export function fetchAddAlbumMember(albumId: string, userId: string) { + return request.post({ + url: `/v1/albums/${albumId}/members`, + data: { userId, role: 'collaborator' } + }) +} + +/** 移除成员(owner 不可移除 403;目标不存在幂等 204) */ +export function fetchRemoveAlbumMember(albumId: string, userId: string) { + return request.del({ 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({ + url: `/v1/albums/${albumId}/categories`, + data + }) +} + +/** 更新分类(translations 全量替换) */ +export function fetchUpdateCategory(categoryId: string, data: Api.Album.CategoryUpdatePayload) { + return request.patch({ url: `/v1/categories/${categoryId}`, data }) +} + +/** 删除分类(204;分类下有照片时服务端 400 拒绝) */ +export function fetchDeleteCategory(categoryId: string) { + return request.del({ 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({ + url: `/v1/albums/${albumId}/photos`, + params + }) +} + +/** 调整照片可见性(仅 hidden ↔ published) */ +export function fetchUpdatePhotoVisibility(photoId: string, visibility: 'hidden' | 'published') { + return request.patch({ + url: `/v1/photos/${photoId}`, + data: { visibility } + }) +} + +/** 软删除照片(204;deleted 为员工侧终态) */ +export function fetchDeletePhoto(photoId: string) { + return request.del({ url: `/v1/photos/${photoId}` }) +} diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index 2600123..a8d46d1 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -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" } } diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 1872461..7711669 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -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": "该相册已关闭上传" } } diff --git a/apps/admin/src/router/modules/albums.ts b/apps/admin/src/router/modules/albums.ts index bdbd5d3..1370eb5 100644 --- a/apps/admin/src/router/modules/albums.ts +++ b/apps/admin/src/router/modules/albums.ts @@ -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' + } } ] } diff --git a/apps/admin/src/types/api/album.d.ts b/apps/admin/src/types/api/album.d.ts new file mode 100644 index 0000000..ac9c94b --- /dev/null +++ b/apps/admin/src/types/api/album.d.ts @@ -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 + 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' + } + } +} diff --git a/apps/admin/src/types/import/components.d.ts b/apps/admin/src/types/import/components.d.ts index bd64c66..9e5f528 100644 --- a/apps/admin/src/types/import/components.d.ts +++ b/apps/admin/src/types/import/components.d.ts @@ -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'] diff --git a/apps/admin/src/utils/albums/albumValidation.spec.ts b/apps/admin/src/utils/albums/albumValidation.spec.ts new file mode 100644 index 0000000..d15ca76 --- /dev/null +++ b/apps/admin/src/utils/albums/albumValidation.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { + toTranslationPayload, + validateTimeRange, + validateTranslations, + type TranslationDraft +} from './albumValidation' + +const drafts = ( + over: Partial>> = {} +): 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) + }) +}) diff --git a/apps/admin/src/utils/albums/albumValidation.ts b/apps/admin/src/utils/albums/albumValidation.ts new file mode 100644 index 0000000..d06169e --- /dev/null +++ b/apps/admin/src/utils/albums/albumValidation.ts @@ -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(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 + })) +} diff --git a/apps/admin/src/utils/albums/assetUpload.spec.ts b/apps/admin/src/utils/albums/assetUpload.spec.ts new file mode 100644 index 0000000..04158b8 --- /dev/null +++ b/apps/admin/src/utils/albums/assetUpload.spec.ts @@ -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') + }) +}) diff --git a/apps/admin/src/utils/albums/assetUpload.ts b/apps/admin/src/utils/albums/assetUpload.ts new file mode 100644 index 0000000..f6a8ba7 --- /dev/null +++ b/apps/admin/src/utils/albums/assetUpload.ts @@ -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 + } +} diff --git a/apps/admin/src/utils/albums/cursorList.spec.ts b/apps/admin/src/utils/albums/cursorList.spec.ts new file mode 100644 index 0000000..f051994 --- /dev/null +++ b/apps/admin/src/utils/albums/cursorList.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { accumulatePage, firstPage, type CursorPage } from './cursorList' + +const pageOf = (items: T[], nextCursor: string | null): CursorPage => ({ + 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']) + }) +}) diff --git a/apps/admin/src/utils/albums/cursorList.ts b/apps/admin/src/utils/albums/cursorList.ts new file mode 100644 index 0000000..97bd3f1 --- /dev/null +++ b/apps/admin/src/utils/albums/cursorList.ts @@ -0,0 +1,67 @@ +/** + * 游标分页累积(纯函数) + * + * 契约列表端点(相册/成员/管理照片)一律 nextCursor 键集分页,而模板 + * useTable 假设 offset 分页(current/size/total 并整页替换 data)——两者 + * 语义不兼容。此处以「页内累积 + 加载更多」适配:每次把新页 append 到 + * 已有行,nextCursor 为 null 即到底。保持纯函数以便单测。 + */ + +/** 契约游标页形状(CursorPageMeta + items) */ +export interface CursorPage { + items: T[] + nextCursor: string | null + hasMore: boolean +} + +/** 累积状态:已加载行 + 下一页游标 */ +export interface CursorState { + items: T[] + nextCursor: string | null + hasMore: boolean +} + +export const EMPTY_CURSOR_STATE: CursorState = { + items: [], + nextCursor: null, + hasMore: false +} + +/** 首页:整页替换(重置语义) */ +export function firstPage(page: CursorPage): CursorState { + return { items: [...page.items], nextCursor: page.nextCursor, hasMore: page.hasMore } +} + +/** + * 追加一页(加载更多语义) + * + * @param state 当前累积状态 + * @param page 新拉取的一页 + * @param keyOf 行唯一键(可选):服务端排序键含 id,正常不会跨页重复; + * 传入时防御性去重(重复行以旧位置为准,不重复 append) + */ +export function accumulatePage( + state: CursorState, + page: CursorPage, + keyOf?: (item: T) => string +): CursorState { + 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 + } +} diff --git a/apps/admin/src/utils/albums/format.ts b/apps/admin/src/utils/albums/format.ts new file mode 100644 index 0000000..32823f8 --- /dev/null +++ b/apps/admin/src/utils/albums/format.ts @@ -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` +} diff --git a/apps/admin/src/utils/http/index.ts b/apps/admin/src/utils/http/index.ts index 6afaf0c..b7de877 100644 --- a/apps/admin/src/utils/http/index.ts +++ b/apps/admin/src/utils/http/index.ts @@ -217,6 +217,9 @@ const api = { post(config: ExtendedAxiosRequestConfig) { return retryRequest({ ...config, method: 'POST' }) }, + patch(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'PATCH' }) + }, put(config: ExtendedAxiosRequestConfig) { return retryRequest({ ...config, method: 'PUT' }) }, diff --git a/apps/admin/src/views/albums/composables/useCursorList.ts b/apps/admin/src/views/albums/composables/useCursorList.ts new file mode 100644 index 0000000..a847d3f --- /dev/null +++ b/apps/admin/src/views/albums/composables/useCursorList.ts @@ -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( + fetchPage: (cursor?: string) => Promise>, + keyOf?: (item: T) => string +) { + // shallowRef:状态整体替换(items 数组不深层响应,行内字段更新由调用方整行替换) + const state = shallowRef>({ items: [], nextCursor: null, hasMore: false }) + const loading = ref(false) + const error = ref(null) + /** 首屏是否已加载过(区分「空数据」与「未加载」) */ + const loaded = ref(false) + + const items = computed(() => state.value.items) + const hasMore = computed(() => state.value.hasMore) + + async function loadFirst(): Promise { + 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 { + 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 + } +} diff --git a/apps/admin/src/views/albums/detail/AlbumBannerUpload.vue b/apps/admin/src/views/albums/detail/AlbumBannerUpload.vue new file mode 100644 index 0000000..e21f1b8 --- /dev/null +++ b/apps/admin/src/views/albums/detail/AlbumBannerUpload.vue @@ -0,0 +1,199 @@ + + + + diff --git a/apps/admin/src/views/albums/detail/AlbumCategories.vue b/apps/admin/src/views/albums/detail/AlbumCategories.vue new file mode 100644 index 0000000..f1071b4 --- /dev/null +++ b/apps/admin/src/views/albums/detail/AlbumCategories.vue @@ -0,0 +1,176 @@ + + + + diff --git a/apps/admin/src/views/albums/detail/AlbumMembers.vue b/apps/admin/src/views/albums/detail/AlbumMembers.vue new file mode 100644 index 0000000..7e522ec --- /dev/null +++ b/apps/admin/src/views/albums/detail/AlbumMembers.vue @@ -0,0 +1,174 @@ + + + + diff --git a/apps/admin/src/views/albums/detail/AlbumPhotos.vue b/apps/admin/src/views/albums/detail/AlbumPhotos.vue new file mode 100644 index 0000000..4989502 --- /dev/null +++ b/apps/admin/src/views/albums/detail/AlbumPhotos.vue @@ -0,0 +1,248 @@ + + + + diff --git a/apps/admin/src/views/albums/detail/index.vue b/apps/admin/src/views/albums/detail/index.vue new file mode 100644 index 0000000..6fbbff2 --- /dev/null +++ b/apps/admin/src/views/albums/detail/index.vue @@ -0,0 +1,206 @@ + + + + diff --git a/apps/admin/src/views/albums/manage/AlbumFormDialog.vue b/apps/admin/src/views/albums/manage/AlbumFormDialog.vue new file mode 100644 index 0000000..6fdafbd --- /dev/null +++ b/apps/admin/src/views/albums/manage/AlbumFormDialog.vue @@ -0,0 +1,294 @@ + + + + diff --git a/apps/admin/src/views/albums/manage/TranslationFields.vue b/apps/admin/src/views/albums/manage/TranslationFields.vue new file mode 100644 index 0000000..b96e4e5 --- /dev/null +++ b/apps/admin/src/views/albums/manage/TranslationFields.vue @@ -0,0 +1,53 @@ + + + + diff --git a/apps/admin/src/views/albums/manage/index.vue b/apps/admin/src/views/albums/manage/index.vue index 9425dd1..380c753 100644 --- a/apps/admin/src/views/albums/manage/index.vue +++ b/apps/admin/src/views/albums/manage/index.vue @@ -1,15 +1,203 @@ - +