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:
浅念
2026-09-18 09:12:45 +08:00
parent 8ba98ace63
commit f9cb72ed06
24 changed files with 2396 additions and 6 deletions
+3
View File
@@ -2,6 +2,9 @@ node_modules/
dist/
.turbo/
coverage/
playwright-report/
test-results/
blob-report/
.env
.env.*
!.env.example
+9
View File
@@ -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();
});
+13
View File
@@ -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>
+31
View File
@@ -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"
}
}
+21
View File
@@ -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,
},
});
+56
View File
@@ -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>
+96
View File
@@ -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');
});
});
+35
View File
@@ -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');
});
});
+28
View File
@@ -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');
});
});
+108
View File
@@ -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 }),
);
}
+25
View File
@@ -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>
+43
View File
@@ -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 {
// 忽略持久化失败
}
}
+20
View File
@@ -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',
},
};
+20
View File
@@ -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: '网络连接失败,请检查网络',
},
};
+6
View File
@@ -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');
+21
View File
@@ -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>
+9
View File
@@ -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>
+13
View File
@@ -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 },
],
});
+19
View File
@@ -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"]
}
+23
View File
@@ -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
View File
@@ -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',
},
},
);
+6
View File
@@ -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"]
}
}
+1735 -5
View File
File diff suppressed because it is too large Load Diff