From f08d68bebf833cff1db78609bdf5cafa8b921e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sun, 20 Sep 2026 21:10:30 +0800 Subject: [PATCH] feat(admin): batch ops, transfer, watermark, usage and qr share pages Co-Authored-By: Claude Code --- apps/admin/.env.example | 4 + apps/admin/e2e/albums-governance.spec.ts | 281 ++++++++++++++ apps/admin/e2e/helpers.ts | 22 +- apps/admin/e2e/seed-fixture.cjs | 243 +++++++++++- apps/admin/package.json | 2 + apps/admin/src/api/albums.ts | 31 ++ apps/admin/src/api/usage.ts | 23 ++ apps/admin/src/env.d.ts | 10 + apps/admin/src/locales/langs/en.json | 120 +++++- apps/admin/src/locales/langs/zh.json | 120 +++++- apps/admin/src/router/modules/system.ts | 12 +- apps/admin/src/types/api/album.d.ts | 71 ++++ apps/admin/src/types/api/usage.d.ts | 69 ++++ apps/admin/src/types/import/components.d.ts | 2 + apps/admin/src/utils/albums/batchHide.spec.ts | 68 ++++ apps/admin/src/utils/albums/batchHide.ts | 47 +++ apps/admin/src/utils/albums/format.ts | 6 +- apps/admin/src/utils/albums/shareLink.spec.ts | 43 +++ apps/admin/src/utils/albums/shareLink.ts | 39 ++ .../src/utils/albums/usageStorage.spec.ts | 63 ++++ apps/admin/src/utils/albums/usageStorage.ts | 66 ++++ .../src/utils/albums/watermarkForm.spec.ts | 100 +++++ apps/admin/src/utils/albums/watermarkForm.ts | 108 ++++++ .../src/views/albums/detail/AlbumPhotos.vue | 145 +++++++- .../views/albums/detail/AlbumShareCard.vue | 92 +++++ .../albums/detail/AlbumTransferDialog.vue | 140 +++++++ .../views/albums/detail/AlbumWatermark.vue | 350 ++++++++++++++++++ apps/admin/src/views/albums/detail/index.vue | 57 ++- .../views/system/usage/ThresholdsDialog.vue | 102 +++++ apps/admin/src/views/system/usage/index.vue | 204 ++++++++++ pnpm-lock.yaml | 111 +++++- 31 files changed, 2717 insertions(+), 34 deletions(-) create mode 100644 apps/admin/e2e/albums-governance.spec.ts create mode 100644 apps/admin/src/api/usage.ts create mode 100644 apps/admin/src/types/api/usage.d.ts create mode 100644 apps/admin/src/utils/albums/batchHide.spec.ts create mode 100644 apps/admin/src/utils/albums/batchHide.ts create mode 100644 apps/admin/src/utils/albums/shareLink.spec.ts create mode 100644 apps/admin/src/utils/albums/shareLink.ts create mode 100644 apps/admin/src/utils/albums/usageStorage.spec.ts create mode 100644 apps/admin/src/utils/albums/usageStorage.ts create mode 100644 apps/admin/src/utils/albums/watermarkForm.spec.ts create mode 100644 apps/admin/src/utils/albums/watermarkForm.ts create mode 100644 apps/admin/src/views/albums/detail/AlbumShareCard.vue create mode 100644 apps/admin/src/views/albums/detail/AlbumTransferDialog.vue create mode 100644 apps/admin/src/views/albums/detail/AlbumWatermark.vue create mode 100644 apps/admin/src/views/system/usage/ThresholdsDialog.vue create mode 100644 apps/admin/src/views/system/usage/index.vue diff --git a/apps/admin/.env.example b/apps/admin/.env.example index bb65c2f..fa3f6be 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -18,3 +18,7 @@ VITE_OPEN_ROUTE_INFO = false # 锁屏加密密钥 VITE_LOCK_ENCRYPT_KEY = change-me-lock-key + +# viewer 部署基址(相册详情「分享」卡拼 viewer URL 与二维码;缺省兜底 +# http://127.0.0.1:5175 —— 本机 dev 端口,apps/viewer vite 固定 5175) +VITE_VIEWER_BASE = http://127.0.0.1:5175 diff --git a/apps/admin/e2e/albums-governance.spec.ts b/apps/admin/e2e/albums-governance.spec.ts new file mode 100644 index 0000000..ccf6c17 --- /dev/null +++ b/apps/admin/e2e/albums-governance.spec.ts @@ -0,0 +1,281 @@ +// 相册治理增量 e2e(P4a Task 9,真实后端 + compose 基础设施):批量隐藏 +// (多选 2 → toast 成功汇总 + 行原位已隐藏 + cn 公开列表仅剩 1)→ 负责人交接 +// (前缀搜索 staff → API 断言 ownerId 变更)→ 水印(配置保存 → 任务受理 → +// fixture 直驱 worker 核心到 done → UI 轮询见已完成 + 公开 browse URL 指向 +// /watermark.jpg)→ 用量(阈值调小触发告警 → 上调解除)→ 分享二维码 +// (canvas 非空 + URL 复制)。选择器一律 data-test;文案断言仅用稳定短语。 +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 标题),返回标题后缀(同 albums.spec 模式) */ +async function createAlbumViaUi(page: Page): Promise { + const suffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + await page.getByTestId('album-create-btn').click() + await expect(page.getByTestId('album-form-dialog')).toBeVisible() + await page.getByTestId('album-form-title-zh-CN').fill(`E2E治理相册 ${suffix}`) + await page.getByRole('tab', { name: '英文' }).click() + await page.getByTestId('album-form-title-en').fill(`E2E Gov Album ${suffix}`) + 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}` }) +} + +/** 进入详情页,返回 albumId */ +async function openAlbumDetail(page: Page, suffix: string): Promise { + 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('') + return albumId +} + +/** API 登录(UI 之外的断言用):返回 Bearer 会话令牌 */ +async function apiLogin(page: Page): Promise { + const { account, password } = adminCredentials() + const res = await page.request.post('/v1/auth/password', { data: { account, password } }) + expect(res.ok()).toBeTruthy() + const body = (await res.json()) as { sessionToken: string } + return body.sessionToken +} + +/** cn 公开列表条数(批量隐藏的观众侧断言) */ +async function publicPhotoCount(page: Page, token: string): Promise { + const res = await page.request.get(`/v1/public/albums/${token}/photos`) + expect(res.ok()).toBeTruthy() + const body = (await res.json()) as { items: unknown[] } + return body.items.length +} + +/** ElSwitch 幂等设到目标态(is-checked 类为准;已同态不点击)。 */ +async function setSwitchState(page: Page, testId: string, on: boolean): Promise { + const isOn = await page.getByTestId(testId).evaluate((el) => el.classList.contains('is-checked')) + if (isOn !== on) { + await page.getByTestId(testId).click() + } +} + +test('批量隐藏:多选 2 → 汇总 toast + 行原位已隐藏 + 公开列表仅剩 1', async ({ page }) => { + await loginAsAdmin(page) + const suffix = await createAlbumViaUi(page) + const albumId = await openAlbumDetail(page, suffix) + + seedFixture('gov-photos', albumId, adminCredentials().account, '3') + await page.reload() + await expect(page.getByTestId('photo-list-table')).toBeVisible() + const rows = page.getByTestId('photo-list-table').locator('tbody tr') + await expect(rows).toHaveCount(3) + for (const row of await rows.all()) { + await expect(row.getByTestId('photo-visibility-tag')).toHaveText('已发布') + } + + // 勾选前两行 → 批量按钮亮起(计数 2)→ 确认 + await rows.nth(0).locator('.el-checkbox').click() + await rows.nth(1).locator('.el-checkbox').click() + const batchBtn = page.getByTestId('photo-batch-hide-btn') + await expect(batchBtn).toBeEnabled() + await expect(batchBtn).toContainText('2') + await batchBtn.click() + await page.getByRole('button', { name: '确定' }).click() + + // 汇总 toast(全部成功分支)+ 两行原位已隐藏、一行保持已发布 + await expect(page.locator('.el-message').filter({ hasText: '成功 2 张' })).toBeVisible() + await expect(rows.nth(0).getByTestId('photo-visibility-tag')).toHaveText('已隐藏') + await expect(rows.nth(1).getByTestId('photo-visibility-tag')).toHaveText('已隐藏') + await expect(rows.nth(2).getByTestId('photo-visibility-tag')).toHaveText('已发布') + + // 观众侧:公开列表 3 → 1 + const token = await page.getByTestId('album-detail-token').textContent() + expect(await publicPhotoCount(page, token ?? '')).toBe(1) +}) + +test('负责人交接:搜索 staff → 交接 → ownerId 变更(API + 详情回显)', async ({ page }) => { + await loginAsAdmin(page) + const suffix = await createAlbumViaUi(page) + const albumId = await openAlbumDetail(page, suffix) + + const sessionToken = await apiLogin(page) + // 交接前 owner 是 admin 自己 + const before = (await ( + await page.request.get(`/v1/albums/${albumId}`, { + headers: { Authorization: `Bearer ${sessionToken}` } + }) + ).json()) as { ownerId: string } + const staffId = ( + ( + (await ( + await page.request.get('/v1/admin/users', { + params: { accountPrefix: 'staff-e2e' }, + headers: { Authorization: `Bearer ${sessionToken}` } + }) + ).json()) as { items: { id: string }[] } + ).items[0] ?? { id: '' } + ).id + expect(staffId).not.toBe('') + expect(before.ownerId).not.toBe(staffId) + + // 交接对话框:前缀搜索(≥2 字符)→ 选中 → 保留协作者开关默认开 → 提交 + await page.getByTestId('album-detail-transfer').click() + await expect(page.getByTestId('transfer-dialog')).toBeVisible() + const targetSelect = page.getByTestId('transfer-target-select') + await targetSelect.click() + await targetSelect.locator('input').fill('staff-e2e') + await page.getByTestId('transfer-option-staff-e2e').click() + await expect(page.getByTestId('transfer-keep-switch')).toHaveClass(/is-checked/) + await page.getByTestId('transfer-submit').click() + await expect(page.getByTestId('transfer-dialog')).toBeHidden() + await expect(page.locator('.el-message').filter({ hasText: '负责人已交接' })).toBeVisible() + + // 详情回显 + API 权威断言 + await expect(page.getByTestId('album-detail-owner-id')).toHaveText(staffId) + const after = (await ( + await page.request.get(`/v1/albums/${albumId}`, { + headers: { Authorization: `Bearer ${sessionToken}` } + }) + ).json()) as { ownerId: string } + expect(after.ownerId).toBe(staffId) +}) + +test('水印:配置保存 → 任务受理 → 直驱到已完成 → 公开 browse URL 指向水印图', async ({ page }) => { + await loginAsAdmin(page) + const suffix = await createAlbumViaUi(page) + const albumId = await openAlbumDetail(page, suffix) + const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim() + + // 真实对象照片先行(任务 total=3,非空相册 autoDone 分支不触发) + seedFixture('wm-photos', albumId, adminCredentials().account, '3') + + await page.getByTestId('album-watermark-tab').click() + await expect(page.getByTestId('watermark-panel')).toBeVisible() + await setSwitchState(page, 'watermark-enabled-switch', true) + await page.getByTestId('watermark-text-content').fill(`E2E水印 ${suffix}`) + + // 保存 → 202 受理(v1)→ 任务行进入列表(pending/running) + await page.getByTestId('watermark-save-btn').click() + await expect(page.locator('.el-message').filter({ hasText: 'v1' })).toBeVisible() + const stateTag = page.getByTestId('watermark-jobs-table').locator('[data-state]') + await expect(stateTag.first()).toBeVisible() + await expect(stateTag.first()).toHaveText(/等待中|进行中/) + + // e2e 无常驻 worker:fixture 直驱 worker dist 服务核心把任务跑到终态 + // (页面进程独立,轮询不受 execSync 阻塞影响) + seedFixture('watermark-run', albumId) + + // UI 3s 轮询翻到已完成;配置卡回显开启 + await expect(stateTag.first()).toHaveText('已完成') + await expect(page.getByTestId('watermark-current-tag')).toBeVisible() + + // 公开侧:album.watermarkEnabled + watermark 行存在 → browse 现签水印键 + // (SignedUrlResponse 形状:{url, expiresAt}) + const res = await page.request.get(`/v1/public/albums/${token}/photos`) + expect(res.ok()).toBeTruthy() + const body = (await res.json()) as { items: { browse: { url: string } }[] } + expect(body.items.length).toBe(3) + for (const item of body.items) { + expect(item.browse.url).toContain('/watermark.jpg') + } +}) + +test('用量:阈值调小触发告警 → 上调解除;合计与相册表可见', async ({ page }) => { + await loginAsAdmin(page) + + // 独立数据:API 建相册 + 大字节照片(2MB > 0.00001GB 阈值),与 UI 顺序解耦 + const sessionToken = await apiLogin(page) + const created = (await ( + await page.request.post('/v1/albums', { + headers: { Authorization: `Bearer ${sessionToken}` }, + data: { + defaultLocale: 'zh-CN', + startAt: '2026-10-01T10:00:00.000Z', + translations: [{ locale: 'zh-CN', title: `E2E用量相册 ${Date.now().toString(36)}` }] + } + }) + ).json()) as { id: string } + seedFixture('gov-photos', created.id, adminCredentials().account, '2', '2097152') + + await page.goto('/#/system/usage') + await expect(page.getByTestId('usage-totals')).toBeVisible() + await expect(page.getByTestId('usage-albums-table')).toBeVisible() + // 合计已加载(种子照片计入,非空占位) + await expect(page.getByTestId('usage-total-photos')).not.toHaveText('-') + + // 阈值调小(perAlbum 0.00001 GB ≈ 10KB < 2MB)→ 即刻评估出未解决告警。 + // 开关状态感知:共享开发库可能残留上次运行的已存阈值(开关已开)—— + // 幂等设到目标态而非盲目点击。 + await page.getByTestId('usage-thresholds-btn').click() + await expect(page.getByTestId('thresholds-dialog')).toBeVisible() + await setSwitchState(page, 'thresholds-per-album-switch', true) + await page.getByTestId('thresholds-per-album-input').locator('input').fill('0.00001') + await page.keyboard.press('Enter') + await page.getByTestId('thresholds-submit').click() + await expect(page.getByTestId('thresholds-dialog')).toBeHidden() + + await expect( + page.getByTestId('usage-alerts-table').locator('.alert-row-active').first() + ).toBeVisible() + await expect( + page + .getByTestId('usage-alerts-table') + .locator('.alert-row-active') + .first() + .getByTestId('alert-state-tag') + ).toHaveText('未解决') + + // 上调到 100GB(storage 保持关闭=显式清除,避免历史阈值残留干扰)→ 解除 + await page.getByTestId('usage-thresholds-btn').click() + await expect(page.getByTestId('thresholds-dialog')).toBeVisible() + await setSwitchState(page, 'thresholds-per-album-switch', true) + await setSwitchState(page, 'thresholds-storage-switch', false) + await page.getByTestId('thresholds-per-album-input').locator('input').fill('100') + await page.keyboard.press('Enter') + await page.getByTestId('thresholds-submit').click() + await expect(page.getByTestId('thresholds-dialog')).toBeHidden() + + await expect(page.getByTestId('usage-alerts-table').locator('.alert-row-active')).toHaveCount(0) + await expect( + page.getByTestId('usage-alerts-table').locator('.alert-row-resolved').first() + ).toBeVisible() +}) + +test('分享二维码:canvas 非空 + viewer URL + 复制链接', async ({ page, context }) => { + await loginAsAdmin(page) + const suffix = await createAlbumViaUi(page) + await openAlbumDetail(page, suffix) + const token = ((await page.getByTestId('album-detail-token').textContent()) ?? '').trim() + + await page.getByTestId('album-share-tab').click() + await expect(page.getByTestId('share-panel')).toBeVisible() + + // URL = VITE_VIEWER_BASE + /a/{token} + const urlText = ((await page.getByTestId('share-url').textContent()) ?? '').trim() + expect(urlText).toBe(`http://127.0.0.1:5175/a/${token}`) + + // canvas 已绘制(空白 canvas 的 dataURL 约几十字节,绘制后远大于 100) + const dataUrlLength = await page + .getByTestId('share-qr-canvas') + .evaluate((canvas: HTMLCanvasElement) => canvas.toDataURL().length) + expect(dataUrlLength).toBeGreaterThan(100) + + // 复制 → toast + 剪贴板内容一致(授 chromium 剪贴板权限) + await context.grantPermissions(['clipboard-read', 'clipboard-write']) + await page.getByTestId('share-copy-btn').click() + await expect(page.locator('.el-message').filter({ hasText: '已复制' })).toBeVisible() + const clipped = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipped).toBe(urlText) +}) diff --git a/apps/admin/e2e/helpers.ts b/apps/admin/e2e/helpers.ts index 57b886b..ae2bc64 100644 --- a/apps/admin/e2e/helpers.ts +++ b/apps/admin/e2e/helpers.ts @@ -43,19 +43,33 @@ export async function loginAsAdmin(page: Page): Promise { await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 }) } -/** 直种夹具(seed-fixture.cjs):staff 账号 / 相册种子照片(幂等) */ -export function seedFixture(mode: 'staff' | 'photos', ...args: string[]): void { +/** 直种夹具(seed-fixture.cjs):staff 账号 / 相册种子照片 / 治理批量照片 / + * 水印旅程真实对象照片 / 水印任务直驱(详见 seed-fixture.cjs 文件头) */ +export function seedFixture( + mode: 'staff' | 'photos' | 'gov-photos' | 'wm-photos' | 'watermark-run', + ...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 + // @photo/db-cn / @photo/storage 绝对路径:apps/admin 不依赖这些包,借 + // services/api 的 node_modules(node 从 e2e 目录解析不到 workspace 链接) const dbCn = join(repoRoot, 'services', 'api', 'node_modules', '@photo', 'db-cn') + const storage = join(repoRoot, 'services', 'api', 'node_modules', '@photo', 'storage') + // 全量根 .env 注入(S3_* 供水印对象直写与 worker 直驱;process.env 覆盖 + // 优先级更高——playwright 进程自身的环境变量优先于文件值) 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 } + env: { + ...Object.fromEntries(vars), + ...process.env, + PHOTO_DB_CN: dbCn, + PHOTO_STORAGE: storage, + DATABASE_URL_CN: databaseUrl + } }) } diff --git a/apps/admin/e2e/seed-fixture.cjs b/apps/admin/e2e/seed-fixture.cjs index 315d570..4078bec 100644 --- a/apps/admin/e2e/seed-fixture.cjs +++ b/apps/admin/e2e/seed-fixture.cjs @@ -2,24 +2,50 @@ // 为什么不用 API 造数:目标照片需要 published/processing_failed 等终态, // 真实链路要 worker 处理流水线;e2e 只验证管理页展示与操作,直插 DB 行 // (uploadedAt 置位即可入管理列表,见 services/api listAlbumPhotos 过滤条件)。 -// 依赖经 PHOTO_DB_CN 环境变量注入 @photo/db-cn 绝对路径(apps/admin 不依赖 -// 该包,node 从文件位置解析不到;services/api 的 node_modules 里有现成链接)。 -// 用法: -// node seed-fixture.cjs staff -// node seed-fixture.cjs photos -// 幂等:账号存在即跳过;照片按 objectKey upsert(同相册重复种不重复建行)。 -const db_cn = process.env.PHOTO_DB_CN +// 依赖经环境变量注入包绝对路径(apps/admin 不依赖这些包,node 从文件位置 +// 解析不到;services/api 的 node_modules 里有现成链接): +// PHOTO_DB_CN=@photo/db-cn(必填) PHOTO_STORAGE=@photo/storage(水印模式) +// 模式: +// staff 员工账号(不可登录) +// photos published/failed 各 1 张 +// gov-photos [bytes] 批量隐藏/用量旅程: +// N 张 published/ready +// DB 直种(含 thumb/browse +// 变体行——cn 公开列表计入; +// bytes 可调大以跨阈值) +// wm-photos 水印旅程:N 张已发布 +// 照片 + 真实 MinIO 对象 +// (原图/browse/thumb,键 +// 对齐 worker 发布后形态) +// watermark-run 直驱 worker dist 的 +// WatermarkRegenService 把 +// 最新任务跑到终态(e2e 无 +// 常驻 worker,同 services/ +// api/test/watermark.e2e- +// spec.ts 直驱模式) +// 幂等:账号存在即跳过;照片按确定性 photoId/objectKey upsert(同相册重复种 +// 不重复建行);watermark-run 对已 done 的任务返回 skipped 视为成功。 +/* eslint-disable @typescript-eslint/no-require-imports */ +const path = require('node:path') +const { execSync } = require('node:child_process') +const { existsSync, statSync } = require('node:fs') +const { createHash } = require('node:crypto') + +const dbCn = process.env.PHOTO_DB_CN const databaseUrl = process.env.DATABASE_URL_CN -if (!db_cn || !databaseUrl) { +if (!dbCn || !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 { PrismaClient } = require(dbCn) const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }) +/** worker 包根(sharp / dist 直驱均从这里解析)。 */ +const workerRoot = path.resolve(__dirname, '..', '..', '..', 'services', 'worker') + async function seedStaff(account, displayName) { const existing = await prisma.users.findUnique({ where: { account } }) if (existing) { @@ -88,12 +114,207 @@ async function seedPhotos(albumId, uploaderAccount) { console.log(`seed-fixture: 2 photos seeded for album ${albumId}`) } +/** 确定性 photoId(同相册同序号重复种不重复建行;长度/字符集适配 cuid 列)。 */ +function deterministicPhotoId(scope, index) { + return `e2e${createHash('md5').update(`${scope}:${index}`).digest('hex')}` +} + +async function findUploader(account) { + const uploader = await prisma.users.findUnique({ where: { account } }) + if (!uploader) { + throw new Error(`seed-fixture: uploader "${account}" not found`) + } + return uploader +} + +/** + * gov-photos:N 张 published/ready 照片 DB 直种。带 thumb/browse 变体行 + * (cn 公开列表计入的必要条件——服务端只现签行上的键,不校验对象存在, + * 故无需真实对象);bytes 可调(用量旅程用大值跨 perAlbum 阈值)。 + */ +async function seedGovPhotos(albumId, uploaderAccount, count, bytesEach) { + const uploader = await findUploader(uploaderAccount) + const total = Math.max(1, Number(count) || 1) + const bytes = Math.max(1, Number(bytesEach) || 2048) + const now = new Date() + for (let i = 0; i < total; i++) { + const photoId = deterministicPhotoId(`${albumId}:gov`, i) + const objectKey = `e2e-gov/albums/${albumId}/originals/${photoId}.jpg` + await prisma.photos.upsert({ + where: { objectKey }, + update: { visibility: 'published', processingState: 'ready' }, + create: { + id: photoId, + albumId, + uploaderId: uploader.id, + objectKey, + bytes, + width: 1200, + height: 800, + uploadedAt: now, + publishedAt: now, + processingState: 'ready', + visibility: 'published' + } + }) + // 变体行(thumb/browse):公开列表计入 + 现签 URL 可用;对象不落(e2e 不取图) + const variants = [ + { variant: 'thumb', width: 480, height: 320 }, + { variant: 'browse', width: 1200, height: 800 } + ] + for (const v of variants) { + await prisma.photo_variants.upsert({ + where: { photoId_variant: { photoId, variant: v.variant } }, + update: {}, + create: { + photoId, + variant: v.variant, + objectKey: `e2e-gov/albums/${albumId}/variants/${photoId}/1/${v.variant}.jpg`, + bytes: Math.round(bytes / 4), + version: 1, + width: v.width, + height: v.height + } + }) + } + } + console.log(`seed-fixture: ${total} gov photos seeded for album ${albumId} (${bytes} B each)`) +} + +/** 测试内生成真实 JPEG(带纹理;与 worker test makeJpeg 同法,水印差异可观测)。 */ +function makeJpegBuffer(sharp, width, height) { + const svg = Buffer.from( + `` + + Array.from( + { length: 12 }, + (_, i) => + `` + ).join('') + + '' + ) + return sharp(svg).jpeg({ quality: 85 }).toBuffer() +} + +/** + * wm-photos:N 张已发布照片(原图 + browse/thumb 对象与行,形状对齐 worker + * 发布后形态——watermark regen 读原图、公开列表需 thumb/browse 行)。 + * 键布局走 @photo/storage OBJECT_KEY_ROOT 约定(photo-live/dev)。 + */ +async function seedWmPhotos(albumId, uploaderAccount, count) { + const storagePath = process.env.PHOTO_STORAGE + if (!storagePath) { + throw new Error('seed-fixture: PHOTO_STORAGE env var is required for wm-photos') + } + + const { OBJECT_KEY_ROOT, createStorageFromEnv } = require(storagePath) + + const sharp = require(path.join(workerRoot, 'node_modules', 'sharp')) + const uploader = await findUploader(uploaderAccount) + const storage = createStorageFromEnv() + const total = Math.max(1, Number(count) || 1) + const now = new Date() + for (let i = 0; i < total; i++) { + const photoId = deterministicPhotoId(`${albumId}:wm`, i) + const objectKey = `${OBJECT_KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg` + const browseKey = `${OBJECT_KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/browse.jpg` + const thumbKey = `${OBJECT_KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/thumb.jpg` + const original = await makeJpegBuffer(sharp, 900, 600) + // browse/thumb 与原图同缓冲即可(e2e 断言 URL 指向,不比对变体字节) + await storage.put(objectKey, original, 'image/jpeg') + await storage.put(browseKey, original, 'image/jpeg') + await storage.put(thumbKey, original, 'image/jpeg') + await prisma.photos.upsert({ + where: { objectKey }, + update: {}, + create: { + id: photoId, + albumId, + uploaderId: uploader.id, + objectKey, + bytes: original.length, + width: 900, + height: 600, + uploadedAt: now, + publishedAt: now, + processingState: 'ready', + visibility: 'published' + } + }) + const variants = [ + { variant: 'thumb', key: thumbKey, width: 480, height: 320 }, + { variant: 'browse', key: browseKey, width: 900, height: 600 } + ] + for (const v of variants) { + await prisma.photo_variants.upsert({ + where: { photoId_variant: { photoId, variant: v.variant } }, + update: {}, + create: { + photoId, + variant: v.variant, + objectKey: v.key, + bytes: original.length, + version: 1, + width: v.width, + height: v.height + } + }) + } + } + console.log(`seed-fixture: ${total} wm photos (with real objects) seeded for album ${albumId}`) +} + +/** + * watermark-run:把相册最新水印任务直驱到终态。e2e 环境无常驻 worker + * (Playwright 只起 api + admin dev),借 worker dist 的服务核心直跑——与 + * services/api/test/watermark.e2e-spec.ts 同一入口(processJob),API 侧 + * POST 已落好 config/job 行。dist 过期(缺文件或源更新)则先 tsc 构建。 + */ +async function runWatermark(albumId) { + const distService = path.join(workerRoot, 'dist', 'watermark.service.js') + const srcService = path.join(workerRoot, 'src', 'watermark.service.ts') + const stale = + !existsSync(distService) || + (existsSync(srcService) && statSync(srcService).mtimeMs > statSync(distService).mtimeMs) + if (stale) { + console.log('seed-fixture: building services/worker dist (stale or missing)...') + execSync('npx tsc -p tsconfig.build.json', { cwd: workerRoot, stdio: 'inherit' }) + } + + const { WatermarkRegenService } = require(distService) + const storagePath = process.env.PHOTO_STORAGE + if (!storagePath) { + throw new Error('seed-fixture: PHOTO_STORAGE env var is required for watermark-run') + } + + const { createStorageFromEnv } = require(storagePath) + const storage = createStorageFromEnv() + const job = await prisma.watermark_jobs.findFirst({ + where: { albumId }, + orderBy: { createdAt: 'desc' } + }) + if (!job) { + throw new Error(`seed-fixture: no watermark job found for album ${albumId}`) + } + const service = new WatermarkRegenService(prisma, storage) + const outcome = await service.processJob(job.id) + console.log(`seed-fixture: watermark job ${job.id} → ${outcome}`) + if (outcome === 'failed') { + throw new Error('seed-fixture: watermark job failed (task-level abort)') + } +} + async function main() { - const [mode, argA, argB] = process.argv.slice(2) + const [mode, argA, argB, argC, argD] = process.argv.slice(2) if (mode === 'staff') { await seedStaff(argA, argB) } else if (mode === 'photos') { await seedPhotos(argA, argB) + } else if (mode === 'gov-photos') { + await seedGovPhotos(argA, argB, argC, argD) + } else if (mode === 'wm-photos') { + await seedWmPhotos(argA, argB, argC) + } else if (mode === 'watermark-run') { + await runWatermark(argA) } else { throw new Error(`seed-fixture: unknown mode "${mode}"`) } diff --git a/apps/admin/package.json b/apps/admin/package.json index a555412..8646613 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -31,6 +31,7 @@ "ohash": "^2.0.11", "pinia": "^3.0.3", "pinia-plugin-persistedstate": "^4.3.0", + "qrcode": "^1.5.4", "tailwindcss": "^4.1.14", "vue": "3.5.22", "vue-draggable-plus": "^0.6.0", @@ -41,6 +42,7 @@ "@eslint/js": "^9.9.1", "@playwright/test": "^1.48.0", "@types/node": "^24.0.5", + "@types/qrcode": "^1.5.6", "@typescript-eslint/eslint-plugin": "^8.3.0", "@typescript-eslint/parser": "^8.3.0", "@vitejs/plugin-vue": "^6.0.1", diff --git a/apps/admin/src/api/albums.ts b/apps/admin/src/api/albums.ts index 5875b96..127e06d 100644 --- a/apps/admin/src/api/albums.ts +++ b/apps/admin/src/api/albums.ts @@ -121,3 +121,34 @@ export function fetchUpdatePhotoVisibility(photoId: string, visibility: 'hidden' export function fetchDeletePhoto(photoId: string) { return request.del({ url: `/v1/photos/${photoId}` }) } + +/** 批量隐藏(P4a A16:逐项结果部分成功;单批上限 200) */ +export function fetchHidePhotosBatch(albumId: string, photoIds: string[]) { + return request.post({ + url: `/v1/albums/${albumId}/photos/hide-batch`, + data: { photoIds } + }) +} + +/** 相册负责人交接(P4a A17:仅 admin;历史上传人不变) */ +export function fetchTransferAlbum(albumId: string, data: Api.Album.TransferPayload) { + return request.post({ + url: `/v1/admin/albums/${albumId}/transfer`, + data + }) +} + +/** 水印配置与任务状态(current 从未配置为 null) */ +export function fetchWatermarkJobs(albumId: string) { + return request.get({ + url: `/v1/albums/${albumId}/watermark-jobs` + }) +} + +/** 保存水印配置并发起历史重生成(202 受理;进度经 GET 轮询) */ +export function fetchCreateWatermarkJob(albumId: string, config: Api.Album.WatermarkConfig) { + return request.post({ + url: `/v1/albums/${albumId}/watermark-jobs`, + data: config + }) +} diff --git a/apps/admin/src/api/usage.ts b/apps/admin/src/api/usage.ts new file mode 100644 index 0000000..7375548 --- /dev/null +++ b/apps/admin/src/api/usage.ts @@ -0,0 +1,23 @@ +/** + * 用量统计接口(对齐 @photo/contracts tags [admin]:getUsageSummary / + * updateUsageThresholds,P4a Task 5 / A23 后台侧) + * + * - summary 为实时聚合(相册按 totalBytes 降序)+ 阈值 + 告警列表 + * (未解决在前,含近期已解除) + * - PUT thresholds:字段缺省=保持不变,显式 null=清除该维度(告警停用); + * 服务端保存后即刻重新评估(超阈产生新告警/阈值上调解除旧告警) + */ +import request from '@/utils/http' + +/** 用量汇总(相册聚合 + 合计 + 阈值 + 告警) */ +export function fetchUsageSummary() { + return request.get({ url: '/v1/admin/usage/summary' }) +} + +/** 更新用量阈值(修改后服务端即刻重新评估告警) */ +export function fetchUpdateUsageThresholds(data: Api.Usage.ThresholdsUpdatePayload) { + return request.put({ + url: '/v1/admin/usage/thresholds', + data + }) +} diff --git a/apps/admin/src/env.d.ts b/apps/admin/src/env.d.ts index 4401f21..7e5ecc0 100644 --- a/apps/admin/src/env.d.ts +++ b/apps/admin/src/env.d.ts @@ -1,5 +1,15 @@ /// +// Vite 环境变量类型增补(vite/client 的 ImportMetaEnv 声明合并) +interface ImportMetaEnv { + /** viewer 部署基址(分享链接卡;缺省兜底 http://127.0.0.1:5175) */ + readonly VITE_VIEWER_BASE?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + declare module 'nprogress' declare module 'crypto-js' diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index 5681709..2831207 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -233,7 +233,8 @@ "system": { "title": "Accounts & Sync", "users": "Account Management", - "userSync": "User Sync" + "userSync": "User Sync", + "usage": "Usage" }, "albums": { "title": "Albums", @@ -339,6 +340,8 @@ "tabPhotos": "Photos", "tabMembers": "Members", "tabCategories": "Categories", + "tabWatermark": "Watermark", + "tabShare": "Share", "loadFailed": "Failed to load album", "retry": "Retry", "fieldTitle": "Title", @@ -348,7 +351,10 @@ "fieldStartAt": "Start", "fieldEndAt": "End", "fieldCreatedAt": "Created", - "fieldUpdatedAt": "Updated" + "fieldUpdatedAt": "Updated", + "fieldStatsSyncedAt": "Stats Synced At", + "statsNotSynced": "Not synced yet", + "fieldOwnerId": "Owner" }, "banner": { "kind": "Asset Kind", @@ -438,7 +444,114 @@ "hidden": "Photo hidden", "restored": "Photo restored", "deleted": "Photo deleted", - "empty": "No photos" + "empty": "No photos", + "colLikeCount": "Likes", + "batchHide": "Batch Hide", + "batchHideConfirm": "Batch hide the {count} selected photos? They become invisible to the public immediately.", + "batchResult": "Batch hide finished: {ok} succeeded / {fail} failed", + "batchResultAllOk": "Batch hide finished: {ok} succeeded", + "batchFailuresTitle": "Failures (per item)", + "batchColPhoto": "Photo ID", + "batchColReason": "Reason" + }, + "transfer": { + "entry": "Transfer Owner", + "title": "Transfer Album Owner", + "targetLabel": "New Owner", + "searchPlaceholder": "Type an account prefix (min 2 chars) to search", + "keepLabel": "Keep as collaborator", + "keepHint": "On: the previous owner becomes a collaborator; Off: removed from members", + "historyHint": "Transfer only moves the owner role: upload attribution of existing photos stays unchanged.", + "submit": "Transfer", + "success": "Owner transferred" + }, + "watermark": { + "loadFailed": "Failed to load watermark config", + "retry": "Retry", + "currentVersion": "Current Config", + "versionTag": "v{version} · {state}", + "neverConfigured": "Never configured", + "enabled": "Enabled", + "disabled": "Disabled", + "switchLabel": "Browse watermark", + "positionLabel": "Position", + "position_topLeft": "Top Left", + "position_topRight": "Top Right", + "position_bottomLeft": "Bottom Left", + "position_bottomRight": "Bottom Right", + "position_center": "Center", + "opacityLabel": "Opacity", + "scaleLabel": "Scale", + "textContentLabel": "Text", + "textContentPlaceholder": "Leave empty for no text", + "fontSizeLabel": "Font Size", + "save": "Save & Regenerate", + "saveHint": "Saving creates a new config version and starts regeneration for all existing photos (atomic switch — old images stay available until done)", + "accepted": "Config saved (v{version}), regeneration job accepted", + "previewTitle": "Test Watermark (schematic)", + "previewHint": "CSS overlay schematic; actual result follows the regenerated browse images", + "jobsTitle": "Version History", + "regen": "Regenerate", + "colVersion": "Version", + "colState": "State", + "colProgress": "Progress", + "colCreatedAt": "Created", + "state_pending": "Pending", + "state_running": "Running", + "state_done": "Done", + "state_failed": "Failed", + "failedCount": "{count} failed", + "jobsEmpty": "No regeneration jobs" + }, + "share": { + "viewerUrl": "Viewer URL", + "copy": "Copy Link", + "hint": "Guests open the public album page via this link; it stops working when \"Publicly visible\" is off.", + "qrTitle": "Scan to open", + "publicOffHint": "\"Publicly visible\" is off for this album — the viewer link is currently unreachable" + } + }, + "usage": { + "loadFailed": "Failed to load usage", + "retry": "Retry", + "totals": { + "photoCount": "Total Photos", + "totalBytes": "Total Storage", + "thresholds": "Alert Thresholds" + }, + "albums": { + "title": "Album Usage", + "colTitle": "Album", + "colPhotoCount": "Photos", + "colOriginal": "Originals", + "colVariant": "Variants", + "colTotal": "Total", + "empty": "No albums" + }, + "thresholds": { + "entry": "Thresholds", + "title": "Usage Alert Thresholds", + "storageLabel": "Storage threshold", + "storageShort": "Storage", + "perAlbumLabel": "Per-album threshold", + "perAlbumShort": "Per album", + "off": "Not set", + "hint": "Turning a switch off clears that threshold (alerts disabled); saving re-evaluates immediately — new alerts appear when exceeded, raising a threshold resolves old alerts.", + "submit": "Save", + "saved": "Thresholds saved" + }, + "alerts": { + "title": "Alerts", + "colState": "State", + "colKind": "Kind", + "colMessage": "Message", + "colCreatedAt": "Raised", + "colResolvedAt": "Resolved", + "active": "Active", + "resolved": "Resolved", + "kind_storage": "Storage", + "kind_perAlbum": "Per album", + "empty": "No alerts" } }, "usersManage": { @@ -594,6 +707,7 @@ "MEMBER_CONFLICT": "This user is already a member", "ALBUM_NOT_FOUND": "Album not found or not visible", "PHOTO_NOT_FOUND": "Photo not found", + "PHOTO_NOT_PUBLIC": "Photo state cannot be hidden (not published)", "UPLOAD_VERIFY_FAILED": "Upload verification failed, please retry", "UPLOAD_SESSION_CONFLICT": "Upload session conflict, please start over", "ALBUM_UPLOAD_DISABLED": "Upload is disabled for this album" diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 21c4b03..5abaecb 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -233,7 +233,8 @@ "system": { "title": "账号与同步", "users": "账号管理", - "userSync": "用户同步" + "userSync": "用户同步", + "usage": "用量统计" }, "albums": { "title": "相册管理", @@ -339,6 +340,8 @@ "tabPhotos": "照片管理", "tabMembers": "成员管理", "tabCategories": "分类管理", + "tabWatermark": "水印管理", + "tabShare": "分享", "loadFailed": "相册加载失败", "retry": "重试", "fieldTitle": "标题", @@ -348,7 +351,10 @@ "fieldStartAt": "开始时间", "fieldEndAt": "结束时间", "fieldCreatedAt": "创建时间", - "fieldUpdatedAt": "更新时间" + "fieldUpdatedAt": "更新时间", + "fieldStatsSyncedAt": "统计同步时间", + "statsNotSynced": "尚无回传", + "fieldOwnerId": "负责人" }, "banner": { "kind": "资产类型", @@ -438,7 +444,114 @@ "hidden": "照片已隐藏", "restored": "照片已恢复发布", "deleted": "照片已删除", - "empty": "暂无照片" + "empty": "暂无照片", + "colLikeCount": "点赞数", + "batchHide": "批量隐藏", + "batchHideConfirm": "确定批量隐藏选中的 {count} 张照片?隐藏后公开端即刻不可见。", + "batchResult": "批量隐藏完成:成功 {ok} 张 / 失败 {fail} 张", + "batchResultAllOk": "批量隐藏完成:成功 {ok} 张", + "batchFailuresTitle": "失败明细(逐项原因)", + "batchColPhoto": "照片 ID", + "batchColReason": "原因" + }, + "transfer": { + "entry": "负责人交接", + "title": "相册负责人交接", + "targetLabel": "新负责人", + "searchPlaceholder": "输入账号前缀(至少 2 字符)搜索", + "keepLabel": "保留原负责人", + "keepHint": "开启则原负责人转为协作者,关闭则移出成员表", + "historyHint": "交接仅转移负责人角色:历史照片的上传人保持不变。", + "submit": "交接", + "success": "负责人已交接" + }, + "watermark": { + "loadFailed": "水印配置加载失败", + "retry": "重试", + "currentVersion": "当前配置", + "versionTag": "v{version} · {state}", + "neverConfigured": "从未配置", + "enabled": "已开启", + "disabled": "已关闭", + "switchLabel": "浏览图水印", + "positionLabel": "位置", + "position_topLeft": "左上", + "position_topRight": "右上", + "position_bottomLeft": "左下", + "position_bottomRight": "右下", + "position_center": "居中", + "opacityLabel": "不透明度", + "scaleLabel": "尺寸占比", + "textContentLabel": "文字内容", + "textContentPlaceholder": "留空则不绘制文字", + "fontSizeLabel": "字号", + "save": "保存并发起重生成", + "saveHint": "保存即创建新配置版本,并为全部历史照片发起重生成(原子切换,完成前旧图可用)", + "accepted": "配置已保存(v{version}),重生成任务已受理", + "previewTitle": "测试水印(示意预览)", + "previewHint": "CSS 叠加示意,实际效果以重生成后的浏览图为准", + "jobsTitle": "版本历史", + "regen": "发起重生成", + "colVersion": "版本", + "colState": "状态", + "colProgress": "进度", + "colCreatedAt": "创建时间", + "state_pending": "等待中", + "state_running": "进行中", + "state_done": "已完成", + "state_failed": "已失败", + "failedCount": "失败 {count} 张", + "jobsEmpty": "暂无重生成任务" + }, + "share": { + "viewerUrl": "观众端链接", + "copy": "复制链接", + "hint": "观众经此链接访问相册公开页;关闭「公开可见」开关后链接失效。", + "qrTitle": "扫码打开", + "publicOffHint": "该相册已关闭「公开可见」,观众端链接当前不可访问" + } + }, + "usage": { + "loadFailed": "用量加载失败", + "retry": "重试", + "totals": { + "photoCount": "照片总数", + "totalBytes": "存储总量", + "thresholds": "告警阈值" + }, + "albums": { + "title": "相册用量", + "colTitle": "相册", + "colPhotoCount": "照片数", + "colOriginal": "原图", + "colVariant": "变体", + "colTotal": "合计", + "empty": "暂无相册" + }, + "thresholds": { + "entry": "阈值设置", + "title": "用量告警阈值", + "storageLabel": "全库存储阈值", + "storageShort": "全库", + "perAlbumLabel": "单相册阈值", + "perAlbumShort": "单相册", + "off": "未设置", + "hint": "开关关闭即清除该阈值(告警停用);保存后立即重新评估——超阈产生新告警、阈值上调解除旧告警。", + "submit": "保存", + "saved": "阈值已保存" + }, + "alerts": { + "title": "告警列表", + "colState": "状态", + "colKind": "维度", + "colMessage": "描述", + "colCreatedAt": "产生时间", + "colResolvedAt": "解除时间", + "active": "未解决", + "resolved": "已解决", + "kind_storage": "全库存储", + "kind_perAlbum": "单相册", + "empty": "暂无告警" } }, "usersManage": { @@ -594,6 +707,7 @@ "MEMBER_CONFLICT": "该用户已是相册成员", "ALBUM_NOT_FOUND": "相册不存在或不可见", "PHOTO_NOT_FOUND": "照片不存在", + "PHOTO_NOT_PUBLIC": "照片状态不可隐藏(非已发布)", "UPLOAD_VERIFY_FAILED": "上传核验失败,请重试", "UPLOAD_SESSION_CONFLICT": "上传会话状态冲突,请重新开始", "ALBUM_UPLOAD_DISABLED": "该相册已关闭上传" diff --git a/apps/admin/src/router/modules/system.ts b/apps/admin/src/router/modules/system.ts index e945c31..ba39062 100644 --- a/apps/admin/src/router/modules/system.ts +++ b/apps/admin/src/router/modules/system.ts @@ -1,5 +1,5 @@ /** - * 账号治理路由(P4a Task 8:账号管理 + 用户同步) + * 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计) */ import { AppRouteRecord } from '@/types/router' @@ -31,6 +31,16 @@ export const systemRoutes: AppRouteRecord = { icon: 'ri:refresh-line', keepAlive: false } + }, + { + path: 'usage', + name: 'SystemUsage', + component: '/system/usage', + meta: { + title: 'menus.system.usage', + icon: 'ri:pie-chart-line', + keepAlive: false + } } ] } diff --git a/apps/admin/src/types/api/album.d.ts b/apps/admin/src/types/api/album.d.ts index 2dbe8fb..6f69714 100644 --- a/apps/admin/src/types/api/album.d.ts +++ b/apps/admin/src/types/api/album.d.ts @@ -44,6 +44,8 @@ declare namespace Api { translations: Translation[] createdAt: string updatedAt: string + /** 统计副本最近同步时间(P2 A27 ov→cn 快照应用时刻;null=尚无回传) */ + statsSyncedAt: string | null } /** 分类(相册详情内嵌,按 sortOrder 升序) */ @@ -214,5 +216,74 @@ declare namespace Api { interface PhotoVisibilityUpdatePayload { visibility: 'hidden' | 'published' } + + /** 批量隐藏单项结果(P4a A16:逐项部分成功语义) */ + interface BatchHideItem { + photoId: string + ok: boolean + /** ok=false 时必有(PHOTO_NOT_PUBLIC / PHOTO_NOT_FOUND …) */ + code?: string + } + + /** 批量隐藏结果(results 与请求 photoIds 同序) */ + interface BatchHideResult { + results: BatchHideItem[] + } + + /** 负责人交接请求(A17:owner 转移、历史上传人不变) */ + interface TransferPayload { + targetUserId: string + keepAsCollaborator: boolean + } + + /** 水印位置(契约 WatermarkConfig.position) */ + type WatermarkPosition = 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight' | 'center' + + /** 文字水印参数(SVG 渲染实现) */ + interface WatermarkTextConfig { + content: string + fontSize?: number + } + + /** 水印配置(创建请求与当前配置响应共用;enabled=false 其余字段被忽略) */ + interface WatermarkConfig { + enabled: boolean + position?: WatermarkPosition + opacity?: number + scalePercent?: number + text?: WatermarkTextConfig + } + + /** 水印任务受理结果(202;configVersion 即本次保存版本) */ + interface WatermarkJobCreated { + jobId: string + configVersion: number + } + + type WatermarkJobState = 'pending' | 'running' | 'done' | 'failed' + + /** 历史重生成任务条目(进度供前端轮询) */ + interface WatermarkJob { + jobId: string + configVersion: number + state: WatermarkJobState + progress: number + total: number + failed: number + errorCode?: string + createdAt: string + } + + /** 当前生效配置(从未配置为 null,watermarkVersion=0) */ + interface WatermarkCurrent { + configVersion: number + config: WatermarkConfig + } + + /** 水印配置与任务状态 */ + interface WatermarkJobsStatus { + current: WatermarkCurrent | null + jobs: WatermarkJob[] + } } } diff --git a/apps/admin/src/types/api/usage.d.ts b/apps/admin/src/types/api/usage.d.ts new file mode 100644 index 0000000..a919b44 --- /dev/null +++ b/apps/admin/src/types/api/usage.d.ts @@ -0,0 +1,69 @@ +/** + * 用量统计域接口类型(对齐 @photo/contracts openapi.yaml tags [admin]: + * getUsageSummary / updateUsageThresholds,P4a Task 5 / A23 后台侧) + * + * 与 api.d.ts 同用全局 Api 命名空间(declare namespace 自动合并)。 + * 契约要点: + * - int64 字段经 openapi-typescript 生成 number(BigInt 运输决策见 + * services/api usage.dto;前端仅展示与 GB 换算,均在安全整数范围内) + * - thresholds null 表示该维度未设置(告警停用);更新载荷字段缺省=保持 + * 不变,显式 null=清除 + * - 告警超阈写入一次直到回落(kind+albumId 活跃告警唯一),resolvedAt + * 非空为已解除 + * + * @module types/api/usage + */ + +declare namespace Api { + /** 用量域 */ + namespace Usage { + /** 单相册用量聚合(实时 SUM) */ + interface AlbumItem { + albumId: string + title: string + photoCount: number + originalBytes: number + variantBytes: number + totalBytes: number + } + + /** 全库用量合计 */ + interface Totals { + photoCount: number + totalBytes: number + } + + /** 用量阈值(null = 未设置/停用) */ + interface Thresholds { + storageBytes: number | null + perAlbumBytes: number | null + } + + /** 阈值更新载荷(缺省=不变,显式 null=清除) */ + interface ThresholdsUpdatePayload { + storageBytes?: number | null + perAlbumBytes?: number | null + } + + /** 告警维度 */ + type AlertKind = 'storage' | 'perAlbum' + + /** 阈值告警条目 */ + interface AlertItem { + id: string + kind: AlertKind + albumId: string | null + message: string + createdAt: string + resolvedAt: string | null + } + + /** 用量汇总响应 */ + interface SummaryResponse { + albums: AlbumItem[] + totals: Totals + thresholds: Thresholds + alerts: AlertItem[] + } + } +} diff --git a/apps/admin/src/types/import/components.d.ts b/apps/admin/src/types/import/components.d.ts index d48eef0..7b7469f 100644 --- a/apps/admin/src/types/import/components.d.ts +++ b/apps/admin/src/types/import/components.d.ts @@ -80,11 +80,13 @@ declare module 'vue' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] ElPopover: typeof import('element-plus/es')['ElPopover'] + ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] 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'] + ElSlider: typeof import('element-plus/es')['ElSlider'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElTable: typeof import('element-plus/es')['ElTable'] diff --git a/apps/admin/src/utils/albums/batchHide.spec.ts b/apps/admin/src/utils/albums/batchHide.spec.ts new file mode 100644 index 0000000..1bb31b5 --- /dev/null +++ b/apps/admin/src/utils/albums/batchHide.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { indexOfPhotoId, localizeBatchHideCode, summarizeBatchHideResults } from './batchHide' + +/** 构造管理照片行(仅批量逻辑消费的字段) */ +function photoOf(photoId: string): Api.Album.AdminPhoto { + return { + photoId, + albumId: 'a1', + categoryId: null, + uploaderId: 'u1', + visibility: 'published', + processingState: 'ready', + bytes: 1024, + width: 100, + height: 80, + shotAt: null, + uploadedAt: '2026-09-01T00:00:00.000Z', + publishedAt: '2026-09-01T00:00:00.000Z', + likeCount: 0, + uploader: { userId: 'u1', account: 'staff', displayName: 'Staff' } + } +} + +describe('summarizeBatchHideResults 批量隐藏结果归集', () => { + it('部分成功:计数与失败明细逐项对应(缺 code 兜底 UNKNOWN)', () => { + const summary = summarizeBatchHideResults([ + { photoId: 'p1', ok: true }, + { photoId: 'p2', ok: false, code: 'PHOTO_NOT_PUBLIC' }, + { photoId: 'p3', ok: true }, + { photoId: 'p4', ok: false } + ]) + expect(summary.okCount).toBe(2) + expect(summary.failCount).toBe(2) + expect(summary.failures).toEqual([ + { photoId: 'p2', code: 'PHOTO_NOT_PUBLIC' }, + { photoId: 'p4', code: 'UNKNOWN' } + ]) + }) + + it('全部成功:failures 为空(toast 走 AllOk 文案分支)', () => { + const summary = summarizeBatchHideResults([ + { photoId: 'p1', ok: true }, + { photoId: 'p2', ok: true } + ]) + expect(summary.okCount).toBe(2) + expect(summary.failCount).toBe(0) + expect(summary.failures).toEqual([]) + }) +}) + +describe('localizeBatchHideCode 失败码本地化', () => { + it('apiMsg 有词条返回译文;未知码原样返回', () => { + const dict: Record = { + 'apiMsg.PHOTO_NOT_PUBLIC': '照片状态不可隐藏(非已发布)' + } + const t = (key: string) => dict[key] ?? key + expect(localizeBatchHideCode(t, 'PHOTO_NOT_PUBLIC')).toBe('照片状态不可隐藏(非已发布)') + expect(localizeBatchHideCode(t, 'SOME_FUTURE_CODE')).toBe('SOME_FUTURE_CODE') + }) +}) + +describe('indexOfPhotoId 行内回显定位', () => { + it('命中返回索引;不在当前页返回 -1(换页/过滤竞态兜底)', () => { + const items = [photoOf('p1'), photoOf('p2'), photoOf('p3')] + expect(indexOfPhotoId(items, 'p2')).toBe(1) + expect(indexOfPhotoId(items, 'pX')).toBe(-1) + }) +}) diff --git a/apps/admin/src/utils/albums/batchHide.ts b/apps/admin/src/utils/albums/batchHide.ts new file mode 100644 index 0000000..197e632 --- /dev/null +++ b/apps/admin/src/utils/albums/batchHide.ts @@ -0,0 +1,47 @@ +/** + * 批量隐藏结果汇总(P4a A16 逐项部分成功语义,纯函数供 vitest 覆盖) + * + * - 汇总:成功/失败计数 + 失败明细(photoId + 错误码)——toast 文案与 + * 失败详情弹窗共用同一份归集结果 + * - 错误码本地化:apiMsg 有词条用词条(PHOTO_NOT_PUBLIC/PHOTO_NOT_FOUND), + * 未知码原样展示(契约 code 为开放枚举,服务端后续扩充不破前端) + * - 行内回显归集:结果 photoId → 列表行索引(shallowRef 列表整行替换用) + */ +import type { useI18n } from 'vue-i18n' + +/** 批量隐藏结果归集 */ +export interface BatchHideSummary { + /** 成功项数 */ + okCount: number + /** 失败项数 */ + failCount: number + /** 失败明细(与结果同序) */ + failures: Array<{ photoId: string; code: string }> +} + +/** 逐项结果 → 汇总(ok 计成功;ok=false 收 code,缺 code 兜底 UNKNOWN) */ +export function summarizeBatchHideResults(results: Api.Album.BatchHideItem[]): BatchHideSummary { + const failures: BatchHideSummary['failures'] = [] + let okCount = 0 + for (const item of results) { + if (item.ok) { + okCount += 1 + } else { + failures.push({ photoId: item.photoId, code: item.code ?? 'UNKNOWN' }) + } + } + return { okCount, failCount: failures.length, failures } +} + +/** 失败码 → 本地化文案(apiMsg 词条优先,未知码原样;t 为 i18n 翻译函数) */ +export function localizeBatchHideCode(t: ReturnType['t'], code: string): string { + const key = `apiMsg.${code}` + const text = t(key) + // vue-i18n 缺词条时回退键名本身(apiMsg.${code})——非词条原样展示 code + return text === key ? code : text +} + +/** 结果 photoId → 列表行索引(不在当前页的结果忽略——换页/过滤竞态兜底) */ +export function indexOfPhotoId(items: Api.Album.AdminPhoto[], photoId: string): number { + return items.findIndex((item) => item.photoId === photoId) +} diff --git a/apps/admin/src/utils/albums/format.ts b/apps/admin/src/utils/albums/format.ts index 32823f8..9e1b269 100644 --- a/apps/admin/src/utils/albums/format.ts +++ b/apps/admin/src/utils/albums/format.ts @@ -11,10 +11,12 @@ export function formatDateTime(iso: string | null | undefined): string { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } -/** 字节数 → 人类可读 */ +/** 字节数 → 人类可读(B/KB/MB/GB/TB;用量页大容量场景覆盖到 GB+) */ 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` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` + return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB` } diff --git a/apps/admin/src/utils/albums/shareLink.spec.ts b/apps/admin/src/utils/albums/shareLink.spec.ts new file mode 100644 index 0000000..8548b28 --- /dev/null +++ b/apps/admin/src/utils/albums/shareLink.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { + buildViewerAlbumUrl, + renderShareQr, + viewerBaseFromEnv, + VIEWER_BASE_FALLBACK, + type QrDraw +} from './shareLink' + +describe('buildViewerAlbumUrl viewer 分享链接', () => { + it('base + /a/{token}(尾斜杠容忍)', () => { + expect(buildViewerAlbumUrl('http://127.0.0.1:5175', 'abc123')).toBe( + 'http://127.0.0.1:5175/a/abc123' + ) + expect(buildViewerAlbumUrl('https://view.example.com/', 'abc123')).toBe( + 'https://view.example.com/a/abc123' + ) + expect(buildViewerAlbumUrl('https://view.example.com//', 'abc123')).toBe( + 'https://view.example.com/a/abc123' + ) + }) + + it('环境基址:VITE_VIEWER_BASE 缺省/空白兜底本机 dev', () => { + expect(viewerBaseFromEnv({})).toBe(VIEWER_BASE_FALLBACK) + expect(viewerBaseFromEnv({ VITE_VIEWER_BASE: ' ' })).toBe(VIEWER_BASE_FALLBACK) + expect(viewerBaseFromEnv({ VITE_VIEWER_BASE: ' https://x.example.com ' })).toBe( + 'https://x.example.com' + ) + }) +}) + +describe('renderShareQr 二维码渲染(注入实现)', () => { + it('以 canvas + URL + 尺寸出参调用绘制函数', async () => { + const draw = vi.fn().mockResolvedValue(undefined) as unknown as QrDraw + const canvas = {} as HTMLCanvasElement + await renderShareQr(canvas, 'http://127.0.0.1:5175/a/tok', draw) + expect(draw).toHaveBeenCalledTimes(1) + expect(draw).toHaveBeenCalledWith(canvas, 'http://127.0.0.1:5175/a/tok', { + width: 200, + margin: 2 + }) + }) +}) diff --git a/apps/admin/src/utils/albums/shareLink.ts b/apps/admin/src/utils/albums/shareLink.ts new file mode 100644 index 0000000..b26895c --- /dev/null +++ b/apps/admin/src/utils/albums/shareLink.ts @@ -0,0 +1,39 @@ +/** + * 分享链接与二维码纯逻辑(P4a Task 9,依赖注入 qrcode 实现供 vitest 覆盖) + * + * - viewer URL:VITE_VIEWER_BASE + /a/{publicToken}(viewer 路由 + * /a/:token;base 缺省兜底本机 dev 5175,尾斜杠容忍) + * - 二维码:node-qrcode canvas 渲染;实现经参数注入(单测不拉真库), + * 组件侧默认传 QRCode.toCanvas + */ + +/** viewer 部署基址(缺省兜底本机 dev) */ +export const VIEWER_BASE_FALLBACK = 'http://127.0.0.1:5175' + +/** 部署基址 + 公开令牌 → viewer 相册页 URL(尾斜杠容忍) */ +export function buildViewerAlbumUrl(base: string, publicToken: string): string { + return `${base.replace(/\/+$/, '')}/a/${publicToken}` +} + +/** 环境基址(VITE_VIEWER_BASE 缺省兜底;Vite 注入 import.meta.env) */ +export function viewerBaseFromEnv(env: { VITE_VIEWER_BASE?: string }): string { + const base = env.VITE_VIEWER_BASE?.trim() + return base && base !== '' ? base : VIEWER_BASE_FALLBACK +} + +/** 二维码单点绘制函数(注入边界:组件包 QRCode.toCanvas,单测传假实现—— + @types/qrcode 的 callback 重载使整模块注入不可赋值,收窄到本签名) */ +export type QrDraw = ( + canvas: HTMLCanvasElement, + text: string, + options: { width: number; margin: number } +) => Promise | void + +/** URL → 二维码 canvas(组件 watch 相册变化时调用) */ +export async function renderShareQr( + canvas: HTMLCanvasElement, + url: string, + draw: QrDraw +): Promise { + await draw(canvas, url, { width: 200, margin: 2 }) +} diff --git a/apps/admin/src/utils/albums/usageStorage.spec.ts b/apps/admin/src/utils/albums/usageStorage.spec.ts new file mode 100644 index 0000000..ffa590c --- /dev/null +++ b/apps/admin/src/utils/albums/usageStorage.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes } from './format' +import { + alertTagType, + buildThresholdsPayload, + bytesToGb, + draftFromThresholds, + gbToBytes, + isAlertActive +} from './usageStorage' + +describe('formatBytes 人类可读(含 GB/TB,用量页大容量)', () => { + it('B/KB/MB/GB/TB 阶梯与缺省', () => { + expect(formatBytes(null)).toBe('-') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(5 * 1024 * 1024)).toBe('5.0 MB') + expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe('2.50 GB') + expect(formatBytes(3 * 1024 ** 4)).toBe('3.00 TB') + }) +}) + +describe('GB ↔ bytes 换算', () => { + it('GB → bytes 取整;负数/非法兜底 0;bytes → GB 回显', () => { + expect(gbToBytes(1)).toBe(1024 ** 3) + expect(gbToBytes(0.001)).toBe(Math.round(0.001 * 1024 ** 3)) + expect(gbToBytes(null)).toBe(0) + expect(gbToBytes(-5)).toBe(0) + expect(bytesToGb(null)).toBeNull() + expect(bytesToGb(2 * 1024 ** 3)).toBe(2) + }) +}) + +describe('阈值表单 ↔ 契约载荷', () => { + it('null 阈值 → 关闭态草稿;开启态回填 GB 值', () => { + const draft = draftFromThresholds({ storageBytes: null, perAlbumBytes: 2 * 1024 ** 3 }) + expect(draft.storage).toEqual({ enabled: false, gb: null }) + expect(draft.perAlbum).toEqual({ enabled: true, gb: 2 }) + }) + + it('开启 → bytes;关闭 → 显式 null 清除(告警停用);未填值兜底 0', () => { + const payload = buildThresholdsPayload({ + storage: { enabled: true, gb: 10 }, + perAlbum: { enabled: false, gb: null } + }) + expect(payload).toEqual({ storageBytes: 10 * 1024 ** 3, perAlbumBytes: null }) + expect( + buildThresholdsPayload({ + storage: { enabled: true, gb: null }, + perAlbum: { enabled: true, gb: 0.5 } + }) + ).toEqual({ storageBytes: 0, perAlbumBytes: Math.round(0.5 * 1024 ** 3) }) + }) +}) + +describe('告警条目展示态', () => { + it('未解决 danger(红)/ 已解决 info(灰)', () => { + expect(alertTagType({ resolvedAt: null })).toBe('danger') + expect(alertTagType({ resolvedAt: '2026-09-01T00:00:00.000Z' })).toBe('info') + expect(isAlertActive({ resolvedAt: null })).toBe(true) + expect(isAlertActive({ resolvedAt: '2026-09-01T00:00:00.000Z' })).toBe(false) + }) +}) diff --git a/apps/admin/src/utils/albums/usageStorage.ts b/apps/admin/src/utils/albums/usageStorage.ts new file mode 100644 index 0000000..1c583b3 --- /dev/null +++ b/apps/admin/src/utils/albums/usageStorage.ts @@ -0,0 +1,66 @@ +/** + * 用量页纯逻辑(P4a Task 9 / A23,无组件依赖供 vitest 覆盖) + * + * - GB ↔ bytes 换算:阈值表单以 GB 为单位(运营口径),契约传输 bytes; + * GB 输入按 1024^3 取整(契约 integer) + * - 阈值载荷:开关关闭=显式 null(清除该维度=告警停用);开启=bytes。 + * 两维度一并提交(对未触碰的维度等值重放,幂等无噪音——服务端只在 + * 值变化时评估出告警差异) + * - 告警条目 → 展示态:未解决 danger / 已解决 info(灰) + */ + +/** 1 GB 的字节数(阈值换算基准) */ +export const BYTES_PER_GB = 1024 * 1024 * 1024 + +/** GB 输入 → bytes(负数/非法值兜底 0;契约 minimum: 0) */ +export function gbToBytes(gb: number | null | undefined): number { + if (gb === null || gb === undefined || Number.isNaN(gb) || gb <= 0) return 0 + return Math.round(gb * BYTES_PER_GB) +} + +/** bytes → GB 展示值(null → null:未设置态回填表单) */ +export function bytesToGb(bytes: number | null): number | null { + if (bytes === null) return null + return bytes / BYTES_PER_GB +} + +/** 阈值表单草稿(开关表达「启用/清除」,输入以 GB 为单位) */ +export interface ThresholdFieldDraft { + enabled: boolean + /** GB 值(enabled 时应 > 0) */ + gb: number | null +} + +export interface ThresholdsDraft { + storage: ThresholdFieldDraft + perAlbum: ThresholdFieldDraft +} + +/** 当前阈值 → 表单草稿(null → 关闭态) */ +export function draftFromThresholds(thresholds: Api.Usage.Thresholds): ThresholdsDraft { + return { + storage: { enabled: thresholds.storageBytes !== null, gb: bytesToGb(thresholds.storageBytes) }, + perAlbum: { + enabled: thresholds.perAlbumBytes !== null, + gb: bytesToGb(thresholds.perAlbumBytes) + } + } +} + +/** 表单草稿 → PUT 载荷(关闭=显式 null 清除;开启但未填值兜底 0) */ +export function buildThresholdsPayload(draft: ThresholdsDraft): Api.Usage.ThresholdsUpdatePayload { + return { + storageBytes: draft.storage.enabled ? gbToBytes(draft.storage.gb) : null, + perAlbumBytes: draft.perAlbum.enabled ? gbToBytes(draft.perAlbum.gb) : null + } +} + +/** 告警 → ElTag type(未解决 danger 红 / 已解决 info 灰) */ +export function alertTagType(alert: Pick) { + return alert.resolvedAt === null ? 'danger' : 'info' +} + +/** 告警是否未解决 */ +export function isAlertActive(alert: Pick): boolean { + return alert.resolvedAt === null +} diff --git a/apps/admin/src/utils/albums/watermarkForm.spec.ts b/apps/admin/src/utils/albums/watermarkForm.spec.ts new file mode 100644 index 0000000..b05109c --- /dev/null +++ b/apps/admin/src/utils/albums/watermarkForm.spec.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { + buildWatermarkPayload, + defaultWatermarkForm, + formFromConfig, + hasRunningWatermarkJob, + previewTextStyle, + watermarkProgressPercent +} from './watermarkForm' + +describe('buildWatermarkPayload 表单 → 契约载荷', () => { + it('关闭态仅送 {enabled:false}(契约:其余字段此时被忽略)', () => { + const form = { ...defaultWatermarkForm(), textContent: '遗留文字' } + expect(buildWatermarkPayload(form)).toEqual({ enabled: false }) + }) + + it('开启 + 文字:全量参数 + text(content 去首尾空白)', () => { + const form = { + ...defaultWatermarkForm(), + enabled: true, + position: 'topRight' as const, + opacity: 70, + scalePercent: 30, + textContent: ' © Photo Live ', + fontSize: 32 + } + expect(buildWatermarkPayload(form)).toEqual({ + enabled: true, + position: 'topRight', + opacity: 70, + scalePercent: 30, + text: { content: '© Photo Live', fontSize: 32 } + }) + }) + + it('开启但文字为空:不送 text(服务端要求 content 非空)', () => { + const form = { ...defaultWatermarkForm(), enabled: true, textContent: ' ' } + const payload = buildWatermarkPayload(form) + expect(payload.enabled).toBe(true) + expect(payload.text).toBeUndefined() + }) + + it('GET current 回包(缺省字段)→ 表单填齐服务端默认口径', () => { + const form = formFromConfig({ enabled: true, text: { content: 'x' } }) + expect(form).toEqual({ + enabled: true, + position: 'bottomRight', + opacity: 50, + scalePercent: 20, + textContent: 'x', + fontSize: 24 + }) + }) +}) + +describe('hasRunningWatermarkJob 轮询决策', () => { + it('任一 pending/running → 继续轮询;全终态 → 停', () => { + const job = (state: Api.Album.WatermarkJobState): Api.Album.WatermarkJob => ({ + jobId: 'j1', + configVersion: 1, + state, + progress: 0, + total: 3, + failed: 0, + createdAt: '2026-09-01T00:00:00.000Z' + }) + expect(hasRunningWatermarkJob([job('pending'), job('done')])).toBe(true) + expect(hasRunningWatermarkJob([job('running')])).toBe(true) + expect(hasRunningWatermarkJob([job('done'), job('failed')])).toBe(false) + expect(hasRunningWatermarkJob([])).toBe(false) + }) + + it('进度百分比:total=0 视为 100(空相册即建即 done)', () => { + const base = { jobId: 'j', configVersion: 1, failed: 0, createdAt: '' } + expect(watermarkProgressPercent({ ...base, state: 'running', progress: 0, total: 0 })).toBe(100) + expect(watermarkProgressPercent({ ...base, state: 'running', progress: 1, total: 4 })).toBe(25) + // progress 可超 total(发布中途新照片纳入处理)——封顶 100 + expect(watermarkProgressPercent({ ...base, state: 'running', progress: 5, total: 4 })).toBe(100) + }) +}) + +describe('previewTextStyle 示意预览样式映射', () => { + it('不透明度百分比 → CSS opacity;位置锚点随 position 变化', () => { + const style = previewTextStyle({ + ...defaultWatermarkForm(), + enabled: true, + opacity: 50, + position: 'bottomRight' + }) + expect(style.opacity).toBe('0.5') + expect(style.bottom).toBe('8px') + expect(style.right).toBe('8px') + + const center = previewTextStyle({ + ...defaultWatermarkForm(), + position: 'center' + }) + expect(center.transform).toBe('translate(-50%, -50%)') + }) +}) diff --git a/apps/admin/src/utils/albums/watermarkForm.ts b/apps/admin/src/utils/albums/watermarkForm.ts new file mode 100644 index 0000000..50b5467 --- /dev/null +++ b/apps/admin/src/utils/albums/watermarkForm.ts @@ -0,0 +1,108 @@ +/** + * 水印卡纯逻辑(P4a Task 9,无组件依赖供 vitest 覆盖) + * + * - 表单草稿与缺省值:对齐服务端缺省(position=bottomRight / opacity=50 / + * scalePercent=20 / fontSize=24,watermark e2e ① 的填齐口径)——GET current + * 回包可直接喂表单,POST 载荷保持全量显式(值即所见) + * - 载荷构建:enabled=false 仅送 {enabled:false}(契约:其余字段此时被忽略); + * enabled=true 且文字非空才送 text(服务端要求 content 必填非空) + * - 轮询决策:任一任务 pending/running 即继续轮询(3s),全终态即停 + * - 预览样式映射:CSS 叠加示意(位置/不透明度/字号),明确标注非实际渲染 + */ + +/** 水印表单草稿(全量字段;UI 可用性由 enabled 派生) */ +export interface WatermarkFormState { + enabled: boolean + position: Api.Album.WatermarkPosition + opacity: number + scalePercent: number + textContent: string + fontSize: number +} + +/** 服务端缺省对齐的表单初值(从未配置时) */ +export function defaultWatermarkForm(): WatermarkFormState { + return { + enabled: false, + position: 'bottomRight', + opacity: 50, + scalePercent: 20, + textContent: '', + fontSize: 24 + } +} + +/** 当前生效配置 → 表单草稿(GET current 回包适配) */ +export function formFromConfig(config: Api.Album.WatermarkConfig): WatermarkFormState { + return { + enabled: config.enabled, + position: config.position ?? 'bottomRight', + opacity: config.opacity ?? 50, + scalePercent: config.scalePercent ?? 20, + textContent: config.text?.content ?? '', + fontSize: config.text?.fontSize ?? 24 + } +} + +/** 表单 → 请求载荷(enabled=false 仅送开关;文字非空才送 text) */ +export function buildWatermarkPayload(form: WatermarkFormState): Api.Album.WatermarkConfig { + if (!form.enabled) return { enabled: false } + const content = form.textContent.trim() + return { + enabled: true, + position: form.position, + opacity: form.opacity, + scalePercent: form.scalePercent, + ...(content !== '' ? { text: { content, fontSize: form.fontSize } } : {}) + } +} + +/** 是否仍有进行中的任务(pending/running → 继续轮询) */ +export function hasRunningWatermarkJob(jobs: Api.Album.WatermarkJob[]): boolean { + return jobs.some((job) => job.state === 'pending' || job.state === 'running') +} + +/** 任务进度百分比(total=0 视为 100——空相册 POST 即建即 done) */ +export function watermarkProgressPercent(job: Api.Album.WatermarkJob): number { + if (job.total <= 0) return 100 + return Math.min(100, Math.round((job.progress / job.total) * 100)) +} + +/** 预览框宽度(px;示意尺寸,等比缩放实际效果) */ +export const WATERMARK_PREVIEW_WIDTH = 280 +/** 预览框高度(px) */ +export const WATERMARK_PREVIEW_HEIGHT = 180 + +/** 位置 → 预览叠加层的定位方式(absolute 锚点) */ +export function previewPositionStyle( + position: Api.Album.WatermarkPosition +): Record { + switch (position) { + case 'topLeft': + return { top: '8px', left: '8px' } + case 'topRight': + return { top: '8px', right: '8px' } + case 'bottomLeft': + return { bottom: '8px', left: '8px' } + case 'bottomRight': + return { bottom: '8px', right: '8px' } + case 'center': + return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' } + } +} + +/** + * 表单 → 预览文字叠加样式(CSS 示意,非 sharp 实际渲染)。 + * 字号按预览框与常用浏览图宽(1400px)等比缩放;scalePercent 体现为 + * 文字最大宽度占预览框的百分比。 + */ +export function previewTextStyle(form: WatermarkFormState): Record { + const scale = WATERMARK_PREVIEW_WIDTH / 1400 + return { + ...previewPositionStyle(form.position), + opacity: String(form.opacity / 100), + fontSize: `${Math.max(8, Math.round(form.fontSize * scale))}px`, + maxWidth: `${form.scalePercent}%`, + position: 'absolute' + } +} diff --git a/apps/admin/src/views/albums/detail/AlbumPhotos.vue b/apps/admin/src/views/albums/detail/AlbumPhotos.vue index 4989502..85f913e 100644 --- a/apps/admin/src/views/albums/detail/AlbumPhotos.vue +++ b/apps/admin/src/views/albums/detail/AlbumPhotos.vue @@ -1,6 +1,10 @@ + 单条操作:隐藏/恢复发布/软删除(行内回显);P4a Task 9 增量: + · 多选(仅 published 可选——批量隐藏的状态机前置)+ 批量隐藏(逐项结果 + toast 汇总 成功x/失败y + 失败详情弹窗逐条错误码本地化;成功行原位 + 置 hidden,失败行不动——A16 部分成功语义) + · likeCount 副本列(P2 PhotoRecord 回传;相册级 statsSyncedAt 在详情 + 信息卡展示,照片行无单行同步时间)。 --> @@ -82,6 +92,18 @@ {{ formatDateTime(album.updatedAt) }} + + + {{ + album.statsSyncedAt + ? formatDateTime(album.statsSyncedAt) + : $t('albumsManage.detail.statsNotSynced') + }} + + + + {{ album.ownerId }} +
@@ -111,10 +133,30 @@ + + + + + + + + +
@@ -129,6 +171,9 @@ import AlbumMembers from './AlbumMembers.vue' import AlbumCategories from './AlbumCategories.vue' import AlbumPhotos from './AlbumPhotos.vue' + import AlbumWatermark from './AlbumWatermark.vue' + import AlbumShareCard from './AlbumShareCard.vue' + import AlbumTransferDialog from './AlbumTransferDialog.vue' import { fetchAlbum } from '@/api/albums' import { formatDateTime } from '@/utils/albums/format' @@ -144,6 +189,7 @@ const loading = ref(false) const error = ref(null) const editVisible = ref(false) + const transferVisible = ref(false) const activeTab = ref('photos') async function load() { @@ -163,6 +209,11 @@ album.value = saved } + /** 交接成功:回包 ownerId 已变更,原位替换相册详情 */ + function onTransferred(transferred: Api.Album.Detail) { + album.value = transferred + } + function titleOf(target: Api.Album.Detail | null): string { if (!target) return '' const preferred = target.translations.find((item) => item.locale === target.defaultLocale) diff --git a/apps/admin/src/views/system/usage/ThresholdsDialog.vue b/apps/admin/src/views/system/usage/ThresholdsDialog.vue new file mode 100644 index 0000000..7c1138e --- /dev/null +++ b/apps/admin/src/views/system/usage/ThresholdsDialog.vue @@ -0,0 +1,102 @@ + + + + diff --git a/apps/admin/src/views/system/usage/index.vue b/apps/admin/src/views/system/usage/index.vue new file mode 100644 index 0000000..dfbb90b --- /dev/null +++ b/apps/admin/src/views/system/usage/index.vue @@ -0,0 +1,204 @@ + + + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3da7a92..231429e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: pinia-plugin-persistedstate: specifier: ^4.3.0 version: 4.7.1(pinia@3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3))) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 tailwindcss: specifier: ^4.1.14 version: 4.3.3 @@ -102,6 +105,9 @@ importers: '@types/node': specifier: ^24.0.5 version: 24.13.5 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@typescript-eslint/eslint-plugin': specifier: ^8.3.0 version: 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.6.3) @@ -2331,6 +2337,9 @@ packages: '@types/node@24.13.5': resolution: {integrity: sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/sortablejs@1.15.9': resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} @@ -2974,6 +2983,9 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -3137,6 +3149,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -3212,6 +3228,9 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dompurify@3.4.15: resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==} @@ -4721,6 +4740,10 @@ packages: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -4790,6 +4813,11 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -4885,6 +4913,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -4974,6 +5005,9 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@2.7.1: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} @@ -5806,6 +5840,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -5823,6 +5860,10 @@ packages: wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5889,6 +5930,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5903,6 +5947,10 @@ packages: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -5911,6 +5959,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.0.1: resolution: {integrity: sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ==} engines: {node: '>=12'} @@ -7762,6 +7814,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.13.5 + '@types/sortablejs@1.15.9': {} '@types/stack-utils@2.0.3': {} @@ -8690,6 +8746,12 @@ snapshots: classnames@2.5.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -8847,6 +8909,8 @@ snapshots: optionalDependencies: supports-color: 10.2.2 + decamelize@1.2.0: {} + decimal.js@10.6.0: {} decko@1.2.0: {} @@ -8891,6 +8955,8 @@ snapshots: diff@4.0.4: {} + dijkstrajs@1.0.3: {} + dompurify@3.4.15: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -10503,7 +10569,7 @@ snapshots: oas-kit-common: 1.0.8 reftools: 1.1.9 yaml: 1.10.3 - yargs: 17.0.1 + yargs: 17.7.3 oas-schema-walker@1.1.5: {} @@ -10730,6 +10796,8 @@ snapshots: pluralize@8.0.0: {} + pngjs@5.0.0: {} + polished@4.3.1: dependencies: '@babel/runtime': 7.29.7 @@ -10797,6 +10865,12 @@ snapshots: pure-rand@6.1.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.2: dependencies: side-channel: 1.1.1 @@ -10901,6 +10975,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -11017,6 +11093,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-cookie-parser@2.7.1: {} setprototypeof@1.2.0: {} @@ -11286,7 +11364,7 @@ snapshots: oas-validator: 5.0.8 reftools: 1.1.9 yaml: 1.10.3 - yargs: 17.0.1 + yargs: 17.7.3 transitivePeerDependencies: - encoding @@ -11917,6 +11995,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -11930,6 +12010,12 @@ snapshots: wordwrap@1.0.0: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -11959,6 +12045,8 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -11967,10 +12055,29 @@ snapshots: yaml@1.10.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.0.1: dependencies: cliui: 7.0.4