feat(i18n): T15 full-stack language audit with coverage assertions

- viewer/admin 错误码→文案映射按契约 ErrorCodes 枚举全集登记(机器守护
  spec 遍历枚举断言无缺漏 + 映射键在 zh/en 词条均存在 + locale 键集
  递归相等);mobile 镜像补齐 DOWNLOAD_DISABLED(P4a 增补漏登)
- 漏译修复:viewer.photoRemoved en 侧缺失(审计测试捕获);
  DOWNLOAD_DISABLED 观众侧专属文案 + useDownload 专属提示接线;
  admin apiMsg 增 RATE_LIMITED/ALBUM_NOT_PUBLIC/DOWNLOAD_DISABLED/
  SYNC_AUTH_FAILED 四码词条
- 运行时抽查:admin 主旅程 en 版 e2e(登录→相册管理全英文 + 偏好
  持久);embed iframe lang=en 渲染与深链跟随 e2e(zh 侧既有用例覆盖)
- contracts 新增 ./errors 浏览器安全子路径(errors.ts 纯 TS);
  admin 增 @photo/contracts devDep(仅测试消费)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 23:44:27 +08:00
parent 0238f505c4
commit 7385785504
17 changed files with 310 additions and 13 deletions
+36
View File
@@ -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()
})
+1
View File
@@ -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",
+5 -1
View File
@@ -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"
}
}
+5 -1
View File
@@ -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": "同步服务鉴权失败,请检查服务配置"
}
}
+76
View File
@@ -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<string, unknown>
/** 递归收集 locale 文件的叶子键点路径集合(apiMsg.ALBUM_NOT_FOUND 等)。 */
function leafPaths(messages: Messages, prefix = ''): Set<string> {
const paths = new Set<string>()
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([])
})
})
+28 -2
View File
@@ -113,12 +113,38 @@ const getErrorMessage = (status: number): string => {
return $t(errorMap[status] || 'httpMsg.internalServerError')
}
/** 契约错误码 → 本地化文案键(服务端 message 是英文兜底,优先本地化) */
const API_CODE_MSG_KEYS: Record<string, string> = {
/**
* 契约错误码 → 本地化文案键(服务端 message 是英文兜底,优先本地化)。
*
* T15 全端语言验收(P4b Task 6):按契约 ErrorCodes 枚举全集登记(机器守护
* 见同目录 error.spec.ts,遍历 @photo/contracts ErrorCodes 断言无缺漏)——
* 登录/会话/框架码复用 httpMsg 既有文案,业务码走 apiMsg 专属词条。
*/
export const API_CODE_MSG_KEYS: Record<string, string> = {
// 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'
}
+6 -1
View File
@@ -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';
+20
View File
@@ -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');
@@ -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());
@@ -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<string, unknown>;
/** 递归收集词条文件的叶子键点路径集合(errors.albumNotFound 等)。 */
function leafPaths(messages: Messages, prefix = ''): Set<string> {
const paths = new Set<string>();
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([]);
});
});
+20 -1
View File
@@ -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<Record<string, string>> = {
// 客户端合成码
[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',
+2 -2
View File
@@ -439,9 +439,9 @@ onBeforeUnmount(() => {
<p v-if="extending" class="loading">{{ t('album.loading') }}</p>
<!-- 下载失败轻提示(端点失败/网络;可重点击重试)。 -->
<!-- 下载失败轻提示(端点失败/网络;DOWNLOAD_DISABLED 专属文案;可重点击重试)。 -->
<p v-if="download !== undefined && download.failed" class="download-failed" role="alert">
{{ t('download.failed') }}
{{ t(download.failed) }}
</p>
<!-- 微信内置浏览器引导(P4a Task 4):替代直接下载——长按保存 + 复制深链。 -->
+11 -4
View File
@@ -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<string>;
@@ -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;
}
+4
View File
@@ -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',
},
};
+2
View File
@@ -69,5 +69,7 @@ export default {
internal: '服务暂时不可用,请稍后再试',
unknown: '出错了,请稍后再试',
network: '网络连接失败,请检查网络',
// DOWNLOAD_DISABLED(P4a 下载两级开关):相册可见但未开放下载。
downloadDisabled: '该相册未开放下载',
},
};
+5 -1
View File
@@ -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"
+3
View File
@@ -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