chore(qa): 验收台账与证据入库 + 构建/部署配置同步

- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。
- 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。
- 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径;
  next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐
  standalone 产物装配与部署形态。
This commit is contained in:
2026-09-28 10:48:09 +08:00
parent 6bb7c557ee
commit a0328a623f
128 changed files with 22455 additions and 504 deletions
+95 -26
View File
@@ -12,7 +12,25 @@ npm run dev:clean # Clean .next/dist then start dev server
# Build & Preview
npm run build # Build production files to dist/
npm run build:clean # Clean then build
npm run preview # Serve dist/ on port 3000 (npx serve)
npm run start # next start -p 3000 — serve the built output
npm run preview # ALIAS of `start` (identical `next start -p 3000`); it is NOT `npx serve`
# 说明(2026-09-23 文档同步轮核实):`next start` 与 `output: 'standalone'` 组合下 Next 16 会打印
# `"next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js"
# instead.`(实测见 `docs/acceptance/2026-09-21-gates/ga4-production-run.txt`;抛出点
# `node_modules/next/dist/server/next.js` 的 `getServer()`)。它是 **warn 而非 throw**——服务照常起来,
# 但走的是不受支持的降级路径,所以门禁脚本正逐步脱离 `next start`:`test:e2e:prod` 已改为直接
# `node dist/standalone/server.js`(`e2e/playwright.config.ts:132-135`);`check:headings` 也已脱离
# `next start` —— `scripts/utils/check-heading-hierarchy.ts:55-66` 现为「存在 dist/standalone/server.js 就直接
# 起 standalone(注入 HOSTNAME/PORT/NODE_ENV),仅当产物缺失才回退 `npm run preview` 并打印告警」,
# 见验收报告 N-24③。至此门禁脚本不再依赖不受支持的 `next start` 路径。
# standalone 的受支持启动方式是 `node dist/standalone/server.js`,且服务启动前 `public/`
# 与 `dist/static` 必须已在 standalone 根目录下(server.js 启动时缓存静态索引,缺资源会让
# /_next/static/** 全 404)。**这一步由 `package.json:9` 的 `postbuild` 自动完成**(`npm run build`
# 结尾 `rm -rf` + `cp -R dist/static → dist/standalone/dist/static`、`cp -R public → dist/standalone/public`),
# 所以正常路径下"先 build 再起 standalone"即可,不必手工拷;仅当产物来自别处或要复用旧 dist 时才参照
# `Jenkinsfile:353-355`(axe 阶段复用上一阶段产物时的同套拷贝)与 `scripts/deploy.sh:162-175`
# (发布侧把 `public/` 与 `dist/static → dist/_next/static` 同步进 Nginx 站点根)。npm 侧没有包一层的启动脚本。
# Deploy (统一发布脚本)
./scripts/deploy.sh build # 构建静态产物
@@ -22,27 +40,61 @@ npm run preview # Serve dist/ on port 3000 (npx serve)
./scripts/deploy.sh status # 查看生产环境发布状态
# Linting & Type Checking
npm run lint # ESLint (configured in config/lint/.eslintrc.json)
npm run lint # ESLint flat config at repo root (`eslint.config.mjs`; `config/lint/.eslintrc.json` no longer exists)
npm run type-check # tsc --noEmit
# E2E Testing (Playwright)
npm run test # Run all E2E tests
npm run test:e2e # Same as above
npm run test # 全链路 E2E:先 test:functional(4 功能 project),再 test:visual:all(5 视觉 project),串行以免写库用例与截图并发污染基线
npm run test:functional # 仅功能 project(chromium / chromium-mobile / firefox / webkit)
npm run test:e2e # Same as test:functional
npm run test:smoke # Only @smoke-tagged tests
npm run test:e2e:prod # @smoke+@critical+@journey 打构建产物(E2E_TARGET=production → node dist/standalone/server.js,CI 门禁)
npm run test:visual # Visual regression (Desktop Chrome)
npm run test:visual:all # Visual regression (all projects)
npm run test:visual:update # Update visual snapshots
npx playwright test --grep "test name" # Run a single E2E test by name
# ⚠ E2E 目标口径(默认跑的是 dev server,不是产物)
# `npm run test` / `test:functional` 的 webServer 是 `npm run dev`(`e2e/playwright.config.ts:132-135`),
# 因此 **16 个 @critical GA4 用例(TC-GA4-001..004 × 4 project)在这一轮里是 skipped,不是 passed**:
# `NEXT_PUBLIC_GA_MEASUREMENT_ID` 只写在 `.env.production`,dev 读不到 → `GoogleAnalytics.tsx:81,119`
# 提前 return null → 用例内 `test.skip(measurementId === null, …)` 触发。证据:
# `docs/acceptance/2026-09-21-gates/skipped-tests-final-tree.json`(28 skipped 中该组恰为 16)。
# 它们真正断言的唯一入口是 `npm run test:e2e:prod`(`E2E_TARGET=production` → 直接
# `HOSTNAME=localhost PORT=3000 node dist/standalone/server.js`,非 `next start`;须先 `npm run build`
# 并把 `dist/static` + `public` 并入 standalone 根目录,见 Build & Preview 段的装配口径),
# 见同目录 `ga4-production-run.txt` 的 4 passed。故「`npm run test` 全绿」不等于 GA4 覆盖已验证。
# Unit Testing (Jest)
npm run test:unit # Run all unit tests
npm run test:coverage # Coverage report (thresholds: 80% branches/functions/lines)
npm run test:coverage # Coverage report(阈值以 jest.config.js 的 coverageThreshold 为准;根配置 re-export config/test/jest.config.js 以免双份漂移)
npx jest --testPathPattern="button" # Run a single test file matching pattern
# Quality Checks
npm run check:contrast # Color contrast audit
npm run check:headings # Heading hierarchy audit
npm run lighthouse # Lighthouse CI performance audit
npm run check:a11y # 可访问性静态门禁伞(= check:contrast + check:headings + check:brand-token,已并入 test:all)
npm run check:contrast # 令牌对比度审计(读 globals.css 令牌表,浅色+暗色双主题、含 alpha 组,缺令牌即红)
npm run check:headings # 标题层级审计(有 dist/standalone/server.js 时直起 standalone;产物缺失才回退 `npm run preview` 并打印告警 —— scripts/utils/check-heading-hierarchy.ts:52-67)
npm run check:brand-token # 品牌红文字必须走 text-brand-ink* 令牌(DESIGN.md 双通道红规则)
npm run check:motion # 动效契约门禁(N-29 收口):R1 令牌档位(instant 100ms / fast·normal 180–280ms,CONTEXT.md:76)、
# R2 时长 ≤700ms(CSS 声明、framer transition 对象的 duration、Tailwind duration-* 类、tailwind.config 的 animation;
# infinite 循环与 *-delay 豁免)、R3 transition 时长必须走 var(--transition-*)、
# R4 写死的 cubic-bezier 必须属于令牌层 --ease-*(允许集合从 CSS 解析,不硬编码)且 --ease-ink==[0.22,1,0.36,1]。
# 退出码 0 干净 / 1 违规 / 2 未能度量(空扫描、缺令牌文件)—— 空扫描不读作干净。
# ⚠ 当前树 23 处违规(EXIT=1),故**尚未**并入 check:a11y,详见 docs/development/quality-gates.md §5.1
npm run check:motion:test # 上述门禁的自证伪测试(19 例,双向:合规夹具判 0 + 违规夹具判对应规则红);
# 测试在 scripts/ 下,jest 默认 roots 只含 src,故该脚本显式传 --roots
npm run check:axe:routes # 全站路由清单(sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)→ /tmp/axe-routes.xml
# 同样需要 :3100 的生产服务(SEED 默认取 ${BASE}/sitemap.xml)
npm run check:axe # 双引擎(chromium/firefox)×双主题(light/dark)逐页 axe 节点计数 + 三条规则级
# 规则(autocomplete-valid/presentation-role-conflict/svg-img-alt)的分母断言;
# 退出码 0 通过 / 1 判红 / 2 清单缺失或 0 条(假绿熔断)。证据落
# docs/acceptance/2026-09-21-axe/axe-evidence.json。需一个生产服务在 :3100:
# PORT=3100 HOSTNAME=127.0.0.1 node dist/standalone/server.js(先拷 dist/static 与 public,见上文)
# CI 侧由 Jenkinsfile「♿♿ 全站 axe 节点计数」阶段(仅 main)自动串起
npm run test:all # type-check + lint + test:coverage + test:integration:real + check:a11y + test:e2e:fast + test:security:headers
npm run test:e2e:prod # E2E_TARGET=production(构建产物目标);harness **不代跑构建**,须先 `npm run build`
# (standalone 根目录的 dist/static + public 由 postbuild 自动装配,见上文)
npm run lighthouse # Lighthouse CI(配置 config/test/lighthouserc.json;axe critical/serious 逐条 minScore:1;inspector-issues 亦判红——CSP/弃用类问题只走 DevTools issue 通道,errors-in-console 与 0.9 分类阈值都看不见它;报告落 lighthouse-reports/)
# Database (Prisma + SQLite)
npm run db:seed # Seed the dev database
@@ -52,7 +104,8 @@ npm run db:reset # Reset database with migrations
## Architecture
### Tech Stack
- **Next.js 14** (App Router) with **hybrid rendering** — static pages + API routes (Note: `output: 'export'` was recently removed; the project is transitioning away from pure static export)
- **Next.js 16.3**(App Router)混合渲染 — SSG/ISR 静态页 + API routes;`output: 'standalone'`(`next.config.mjs:44`)、`distDir: 'dist'`(`:46`)。standalone 入口是 `node dist/standalone/server.js`,需另拷 `dist/static` 与 `public`;`npm run start`(= `next start`)在 standalone 下只算不受支持的降级路径,见下文 Build Output
- **中间件命名为 `proxy.ts`**(Next 16 起 `middleware.ts` 更名);其 matcher 排除 `/api/*`,因此每个 API 路由必须自行做鉴权
- **React 18**, **TypeScript 5** (strict mode with `noUncheckedIndexedAccess`)
- **Tailwind CSS 3** with design tokens exposed as CSS custom properties (all tokenized via `var()` references in `tailwind.config.js`)
- **Framer Motion** for animations, **Lucide React** for icons, **Zod** for validation
@@ -73,7 +126,7 @@ src/app/
├── layout.tsx # Root layout: fonts, metadata, theme, analytics, SEO schemas
├── (marketing)/ # Route group — all public marketing pages
│ ├── layout.tsx # Shared: Header + Footer + PageTransition + ErrorBoundary
│ ├── page.tsx # Home (delegates to home-content-cms.tsx)
│ ├── page.tsx # Home: server component, CMS zones via `lib/cms/data-server` → `home-content-v15.tsx`
│ ├── about/ # About page (client.tsx)
│ ├── news/ # News list + [slug] detail
│ ├── contact/ # Contact form
@@ -132,13 +185,14 @@ The detail components implementing this live in `src/components/detail/`:
- `detail-cross-recommend.tsx` (cross-links between products↔solutions↔services)
### CMS Content Architecture
The app has a **mock CMS layer** at `src/lib/cms/` that simulates a headless CMS (no real backend — designed to be swapped with real API calls later):
- `types.ts` — ContentModel, ContentItem, ContentZone, ThemeConfig definitions
- `ContentZoneRenderer.tsx` — Renders a zone's items in grid/list/carousel layouts, delegating each item to `getItemRenderer(modelCode)` from the component registry
- `mock-home.ts` — Mock data for the homepage zones
- `component-registry.ts` — Maps model codes to React renderer components
`src/lib/cms/` is a **real, server-side CMS layer over Prisma** — there is no mock layer and no client-side CMS SDK (the historical names `mock-home.ts`, `ContentZoneRenderer.tsx`, `component-registry.ts`, `src/components/cms/` do not exist in the tree; do not look for them):
- `types.ts` — `FieldType`/`FieldDefinition`, `ContentModel`, `ContentStatus`, `ContentItem`, `ContentZone`, `ThemeConfig` definitions
- `content-types.ts` — `CONTENT_TYPE_CONFIGS` + `registerAllContentTypes()`: the per-model field schemas the admin form and the validator both read
- `data-server.ts` — the read path: `getPublishedItems` (`:37`), `getPublishedItemBySlug` (`:48`), `getPageZones` (`:70`), `getZone` (`:85`), `getHomePageCopy` (`:171`), each wrapped in React `cache` so a render pass hits the DB once
- `workflow.ts` / `validate-content-data.ts` / `notifications.ts` — status state machine, field validation, in-app notifications (status changes only through `workflow.ts`; see `docs/cms/api-contract.md` §10.5)
- `index.ts` — re-exports the types plus `CONTENT_TYPE_CONFIGS`
The homepage (`home-content-cms.tsx`) initializes the CMS and fetches mock content zones (hero, stats, services, solutions, cases, news).
The homepage is `src/app/(marketing)/page.tsx` (server component, `export const revalidate = 3600` at `:5`): it resolves the home zones with `getResolvedHomeZones()` (`:8`) and, when the CMS has nothing configured, falls back to per-model `getPublishedItems(...)` calls (`:23-29`); the markup lives in `home-content-v15.tsx`. Earlier documents calling it `home-content-cms.tsx` are stale.
### Design Token System
All visual tokens are defined as **CSS custom properties** in `src/app/globals.css` (`:root` block) and mapped into Tailwind's config via `var()` references:
@@ -147,6 +201,11 @@ All visual tokens are defined as **CSS custom properties** in `src/app/globals.c
- **Spacing, radius, shadows** all tokenized through CSS variables
- **Transitions**: `--transition-fast/normal/slow`, `--ease-ink` (cubic-bezier), `--ease-sharp`(`--ease-spring*` 死令牌已于 2026-09-19 polish 删除)
- **Dark mode**: `data-theme="dark"` attribute (set via inline script before paint to prevent flash); flipped by the `[data-theme='dark']` override block in `globals.css`, which only re-maps CSS variables — components do not restyle per element
- **Opacity**: use theme tokens (`bg-brand/20`, `border-ink/30`) — they resolve through `rgb(var(--x-rgb) / <alpha-value>)`. The arbitrary-value form `bg-[var(--color-brand)]/20` is **accepted by the parser but emits no CSS** (Tailwind v3 cannot apply an alpha modifier to a plain-hex custom property; see `tailwind.config.js:12-15`), so it fails silently. Verify any "is this style live?" claim against the compiled stylesheet, not against the source class list.
- **Two-channel red**: `--color-brand` (#C41E3A) is for surfaces only and does not flip in dark mode; text must use `text-brand-ink` (flips to #F87171). Enforced by `scripts/utils/check-brand-text-token.ts` on the text channel.
- **Hover / translucent states are unguarded**: axe skips `:hover` and `check:contrast` only asserts declared token *pairs*, so a hover background nobody noticed was dead becomes a live AA failure the moment you fix it. Composite it yourself — a semi-transparent layer **replaces** the element's own base color: `alpha*brand + (1-alpha)*pageBg`; at 20% that yields 4.18:1 against `text-brand-ink` in light theme (fail), at the `brand-soft` 12% token 4.80:1 (pass).
- **Motion tokens are gated, and the gate is currently RED**: `scripts/utils/check-motion-constraints.ts` (`npm run check:motion`, 配套 `npm run check:motion:test`) enforces the `CONTEXT.md` §动效设计四原则 bands (entrance 180–280ms, ≤700ms ceiling, durations must flow through `--transition-*` tokens, bezier allow-list **parsed from the CSS `--ease-*` tokens** — `--ease-out` is an alias of `ease-ink`, and the token layer deliberately carries 5 further curves). It is deliberately **not** part of `check:a11y`: the tree measures 23 violations / 13 files, so wiring it in would make the aggregate permanently red. `scripts/**` is eslint-ignored, so adding it did not move the lint baseline.
### Design DNA Framework
The site follows a three-dimensional design system (see `CONTEXT.md`):
@@ -158,10 +217,10 @@ The **Consulting Professional** aesthetic (inspired by Accenture + Bain) is the
**Brand red (#C41E3A) usage rule**: Every page must have ≥3 brand-red touchpoints. It must never be used as paragraph text color, as a large background, or alongside accent colors in the same card. Area ≤10%. Exception: full-bleed dark-red statement/CTA blocks use the separate `crimson-veil` (#8B1530) token — see DESIGN.md「Crimson Veil」(rule clarified 2026-09-19 critique to resolve this apparent contradiction).
**Motion design**: Animations are purposeful (not decorative), fast (150-300ms), use `ease-ink` [0.22, 1, 0.36, 1] as default easing, and stagger children by 30-60ms. No continuous looping animations except pulse-soft for skeletons. No spring easings for content entry. No animations >700ms.
**Motion design**: Animations are purposeful (not decorative). Durations come from tokens, **not** from a free-form "150-300ms" range — that phrasing conflicted with `CONTEXT.md`「动效设计四原则」and is replaced by its three tiers: **入场 180–280ms**(`--transition-fast: 180ms` … `--transition-normal: 280ms`,`src/app/globals.css:264-265`)· **hover 150ms** · **反馈 100ms**;`--transition-slow: 450ms` / `--transition-slower: 700ms` 只用于揭示型动效。`ease-ink` `[0.22, 1, 0.36, 1]`(`globals.css:280`)为默认缓动,children stagger 30-60ms、Section 间 stagger 100-150ms。No continuous looping animations except `pulse-soft` for skeletons. No spring easings for content entry(spring 仅用于按钮按压反馈). **No entry animations >700ms.** Note that `duration-300` 一类的 Tailwind 预设时长绕过令牌、且 300ms 不属任何档位(残留清点见 `CONTEXT.md`「duration-300 → 动效令牌」行,该项 in flight,勿引用其计数)。
### Data Layer
All structured content data is in `src/lib/constants/` as TypeScript constants — no API/database for marketing content:
Most structured marketing content is still `src/lib/constants/` TypeScript constants, and the pages render from them; on top of that, published CMS items now override specific slots — the home page's hero/services/cases/stats/news data comes from `lib/cms/data-server` (Prisma) and only falls back to constants when the CMS has nothing configured (`src/app/(marketing)/page.tsx:8-36`), and per-section copy comes from `getHomePageCopy()` with in-component fallbacks. "Marketing content has no database" is therefore no longer true:
- `products.ts` — 6 enterprise products (ERP, CRM, CMS, BI, SDS, OA) + standalone products (NovaVis)
- `services.ts`, `solutions.ts` — Service and solution definitions
- `cases.ts` — Case studies with industry filtering
@@ -176,7 +235,10 @@ Each product/solution/service implements the `Product`/`Solution`/`Service` inte
The project has a backend layer for admin/auth/CMS functionality:
- **Prisma** with SQLite (`prisma/dev.db`) for admin user data, auth, and CMS content management
- API routes at `src/app/api/admin/`, `auth/`, `cms/`, `contact/`
- The marketing pages use mock data from `src/lib/cms/mock-home.ts`, but the CMS API routes suggest a real CMS backend is planned
- The marketing read path and the admin write path share this backend: pages fetch published items through `src/lib/cms/data-server.ts`, while `api/admin/*` writes drafts and `api/cms/*` (`draft/enable`, `draft/disable`, `revalidate`) handles preview cache invalidation
### Error Monitoring (Sentry)
`@sentry/nextjs@10` is wired through `src/instrumentation.ts` (server/edge, incl. `onRequestError`) and `src/instrumentation-client.ts` (client init + `onRouterTransitionStart`); `next.config.mjs` exports `withSentryConfig(nextConfig)`. **This Next 16 build defaults to Turbopack, which never loads the root `sentry.client.config.ts`** — client init therefore lives in `instrumentation-client.ts`, and the old file is kept as an empty stub solely to prevent a double `Sentry.init` on the `--webpack` path. Everything is gated on `NEXT_PUBLIC_SENTRY_DSN`: absent it, `Sentry.init` is skipped, both hooks no-op, **and the CSP is byte-identical** (`connect-src`/`worker-src` only gain the DSN's `protocol//host` when a valid DSN is present, `next.config.mjs:17-26`). Verify "is this inert?" by building without a DSN and diffing the emitted header, not by reading the source.
### Component Organization
```
@@ -185,19 +247,26 @@ src/components/
├── layout/ # Header, Footer, MobileTabBar, Breadcrumb, MegaDropdown(MobileMenu 已删除,抽屉内置于 Header)
├── sections/ # Page section components: HeroSectionV2, ServiceGrid, CTASection, etc.
├── detail/ # Four-layer narrative: DetailHero, ProductValueSection, DetailTrustSection, etc.
├── content/ # 仅 sections.tsx(+ 同名 .test.tsx)
├── admin/ # admin-layout.tsx、auth-context.tsx(React Context 在此,不在 src/contexts/)
├── theme/ # theme-toggle.tsx
├── seo/ # Structured data (OrganizationSchema, WebsiteSchema, etc.)
├── analytics/ # GA4, error tracking, cookie consent, scroll depth, outbound links
├── cms/ # CMS renderers (ContentRenderer, SectionRenderer, FieldRenderer)
├── content/ # sections.tsx, testimonials.tsx
└── providers/ # (currently empty)
└── analytics/ # GA4, error tracking, cookie consent, scroll depth, outbound links
```
> 口径核对(2026-09-23,验收 N-33「不存在的目录」一项):`cms/`、`providers/`、`effects/` 三个目录在工作树与 `git ls-tree -r HEAD` 中**均不存在**,旧图里的对应条目是残留。`ContentRenderer` / `SectionRenderer` / `FieldRenderer` 三个标识符在 `src/**/*.{ts,tsx}` 中零命中,CMS 区块渲染实际由 `src/components/content/sections.tsx` + `src/lib/cms/` 承担;`effects/` 一族已在提交 `37296b5`(2026-05-10)删除,见 `CONTEXT.md`「特效组件」。
### Testing Setup
- **Jest** (unit): Tests alongside source in `src/**/*.test.{ts,tsx}`, coverage threshold 80%. Config in `config/test/jest.config.js`. Uses `@/` path alias and ts-jest with `jsx: 'react-jsx'` transform (since tsconfig uses `'preserve'`). Run single tests with `npx jest --testPathPattern="component-name"`.
- **Playwright** (E2E): Tests in `e2e/`, config in `e2e/playwright.config.ts`. Auto-starts `npm run preview` as web server. Visual regression snapshots in `e2e/visual-snapshots/`. Three browser projects (chromium, firefox, webkit) plus dedicated visual regression projects at desktop/tablet/mobile breakpoints. Run single tests with `npx playwright test --grep "test name"`.
- **Jest** (unit): Tests alongside source in `src/**/*.test.{ts,tsx}`, coverage **thresholds** live in `config/test/jest.config.js` (global 75% stmts/lines/funcs, 82% branches) and are the single source of truth — root `jest.config.js` only re-exports it, never duplicate thresholds. **Measured values are recorded in exactly one place: `docs/development/quality-gates.md` §3** (2026-09-23 本树复跑:134 suites / 1697 tests / EXIT=0);该文件顶部注释里的百分比是滞后快照,引用前先复跑。Uses `@/` path alias and ts-jest with `jsx: 'react-jsx'` transform (since tsconfig uses `'preserve'`). Run single tests with `npx jest --testPathPattern="component-name"`.
- **Playwright** (E2E): Tests in `e2e/`, config in `e2e/playwright.config.ts` — run from `cd e2e` (`npm run test` does this). webServer is `HOSTNAME=localhost PORT=3000 node dist/standalone/server.js` when `E2E_TARGET=production` (`e2e/playwright.config.ts:132-135` — build artifacts, real security headers; CI gate uses `npm run test:e2e:prod`, and `npm run build` + the `dist/static` / `public` copies must already exist because the harness never builds) and `npm run dev` otherwise; `reuseExistingServer` is disabled for production targets, so a busy :3000 aborts the run. Functional projects: chromium, chromium-mobile (iPhone 14, 390×844), firefox, webkit — plus 5 visual-regression projects at desktop/tablet/mobile breakpoints; snapshots in `e2e/visual-snapshots/`. `mobile-*.spec.ts` must pin their own viewport because they also execute under the desktop projects. Touch-target gate lives in `e2e/touch-targets.ts` (AA SC 2.5.8 ≥24px hard, AAA SC 2.5.5 44px advisory). Visual snapshots are server-target independent — every spec imports `test` from `e2e/fixtures.ts`, whose `context` fixture injects an init script that removes the `next dev`-only `<nextjs-portal>` devtools indicator (MutationObserver on `document`, because `documentElement` is still null at document start). The indicator's shadow-DOM `<footer class="error-overlay-footer">` is pierced by Playwright's CSS engine and made `locator('footer')` resolve to 2 elements (56 failures). `devIndicators: false` is *not* a fix here — measured: the CSP-blocked-`eval` dev error keeps the overlay mounted, so the portal survives; don't reintroduce that env gate. `visual-regression.spec.ts` additionally pins the cookie-consent state via `addInitScript` (constant timestamp) because the banner renders on a 2 s timer and the `visual-firefox-desktop` / `visual-webkit-desktop` projects use an empty storage state — without the pin, slower engines photograph the banner into the first viewport and faster ones don't (34 engine-specific failures). Client-component interactions must call `expectHydrated()` from `e2e/hydrated.ts` before hover/click — SSR HTML is visible ~1.7 s before React attaches handlers, and pointer events in that window are dropped without replay. Hover-driven UI additionally needs `expect(...).toPass()` re-driving the pointer, because a single `hover()` can land mid-layout-transition and `mouseenter` never re-fires. Run single tests with `npx playwright test --grep "test name"`.
### Build Output
The project builds to `dist/` (configured via `distDir` in `next.config.mjs`). It is served via Nginx (see `nginx-static-production.conf`) with CDN support (`assetPrefix` respects `CDN_DOMAIN` env var). Images are unoptimized (static export limitation). Note: `output: 'export'` was recently removed from the config — the project may be moving toward a hybrid SSR + static model.
The project builds to `dist/` (`distDir` in `next.config.mjs:46`) with **`output: 'standalone'`** (`next.config.mjs:44`) — this is a hybrid render model, *not* a static export (`output: 'export'` was dropped and the project has since moved on to standalone; `CONTEXT.md`「生产部署模式」records the 2026-08 switch). Concretely:
- **Prerendered/ISR**: the marketing pages, `privacy`, `terms`, `sitemap.ts`, `robots.ts`.
- **Runtime-backed**: `src/app/api/**` (20 route files — `admin/*`, `auth/*`, `cms/*`, `contact`), the `admin/` pages, and any ISR revalidation source. `proxy.ts` matchers exclude `/api/*`, so each API route authenticates itself.
- **Serving**: Nginx keeps a *disk copy* of the client static assets (`/_next/static/` with `try_files … @nextjs`, `nginx-static-production.conf:151-157`) plus `/uploads/` hardening, and proxies `/api/` (`:213`), `/admin` (`:226`) and everything else (`location /` `:238-240`, `@nextjs` `:249`) to the `nextjs_app` upstream running `dist/standalone/server.js`. Page HTML no longer has a static-file fast path — it was removed on purpose (`nginx-static-production.conf:20-25`), so "nginx serves the pages from `dist/`" is no longer true. `assetPrefix` is driven by `CDN_DOMAIN`. Container build is `Dockerfile.prod` + `docker-compose.server.yml`.
- **Images are unoptimized** (`next.config.mjs:48-61`, value at `:58`) because distribution goes through Nginx + CDN, and flipping it to `false` would require every environment to have a Node runtime taking over `/_next/image` (otherwise every image 404s). The reason is *deployment topology*, not a static-export limitation.
- **Boot command**: `npm run start` / `npm run preview` both run `next start -p 3000`, which Next 16 warns "does not work with output: standalone" (warning only — see the Build & Preview note above). The supported server is `dist/standalone/server.js`; `public/` and `dist/static` are copied into the standalone root **automatically by `package.json:9` 的 `postbuild`**,所以 `npm run build` 之后直接 `node dist/standalone/server.js` 即可。该启动方式仍没有包成 npm script —— CI 在 `Jenkinsfile:353-355` 复用产物时重做同套拷贝,发布侧由 `scripts/deploy.sh:162-175` 把 `public/` 与 `dist/static → dist/_next/static` 同步进 Nginx 站点根。
### Commit Convention
Uses Conventional Commits with commitlint (`@commitlint/config-conventional`). Husky + lint-staged enforces linting on pre-commit.