feat(viewer): vite vue3 scaffold with routing and i18n
- apps/viewer (@photo/viewer): Vite 5 + Vue 3.4 + TS 最小骨架(无 Pinia/UI 库) - router: /a/:token 活动页 + /a/:token/p/:photoId 单图深链(复用活动页组件) - i18n: vue-i18n 9,zh-CN/en 词条,localStorage(viewer.locale) 持久 + navigator.language 检测兜底 zh-CN;顶栏语言切换组件;文案全量走 i18n - api client: 类型化 fetch 封装(契约生成类型直连),错误信封 → ApiError, 错误码 → i18n key 映射表(src/api/errors.ts) - scripts: dev/build/preview/test(vitest 10 例)/test:e2e(Playwright 冒烟, webServer 自拉 dev)/lint/vue-tsc 全部接入 turbo - root: +@playwright/test、+eslint-plugin-vue(flat config 收敛到 apps/**/*.vue)、 pnpm.onlyBuiltDependencies 放行 esbuild Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.turbo/
|
.turbo/
|
||||||
coverage/
|
coverage/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
blob-report/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// 冒烟:dev server 可服务根页面并渲染基础壳(顶栏 + 语言切换入口)。
|
||||||
|
// 真实观众旅程(活动页/瀑布流/大图深链)由 Task 6-8/11 补充。
|
||||||
|
test('dev server serves viewer root with top bar and locale switcher', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('.brand')).toHaveText(/现场相册|Live Gallery/);
|
||||||
|
await expect(page.locator('button.locale-switcher')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 无 JS 时的兜底标题;运行时由 App.vue 按 i18n 覆写 -->
|
||||||
|
<title>现场相册</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "@photo/viewer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Viewer lightweight entry (Vue 3 + Vite): album page, waterfall, photo viewer, zh-CN/en i18n; reads cn public API",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@photo/contracts": "workspace:*",
|
||||||
|
"vue": "~3.4.0",
|
||||||
|
"vue-i18n": "^9.14.0",
|
||||||
|
"vue-router": "~4.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"jsdom": "^25.0.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vite": "~5.4.0",
|
||||||
|
"vitest": "^2.1.0",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
// 冒烟占位(Task 5):真实旅程(活动页/瀑布流/大图/admin 联调)随 Task 6-8/11 扩充。
|
||||||
|
// webServer 自动拉起 vite dev(5173,/v1 代理 → 127.0.0.1:8080),本地已有实例则复用。
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
timeout: 30_000,
|
||||||
|
fullyParallel: true,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://127.0.0.1:5173',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
webServer: {
|
||||||
|
command: 'pnpm run dev',
|
||||||
|
url: 'http://127.0.0.1:5173',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { watchEffect } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import LocaleSwitcher from './components/LocaleSwitcher.vue';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
|
||||||
|
// 所有可见文案一律经 i18n(P1b Task 5 起约定);标题与 html lang 随语言联动。
|
||||||
|
watchEffect(() => {
|
||||||
|
document.title = t('app.title');
|
||||||
|
document.documentElement.setAttribute('lang', locale.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="brand">{{ t('app.title') }}</span>
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 极简基线样式:视觉打磨留待 P4a */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, 'Segoe UI', 'PingFang SC', sans-serif;
|
||||||
|
color: #1f2328;
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, getPublicPhotos } from '../api/client';
|
||||||
|
import { errorMessageKey } from '../api/errors';
|
||||||
|
|
||||||
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api client', () => {
|
||||||
|
it('returns parsed payload and targets the /v1 public album endpoint', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse(200, { publicToken: 'tok-1', resolvedLocale: 'zh-CN', defaultLocale: 'zh-CN' }),
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const album = await getPublicAlbum('tok-1', 'zh-CN');
|
||||||
|
expect(album.publicToken).toBe('tok-1');
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'/v1/public/albums/tok-1?locale=zh-CN',
|
||||||
|
expect.objectContaining({ headers: expect.objectContaining({ accept: 'application/json' }) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds cursor query for the photo list', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { items: [], nextCursor: null }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
await getPublicPhotos('tok-1', { cursor: 'c-9', limit: 30, category: 'cat-2' });
|
||||||
|
const calledUrl = new URL(String(fetchMock.mock.calls[0]?.[0]), 'http://viewer.local');
|
||||||
|
expect(calledUrl.pathname).toBe('/v1/public/albums/tok-1/photos');
|
||||||
|
expect(calledUrl.searchParams.get('cursor')).toBe('c-9');
|
||||||
|
expect(calledUrl.searchParams.get('limit')).toBe('30');
|
||||||
|
expect(calledUrl.searchParams.get('category')).toBe('cat-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws ApiError carrying code/status/requestId from the error envelope', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue(jsonResponse(404, { code: 'ALBUM_NOT_FOUND', message: 'album not found', requestId: 'req-1' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const thrown = await getPublicAlbum('nope').then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
expect(thrown).toBeInstanceOf(ApiError);
|
||||||
|
const apiError = thrown as ApiError;
|
||||||
|
expect(apiError.code).toBe('ALBUM_NOT_FOUND');
|
||||||
|
expect(apiError.status).toBe(404);
|
||||||
|
expect(apiError.requestId).toBe('req-1');
|
||||||
|
expect(apiError.message).toBe('album not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades non-envelope error bodies to INTERNAL_ERROR with fallback message', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('<html>boom</html>', { status: 502 })));
|
||||||
|
|
||||||
|
const thrown = (await getPublicAlbum('tok').then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
)) as ApiError;
|
||||||
|
expect(thrown).toBeInstanceOf(ApiError);
|
||||||
|
expect(thrown.code).toBe('INTERNAL_ERROR');
|
||||||
|
expect(thrown.status).toBe(502);
|
||||||
|
expect(thrown.requestId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps fetch transport failures to the NETWORK_ERROR code', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')));
|
||||||
|
|
||||||
|
const thrown = (await getPublicAlbum('tok').then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
)) as ApiError;
|
||||||
|
expect(thrown).toBeInstanceOf(ApiError);
|
||||||
|
expect(thrown.code).toBe(NETWORK_ERROR_CODE);
|
||||||
|
expect(errorMessageKey(thrown.code)).toBe('errors.network');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error code to i18n key mapping', () => {
|
||||||
|
it('maps known codes to dedicated message keys', () => {
|
||||||
|
expect(errorMessageKey('ALBUM_NOT_FOUND')).toBe('errors.albumNotFound');
|
||||||
|
expect(errorMessageKey('ALBUM_NOT_PUBLIC')).toBe('errors.albumNotAvailable');
|
||||||
|
expect(errorMessageKey('PHOTO_NOT_PUBLIC')).toBe('errors.photoNotFound');
|
||||||
|
expect(errorMessageKey('NOT_FOUND')).toBe('errors.notFound');
|
||||||
|
expect(errorMessageKey('AUTH_TOKEN_EXPIRED')).toBe('errors.authExpired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to errors.unknown for unregistered codes', () => {
|
||||||
|
expect(errorMessageKey('SOMETHING_ELSE')).toBe('errors.unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
import App from '../App.vue';
|
||||||
|
import { LOCALE_STORAGE_KEY, i18n } from '../i18n';
|
||||||
|
import { router } from '../router';
|
||||||
|
|
||||||
|
describe('viewer i18n', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
i18n.global.locale.value = 'zh-CN';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switching locale re-renders messages and persists the choice', async () => {
|
||||||
|
await router.push('/a/tok-i18n');
|
||||||
|
await router.isReady();
|
||||||
|
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('照片即将上线');
|
||||||
|
expect(document.title).toBe('现场相册');
|
||||||
|
|
||||||
|
await wrapper.find('button.locale-switcher').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.text()).toContain('Photos coming soon');
|
||||||
|
expect(document.title).toBe('Live Gallery');
|
||||||
|
expect(document.documentElement.getAttribute('lang')).toBe('en');
|
||||||
|
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('en');
|
||||||
|
|
||||||
|
// 切回 zh-CN 同样生效
|
||||||
|
await wrapper.find('button.locale-switcher').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.text()).toContain('照片即将上线');
|
||||||
|
expect(localStorage.getItem(LOCALE_STORAGE_KEY)).toBe('zh-CN');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import App from '../App.vue';
|
||||||
|
import { i18n } from '../i18n';
|
||||||
|
import { router } from '../router';
|
||||||
|
|
||||||
|
describe('viewer routing', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
i18n.global.locale.value = 'zh-CN';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('activity route resolves token param and renders empty notice', async () => {
|
||||||
|
await router.push('/a/tok-abc');
|
||||||
|
await router.isReady();
|
||||||
|
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||||
|
expect(wrapper.text()).toContain('tok-abc');
|
||||||
|
expect(wrapper.text()).toContain('照片即将上线');
|
||||||
|
expect(wrapper.text()).not.toContain('photo-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep link resolves token and photoId params on the activity page', async () => {
|
||||||
|
await router.push('/a/tok-abc/p/photo-42');
|
||||||
|
const wrapper = mount(App, { global: { plugins: [router, i18n] } });
|
||||||
|
await router.isReady();
|
||||||
|
expect(wrapper.text()).toContain('tok-abc');
|
||||||
|
expect(wrapper.text()).toContain('photo-42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { components } from '@photo/contracts';
|
||||||
|
|
||||||
|
// 契约生成类型直连(Vite 原生 ESM,无需 mapper)
|
||||||
|
export type PublicAlbum = components['schemas']['PublicAlbum'];
|
||||||
|
export type PublicPhotoList = components['schemas']['PublicPhotoList'];
|
||||||
|
export type PublicPhotoDetail = components['schemas']['PublicPhotoDetail'];
|
||||||
|
|
||||||
|
/** 客户端合成码:fetch 本身失败(离线/DNS),非服务端错误信封。 */
|
||||||
|
export const NETWORK_ERROR_CODE = 'NETWORK_ERROR';
|
||||||
|
|
||||||
|
/** 服务端错误信封 {code,message,requestId} 的类型化形态。 */
|
||||||
|
export class ApiError extends Error {
|
||||||
|
readonly code: string;
|
||||||
|
readonly status: number;
|
||||||
|
readonly requestId: string | null;
|
||||||
|
|
||||||
|
constructor(code: string, message: string, status: number, requestId: string | null) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
this.requestId = requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorEnvelopeLike {
|
||||||
|
code?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
requestId?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrorEnvelope(body: unknown): { code: string | null; message: string | null; requestId: string | null } {
|
||||||
|
if (typeof body === 'object' && body !== null) {
|
||||||
|
const env = body as ErrorEnvelopeLike;
|
||||||
|
return {
|
||||||
|
code: typeof env.code === 'string' ? env.code : null,
|
||||||
|
message: typeof env.message === 'string' ? env.message : null,
|
||||||
|
requestId: typeof env.requestId === 'string' ? env.requestId : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { code: null, message: null, requestId: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型化 fetch 封装:统一前缀 /v1、JSON 解析、错误信封 → ApiError。 */
|
||||||
|
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/v1${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { accept: 'application/json', ...init?.headers },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(NETWORK_ERROR_CODE, 'network request failed', 0, null);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
let body: unknown = null;
|
||||||
|
if (text.length > 0) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const env = parseErrorEnvelope(body);
|
||||||
|
throw new ApiError(
|
||||||
|
env.code ?? 'INTERNAL_ERROR',
|
||||||
|
env.message ?? `request failed with status ${response.status}`,
|
||||||
|
response.status,
|
||||||
|
env.requestId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withQuery(path: string, query: Record<string, string | number | undefined>): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value !== undefined) params.set(key, String(value));
|
||||||
|
}
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs.length > 0 ? `${path}?${qs}` : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公开相册信息(观众页入口;locale 缺省由服务端按相册 defaultLocale 回退)。 */
|
||||||
|
export function getPublicAlbum(token: string, locale?: string): Promise<PublicAlbum> {
|
||||||
|
return apiRequest<PublicAlbum>(withQuery(`/public/albums/${encodeURIComponent(token)}`, { locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// type 别名(非 interface):可赋给 Record 索引签名形参
|
||||||
|
export type PublicPhotosQuery = {
|
||||||
|
category?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
locale?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 公开照片列表(游标分页;Task 7 瀑布流数据源)。 */
|
||||||
|
export function getPublicPhotos(token: string, query: PublicPhotosQuery = {}): Promise<PublicPhotoList> {
|
||||||
|
return apiRequest<PublicPhotoList>(withQuery(`/public/albums/${encodeURIComponent(token)}/photos`, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公开单图(深链定位与签名过期刷新入口;Task 8 大图数据源)。 */
|
||||||
|
export function getPublicPhoto(token: string, photoId: string, locale?: string): Promise<PublicPhotoDetail> {
|
||||||
|
return apiRequest<PublicPhotoDetail>(
|
||||||
|
withQuery(`/public/albums/${encodeURIComponent(token)}/photos/${encodeURIComponent(photoId)}`, { locale }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NETWORK_ERROR_CODE } from './client';
|
||||||
|
|
||||||
|
// 错误码 → i18n 文案键映射表:401/404 等观众可认知的码给出确定性文案,
|
||||||
|
// 未登记码统一走 errors.unknown(原始 message 仅用于日志/排查)。
|
||||||
|
export const errorCodeI18nKeys: Readonly<Record<string, string>> = {
|
||||||
|
// 客户端合成码
|
||||||
|
[NETWORK_ERROR_CODE]: 'errors.network',
|
||||||
|
// 401 域
|
||||||
|
AUTH_TOKEN_EXPIRED: 'errors.authExpired',
|
||||||
|
AUTH_FORBIDDEN: 'errors.forbidden',
|
||||||
|
AUTH_RATE_LIMITED: 'errors.rateLimited',
|
||||||
|
// 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',
|
||||||
|
// 其余
|
||||||
|
VALIDATION_FAILED: 'errors.validationFailed',
|
||||||
|
INTERNAL_ERROR: 'errors.internal',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function errorMessageKey(code: string): string {
|
||||||
|
return errorCodeI18nKeys[code] ?? 'errors.unknown';
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { setLocale, type ViewerLocale } from '../i18n';
|
||||||
|
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
|
||||||
|
const nextLocale = computed<ViewerLocale>(() => (locale.value === 'zh-CN' ? 'en' : 'zh-CN'));
|
||||||
|
// 按钮始终显示目标语言的自称,便于不识当前语言的用户发现
|
||||||
|
const nextLabel = computed(() => (nextLocale.value === 'zh-CN' ? '中文' : 'English'));
|
||||||
|
|
||||||
|
function switchLocale(): void {
|
||||||
|
setLocale(nextLocale.value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button type="button" class="locale-switcher" :aria-label="t('lang.switch')" @click="switchLocale">
|
||||||
|
{{ nextLabel }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.locale-switcher {
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { createI18n } from 'vue-i18n';
|
||||||
|
import en from './locales/en';
|
||||||
|
import zhCN from './locales/zh-CN';
|
||||||
|
|
||||||
|
export const LOCALE_STORAGE_KEY = 'viewer.locale';
|
||||||
|
export const SUPPORTED_LOCALES = ['zh-CN', 'en'] as const;
|
||||||
|
export type ViewerLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||||
|
export const FALLBACK_LOCALE: ViewerLocale = 'zh-CN';
|
||||||
|
|
||||||
|
function isSupported(value: string): value is ViewerLocale {
|
||||||
|
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 持久化优先(localStorage),其次浏览器语言(zh*/en 前缀),兜底 zh-CN。
|
||||||
|
function resolveInitialLocale(): ViewerLocale {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||||
|
if (stored !== null && isSupported(stored)) return stored;
|
||||||
|
} catch {
|
||||||
|
// localStorage 不可用(隐私模式等)——退回浏览器语言检测
|
||||||
|
}
|
||||||
|
const nav = typeof navigator !== 'undefined' ? navigator.language : '';
|
||||||
|
if (nav.startsWith('zh')) return 'zh-CN';
|
||||||
|
if (nav.startsWith('en')) return 'en';
|
||||||
|
return FALLBACK_LOCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: resolveInitialLocale(),
|
||||||
|
fallbackLocale: FALLBACK_LOCALE,
|
||||||
|
messages: { 'zh-CN': zhCN, en },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换语言并持久化;写失败不阻断切换。
|
||||||
|
export function setLocale(locale: ViewerLocale): void {
|
||||||
|
i18n.global.locale.value = locale;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||||
|
} catch {
|
||||||
|
// 忽略持久化失败
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export default {
|
||||||
|
app: { title: 'Live Gallery' },
|
||||||
|
home: { hint: 'Open an album link to start viewing' },
|
||||||
|
album: { emptyNotice: 'Photos coming soon' },
|
||||||
|
lang: { switch: 'Switch language' },
|
||||||
|
route: { tokenLabel: 'Album token', photoLabel: 'Photo' },
|
||||||
|
errors: {
|
||||||
|
albumNotFound: 'Album not found',
|
||||||
|
albumNotAvailable: 'Album unavailable',
|
||||||
|
photoNotFound: 'Photo not found',
|
||||||
|
notFound: 'Page not found',
|
||||||
|
authExpired: 'Session expired',
|
||||||
|
forbidden: 'Access denied',
|
||||||
|
rateLimited: 'Too many requests, please try again later',
|
||||||
|
validationFailed: 'Invalid request',
|
||||||
|
internal: 'Service temporarily unavailable, please try again later',
|
||||||
|
unknown: 'Something went wrong, please try again later',
|
||||||
|
network: 'Network error, please check your connection',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export default {
|
||||||
|
app: { title: '现场相册' },
|
||||||
|
home: { hint: '打开相册链接即可观看' },
|
||||||
|
album: { emptyNotice: '照片即将上线' },
|
||||||
|
lang: { switch: '切换语言' },
|
||||||
|
route: { tokenLabel: '相册令牌', photoLabel: '照片' },
|
||||||
|
errors: {
|
||||||
|
albumNotFound: '相册不存在',
|
||||||
|
albumNotAvailable: '相册不可用',
|
||||||
|
photoNotFound: '照片不存在',
|
||||||
|
notFound: '页面不存在',
|
||||||
|
authExpired: '登录已过期',
|
||||||
|
forbidden: '没有权限',
|
||||||
|
rateLimited: '操作过于频繁,请稍后再试',
|
||||||
|
validationFailed: '请求参数有误',
|
||||||
|
internal: '服务暂时不可用,请稍后再试',
|
||||||
|
unknown: '出错了,请稍后再试',
|
||||||
|
network: '网络连接失败,请检查网络',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { createApp } from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
import { i18n } from './i18n';
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
createApp(App).use(router).use(i18n).mount('#app');
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
// 脚手架占位页:Task 6 起替换为活动信息区 + 空态,Task 7/8 补瀑布流与大图。
|
||||||
|
// 单图深链(/a/:token/p/:photoId)复用本组件,photoId 用于定位目标照片。
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const token = computed(() => String(route.params.token ?? ''));
|
||||||
|
const photoId = computed(() => (route.params.photoId !== undefined ? String(route.params.photoId) : null));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="activity">
|
||||||
|
<p class="meta">{{ t('route.tokenLabel') }}: {{ token }}</p>
|
||||||
|
<p v-if="photoId !== null" class="meta">{{ t('route.photoLabel') }}: {{ photoId }}</p>
|
||||||
|
<p class="empty-notice">{{ t('album.emptyNotice') }}</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p class="home-hint">{{ t('home.hint') }}</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import ActivityPage from '../pages/ActivityPage.vue';
|
||||||
|
import HomePage from '../pages/HomePage.vue';
|
||||||
|
|
||||||
|
// 单图深链复用活动页组件(Task 8 起在此路由加载目标照片并进入大图模式)。
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'home', component: HomePage },
|
||||||
|
{ path: '/a/:token', name: 'activity', component: ActivityPage },
|
||||||
|
{ path: '/a/:token/p/:photoId', name: 'activity-photo', component: ActivityPage },
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
// dev server 固定 5173(CORS_ORIGINS 已包含);/v1 代理仅作用于 `vite dev`,
|
||||||
|
// build/preview 不经此处——生产由网关同源挂载 /v1。
|
||||||
|
// host 显式 127.0.0.1:Windows 下 localhost 默认可能只绑 [::1],代理/e2e 探活走 IPv4。
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
'/v1': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: { host: '127.0.0.1', port: 5173, strictPort: true },
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
// 仅收集组件/单元测试;e2e/ 归 Playwright(test:e2e)
|
||||||
|
include: ['src/**/*.{test,spec}.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
+25
-1
@@ -1,10 +1,13 @@
|
|||||||
// eslint.config.js —— 根 flat config(覆盖 packages/services/apps 全部 TS)。
|
// eslint.config.js —— 根 flat config(覆盖 packages/services/apps 全部 TS + apps 的 Vue SFC)。
|
||||||
// 各包 lint 脚本为 `eslint .`,在包目录执行时向上发现本文件,turbo 不会静默跳过。
|
// 各包 lint 脚本为 `eslint .`,在包目录执行时向上发现本文件,turbo 不会静默跳过。
|
||||||
// 仅 typescript-eslint recommended(无类型感知规则,无需 project service),
|
// 仅 typescript-eslint recommended(无类型感知规则,无需 project service),
|
||||||
// 避免跨包 tsconfig 差异(contracts NodeNext / api node16)带来的配置负担。
|
// 避免跨包 tsconfig 差异(contracts NodeNext / api node16)带来的配置负担。
|
||||||
|
// Vue SFC(P1b Task 5 起,apps 前端):vue-eslint Parser 解析 .vue,script 块交给
|
||||||
|
// @typescript-eslint/parser;逐项收敛 files 到 apps/**/*.vue,不影响既有纯 TS 包。
|
||||||
// @ts-check
|
// @ts-check
|
||||||
const globals = require('globals');
|
const globals = require('globals');
|
||||||
const tseslint = require('typescript-eslint');
|
const tseslint = require('typescript-eslint');
|
||||||
|
const pluginVue = require('eslint-plugin-vue');
|
||||||
|
|
||||||
module.exports = tseslint.config(
|
module.exports = tseslint.config(
|
||||||
{
|
{
|
||||||
@@ -15,6 +18,10 @@ module.exports = tseslint.config(
|
|||||||
'**/.turbo/',
|
'**/.turbo/',
|
||||||
// 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查)
|
// 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查)
|
||||||
'**/generated/',
|
'**/generated/',
|
||||||
|
// Playwright 运行产物
|
||||||
|
'**/playwright-report/',
|
||||||
|
'**/test-results/',
|
||||||
|
'**/blob-report/',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
@@ -24,4 +31,21 @@ module.exports = tseslint.config(
|
|||||||
globals: { ...globals.node },
|
globals: { ...globals.node },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// eslint-plugin-vue flat/recommended 的每一段都显式限定到 apps/**/*.vue,
|
||||||
|
// 避免其无 files 的基础段作用于全部包。
|
||||||
|
...pluginVue.configs['flat/recommended'].map((config) => ({ ...config, files: ['apps/**/*.vue'] })),
|
||||||
|
{
|
||||||
|
files: ['apps/**/*.vue'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser },
|
||||||
|
parserOptions: { parser: tseslint.parser, sourceType: 'module' },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// 根组件 App.vue 是约定俗成的单词名,其余组件仍要求多词命名
|
||||||
|
'vue/multi-word-component-names': ['error', { ignores: ['App'] }],
|
||||||
|
// 模板排版类规则与 prettier 职责重叠,交给格式化工具(与官方建议一致)
|
||||||
|
'vue/max-attributes-per-line': 'off',
|
||||||
|
'vue/singleline-html-element-content-newline': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.7.1",
|
"packageManager": "pnpm@10.7.1",
|
||||||
"engines": { "node": ">=22.12" },
|
"engines": { "node": ">=22.12" },
|
||||||
|
"//": "pnpm 10 默认拦截依赖 install 脚本;onlyBuiltDependencies 白名单放行 esbuild(vite 需下载平台二进制)。Playwright 浏览器另行执行 `pnpm exec playwright install chromium`。",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"typecheck": "turbo run typecheck",
|
"typecheck": "turbo run typecheck",
|
||||||
@@ -16,12 +17,17 @@
|
|||||||
"e2e": "turbo run test:e2e"
|
"e2e": "turbo run test:e2e"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.48.0",
|
||||||
"turbo": "^2.3.0",
|
"turbo": "^2.3.0",
|
||||||
"typescript": "^5.6.0",
|
"typescript": "^5.6.0",
|
||||||
"prettier": "^3.3.0",
|
"prettier": "^3.3.0",
|
||||||
"dotenv-cli": "^7.4.0",
|
"dotenv-cli": "^7.4.0",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-plugin-vue": "^9.28.0",
|
||||||
"typescript-eslint": "^8.0.0",
|
"typescript-eslint": "^8.0.0",
|
||||||
"globals": "^16.0.0"
|
"globals": "^16.0.0"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": ["esbuild"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1735
-5
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user