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/
|
||||
.turbo/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
.env
|
||||
.env.*
|
||||
!.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 不会静默跳过。
|
||||
// 仅 typescript-eslint recommended(无类型感知规则,无需 project service),
|
||||
// 避免跨包 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
|
||||
const globals = require('globals');
|
||||
const tseslint = require('typescript-eslint');
|
||||
const pluginVue = require('eslint-plugin-vue');
|
||||
|
||||
module.exports = tseslint.config(
|
||||
{
|
||||
@@ -15,6 +18,10 @@ module.exports = tseslint.config(
|
||||
'**/.turbo/',
|
||||
// 生成产物不参与 lint(契约生成类型已提交入库,改动随 `pnpm gen` 审查)
|
||||
'**/generated/',
|
||||
// Playwright 运行产物
|
||||
'**/playwright-report/',
|
||||
'**/test-results/',
|
||||
'**/blob-report/',
|
||||
],
|
||||
},
|
||||
...tseslint.configs.recommended,
|
||||
@@ -24,4 +31,21 @@ module.exports = tseslint.config(
|
||||
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,
|
||||
"packageManager": "pnpm@10.7.1",
|
||||
"engines": { "node": ">=22.12" },
|
||||
"//": "pnpm 10 默认拦截依赖 install 脚本;onlyBuiltDependencies 白名单放行 esbuild(vite 需下载平台二进制)。Playwright 浏览器另行执行 `pnpm exec playwright install chromium`。",
|
||||
"scripts": {
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
@@ -16,12 +17,17 @@
|
||||
"e2e": "turbo run test:e2e"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"prettier": "^3.3.0",
|
||||
"dotenv-cli": "^7.4.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-plugin-vue": "^9.28.0",
|
||||
"typescript-eslint": "^8.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