feat(admin): batch ops, transfer, watermark, usage and qr share pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -18,3 +18,7 @@ VITE_OPEN_ROUTE_INFO = false
|
|||||||
|
|
||||||
# 锁屏加密密钥
|
# 锁屏加密密钥
|
||||||
VITE_LOCK_ENCRYPT_KEY = change-me-lock-key
|
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
|
||||||
|
|||||||
@@ -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<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}`)
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -43,19 +43,33 @@ export async function loginAsAdmin(page: Page): Promise<void> {
|
|||||||
await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 })
|
await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 直种夹具(seed-fixture.cjs):staff 账号 / 相册种子照片(幂等) */
|
/** 直种夹具(seed-fixture.cjs):staff 账号 / 相册种子照片 / 治理批量照片 /
|
||||||
export function seedFixture(mode: 'staff' | 'photos', ...args: string[]): void {
|
* 水印旅程真实对象照片 / 水印任务直驱(详见 seed-fixture.cjs 文件头) */
|
||||||
|
export function seedFixture(
|
||||||
|
mode: 'staff' | 'photos' | 'gov-photos' | 'wm-photos' | 'watermark-run',
|
||||||
|
...args: string[]
|
||||||
|
): void {
|
||||||
const vars = rootEnv()
|
const vars = rootEnv()
|
||||||
const databaseUrl = vars.get('DATABASE_URL_CN')
|
const databaseUrl = vars.get('DATABASE_URL_CN')
|
||||||
if (!databaseUrl) {
|
if (!databaseUrl) {
|
||||||
throw new Error('[admin e2e] 根 .env 缺少 DATABASE_URL_CN')
|
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 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(' ')}`, {
|
execSync(`node seed-fixture.cjs ${mode} ${args.join(' ')}`, {
|
||||||
cwd: fileURLToPath(new URL('.', import.meta.url)),
|
cwd: fileURLToPath(new URL('.', import.meta.url)),
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
shell: true,
|
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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+232
-11
@@ -2,24 +2,50 @@
|
|||||||
// 为什么不用 API 造数:目标照片需要 published/processing_failed 等终态,
|
// 为什么不用 API 造数:目标照片需要 published/processing_failed 等终态,
|
||||||
// 真实链路要 worker 处理流水线;e2e 只验证管理页展示与操作,直插 DB 行
|
// 真实链路要 worker 处理流水线;e2e 只验证管理页展示与操作,直插 DB 行
|
||||||
// (uploadedAt 置位即可入管理列表,见 services/api listAlbumPhotos 过滤条件)。
|
// (uploadedAt 置位即可入管理列表,见 services/api listAlbumPhotos 过滤条件)。
|
||||||
// 依赖经 PHOTO_DB_CN 环境变量注入 @photo/db-cn 绝对路径(apps/admin 不依赖
|
// 依赖经环境变量注入包绝对路径(apps/admin 不依赖这些包,node 从文件位置
|
||||||
// 该包,node 从文件位置解析不到;services/api 的 node_modules 里有现成链接)。
|
// 解析不到;services/api 的 node_modules 里有现成链接):
|
||||||
// 用法:
|
// PHOTO_DB_CN=@photo/db-cn(必填) PHOTO_STORAGE=@photo/storage(水印模式)
|
||||||
// node seed-fixture.cjs staff <account> <displayName>
|
// 模式:
|
||||||
// node seed-fixture.cjs photos <albumId> <uploaderAccount>
|
// staff <account> <displayName> 员工账号(不可登录)
|
||||||
// 幂等:账号存在即跳过;照片按 objectKey upsert(同相册重复种不重复建行)。
|
// photos <albumId> <uploaderAccount> published/failed 各 1 张
|
||||||
const db_cn = process.env.PHOTO_DB_CN
|
// gov-photos <albumId> <uploaderAccount> <count> [bytes] 批量隐藏/用量旅程:
|
||||||
|
// N 张 published/ready
|
||||||
|
// DB 直种(含 thumb/browse
|
||||||
|
// 变体行——cn 公开列表计入;
|
||||||
|
// bytes 可调大以跨阈值)
|
||||||
|
// wm-photos <albumId> <uploaderAccount> <count> 水印旅程:N 张已发布
|
||||||
|
// 照片 + 真实 MinIO 对象
|
||||||
|
// (原图/browse/thumb,键
|
||||||
|
// 对齐 worker 发布后形态)
|
||||||
|
// watermark-run <albumId> 直驱 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
|
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')
|
console.error('seed-fixture: PHOTO_DB_CN and DATABASE_URL_CN env vars are required')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CommonJS(node 直跑,无构建步骤):动态 require 经环境变量注入的包路径
|
// 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 } } })
|
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } })
|
||||||
|
|
||||||
|
/** worker 包根(sharp / dist 直驱均从这里解析)。 */
|
||||||
|
const workerRoot = path.resolve(__dirname, '..', '..', '..', 'services', 'worker')
|
||||||
|
|
||||||
async function seedStaff(account, displayName) {
|
async function seedStaff(account, displayName) {
|
||||||
const existing = await prisma.users.findUnique({ where: { account } })
|
const existing = await prisma.users.findUnique({ where: { account } })
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -88,12 +114,207 @@ async function seedPhotos(albumId, uploaderAccount) {
|
|||||||
console.log(`seed-fixture: 2 photos seeded for album ${albumId}`)
|
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(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">` +
|
||||||
|
Array.from(
|
||||||
|
{ length: 12 },
|
||||||
|
(_, i) =>
|
||||||
|
`<rect x="${(i * 83) % width}" y="${(i * 47) % height}" width="${50 + i * 9}" height="${35 + i * 7}" fill="#${(0x50 + i * 7).toString(16).padStart(2, '0')}6030" fill-opacity="0.85"/>`
|
||||||
|
).join('') +
|
||||||
|
'</svg>'
|
||||||
|
)
|
||||||
|
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() {
|
async function main() {
|
||||||
const [mode, argA, argB] = process.argv.slice(2)
|
const [mode, argA, argB, argC, argD] = process.argv.slice(2)
|
||||||
if (mode === 'staff') {
|
if (mode === 'staff') {
|
||||||
await seedStaff(argA, argB)
|
await seedStaff(argA, argB)
|
||||||
} else if (mode === 'photos') {
|
} else if (mode === 'photos') {
|
||||||
await seedPhotos(argA, argB)
|
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 {
|
} else {
|
||||||
throw new Error(`seed-fixture: unknown mode "${mode}"`)
|
throw new Error(`seed-fixture: unknown mode "${mode}"`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"ohash": "^2.0.11",
|
"ohash": "^2.0.11",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinia-plugin-persistedstate": "^4.3.0",
|
"pinia-plugin-persistedstate": "^4.3.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tailwindcss": "^4.1.14",
|
"tailwindcss": "^4.1.14",
|
||||||
"vue": "3.5.22",
|
"vue": "3.5.22",
|
||||||
"vue-draggable-plus": "^0.6.0",
|
"vue-draggable-plus": "^0.6.0",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
"@playwright/test": "^1.48.0",
|
"@playwright/test": "^1.48.0",
|
||||||
"@types/node": "^24.0.5",
|
"@types/node": "^24.0.5",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||||
"@typescript-eslint/parser": "^8.3.0",
|
"@typescript-eslint/parser": "^8.3.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
|||||||
@@ -121,3 +121,34 @@ export function fetchUpdatePhotoVisibility(photoId: string, visibility: 'hidden'
|
|||||||
export function fetchDeletePhoto(photoId: string) {
|
export function fetchDeletePhoto(photoId: string) {
|
||||||
return request.del<void>({ url: `/v1/photos/${photoId}` })
|
return request.del<void>({ url: `/v1/photos/${photoId}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 批量隐藏(P4a A16:逐项结果部分成功;单批上限 200) */
|
||||||
|
export function fetchHidePhotosBatch(albumId: string, photoIds: string[]) {
|
||||||
|
return request.post<Api.Album.BatchHideResult>({
|
||||||
|
url: `/v1/albums/${albumId}/photos/hide-batch`,
|
||||||
|
data: { photoIds }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 相册负责人交接(P4a A17:仅 admin;历史上传人不变) */
|
||||||
|
export function fetchTransferAlbum(albumId: string, data: Api.Album.TransferPayload) {
|
||||||
|
return request.post<Api.Album.Detail>({
|
||||||
|
url: `/v1/admin/albums/${albumId}/transfer`,
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 水印配置与任务状态(current 从未配置为 null) */
|
||||||
|
export function fetchWatermarkJobs(albumId: string) {
|
||||||
|
return request.get<Api.Album.WatermarkJobsStatus>({
|
||||||
|
url: `/v1/albums/${albumId}/watermark-jobs`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存水印配置并发起历史重生成(202 受理;进度经 GET 轮询) */
|
||||||
|
export function fetchCreateWatermarkJob(albumId: string, config: Api.Album.WatermarkConfig) {
|
||||||
|
return request.post<Api.Album.WatermarkJobCreated>({
|
||||||
|
url: `/v1/albums/${albumId}/watermark-jobs`,
|
||||||
|
data: config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Api.Usage.SummaryResponse>({ url: '/v1/admin/usage/summary' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新用量阈值(修改后服务端即刻重新评估告警) */
|
||||||
|
export function fetchUpdateUsageThresholds(data: Api.Usage.ThresholdsUpdatePayload) {
|
||||||
|
return request.put<Api.Usage.Thresholds>({
|
||||||
|
url: '/v1/admin/usage/thresholds',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
Vendored
+10
@@ -1,5 +1,15 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// 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 'nprogress'
|
||||||
|
|
||||||
declare module 'crypto-js'
|
declare module 'crypto-js'
|
||||||
|
|||||||
@@ -233,7 +233,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"title": "Accounts & Sync",
|
"title": "Accounts & Sync",
|
||||||
"users": "Account Management",
|
"users": "Account Management",
|
||||||
"userSync": "User Sync"
|
"userSync": "User Sync",
|
||||||
|
"usage": "Usage"
|
||||||
},
|
},
|
||||||
"albums": {
|
"albums": {
|
||||||
"title": "Albums",
|
"title": "Albums",
|
||||||
@@ -339,6 +340,8 @@
|
|||||||
"tabPhotos": "Photos",
|
"tabPhotos": "Photos",
|
||||||
"tabMembers": "Members",
|
"tabMembers": "Members",
|
||||||
"tabCategories": "Categories",
|
"tabCategories": "Categories",
|
||||||
|
"tabWatermark": "Watermark",
|
||||||
|
"tabShare": "Share",
|
||||||
"loadFailed": "Failed to load album",
|
"loadFailed": "Failed to load album",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"fieldTitle": "Title",
|
"fieldTitle": "Title",
|
||||||
@@ -348,7 +351,10 @@
|
|||||||
"fieldStartAt": "Start",
|
"fieldStartAt": "Start",
|
||||||
"fieldEndAt": "End",
|
"fieldEndAt": "End",
|
||||||
"fieldCreatedAt": "Created",
|
"fieldCreatedAt": "Created",
|
||||||
"fieldUpdatedAt": "Updated"
|
"fieldUpdatedAt": "Updated",
|
||||||
|
"fieldStatsSyncedAt": "Stats Synced At",
|
||||||
|
"statsNotSynced": "Not synced yet",
|
||||||
|
"fieldOwnerId": "Owner"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"kind": "Asset Kind",
|
"kind": "Asset Kind",
|
||||||
@@ -438,7 +444,114 @@
|
|||||||
"hidden": "Photo hidden",
|
"hidden": "Photo hidden",
|
||||||
"restored": "Photo restored",
|
"restored": "Photo restored",
|
||||||
"deleted": "Photo deleted",
|
"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": {
|
"usersManage": {
|
||||||
@@ -594,6 +707,7 @@
|
|||||||
"MEMBER_CONFLICT": "This user is already a member",
|
"MEMBER_CONFLICT": "This user is already a member",
|
||||||
"ALBUM_NOT_FOUND": "Album not found or not visible",
|
"ALBUM_NOT_FOUND": "Album not found or not visible",
|
||||||
"PHOTO_NOT_FOUND": "Photo not found",
|
"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_VERIFY_FAILED": "Upload verification failed, please retry",
|
||||||
"UPLOAD_SESSION_CONFLICT": "Upload session conflict, please start over",
|
"UPLOAD_SESSION_CONFLICT": "Upload session conflict, please start over",
|
||||||
"ALBUM_UPLOAD_DISABLED": "Upload is disabled for this album"
|
"ALBUM_UPLOAD_DISABLED": "Upload is disabled for this album"
|
||||||
|
|||||||
@@ -233,7 +233,8 @@
|
|||||||
"system": {
|
"system": {
|
||||||
"title": "账号与同步",
|
"title": "账号与同步",
|
||||||
"users": "账号管理",
|
"users": "账号管理",
|
||||||
"userSync": "用户同步"
|
"userSync": "用户同步",
|
||||||
|
"usage": "用量统计"
|
||||||
},
|
},
|
||||||
"albums": {
|
"albums": {
|
||||||
"title": "相册管理",
|
"title": "相册管理",
|
||||||
@@ -339,6 +340,8 @@
|
|||||||
"tabPhotos": "照片管理",
|
"tabPhotos": "照片管理",
|
||||||
"tabMembers": "成员管理",
|
"tabMembers": "成员管理",
|
||||||
"tabCategories": "分类管理",
|
"tabCategories": "分类管理",
|
||||||
|
"tabWatermark": "水印管理",
|
||||||
|
"tabShare": "分享",
|
||||||
"loadFailed": "相册加载失败",
|
"loadFailed": "相册加载失败",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"fieldTitle": "标题",
|
"fieldTitle": "标题",
|
||||||
@@ -348,7 +351,10 @@
|
|||||||
"fieldStartAt": "开始时间",
|
"fieldStartAt": "开始时间",
|
||||||
"fieldEndAt": "结束时间",
|
"fieldEndAt": "结束时间",
|
||||||
"fieldCreatedAt": "创建时间",
|
"fieldCreatedAt": "创建时间",
|
||||||
"fieldUpdatedAt": "更新时间"
|
"fieldUpdatedAt": "更新时间",
|
||||||
|
"fieldStatsSyncedAt": "统计同步时间",
|
||||||
|
"statsNotSynced": "尚无回传",
|
||||||
|
"fieldOwnerId": "负责人"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"kind": "资产类型",
|
"kind": "资产类型",
|
||||||
@@ -438,7 +444,114 @@
|
|||||||
"hidden": "照片已隐藏",
|
"hidden": "照片已隐藏",
|
||||||
"restored": "照片已恢复发布",
|
"restored": "照片已恢复发布",
|
||||||
"deleted": "照片已删除",
|
"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": {
|
"usersManage": {
|
||||||
@@ -594,6 +707,7 @@
|
|||||||
"MEMBER_CONFLICT": "该用户已是相册成员",
|
"MEMBER_CONFLICT": "该用户已是相册成员",
|
||||||
"ALBUM_NOT_FOUND": "相册不存在或不可见",
|
"ALBUM_NOT_FOUND": "相册不存在或不可见",
|
||||||
"PHOTO_NOT_FOUND": "照片不存在",
|
"PHOTO_NOT_FOUND": "照片不存在",
|
||||||
|
"PHOTO_NOT_PUBLIC": "照片状态不可隐藏(非已发布)",
|
||||||
"UPLOAD_VERIFY_FAILED": "上传核验失败,请重试",
|
"UPLOAD_VERIFY_FAILED": "上传核验失败,请重试",
|
||||||
"UPLOAD_SESSION_CONFLICT": "上传会话状态冲突,请重新开始",
|
"UPLOAD_SESSION_CONFLICT": "上传会话状态冲突,请重新开始",
|
||||||
"ALBUM_UPLOAD_DISABLED": "该相册已关闭上传"
|
"ALBUM_UPLOAD_DISABLED": "该相册已关闭上传"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 账号治理路由(P4a Task 8:账号管理 + 用户同步)
|
* 账号治理路由(P4a Task 8:账号管理 + 用户同步;Task 9:用量统计)
|
||||||
*/
|
*/
|
||||||
import { AppRouteRecord } from '@/types/router'
|
import { AppRouteRecord } from '@/types/router'
|
||||||
|
|
||||||
@@ -31,6 +31,16 @@ export const systemRoutes: AppRouteRecord = {
|
|||||||
icon: 'ri:refresh-line',
|
icon: 'ri:refresh-line',
|
||||||
keepAlive: false
|
keepAlive: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'usage',
|
||||||
|
name: 'SystemUsage',
|
||||||
|
component: '/system/usage',
|
||||||
|
meta: {
|
||||||
|
title: 'menus.system.usage',
|
||||||
|
icon: 'ri:pie-chart-line',
|
||||||
|
keepAlive: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+71
@@ -44,6 +44,8 @@ declare namespace Api {
|
|||||||
translations: Translation[]
|
translations: Translation[]
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
/** 统计副本最近同步时间(P2 A27 ov→cn 快照应用时刻;null=尚无回传) */
|
||||||
|
statsSyncedAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分类(相册详情内嵌,按 sortOrder 升序) */
|
/** 分类(相册详情内嵌,按 sortOrder 升序) */
|
||||||
@@ -214,5 +216,74 @@ declare namespace Api {
|
|||||||
interface PhotoVisibilityUpdatePayload {
|
interface PhotoVisibilityUpdatePayload {
|
||||||
visibility: 'hidden' | 'published'
|
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[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+69
@@ -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[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -80,11 +80,13 @@ declare module 'vue' {
|
|||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||||
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
|
ElSlider: typeof import('element-plus/es')['ElSlider']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTable: typeof import('element-plus/es')['ElTable']
|
||||||
|
|||||||
@@ -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<string, string> = {
|
||||||
|
'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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<typeof useI18n>['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)
|
||||||
|
}
|
||||||
@@ -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())}`
|
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 {
|
export function formatBytes(bytes: number | null | undefined): string {
|
||||||
if (bytes === null || bytes === undefined) return '-'
|
if (bytes === null || bytes === undefined) return '-'
|
||||||
if (bytes < 1024) return `${bytes} B`
|
if (bytes < 1024) return `${bytes} B`
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
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`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<unknown> | void
|
||||||
|
|
||||||
|
/** URL → 二维码 canvas(组件 watch 相册变化时调用) */
|
||||||
|
export async function renderShareQr(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
url: string,
|
||||||
|
draw: QrDraw
|
||||||
|
): Promise<void> {
|
||||||
|
await draw(canvas, url, { width: 200, margin: 2 })
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Api.Usage.AlertItem, 'resolvedAt'>) {
|
||||||
|
return alert.resolvedAt === null ? 'danger' : 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警是否未解决 */
|
||||||
|
export function isAlertActive(alert: Pick<Api.Usage.AlertItem, 'resolvedAt'>): boolean {
|
||||||
|
return alert.resolvedAt === null
|
||||||
|
}
|
||||||
@@ -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%)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string> {
|
||||||
|
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<string, string> {
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
|
<!-- 照片管理(listAlbumPhotos):全状态可见 + uploader 脱敏 + 可见性/分类过滤。
|
||||||
单条操作:隐藏(published→hidden)/恢复发布(hidden→published)/软删除;
|
单条操作:隐藏/恢复发布/软删除(行内回显);P4a Task 9 增量:
|
||||||
批量隐藏属 P4a,此处仅单条。操作结果按条回显(行内状态即时更新 + 消息)。 -->
|
· 多选(仅 published 可选——批量隐藏的状态机前置)+ 批量隐藏(逐项结果
|
||||||
|
toast 汇总 成功x/失败y + 失败详情弹窗逐条错误码本地化;成功行原位
|
||||||
|
置 hidden,失败行不动——A16 部分成功语义)
|
||||||
|
· likeCount 副本列(P2 PhotoRecord 回传;相册级 statsSyncedAt 在详情
|
||||||
|
信息卡展示,照片行无单行同步时间)。 -->
|
||||||
<template>
|
<template>
|
||||||
<div data-test="photos-panel">
|
<div data-test="photos-panel">
|
||||||
<div class="flex flex-wrap items-center gap-3 mb-4">
|
<div class="flex flex-wrap items-center gap-3 mb-4">
|
||||||
@@ -32,13 +36,31 @@
|
|||||||
:label="categoryLabel(category)"
|
:label="categoryLabel(category)"
|
||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
|
<ElButton
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
:disabled="selectedPublished.length === 0"
|
||||||
|
:loading="batchHiding"
|
||||||
|
data-test="photo-batch-hide-btn"
|
||||||
|
@click="batchHide"
|
||||||
|
>
|
||||||
|
{{ t('albumsManage.photos.batchHide') }}
|
||||||
|
<span v-if="selectedPublished.length" class="ml-1">({{ selectedPublished.length }})</span>
|
||||||
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElAlert v-if="error && !items.length" type="error" :closable="false">
|
<ElAlert v-if="error && !items.length" type="error" :closable="false">
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</ElAlert>
|
</ElAlert>
|
||||||
|
|
||||||
<ElTable v-else v-loading="loading" :data="items" data-test="photo-list-table">
|
<ElTable
|
||||||
|
v-else
|
||||||
|
v-loading="loading"
|
||||||
|
:data="items"
|
||||||
|
data-test="photo-list-table"
|
||||||
|
@selection-change="onSelectionChange"
|
||||||
|
>
|
||||||
|
<ElTableColumn type="selection" width="44" :selectable="batchSelectable" />
|
||||||
<ElTableColumn :label="t('albumsManage.photos.colState')" width="120">
|
<ElTableColumn :label="t('albumsManage.photos.colState')" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<ElTag :type="stateTagType(row.processingState)" data-test="photo-state-tag">
|
<ElTag :type="stateTagType(row.processingState)" data-test="photo-state-tag">
|
||||||
@@ -70,6 +92,11 @@
|
|||||||
<span>{{ row.uploader.displayName || row.uploader.account }}</span>
|
<span>{{ row.uploader.displayName || row.uploader.account }}</span>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="t('albumsManage.photos.colLikeCount')" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-gray-500" data-test="photo-like-count">{{ row.likeCount }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn :label="t('albumsManage.photos.colUploadedAt')" width="160">
|
<ElTableColumn :label="t('albumsManage.photos.colUploadedAt')" width="160">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<span>{{ formatDateTime(row.uploadedAt) }}</span>
|
<span>{{ formatDateTime(row.uploadedAt) }}</span>
|
||||||
@@ -116,16 +143,59 @@
|
|||||||
{{ $t('albumsManage.list.loadMore') }}
|
{{ $t('albumsManage.list.loadMore') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量隐藏失败详情(逐条错误码本地化;成功行已原位回显) -->
|
||||||
|
<ElDialog
|
||||||
|
v-model="failuresVisible"
|
||||||
|
:title="t('albumsManage.photos.batchFailuresTitle')"
|
||||||
|
width="480px"
|
||||||
|
data-test="photo-batch-failures-dialog"
|
||||||
|
>
|
||||||
|
<ElTable :data="batchSummary?.failures ?? []" size="small">
|
||||||
|
<ElTableColumn :label="t('albumsManage.photos.batchColPhoto')" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-mono text-xs break-all">{{ row.photoId }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="t('albumsManage.photos.batchColReason')" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag type="danger" effect="plain" size="small">
|
||||||
|
{{ localizeBatchHideCode(t, row.code) }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
</ElTable>
|
||||||
|
<template #footer>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
data-test="photo-batch-failures-close"
|
||||||
|
@click="failuresVisible = false"
|
||||||
|
>
|
||||||
|
{{ t('common.confirm') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { fetchAlbumPhotos, fetchDeletePhoto, fetchUpdatePhotoVisibility } from '@/api/albums'
|
import {
|
||||||
|
fetchAlbumPhotos,
|
||||||
|
fetchDeletePhoto,
|
||||||
|
fetchHidePhotosBatch,
|
||||||
|
fetchUpdatePhotoVisibility
|
||||||
|
} from '@/api/albums'
|
||||||
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
||||||
import { formatBytes, formatDateTime } from '@/utils/albums/format'
|
import { formatBytes, formatDateTime } from '@/utils/albums/format'
|
||||||
|
import {
|
||||||
|
indexOfPhotoId,
|
||||||
|
localizeBatchHideCode,
|
||||||
|
summarizeBatchHideResults,
|
||||||
|
type BatchHideSummary
|
||||||
|
} from '@/utils/albums/batchHide'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
album: Api.Album.Detail
|
album: Api.Album.Detail
|
||||||
@@ -245,4 +315,69 @@
|
|||||||
}
|
}
|
||||||
ElMessage.success(t('albumsManage.photos.deleted'))
|
ElMessage.success(t('albumsManage.photos.deleted'))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 批量隐藏(P4a A16:逐项结果部分成功) ──
|
||||||
|
|
||||||
|
/** 仅 published 可选(批量隐藏的目标态前置;其余状态行勾选禁用) */
|
||||||
|
function batchSelectable(row: Api.Album.AdminPhoto): boolean {
|
||||||
|
return row.visibility === 'published'
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = ref<Api.Album.AdminPhoto[]>([])
|
||||||
|
|
||||||
|
function onSelectionChange(rows: Api.Album.AdminPhoto[]) {
|
||||||
|
selection.value = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/** selection 经 selectable 约束本就全为 published;过滤一层兜底 */
|
||||||
|
const selectedPublished = computed(() =>
|
||||||
|
selection.value.filter((row) => row.visibility === 'published')
|
||||||
|
)
|
||||||
|
|
||||||
|
const batchHiding = ref(false)
|
||||||
|
const failuresVisible = ref(false)
|
||||||
|
const batchSummary = ref<BatchHideSummary | null>(null)
|
||||||
|
|
||||||
|
async function batchHide() {
|
||||||
|
const targets = selectedPublished.value
|
||||||
|
if (targets.length === 0) return
|
||||||
|
if (!(await confirmBox(t('albumsManage.photos.batchHideConfirm', { count: targets.length })))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchHiding.value = true
|
||||||
|
try {
|
||||||
|
const result = await fetchHidePhotosBatch(
|
||||||
|
props.album.id,
|
||||||
|
targets.map((row) => row.photoId)
|
||||||
|
)
|
||||||
|
const summary = summarizeBatchHideResults(result.results)
|
||||||
|
batchSummary.value = summary
|
||||||
|
// 成功项原位置 hidden(结果与请求同序;失败行保持原样);过滤条件下
|
||||||
|
// 与目标态不符则移除
|
||||||
|
for (const item of result.results) {
|
||||||
|
if (!item.ok) continue
|
||||||
|
const index = indexOfPhotoId(items.value, item.photoId)
|
||||||
|
if (index < 0) continue
|
||||||
|
const row = items.value[index]!
|
||||||
|
if (visibilityFilter.value !== '' && visibilityFilter.value !== 'hidden') {
|
||||||
|
list.removeItem(index)
|
||||||
|
} else {
|
||||||
|
list.replaceItem(index, { ...row, visibility: 'hidden' as const })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (summary.failCount > 0) {
|
||||||
|
failuresVisible.value = true
|
||||||
|
ElMessage.warning(
|
||||||
|
t('albumsManage.photos.batchResult', {
|
||||||
|
ok: summary.okCount,
|
||||||
|
fail: summary.failCount
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ElMessage.success(t('albumsManage.photos.batchResultAllOk', { ok: summary.okCount }))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
batchHiding.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!-- 分享链接与二维码卡(P4a Task 9,评审缺口补齐):viewer URL
|
||||||
|
(VITE_VIEWER_BASE + /a/{publicToken})+ qrcode canvas 渲染(相册切换
|
||||||
|
重绘)+ 复制链接。公开开关关闭时提示不可分享(链接仍展示——开关由
|
||||||
|
编辑表单控制,此处只读提示)。 -->
|
||||||
|
<template>
|
||||||
|
<div data-test="share-panel" class="max-w-xl">
|
||||||
|
<ElAlert
|
||||||
|
v-if="!album.publicEnabled"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
:title="t('albumsManage.share.publicOffHint')"
|
||||||
|
class="mb-4"
|
||||||
|
data-test="share-public-off-hint"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-start gap-6">
|
||||||
|
<div class="flex-1 min-w-260px">
|
||||||
|
<div class="text-gray-500 mb-1">{{ t('albumsManage.share.viewerUrl') }}</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code
|
||||||
|
class="bg-gray-100 dark:bg-gray-800 px-3 py-2 rounded text-sm font-mono break-all"
|
||||||
|
data-test="share-url"
|
||||||
|
>
|
||||||
|
{{ viewerUrl }}
|
||||||
|
</code>
|
||||||
|
<ElButton type="primary" plain data-test="share-copy-btn" @click="copyLink">
|
||||||
|
{{ t('albumsManage.share.copy') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-400 text-xs mt-2">{{ t('albumsManage.share.hint') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<canvas ref="qrCanvas" width="200" height="200" data-test="share-qr-canvas" />
|
||||||
|
<div class="text-gray-400 text-xs mt-1">{{ t('albumsManage.share.qrTitle') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import {
|
||||||
|
buildViewerAlbumUrl,
|
||||||
|
renderShareQr,
|
||||||
|
viewerBaseFromEnv,
|
||||||
|
type QrDraw
|
||||||
|
} from '@/utils/albums/shareLink'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
album: Api.Album.Detail
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const viewerUrl = computed(() =>
|
||||||
|
buildViewerAlbumUrl(viewerBaseFromEnv(import.meta.env), props.album.publicToken)
|
||||||
|
)
|
||||||
|
|
||||||
|
const qrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
|
||||||
|
/** QRCode.toCanvas 适配到注入签名(callback 重载在箭头内按实参解析) */
|
||||||
|
const drawQrCode: QrDraw = (canvas, text, options) => QRCode.toCanvas(canvas, text, options)
|
||||||
|
|
||||||
|
async function drawQr(): Promise<void> {
|
||||||
|
if (!qrCanvas.value) return
|
||||||
|
try {
|
||||||
|
await renderShareQr(qrCanvas.value, viewerUrl.value, drawQrCode)
|
||||||
|
} catch {
|
||||||
|
// 渲染失败保留空 canvas(复制链接仍可用);不打断页面
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void drawQr()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 相册变化(路由复用组件)即重绘二维码 */
|
||||||
|
watch(
|
||||||
|
() => props.album.publicToken,
|
||||||
|
() => {
|
||||||
|
void drawQr()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async function copyLink(): Promise<void> {
|
||||||
|
await navigator.clipboard.writeText(viewerUrl.value)
|
||||||
|
ElMessage.success(t('albumsManage.list.copied'))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<!-- 负责人交接对话框(P4a A17):账号前缀搜索目标用户(fetchLookupUsers,
|
||||||
|
≥2 字符)+ 是否保留原 owner 为协作者开关。警告:历史上传人不变。
|
||||||
|
POST /admin/albums/{id}/transfer → 200 相册详情(ownerId 已变更),
|
||||||
|
成功后 emit saved 由详情页刷新。 -->
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="$t('albumsManage.transfer.title')"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
data-test="transfer-dialog"
|
||||||
|
>
|
||||||
|
<ElAlert
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
:title="$t('albumsManage.transfer.historyHint')"
|
||||||
|
data-test="transfer-history-hint"
|
||||||
|
/>
|
||||||
|
<ElForm label-width="140px" class="mt-4">
|
||||||
|
<ElFormItem :label="$t('albumsManage.transfer.targetLabel')">
|
||||||
|
<ElSelect
|
||||||
|
v-model="targetUserId"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
clearable
|
||||||
|
:remote-method="searchUsers"
|
||||||
|
:loading="searching"
|
||||||
|
:placeholder="t('albumsManage.transfer.searchPlaceholder')"
|
||||||
|
style="width: 100%"
|
||||||
|
data-test="transfer-target-select"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="user in options"
|
||||||
|
:key="user.id"
|
||||||
|
:value="user.id"
|
||||||
|
:label="`${user.account}(${user.displayName})`"
|
||||||
|
:data-test="`transfer-option-${user.account}`"
|
||||||
|
/>
|
||||||
|
<template #empty>
|
||||||
|
<span class="px-4 py-2 text-gray-400 text-sm">
|
||||||
|
{{
|
||||||
|
query.length < 2
|
||||||
|
? t('albumsManage.transfer.searchPlaceholder')
|
||||||
|
: searching
|
||||||
|
? t('albumsManage.members.searching')
|
||||||
|
: t('albumsManage.members.noMatch')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('albumsManage.transfer.keepLabel')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElSwitch v-model="keepAsCollaborator" data-test="transfer-keep-switch" />
|
||||||
|
<span class="text-gray-400 text-sm">{{ t('albumsManage.transfer.keepHint') }}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<ElButton data-test="transfer-cancel" @click="visible = false">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:disabled="!targetUserId"
|
||||||
|
:loading="transferring"
|
||||||
|
data-test="transfer-submit"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ t('albumsManage.transfer.submit') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { fetchLookupUsers, fetchTransferAlbum } from '@/api/albums'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
album: Api.Album.Detail
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 交接成功(回传新相册详情,ownerId 已变更) */
|
||||||
|
saved: [album: Api.Album.Detail]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// ── 目标账号前缀搜索(≥2 字符才发起;同成员邀请模式) ──
|
||||||
|
const query = ref('')
|
||||||
|
const options = ref<Api.Album.UserLookupItem[]>([])
|
||||||
|
const searching = ref(false)
|
||||||
|
const targetUserId = ref('')
|
||||||
|
const keepAsCollaborator = ref(true)
|
||||||
|
const transferring = ref(false)
|
||||||
|
|
||||||
|
watch(visible, (open) => {
|
||||||
|
if (open) {
|
||||||
|
query.value = ''
|
||||||
|
options.value = []
|
||||||
|
targetUserId.value = ''
|
||||||
|
keepAsCollaborator.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 submit(): Promise<void> {
|
||||||
|
if (!targetUserId.value) return
|
||||||
|
transferring.value = true
|
||||||
|
try {
|
||||||
|
const album = await fetchTransferAlbum(props.album.id, {
|
||||||
|
targetUserId: targetUserId.value,
|
||||||
|
keepAsCollaborator: keepAsCollaborator.value
|
||||||
|
})
|
||||||
|
ElMessage.success(t('albumsManage.transfer.success'))
|
||||||
|
visible.value = false
|
||||||
|
emit('saved', album)
|
||||||
|
} finally {
|
||||||
|
transferring.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
<!-- 水印管理卡(P4a Task 9 / A18 A20):当前配置展示 + 开关参数表单 →
|
||||||
|
POST watermark-jobs(202 受理)→ 版本历史(进度条轮询 3s,任一任务
|
||||||
|
pending/running 即续轮,全终态停)+ 发起重生成(=以当前表单再次 POST)。
|
||||||
|
「测试水印」为 CSS 叠加示意预览(非 sharp 实际渲染,明确标注)。 -->
|
||||||
|
<template>
|
||||||
|
<div data-test="watermark-panel">
|
||||||
|
<ElAlert v-if="error && !status" type="error" :closable="false">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ t('albumsManage.watermark.loadFailed') }}:{{ error.message }}</span>
|
||||||
|
<ElButton size="small" data-test="watermark-retry" @click="load">
|
||||||
|
{{ t('albumsManage.watermark.retry') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</ElAlert>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- ── 当前配置 ── -->
|
||||||
|
<div class="mb-4" data-test="watermark-current">
|
||||||
|
<span class="text-gray-500">{{ t('albumsManage.watermark.currentVersion') }}</span>
|
||||||
|
<ElTag
|
||||||
|
class="ml-2"
|
||||||
|
:type="form.enabled ? 'success' : 'info'"
|
||||||
|
data-test="watermark-current-tag"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
status?.current
|
||||||
|
? t('albumsManage.watermark.versionTag', {
|
||||||
|
version: status.current.configVersion,
|
||||||
|
state: form.enabled
|
||||||
|
? t('albumsManage.watermark.enabled')
|
||||||
|
: t('albumsManage.watermark.disabled')
|
||||||
|
})
|
||||||
|
: t('albumsManage.watermark.neverConfigured')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<span v-if="status?.current" class="ml-3 text-gray-400 text-sm">
|
||||||
|
{{ configSummary(status.current.config) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<!-- ── 参数表单 ── -->
|
||||||
|
<div>
|
||||||
|
<ElForm label-width="130px">
|
||||||
|
<ElFormItem :label="t('albumsManage.watermark.switchLabel')">
|
||||||
|
<ElSwitch v-model="form.enabled" data-test="watermark-enabled-switch" />
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="t('albumsManage.watermark.positionLabel')">
|
||||||
|
<ElSelect
|
||||||
|
v-model="form.position"
|
||||||
|
:disabled="!form.enabled"
|
||||||
|
style="width: 180px"
|
||||||
|
data-test="watermark-position"
|
||||||
|
>
|
||||||
|
<ElOption
|
||||||
|
v-for="pos in POSITIONS"
|
||||||
|
:key="pos"
|
||||||
|
:value="pos"
|
||||||
|
:label="t(`albumsManage.watermark.position_${pos}`)"
|
||||||
|
/>
|
||||||
|
</ElSelect>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="t('albumsManage.watermark.opacityLabel')">
|
||||||
|
<div class="flex items-center gap-3 w-full">
|
||||||
|
<ElSlider
|
||||||
|
v-model="form.opacity"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
:disabled="!form.enabled"
|
||||||
|
data-test="watermark-opacity"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-500 w-10 text-right">{{ form.opacity }}%</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="t('albumsManage.watermark.scaleLabel')">
|
||||||
|
<div class="flex items-center gap-3 w-full">
|
||||||
|
<ElSlider
|
||||||
|
v-model="form.scalePercent"
|
||||||
|
:min="5"
|
||||||
|
:max="50"
|
||||||
|
:disabled="!form.enabled"
|
||||||
|
data-test="watermark-scale"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-500 w-10 text-right">{{ form.scalePercent }}%</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="t('albumsManage.watermark.textContentLabel')">
|
||||||
|
<ElInput
|
||||||
|
v-model="form.textContent"
|
||||||
|
:disabled="!form.enabled"
|
||||||
|
maxlength="64"
|
||||||
|
:placeholder="t('albumsManage.watermark.textContentPlaceholder')"
|
||||||
|
data-test="watermark-text-content"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="t('albumsManage.watermark.fontSizeLabel')">
|
||||||
|
<ElInputNumber
|
||||||
|
v-model="form.fontSize"
|
||||||
|
:min="8"
|
||||||
|
:max="72"
|
||||||
|
:disabled="!form.enabled || form.textContent.trim() === ''"
|
||||||
|
data-test="watermark-font-size"
|
||||||
|
/>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
:loading="submitting"
|
||||||
|
data-test="watermark-save-btn"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ t('albumsManage.watermark.save') }}
|
||||||
|
</ElButton>
|
||||||
|
<span class="text-gray-400 text-sm">{{ t('albumsManage.watermark.saveHint') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 示意预览(CSS 叠加,非实际渲染) ── -->
|
||||||
|
<div>
|
||||||
|
<div class="text-gray-500 mb-1">{{ t('albumsManage.watermark.previewTitle') }}</div>
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden rounded border border-gray-200 dark:border-gray-700 bg-gradient-to-br from-sky-200 via-indigo-200 to-rose-200"
|
||||||
|
:style="{
|
||||||
|
width: `${WATERMARK_PREVIEW_WIDTH}px`,
|
||||||
|
height: `${WATERMARK_PREVIEW_HEIGHT}px`
|
||||||
|
}"
|
||||||
|
data-test="watermark-preview-box"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-slate-800 select-none whitespace-nowrap"
|
||||||
|
:style="previewTextStyle(form)"
|
||||||
|
data-test="watermark-preview-text"
|
||||||
|
>
|
||||||
|
{{ previewText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-400 text-xs mt-1">
|
||||||
|
{{ t('albumsManage.watermark.previewHint') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 版本历史 ── -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-medium">{{ t('albumsManage.watermark.jobsTitle') }}</span>
|
||||||
|
<ElButton
|
||||||
|
plain
|
||||||
|
:loading="submitting"
|
||||||
|
:disabled="!status?.current"
|
||||||
|
data-test="watermark-regen-btn"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
{{ t('albumsManage.watermark.regen') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
<ElTable
|
||||||
|
v-loading="loading"
|
||||||
|
:data="status?.jobs ?? []"
|
||||||
|
size="small"
|
||||||
|
data-test="watermark-jobs-table"
|
||||||
|
>
|
||||||
|
<ElTableColumn :label="t('albumsManage.watermark.colVersion')" width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>v{{ row.configVersion }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="t('albumsManage.watermark.colState')" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="jobTagType(row.state)" :data-state="row.state">
|
||||||
|
{{ t(`albumsManage.watermark.state_${row.state}`) }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="t('albumsManage.watermark.colProgress')" min-width="200">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElProgress
|
||||||
|
:percentage="watermarkProgressPercent(row)"
|
||||||
|
:status="row.state === 'failed' ? 'exception' : undefined"
|
||||||
|
data-test="watermark-job-progress"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-400 text-xs">
|
||||||
|
{{ row.progress }} / {{ row.total }}
|
||||||
|
<template v-if="row.failed > 0">
|
||||||
|
· {{ t('albumsManage.watermark.failedCount', { count: row.failed }) }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="t('albumsManage.watermark.colCreatedAt')" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ formatDateTime(row.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<template #empty>
|
||||||
|
<ElEmpty v-if="!loading" :description="$t('albumsManage.watermark.jobsEmpty')" />
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { fetchCreateWatermarkJob, fetchWatermarkJobs } from '@/api/albums'
|
||||||
|
import { formatDateTime } from '@/utils/albums/format'
|
||||||
|
import {
|
||||||
|
buildWatermarkPayload,
|
||||||
|
defaultWatermarkForm,
|
||||||
|
formFromConfig,
|
||||||
|
hasRunningWatermarkJob,
|
||||||
|
previewTextStyle,
|
||||||
|
watermarkProgressPercent,
|
||||||
|
WATERMARK_PREVIEW_HEIGHT,
|
||||||
|
WATERMARK_PREVIEW_WIDTH,
|
||||||
|
type WatermarkFormState
|
||||||
|
} from '@/utils/albums/watermarkForm'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
album: Api.Album.Detail
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const POSITIONS: Api.Album.WatermarkPosition[] = [
|
||||||
|
'topLeft',
|
||||||
|
'topRight',
|
||||||
|
'bottomLeft',
|
||||||
|
'bottomRight',
|
||||||
|
'center'
|
||||||
|
]
|
||||||
|
|
||||||
|
const status = ref<Api.Album.WatermarkJobsStatus | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const form = reactive<WatermarkFormState>(defaultWatermarkForm())
|
||||||
|
|
||||||
|
/** 预览文案:表单文字缺省用相册标题示意 */
|
||||||
|
const previewText = computed(() => {
|
||||||
|
const content = form.textContent.trim()
|
||||||
|
if (content !== '') return content
|
||||||
|
const preferred = props.album.translations.find(
|
||||||
|
(item) => item.locale === props.album.defaultLocale
|
||||||
|
)
|
||||||
|
return preferred?.title ?? 'Watermark'
|
||||||
|
})
|
||||||
|
|
||||||
|
function jobTagType(
|
||||||
|
state: Api.Album.WatermarkJobState
|
||||||
|
): 'success' | 'warning' | 'danger' | 'info' {
|
||||||
|
switch (state) {
|
||||||
|
case 'done':
|
||||||
|
return 'success'
|
||||||
|
case 'running':
|
||||||
|
return 'warning'
|
||||||
|
case 'failed':
|
||||||
|
return 'danger'
|
||||||
|
default:
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前配置参数摘要(一行文本) */
|
||||||
|
function configSummary(config: Api.Album.WatermarkConfig): string {
|
||||||
|
const parts = [
|
||||||
|
t(`albumsManage.watermark.position_${config.position ?? 'bottomRight'}`),
|
||||||
|
`${t('albumsManage.watermark.opacityLabel')} ${config.opacity ?? 50}%`,
|
||||||
|
`${t('albumsManage.watermark.scaleLabel')} ${config.scalePercent ?? 20}%`
|
||||||
|
]
|
||||||
|
if (config.text?.content) {
|
||||||
|
parts.push(`${t('albumsManage.watermark.fontSizeLabel')} ${config.text.fontSize ?? 24}`)
|
||||||
|
}
|
||||||
|
return parts.join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
status.value = await fetchWatermarkJobs(props.album.id)
|
||||||
|
if (status.value.current) {
|
||||||
|
Object.assign(form, formFromConfig(status.value.current.config))
|
||||||
|
}
|
||||||
|
syncPolling()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存配置并发起历史重生成(202 受理 → 刷新状态进入进度模式) */
|
||||||
|
async function save(): Promise<void> {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const created = await fetchCreateWatermarkJob(
|
||||||
|
props.album.id,
|
||||||
|
buildWatermarkPayload({ ...form })
|
||||||
|
)
|
||||||
|
ElMessage.success(t('albumsManage.watermark.accepted', { version: created.configVersion }))
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 进度轮询:任一 pending/running 每 3s 刷新,全终态即停 ──
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
let polling = false
|
||||||
|
|
||||||
|
function syncPolling(): void {
|
||||||
|
const running = hasRunningWatermarkJob(status.value?.jobs ?? [])
|
||||||
|
if (running && pollTimer === null) {
|
||||||
|
pollTimer = setInterval(() => void pollTick(), 3000)
|
||||||
|
} else if (!running && pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollTick(): Promise<void> {
|
||||||
|
if (polling) return
|
||||||
|
polling = true
|
||||||
|
try {
|
||||||
|
status.value = await fetchWatermarkJobs(props.album.id)
|
||||||
|
syncPolling()
|
||||||
|
} catch {
|
||||||
|
// 单次轮询失败静默(下一周期重试;持续失败由用户手动刷新兜底)
|
||||||
|
} finally {
|
||||||
|
polling = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (pollTimer !== null) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -21,9 +21,19 @@
|
|||||||
<span class="font-medium">{{ titleOf(album) }}</span>
|
<span class="font-medium">{{ titleOf(album) }}</span>
|
||||||
<ElTag size="small" type="info">{{ album?.defaultLocale }}</ElTag>
|
<ElTag size="small" type="info">{{ album?.defaultLocale }}</ElTag>
|
||||||
</div>
|
</div>
|
||||||
<ElButton type="primary" plain data-test="album-detail-edit" @click="editVisible = true">
|
<div class="flex items-center gap-2">
|
||||||
{{ $t('albumsManage.detail.edit') }}
|
<ElButton plain data-test="album-detail-transfer" @click="transferVisible = true">
|
||||||
</ElButton>
|
{{ $t('albumsManage.transfer.entry') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
data-test="album-detail-edit"
|
||||||
|
@click="editVisible = true"
|
||||||
|
>
|
||||||
|
{{ $t('albumsManage.detail.edit') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -82,6 +92,18 @@
|
|||||||
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldUpdatedAt')">
|
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldUpdatedAt')">
|
||||||
{{ formatDateTime(album.updatedAt) }}
|
{{ formatDateTime(album.updatedAt) }}
|
||||||
</ElDescriptionsItem>
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldStatsSyncedAt')">
|
||||||
|
<span data-test="album-detail-stats-synced-at">
|
||||||
|
{{
|
||||||
|
album.statsSyncedAt
|
||||||
|
? formatDateTime(album.statsSyncedAt)
|
||||||
|
: $t('albumsManage.detail.statsNotSynced')
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
|
<ElDescriptionsItem :label="$t('albumsManage.detail.fieldOwnerId')">
|
||||||
|
<span data-test="album-detail-owner-id">{{ album.ownerId }}</span>
|
||||||
|
</ElDescriptionsItem>
|
||||||
</ElDescriptions>
|
</ElDescriptions>
|
||||||
|
|
||||||
<div v-if="album" class="mt-5">
|
<div v-if="album" class="mt-5">
|
||||||
@@ -111,10 +133,30 @@
|
|||||||
</template>
|
</template>
|
||||||
<AlbumCategories :album="album" @changed="load" />
|
<AlbumCategories :album="album" @changed="load" />
|
||||||
</ElTabPane>
|
</ElTabPane>
|
||||||
|
<ElTabPane name="watermark">
|
||||||
|
<template #label>
|
||||||
|
<span data-test="album-watermark-tab">
|
||||||
|
{{ $t('albumsManage.detail.tabWatermark') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<AlbumWatermark :album="album" />
|
||||||
|
</ElTabPane>
|
||||||
|
<ElTabPane name="share">
|
||||||
|
<template #label>
|
||||||
|
<span data-test="album-share-tab">{{ $t('albumsManage.detail.tabShare') }}</span>
|
||||||
|
</template>
|
||||||
|
<AlbumShareCard :album="album" />
|
||||||
|
</ElTabPane>
|
||||||
</ElTabs>
|
</ElTabs>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
|
|
||||||
<AlbumFormDialog v-if="album" v-model="editVisible" :album="album" @saved="onAlbumSaved" />
|
<AlbumFormDialog v-if="album" v-model="editVisible" :album="album" @saved="onAlbumSaved" />
|
||||||
|
<AlbumTransferDialog
|
||||||
|
v-if="album"
|
||||||
|
v-model="transferVisible"
|
||||||
|
:album="album"
|
||||||
|
@saved="onTransferred"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -129,6 +171,9 @@
|
|||||||
import AlbumMembers from './AlbumMembers.vue'
|
import AlbumMembers from './AlbumMembers.vue'
|
||||||
import AlbumCategories from './AlbumCategories.vue'
|
import AlbumCategories from './AlbumCategories.vue'
|
||||||
import AlbumPhotos from './AlbumPhotos.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 { fetchAlbum } from '@/api/albums'
|
||||||
import { formatDateTime } from '@/utils/albums/format'
|
import { formatDateTime } from '@/utils/albums/format'
|
||||||
|
|
||||||
@@ -144,6 +189,7 @@
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<Error | null>(null)
|
const error = ref<Error | null>(null)
|
||||||
const editVisible = ref(false)
|
const editVisible = ref(false)
|
||||||
|
const transferVisible = ref(false)
|
||||||
const activeTab = ref('photos')
|
const activeTab = ref('photos')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
@@ -163,6 +209,11 @@
|
|||||||
album.value = saved
|
album.value = saved
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 交接成功:回包 ownerId 已变更,原位替换相册详情 */
|
||||||
|
function onTransferred(transferred: Api.Album.Detail) {
|
||||||
|
album.value = transferred
|
||||||
|
}
|
||||||
|
|
||||||
function titleOf(target: Api.Album.Detail | null): string {
|
function titleOf(target: Api.Album.Detail | null): string {
|
||||||
if (!target) return ''
|
if (!target) return ''
|
||||||
const preferred = target.translations.find((item) => item.locale === target.defaultLocale)
|
const preferred = target.translations.find((item) => item.locale === target.defaultLocale)
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<!-- 阈值设置对话框(P4a Task 9 / A23):两维度各以「启用开关 + GB 输入」
|
||||||
|
表达——开关关闭=显式 null 清除(告警停用),开启=GB→bytes 提交。
|
||||||
|
保存后服务端即刻重新评估(超阈产生新告警/阈值上调解除旧告警)。 -->
|
||||||
|
<template>
|
||||||
|
<ElDialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="$t('usage.thresholds.title')"
|
||||||
|
width="520px"
|
||||||
|
destroy-on-close
|
||||||
|
data-test="thresholds-dialog"
|
||||||
|
>
|
||||||
|
<ElForm label-width="150px">
|
||||||
|
<ElFormItem :label="$t('usage.thresholds.storageLabel')">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ElSwitch v-model="draft.storage.enabled" data-test="thresholds-storage-switch" />
|
||||||
|
<!-- key 随开关重挂:element-plus 2.11 的 input-number 只在 mount 时写
|
||||||
|
内层 input 的 aria-disabled,prop 后续变化不重写——重挂保持无障碍
|
||||||
|
状态与实际一致(e2e fill 亦据此判定可编辑) -->
|
||||||
|
<ElInputNumber
|
||||||
|
:key="`storage-${draft.storage.enabled}`"
|
||||||
|
v-model="draft.storage.gb"
|
||||||
|
:min="0"
|
||||||
|
:precision="3"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!draft.storage.enabled"
|
||||||
|
data-test="thresholds-storage-input"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-400">GB</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('usage.thresholds.perAlbumLabel')">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ElSwitch v-model="draft.perAlbum.enabled" data-test="thresholds-per-album-switch" />
|
||||||
|
<ElInputNumber
|
||||||
|
:key="`per-album-${draft.perAlbum.enabled}`"
|
||||||
|
v-model="draft.perAlbum.gb"
|
||||||
|
:min="0"
|
||||||
|
:precision="9"
|
||||||
|
:step="1"
|
||||||
|
:disabled="!draft.perAlbum.enabled"
|
||||||
|
data-test="thresholds-per-album-input"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-400">GB</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
</ElForm>
|
||||||
|
<div class="text-gray-400 text-sm">{{ t('usage.thresholds.hint') }}</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<ElButton data-test="thresholds-cancel" @click="visible = false">
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</ElButton>
|
||||||
|
<ElButton type="primary" :loading="submitting" data-test="thresholds-submit" @click="submit">
|
||||||
|
{{ t('usage.thresholds.submit') }}
|
||||||
|
</ElButton>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { fetchUpdateUsageThresholds } from '@/api/usage'
|
||||||
|
import {
|
||||||
|
buildThresholdsPayload,
|
||||||
|
draftFromThresholds,
|
||||||
|
type ThresholdsDraft
|
||||||
|
} from '@/utils/albums/usageStorage'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** 当前阈值(打开时回填表单) */
|
||||||
|
thresholds: Api.Usage.Thresholds
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
/** 保存成功(回传服务端确认后的阈值) */
|
||||||
|
saved: [thresholds: Api.Usage.Thresholds]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const draft = reactive<ThresholdsDraft>(draftFromThresholds(props.thresholds))
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
watch(visible, (open) => {
|
||||||
|
if (open) Object.assign(draft, draftFromThresholds(props.thresholds))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const saved = await fetchUpdateUsageThresholds(buildThresholdsPayload(draft))
|
||||||
|
ElMessage.success(t('usage.thresholds.saved'))
|
||||||
|
visible.value = false
|
||||||
|
emit('saved', saved)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<!-- 用量页(P4a Task 9 / A23 后台侧):合计头卡(照片数/总存储/阈值)+
|
||||||
|
相册用量表(title/photoCount/originalBytes/variantBytes/totalBytes
|
||||||
|
人类可读)+ 阈值设置对话框 + 告警列表(未解决红/已解决灰,含信息与时间)。 -->
|
||||||
|
<template>
|
||||||
|
<div class="p-5">
|
||||||
|
<!-- ── 合计头卡 ── -->
|
||||||
|
<div class="grid gap-4 mb-4 sm:grid-cols-3" data-test="usage-totals">
|
||||||
|
<ElCard shadow="never">
|
||||||
|
<div class="text-gray-500 text-sm">{{ $t('usage.totals.photoCount') }}</div>
|
||||||
|
<div class="text-2xl font-medium mt-1" data-test="usage-total-photos">
|
||||||
|
{{ summary?.totals.photoCount ?? '-' }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard shadow="never">
|
||||||
|
<div class="text-gray-500 text-sm">{{ $t('usage.totals.totalBytes') }}</div>
|
||||||
|
<div class="text-2xl font-medium mt-1" data-test="usage-total-bytes">
|
||||||
|
{{ summary ? formatBytes(summary.totals.totalBytes) : '-' }}
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
<ElCard shadow="never">
|
||||||
|
<div class="text-gray-500 text-sm">{{ $t('usage.totals.thresholds') }}</div>
|
||||||
|
<div class="mt-1 flex flex-wrap gap-2" data-test="usage-threshold-tags">
|
||||||
|
<ElTag size="small" :type="summary?.thresholds.storageBytes ? 'success' : 'info'">
|
||||||
|
{{ $t('usage.thresholds.storageShort') }}
|
||||||
|
{{
|
||||||
|
summary?.thresholds.storageBytes
|
||||||
|
? formatBytes(summary.thresholds.storageBytes)
|
||||||
|
: $t('usage.thresholds.off')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
<ElTag size="small" :type="summary?.thresholds.perAlbumBytes ? 'success' : 'info'">
|
||||||
|
{{ $t('usage.thresholds.perAlbumShort') }}
|
||||||
|
{{
|
||||||
|
summary?.thresholds.perAlbumBytes
|
||||||
|
? formatBytes(summary.thresholds.perAlbumBytes)
|
||||||
|
: $t('usage.thresholds.off')
|
||||||
|
}}
|
||||||
|
</ElTag>
|
||||||
|
</div>
|
||||||
|
</ElCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 相册用量 + 告警 ── -->
|
||||||
|
<ElCard data-test="usage-albums-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>{{ $t('usage.albums.title') }}</span>
|
||||||
|
<ElButton
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
data-test="usage-thresholds-btn"
|
||||||
|
@click="thresholdsVisible = true"
|
||||||
|
>
|
||||||
|
{{ $t('usage.thresholds.entry') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ElAlert v-if="error && !summary" type="error" :closable="false" data-test="usage-error">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>{{ $t('usage.loadFailed') }}:{{ error.message }}</span>
|
||||||
|
<ElButton size="small" data-test="usage-retry" @click="load">
|
||||||
|
{{ $t('usage.retry') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</ElAlert>
|
||||||
|
|
||||||
|
<ElTable
|
||||||
|
v-else
|
||||||
|
v-loading="loading"
|
||||||
|
:data="summary?.albums ?? []"
|
||||||
|
data-test="usage-albums-table"
|
||||||
|
>
|
||||||
|
<ElTableColumn :label="$t('usage.albums.colTitle')" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.title || row.albumId }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.albums.colPhotoCount')" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ row.photoCount }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.albums.colOriginal')" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-gray-500">{{ formatBytes(row.originalBytes) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.albums.colVariant')" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-gray-500">{{ formatBytes(row.variantBytes) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.albums.colTotal')" min-width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="font-medium">{{ formatBytes(row.totalBytes) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<template #empty>
|
||||||
|
<ElEmpty v-if="!loading" :description="$t('usage.albums.empty')" />
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<!-- ── 告警列表 ── -->
|
||||||
|
<ElCard class="mt-4" data-test="usage-alerts-card">
|
||||||
|
<template #header>
|
||||||
|
<span>{{ $t('usage.alerts.title') }}</span>
|
||||||
|
</template>
|
||||||
|
<ElTable
|
||||||
|
:data="summary?.alerts ?? []"
|
||||||
|
data-test="usage-alerts-table"
|
||||||
|
:row-class-name="alertRowClass"
|
||||||
|
>
|
||||||
|
<ElTableColumn :label="$t('usage.alerts.colState')" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag
|
||||||
|
:type="alertTagType(row)"
|
||||||
|
:effect="isAlertActive(row) ? 'light' : 'plain'"
|
||||||
|
data-test="alert-state-tag"
|
||||||
|
>
|
||||||
|
{{ isAlertActive(row) ? $t('usage.alerts.active') : $t('usage.alerts.resolved') }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.alerts.colKind')" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<ElTag :type="row.kind === 'storage' ? 'warning' : 'info'" effect="plain">
|
||||||
|
{{ $t(`usage.alerts.kind_${row.kind}`) }}
|
||||||
|
</ElTag>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.alerts.colMessage')" min-width="220">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-sm break-all" data-test="alert-message">{{ row.message }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.alerts.colCreatedAt')" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span>{{ formatDateTime(row.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usage.alerts.colResolvedAt')" width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="text-gray-500">{{ formatDateTime(row.resolvedAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<template #empty>
|
||||||
|
<ElEmpty v-if="!loading" :description="$t('usage.alerts.empty')" />
|
||||||
|
</template>
|
||||||
|
</ElTable>
|
||||||
|
</ElCard>
|
||||||
|
|
||||||
|
<ThresholdsDialog
|
||||||
|
v-if="summary"
|
||||||
|
v-model="thresholdsVisible"
|
||||||
|
:thresholds="summary.thresholds"
|
||||||
|
@saved="onThresholdsSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { fetchUsageSummary } from '@/api/usage'
|
||||||
|
import { formatBytes, formatDateTime } from '@/utils/albums/format'
|
||||||
|
import { alertTagType, isAlertActive } from '@/utils/albums/usageStorage'
|
||||||
|
import ThresholdsDialog from './ThresholdsDialog.vue'
|
||||||
|
|
||||||
|
defineOptions({ name: 'SystemUsage' })
|
||||||
|
|
||||||
|
const summary = ref<Api.Usage.SummaryResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const thresholdsVisible = ref(false)
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
summary.value = await fetchUsageSummary()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 阈值保存后刷新整页(服务端保存即评估——新告警/解除即时可见) */
|
||||||
|
function onThresholdsSaved(thresholds: Api.Usage.Thresholds): void {
|
||||||
|
if (summary.value) summary.value = { ...summary.value, thresholds }
|
||||||
|
void load()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 告警行 class(e2e 锚点:未解决/已解决行区分)。ElTable row-class-name
|
||||||
|
* 回调载荷是 {row, rowIndex}——解构取行,直接收行会把整个载荷当行判态。 */
|
||||||
|
function alertRowClass({ row }: { row: Api.Usage.AlertItem }): string {
|
||||||
|
return isAlertActive(row) ? 'alert-row-active' : 'alert-row-resolved'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Generated
+109
-2
@@ -77,6 +77,9 @@ importers:
|
|||||||
pinia-plugin-persistedstate:
|
pinia-plugin-persistedstate:
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.7.1(pinia@3.0.4(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3)))
|
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:
|
tailwindcss:
|
||||||
specifier: ^4.1.14
|
specifier: ^4.1.14
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
@@ -102,6 +105,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^24.0.5
|
specifier: ^24.0.5
|
||||||
version: 24.13.5
|
version: 24.13.5
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
'@typescript-eslint/eslint-plugin':
|
'@typescript-eslint/eslint-plugin':
|
||||||
specifier: ^8.3.0
|
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)
|
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':
|
'@types/node@24.13.5':
|
||||||
resolution: {integrity: sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==}
|
resolution: {integrity: sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/sortablejs@1.15.9':
|
'@types/sortablejs@1.15.9':
|
||||||
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
|
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
|
||||||
|
|
||||||
@@ -2974,6 +2983,9 @@ packages:
|
|||||||
classnames@2.5.1:
|
classnames@2.5.1:
|
||||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@7.0.4:
|
cliui@7.0.4:
|
||||||
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
|
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
|
||||||
|
|
||||||
@@ -3137,6 +3149,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@@ -3212,6 +3228,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dompurify@3.4.15:
|
dompurify@3.4.15:
|
||||||
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
|
resolution: {integrity: sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==}
|
||||||
|
|
||||||
@@ -4721,6 +4740,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
polished@4.3.1:
|
polished@4.3.1:
|
||||||
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
|
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -4790,6 +4813,11 @@ packages:
|
|||||||
pure-rand@6.1.0:
|
pure-rand@6.1.0:
|
||||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
qs@6.14.2:
|
qs@6.14.2:
|
||||||
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
|
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -4885,6 +4913,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve-cwd@3.0.0:
|
resolve-cwd@3.0.0:
|
||||||
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
|
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -4974,6 +5005,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
set-cookie-parser@2.7.1:
|
set-cookie-parser@2.7.1:
|
||||||
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
|
resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
|
||||||
|
|
||||||
@@ -5806,6 +5840,9 @@ packages:
|
|||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -5823,6 +5860,10 @@ packages:
|
|||||||
wordwrap@1.0.0:
|
wordwrap@1.0.0:
|
||||||
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
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:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5889,6 +5930,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5903,6 +5947,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@20.2.9:
|
yargs-parser@20.2.9:
|
||||||
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
|
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -5911,6 +5959,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@17.0.1:
|
yargs@17.0.1:
|
||||||
resolution: {integrity: sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ==}
|
resolution: {integrity: sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -7762,6 +7814,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
undici-types: 7.18.2
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.13.5
|
||||||
|
|
||||||
'@types/sortablejs@1.15.9': {}
|
'@types/sortablejs@1.15.9': {}
|
||||||
|
|
||||||
'@types/stack-utils@2.0.3': {}
|
'@types/stack-utils@2.0.3': {}
|
||||||
@@ -8690,6 +8746,12 @@ snapshots:
|
|||||||
|
|
||||||
classnames@2.5.1: {}
|
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:
|
cliui@7.0.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@@ -8847,6 +8909,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 10.2.2
|
supports-color: 10.2.2
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
decko@1.2.0: {}
|
decko@1.2.0: {}
|
||||||
@@ -8891,6 +8955,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@4.0.4: {}
|
diff@4.0.4: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dompurify@3.4.15:
|
dompurify@3.4.15:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/trusted-types': 2.0.7
|
'@types/trusted-types': 2.0.7
|
||||||
@@ -10503,7 +10569,7 @@ snapshots:
|
|||||||
oas-kit-common: 1.0.8
|
oas-kit-common: 1.0.8
|
||||||
reftools: 1.1.9
|
reftools: 1.1.9
|
||||||
yaml: 1.10.3
|
yaml: 1.10.3
|
||||||
yargs: 17.0.1
|
yargs: 17.7.3
|
||||||
|
|
||||||
oas-schema-walker@1.1.5: {}
|
oas-schema-walker@1.1.5: {}
|
||||||
|
|
||||||
@@ -10730,6 +10796,8 @@ snapshots:
|
|||||||
|
|
||||||
pluralize@8.0.0: {}
|
pluralize@8.0.0: {}
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
polished@4.3.1:
|
polished@4.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
@@ -10797,6 +10865,12 @@ snapshots:
|
|||||||
|
|
||||||
pure-rand@6.1.0: {}
|
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:
|
qs@6.14.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.1
|
side-channel: 1.1.1
|
||||||
@@ -10901,6 +10975,8 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve-cwd@3.0.0:
|
resolve-cwd@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
resolve-from: 5.0.0
|
resolve-from: 5.0.0
|
||||||
@@ -11017,6 +11093,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
set-cookie-parser@2.7.1: {}
|
set-cookie-parser@2.7.1: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
@@ -11286,7 +11364,7 @@ snapshots:
|
|||||||
oas-validator: 5.0.8
|
oas-validator: 5.0.8
|
||||||
reftools: 1.1.9
|
reftools: 1.1.9
|
||||||
yaml: 1.10.3
|
yaml: 1.10.3
|
||||||
yargs: 17.0.1
|
yargs: 17.7.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- encoding
|
- encoding
|
||||||
|
|
||||||
@@ -11917,6 +11995,8 @@ snapshots:
|
|||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -11930,6 +12010,12 @@ snapshots:
|
|||||||
|
|
||||||
wordwrap@1.0.0: {}
|
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:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -11959,6 +12045,8 @@ snapshots:
|
|||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
@@ -11967,10 +12055,29 @@ snapshots:
|
|||||||
|
|
||||||
yaml@1.10.3: {}
|
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@20.2.9: {}
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
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:
|
yargs@17.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 7.0.4
|
cliui: 7.0.4
|
||||||
|
|||||||
Reference in New Issue
Block a user