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