diff --git a/apps/admin/e2e/locale-en.spec.ts b/apps/admin/e2e/locale-en.spec.ts new file mode 100644 index 0000000..c865713 --- /dev/null +++ b/apps/admin/e2e/locale-en.spec.ts @@ -0,0 +1,36 @@ +// T15(A12)运行时抽查(P4b Task 6):admin 主旅程 en 版。 +// 模板 i18n zh/en 键集一致性 + 错误码映射枚举覆盖由 error.spec.ts 机器守护; +// 此处验证真实 DOM:登录页顶栏语言菜单(用户级设置,AuthTopBar)切 English → +// 登录表单/按钮英文文案 → 正确登录 → 相册管理列表页英文(菜单/新建按钮/ +// 空态)。zh 版主旅程由既有 login/albums 系列覆盖;viewer en 旅程由 +// viewer e2e 各 spec 覆盖;embed iframe lang 两语言见 viewer e2e/embed.spec.ts。 +import { expect, test } from '@playwright/test' +import { adminCredentials } from './helpers' + +test('en locale: admin 主旅程(登录 → 相册管理)全英文渲染', async ({ page }) => { + const { account, password } = adminCredentials() + await page.goto('/#/auth/login') + + // 登录页右上语言菜单 → English(ElDropdown teleport 到 body)。 + await page.locator('.language-btn').first().click() + await page.locator('.el-dropdown-menu__item', { hasText: 'English' }).click() + + // 登录表单文案与占位符切换为英文。 + await expect(page.getByText('Welcome back')).toBeVisible() + await expect(page.getByPlaceholder('Enter account')).toBeVisible() + await expect(page.getByPlaceholder('Please enter your password')).toBeVisible() + await expect(page.getByRole('button', { name: 'Login' })).toBeVisible() + + // 英文凭据登录 → 落相册管理列表页,页面级文案英文。 + await page.getByPlaceholder('Enter account').fill(account) + await page.getByPlaceholder('Please enter your password').fill(password) + await page.getByRole('button', { name: 'Login' }).click() + await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 }) + await expect(page.getByText('Album Management').first()).toBeVisible() + await expect(page.getByRole('button', { name: 'New Album' })).toBeVisible() + + // 语言偏好已持久(用户级设置,刷新后仍英文)。 + await page.reload() + await expect(page.getByText('Album Management').first()).toBeVisible() + await expect(page.getByRole('button', { name: 'New Album' })).toBeVisible() +}) diff --git a/apps/admin/package.json b/apps/admin/package.json index 8646613..71939c6 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.1", + "@photo/contracts": "workspace:*", "@playwright/test": "^1.48.0", "@types/node": "^24.0.5", "@types/qrcode": "^1.5.6", diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index e62ade1..03b63c6 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -792,6 +792,10 @@ "PHOTO_NOT_PUBLIC": "Photo state cannot be hidden (not published)", "UPLOAD_VERIFY_FAILED": "Upload verification failed, please retry", "UPLOAD_SESSION_CONFLICT": "Upload session conflict, please start over", - "ALBUM_UPLOAD_DISABLED": "Upload is disabled for this album" + "ALBUM_UPLOAD_DISABLED": "Upload is disabled for this album", + "RATE_LIMITED": "Too many requests, please try again later", + "ALBUM_NOT_PUBLIC": "Album not found or offline", + "DOWNLOAD_DISABLED": "Downloads are not enabled for this album", + "SYNC_AUTH_FAILED": "Sync service authentication failed; check service configuration" } } diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 8a8dd3e..7d236ee 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -792,6 +792,10 @@ "PHOTO_NOT_PUBLIC": "照片状态不可隐藏(非已发布)", "UPLOAD_VERIFY_FAILED": "上传核验失败,请重试", "UPLOAD_SESSION_CONFLICT": "上传会话状态冲突,请重新开始", - "ALBUM_UPLOAD_DISABLED": "该相册已关闭上传" + "ALBUM_UPLOAD_DISABLED": "该相册已关闭上传", + "RATE_LIMITED": "操作过于频繁,请稍后再试", + "ALBUM_NOT_PUBLIC": "相册不存在或已下线", + "DOWNLOAD_DISABLED": "该相册未开放下载", + "SYNC_AUTH_FAILED": "同步服务鉴权失败,请检查服务配置" } } diff --git a/apps/admin/src/utils/http/error.spec.ts b/apps/admin/src/utils/http/error.spec.ts new file mode 100644 index 0000000..3078d52 --- /dev/null +++ b/apps/admin/src/utils/http/error.spec.ts @@ -0,0 +1,76 @@ +// T15 全端语言验收(P4b Task 6):admin 侧错误码→文案映射的机器守护。 +// 1. 契约 ErrorCodes 枚举全集在 API_CODE_MSG_KEYS 无缺漏(A12「主要提示 +// 无漏译」——服务端 message 仅英文诊断兜底,本表即管理端错误文案全集); +// 2. 表内每个文案键在 zh/en 两份 locale 文件都真实存在(点路径解析); +// 3. zh/en locale 键集合递归相等(模板级一致性,镜像 P3 mobile arb 完整性 +// 测试与 viewer 侧 error-coverage 守护)。 +import { describe, expect, it } from 'vitest' +// 根入口导入(非 ./errors 子路径):admin tsconfig 为 classic moduleResolution +// (不走 package exports),根 main/types 字段可解析;仅测试环境(node)使用, +// internal-sync 的 node:crypto 不入产物。production 浏览器代码如需运行时 +// 枚举请走 @photo/contracts/errors 子路径(viewer 先例)。 +import { ErrorCodes } from '@photo/contracts' +import { API_CODE_MSG_KEYS } from './error' +import enMessages from '@/locales/langs/en.json' +import zhMessages from '@/locales/langs/zh.json' + +type Messages = Record + +/** 递归收集 locale 文件的叶子键点路径集合(apiMsg.ALBUM_NOT_FOUND 等)。 */ +function leafPaths(messages: Messages, prefix = ''): Set { + const paths = new Set() + for (const [key, value] of Object.entries(messages)) { + const path = prefix === '' ? key : `${prefix}.${key}` + if (value !== null && typeof value === 'object') { + for (const child of leafPaths(value as Messages, path)) paths.add(child) + } else { + paths.add(path) + } + } + return paths +} + +/** 按点路径取词条值(不存在返回 undefined)。 */ +function resolvePath(messages: Messages, path: string): unknown { + let current: unknown = messages + for (const segment of path.split('.')) { + if (current === null || typeof current !== 'object') return undefined + current = (current as Messages)[segment] + } + return current +} + +describe('错误码文案覆盖(T15/A12)', () => { + const zhPaths = leafPaths(zhMessages as Messages) + const enPaths = leafPaths(enMessages as Messages) + + it('契约 ErrorCodes 枚举全集在 API_CODE_MSG_KEYS 无缺漏', () => { + const codes = Object.values(ErrorCodes) + expect(codes.length).toBeGreaterThanOrEqual(18) // 契约现存 18 码,只增不减 + for (const code of codes) { + expect(API_CODE_MSG_KEYS[code], `缺少错误码映射: ${code}`).toBeDefined() + } + }) + + it('映射表内每个文案键在 zh 与 en locale 均存在且非空', () => { + for (const [code, key] of Object.entries(API_CODE_MSG_KEYS)) { + for (const [locale, messages] of [ + ['zh', zhMessages], + ['en', enMessages] + ] as const) { + const paths = locale === 'zh' ? zhPaths : enPaths + expect(paths.has(key), `${code} 的文案键 ${key} 未登记于 ${locale} locale`).toBe(true) + const value = resolvePath(messages as Messages, key) + expect( + typeof value === 'string' && value.length > 0, + `${key} 在 ${locale} 非非空字符串` + ).toBe(true) + } + } + }) + + it('zh 与 en locale 键集合递归相等(无单侧漏译)', () => { + expect([...zhPaths].filter((path) => !enPaths.has(path))).toEqual([]) + expect([...enPaths].filter((path) => !zhPaths.has(path))).toEqual([]) + }) +}) diff --git a/apps/admin/src/utils/http/error.ts b/apps/admin/src/utils/http/error.ts index fb33790..6ad5f8a 100644 --- a/apps/admin/src/utils/http/error.ts +++ b/apps/admin/src/utils/http/error.ts @@ -113,12 +113,38 @@ const getErrorMessage = (status: number): string => { return $t(errorMap[status] || 'httpMsg.internalServerError') } -/** 契约错误码 → 本地化文案键(服务端 message 是英文兜底,优先本地化) */ -const API_CODE_MSG_KEYS: Record = { +/** + * 契约错误码 → 本地化文案键(服务端 message 是英文兜底,优先本地化)。 + * + * T15 全端语言验收(P4b Task 6):按契约 ErrorCodes 枚举全集登记(机器守护 + * 见同目录 error.spec.ts,遍历 @photo/contracts ErrorCodes 断言无缺漏)—— + * 登录/会话/框架码复用 httpMsg 既有文案,业务码走 apiMsg 专属词条。 + */ +export const API_CODE_MSG_KEYS: Record = { + // 401/会话域(模板 httpMsg 既有文案) AUTH_INVALID_CREDENTIALS: 'apiMsg.AUTH_INVALID_CREDENTIALS', AUTH_TOKEN_EXPIRED: 'httpMsg.unauthorized', AUTH_RATE_LIMITED: 'apiMsg.AUTH_RATE_LIMITED', + // P2 公开互动匿名限速(管理端可达性低,防御性登记专属文案) + RATE_LIMITED: 'apiMsg.RATE_LIMITED', AUTH_FORBIDDEN: 'httpMsg.forbidden', + // 框架/路由 404 + NOT_FOUND: 'httpMsg.notFound', + // 相册/照片域 + ALBUM_NOT_FOUND: 'apiMsg.ALBUM_NOT_FOUND', + ALBUM_NOT_PUBLIC: 'apiMsg.ALBUM_NOT_PUBLIC', + ALBUM_UPLOAD_DISABLED: 'apiMsg.ALBUM_UPLOAD_DISABLED', + PHOTO_NOT_FOUND: 'apiMsg.PHOTO_NOT_FOUND', + PHOTO_NOT_PUBLIC: 'apiMsg.PHOTO_NOT_PUBLIC', + // P4a 下载两级开关(A18) + DOWNLOAD_DISABLED: 'apiMsg.DOWNLOAD_DISABLED', + // 成员/上传域 + MEMBER_CONFLICT: 'apiMsg.MEMBER_CONFLICT', + UPLOAD_SESSION_CONFLICT: 'apiMsg.UPLOAD_SESSION_CONFLICT', + UPLOAD_VERIFY_FAILED: 'apiMsg.UPLOAD_VERIFY_FAILED', + // 服务间内部同步鉴权(/internal/**):管理端不可达,防御性登记 + SYNC_AUTH_FAILED: 'apiMsg.SYNC_AUTH_FAILED', + // 通用 VALIDATION_FAILED: 'apiMsg.VALIDATION_FAILED', INTERNAL_ERROR: 'httpMsg.internalServerError' } diff --git a/apps/mobile/lib/core/error_codes.dart b/apps/mobile/lib/core/error_codes.dart index d921372..0760222 100644 --- a/apps/mobile/lib/core/error_codes.dart +++ b/apps/mobile/lib/core/error_codes.dart @@ -2,7 +2,8 @@ /// /// 1:1 对照 `packages/contracts/src/errors.ts` 的 `ErrorCodes` 与 /// `packages/contracts/openapi.yaml` `components.schemas.Error` 的 enum -/// (17 项)。契约约定:客户端按 code 映射多语言文案(l10n 解析留在 UI +/// (18 项;P4a 增补 DOWNLOAD_DISABLED,P4b Task 6 语言验收时补齐镜像)。 +/// 契约约定:客户端按 code 映射多语言文案(l10n 解析留在 UI /// 层),`message` 仅供诊断。 /// /// 文件末尾的 [networkError] / [networkTimeout] / [cancelled] / @@ -51,6 +52,10 @@ abstract final class ErrorCodes { /// 照片不存在、未发布或不属于该相册(404,观众端)。 static const String photoNotPublic = 'PHOTO_NOT_PUBLIC'; + /// 相册可见但未开放下载(403,P4a 下载两级开关;App 无下载面, + /// 仅为与 errors.ts 全量 1:1 对齐而镜像——文案走 errorGeneric 兜底)。 + static const String downloadDisabled = 'DOWNLOAD_DISABLED'; + /// 参数校验失败(400,经全局校验管道)。 static const String validationFailed = 'VALIDATION_FAILED'; diff --git a/apps/viewer/e2e/embed.spec.ts b/apps/viewer/e2e/embed.spec.ts index c7455e2..700030b 100644 --- a/apps/viewer/e2e/embed.spec.ts +++ b/apps/viewer/e2e/embed.spec.ts @@ -90,6 +90,26 @@ test.describe.serial('embed carousel widget (P4b Task 4)', () => { await popup.close(); }); + // T15(A12)embed lang 参数两语言渲染断言之 en 侧(zh 侧由 lang=auto → + // defaultLocale 各用例覆盖):loader data-lang=en → iframe 内英文标题, + // 深链 lang=en 跟随。 + test('lang=en renders English copy in the iframe and passes en to the photo deep link', async ({ page }) => { + const feed = await seedFeed('langen'); + + await page.goto(`/__test__/embed-host.html?slot=${feed.slotToken}&lang=en`); + const frame = page.frameLocator('iframe'); + await expect(frame.locator('.embed-title')).toHaveText('langen EN Album'); + await expect(frame.locator('.embed-photo').first()).toBeVisible(); + + // 点击 → 新标签深链携带 lang=en(resolvedLocale)。 + const popupPromise = page.context().waitForEvent('page'); + await frame.locator('.embed-photo.active').click(); + const popup = await popupPromise; + await expect(popup).toHaveURL(new RegExp(`/a/${feed.albumToken}/p/`)); + await expect(popup).toHaveURL(/lang=en/); + await popup.close(); + }); + test('collapse button hides the widget, persists after reload, and the handle expands it', async ({ page }) => { const feed = await seedFeed('collapse'); diff --git a/apps/viewer/src/__tests__/download.spec.ts b/apps/viewer/src/__tests__/download.spec.ts index e523bb0..b5e1a4b 100644 --- a/apps/viewer/src/__tests__/download.spec.ts +++ b/apps/viewer/src/__tests__/download.spec.ts @@ -2,6 +2,7 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'; import { defineComponent, ref } from 'vue'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + ApiError, getPhotoDownload, getPublicPhotos, type PublicAlbum, @@ -250,6 +251,20 @@ describe('photo download (P4a Task 4)', () => { expect(openSpy).not.toHaveBeenCalled(); }); + it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => { + // 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。 + mockedGetPhotoDownload.mockReset().mockRejectedValue( + new ApiError('DOWNLOAD_DISABLED', 'downloads disabled', 403, null), + ); + const wrapper = mountLightbox(makeAlbum()); + await flushPromises(); + + await wrapper.find('.control.download').trigger('click'); + await flushPromises(); + expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('该相册未开放下载'); + expect(wrapper.find('.download-failed').text()).not.toContain('下载失败'); + }); + it('WeChat webview: click opens the guidance overlay instead of hitting the endpoint', async () => { stubUserAgent(WECHAT_IOS_UA); const wrapper = mountLightbox(makeAlbum()); diff --git a/apps/viewer/src/__tests__/error-coverage.spec.ts b/apps/viewer/src/__tests__/error-coverage.spec.ts new file mode 100644 index 0000000..0573592 --- /dev/null +++ b/apps/viewer/src/__tests__/error-coverage.spec.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { ErrorCodes } from '@photo/contracts/errors'; +import { errorCodeI18nKeys } from '../api/errors'; +import en from '../i18n/locales/en'; +import zhCN from '../i18n/locales/zh-CN'; + +// T15 全端语言验收(P4b Task 6):错误码→文案映射的机器守护。 +// 1. 契约 ErrorCodes 枚举全集在本表无缺漏(A12「主要提示无漏译」); +// 2. 表内每个文案键在 zh-CN / en 两份词条都真实存在(防键注册了但一侧 +// 没写文案的真漏译;点路径解析); +// 3. zh-CN / en 两份词条键集合递归相等(模板级 zh/en 一致性,镜像 admin +// 侧 locale 守护与 P3 mobile arb 完整性测试)。 +// 服务端不做文案本地化(错误体 message 仅英文诊断兜底,客户端按稳定 code +// 映射——见 openapi.yaml info.description),故本表即观众侧错误文案全集。 + +type Messages = Record; + +/** 递归收集词条文件的叶子键点路径集合(errors.albumNotFound 等)。 */ +function leafPaths(messages: Messages, prefix = ''): Set { + const paths = new Set(); + for (const [key, value] of Object.entries(messages)) { + const path = prefix === '' ? key : `${prefix}.${key}`; + if (value !== null && typeof value === 'object') { + for (const child of leafPaths(value as Messages, path)) paths.add(child); + } else { + paths.add(path); + } + } + return paths; +} + +/** 按点路径取词条值(不存在返回 undefined)。 */ +function resolvePath(messages: Messages, path: string): unknown { + let current: unknown = messages; + for (const segment of path.split('.')) { + if (current === null || typeof current !== 'object') return undefined; + current = (current as Messages)[segment]; + } + return current; +} + +describe('错误码文案覆盖(T15/A12)', () => { + const zhPaths = leafPaths(zhCN as Messages); + const enPaths = leafPaths(en as Messages); + + it('契约 ErrorCodes 枚举全集在 errorCodeI18nKeys 无缺漏', () => { + const codes = Object.values(ErrorCodes); + expect(codes.length).toBeGreaterThanOrEqual(18); // 契约现存 18 码,只增不减 + for (const code of codes) { + expect(errorCodeI18nKeys[code], `缺少错误码映射: ${code}`).toBeDefined(); + } + }); + + it('映射表内每个文案键在 zh-CN 与 en 词条均存在且非空', () => { + for (const [code, key] of Object.entries(errorCodeI18nKeys)) { + for (const [locale, paths] of [ + ['zh-CN', zhPaths], + ['en', enPaths], + ] as const) { + expect(paths.has(key), `${code} 的文案键 ${key} 未登记于 ${locale} 词条`).toBe(true); + const value = resolvePath((locale === 'zh-CN' ? zhCN : en) as Messages, key); + expect(typeof value === 'string' && value.length > 0, `${key} 在 ${locale} 非非空字符串`).toBe(true); + } + } + }); + + it('zh-CN 与 en 词条键集合递归相等(无单侧漏译)', () => { + expect([...zhPaths].filter((path) => !enPaths.has(path))).toEqual([]); + expect([...enPaths].filter((path) => !zhPaths.has(path))).toEqual([]); + }); +}); diff --git a/apps/viewer/src/api/errors.ts b/apps/viewer/src/api/errors.ts index 79f821e..e4b5fe9 100644 --- a/apps/viewer/src/api/errors.ts +++ b/apps/viewer/src/api/errors.ts @@ -2,19 +2,38 @@ import { NETWORK_ERROR_CODE } from './client'; // 错误码 → i18n 文案键映射表:401/404 等观众可认知的码给出确定性文案, // 未登记码统一走 errors.unknown(原始 message 仅用于日志/排查)。 +// +// T15 全端语言验收(P4b Task 6):本表按契约枚举全集登记(机器守护见 +// __tests__/error-coverage.spec.ts,遍历 @photo/contracts ErrorCodes 断言 +// 无缺漏)。viewer 可达码给专属文案;管理/上传/内部域码(登录凭据、成员 +// 冲突、上传校验、服务间鉴权——viewer 无对应界面)按移动端先例防御性 +// 注册到通用文案,杜绝未来路由扩展时静默漏译。 export const errorCodeI18nKeys: Readonly> = { // 客户端合成码 [NETWORK_ERROR_CODE]: 'errors.network', // 401 域 AUTH_TOKEN_EXPIRED: 'errors.authExpired', - AUTH_FORBIDDEN: 'errors.forbidden', + // 登录凭据错误:viewer 无登录面(App/admin 域),防御性注册。 + AUTH_INVALID_CREDENTIALS: 'errors.unknown', AUTH_RATE_LIMITED: 'errors.rateLimited', + // P2 公开互动(likes/visits)匿名限速 429——viewer 直面。 + RATE_LIMITED: 'errors.rateLimited', + AUTH_FORBIDDEN: 'errors.forbidden', // 404 域 NOT_FOUND: 'errors.notFound', ALBUM_NOT_FOUND: 'errors.albumNotFound', ALBUM_NOT_PUBLIC: 'errors.albumNotAvailable', PHOTO_NOT_FOUND: 'errors.photoNotFound', PHOTO_NOT_PUBLIC: 'errors.photoNotFound', + // P4a 下载两级开关(A18):相册可见但禁止下载——下载入口与文案直达。 + DOWNLOAD_DISABLED: 'errors.downloadDisabled', + // 上传/成员域(admin 与 App 上传链路):viewer 不可达,防御性注册。 + ALBUM_UPLOAD_DISABLED: 'errors.unknown', + MEMBER_CONFLICT: 'errors.unknown', + UPLOAD_SESSION_CONFLICT: 'errors.unknown', + UPLOAD_VERIFY_FAILED: 'errors.unknown', + // 服务间内部同步鉴权(/internal/**):外部客户端不可达,防御性注册。 + SYNC_AUTH_FAILED: 'errors.unknown', // 其余 VALIDATION_FAILED: 'errors.validationFailed', INTERNAL_ERROR: 'errors.internal', diff --git a/apps/viewer/src/components/PhotoLightbox.vue b/apps/viewer/src/components/PhotoLightbox.vue index e8abcce..2c49015 100644 --- a/apps/viewer/src/components/PhotoLightbox.vue +++ b/apps/viewer/src/components/PhotoLightbox.vue @@ -439,9 +439,9 @@ onBeforeUnmount(() => {

{{ t('album.loading') }}

- + diff --git a/apps/viewer/src/download/useDownload.ts b/apps/viewer/src/download/useDownload.ts index 7f2d530..9fbc9fb 100644 --- a/apps/viewer/src/download/useDownload.ts +++ b/apps/viewer/src/download/useDownload.ts @@ -17,10 +17,12 @@ // 打开后可正常下载)。clipboard 不可用时降级展示可选中文本供手动长按复制。 import { computed, onBeforeUnmount, reactive, ref, type Ref } from 'vue'; import { + ApiError, getPhotoDownload, type DownloadVariant, type PublicAlbum, } from '../api/client'; +import { ErrorCodes } from '@photo/contracts/errors'; export interface DownloadInputs { token: Ref; @@ -63,7 +65,9 @@ export const COPY_NOTICE_MS = 2500; export function useDownload(inputs: DownloadInputs) { const busy = ref(false); - const failed = ref(false); + /** 失败文案键(''=无失败):默认通用失败;DOWNLOAD_DISABLED 给专属文案 + * (T15 漏译修复——相册中途关闭下载开关时观众可理解的确定性提示)。 */ + const failed = ref<'' | 'download.failed' | 'errors.downloadDisabled'>(''); /** 微信引导浮层可见(点击下载时处于微信内置浏览器)。 */ const wechatGuide = ref(false); const copyNotice = ref<'' | 'copied' | 'failed'>(''); @@ -90,7 +94,7 @@ export function useDownload(inputs: DownloadInputs) { const photoId = inputs.photoId(); if (photoId === null || busy.value) return; busy.value = true; - failed.value = false; + failed.value = ''; try { let credential = await getPhotoDownload(inputs.token.value, photoId, variant); if (isExpired(credential.expiresAt)) { @@ -99,8 +103,11 @@ export function useDownload(inputs: DownloadInputs) { credential = await getPhotoDownload(inputs.token.value, photoId, variant); } openDownloadUrl(credential.url); - } catch { - failed.value = true; + } catch (error) { + failed.value = + error instanceof ApiError && error.code === ErrorCodes.DOWNLOAD_DISABLED + ? 'errors.downloadDisabled' + : 'download.failed'; } finally { busy.value = false; } diff --git a/apps/viewer/src/i18n/locales/en.ts b/apps/viewer/src/i18n/locales/en.ts index 00d8cad..298632a 100644 --- a/apps/viewer/src/i18n/locales/en.ts +++ b/apps/viewer/src/i18n/locales/en.ts @@ -51,6 +51,8 @@ export default { loadFailed: 'Failed to load image', // PHOTO_NOT_PUBLIC 专用:not found / unpublished / hidden share one code (anti-enumeration). photoUnavailable: 'Photo not found or unavailable', + // 轮播中被移除(ws photo.hidden)的轻提示。 + photoRemoved: 'This photo has been removed', backToAlbum: 'Back to album', }, route: { tokenLabel: 'Album token', photoLabel: 'Photo' }, @@ -67,5 +69,7 @@ export default { internal: 'Service temporarily unavailable, please try again later', unknown: 'Something went wrong, please try again later', network: 'Network error, please check your connection', + // DOWNLOAD_DISABLED (P4a two-tier download switches): visible but not downloadable. + downloadDisabled: 'Downloads are not enabled for this album', }, }; diff --git a/apps/viewer/src/i18n/locales/zh-CN.ts b/apps/viewer/src/i18n/locales/zh-CN.ts index 7a0e402..15804be 100644 --- a/apps/viewer/src/i18n/locales/zh-CN.ts +++ b/apps/viewer/src/i18n/locales/zh-CN.ts @@ -69,5 +69,7 @@ export default { internal: '服务暂时不可用,请稍后再试', unknown: '出错了,请稍后再试', network: '网络连接失败,请检查网络', + // DOWNLOAD_DISABLED(P4a 下载两级开关):相册可见但未开放下载。 + downloadDisabled: '该相册未开放下载', }, }; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 359feb2..1ad710e 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "API contract single source of truth (OpenAPI 3.1 + error codes); types generated via openapi-typescript and committed under src/generated/. Subpath ./constants is the browser-safe entry (root index re-exports internal-sync with node:crypto — browser apps like viewer's embed page must import constants from there).", + "description": "API contract single source of truth (OpenAPI 3.1 + error codes); types generated via openapi-typescript and committed under src/generated/. Subpaths ./constants and ./errors are the browser-safe entries (root index re-exports internal-sync with node:crypto — browser apps like viewer must import runtime values from those; errors.ts is pure TS).", "//": "lint runs `eslint .` against the root flat config (Task 7). test is ts-jest with full type-checking (Task 6 type tests).", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -16,6 +16,10 @@ "types": "./dist/constants.d.ts", "default": "./dist/constants.js" }, + "./errors": { + "types": "./dist/errors.d.ts", + "default": "./dist/errors.js" + }, "./realtime": { "types": "./dist/realtime.d.ts", "default": "./dist/realtime.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1666984..3e4c499 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: '@eslint/js': specifier: ^9.9.1 version: 9.39.5 + '@photo/contracts': + specifier: workspace:* + version: link:../../packages/contracts '@playwright/test': specifier: ^1.48.0 version: 1.63.0