- 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 产物装配与部署形态。
285 lines
32 KiB
Markdown
285 lines
32 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Common Commands
|
||
|
||
```bash
|
||
# Development
|
||
npm run dev # Start dev server on port 3000
|
||
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 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 # 构建静态产物
|
||
./scripts/deploy.sh deploy # 构建并发布到生产服务器
|
||
./scripts/deploy.sh deploy --skip-build # 使用现有 dist/ 直接发布
|
||
./scripts/deploy.sh rollback # 回滚到最近一次远程备份
|
||
./scripts/deploy.sh status # 查看生产环境发布状态
|
||
|
||
# Linting & Type Checking
|
||
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 # 全链路 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(阈值以 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: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
|
||
npm run db:reset # Reset database with migrations
|
||
```
|
||
|
||
## Architecture
|
||
|
||
### Tech Stack
|
||
- **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
|
||
- **shadcn/ui** pattern (Radix UI + class-variance-authority + tailwind-merge)
|
||
- **Prisma** with SQLite for backend data (admin, auth, CMS)
|
||
|
||
### Path Alias
|
||
`@/` maps to `src/` — configured in both `tsconfig.json` (`paths`) and `jest.config.js` (`moduleNameMapper`).
|
||
|
||
### TypeScript Strictness
|
||
Beyond `strict: true`, the project enables:
|
||
- `noUncheckedIndexedAccess: true` — array/object index access returns `T | undefined`, requiring null checks. This is a significant constraint to be aware of when writing code.
|
||
- `noImplicitReturns`, `noFallthroughCasesInSwitch`, `noUnusedLocals`, `noUnusedParameters`
|
||
|
||
### Route Structure (App Router)
|
||
```
|
||
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: 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
|
||
│ ├── products/ # Products hub (HSI model)
|
||
│ │ ├── page.tsx # Product listing (enterprise suites + standalone)
|
||
│ │ ├── [id]/ # Product detail (four-layer narrative)
|
||
│ │ ├── standalone/[id]/ # Standalone product detail
|
||
│ │ ├── erp-upgrade/ # Specific product landing pages
|
||
│ │ └── erp-upgrade-v3/
|
||
│ ├── services/ # Services list + [id] detail
|
||
│ ├── solutions/ # Solutions list + [id] detail (cross-references products)
|
||
│ ├── cases/ # Case studies
|
||
│ └── team/ # Team page
|
||
├── api/
|
||
│ ├── admin/ # Admin API
|
||
│ ├── auth/ # Authentication API
|
||
│ ├── cms/ # CMS API routes (draft mode, revalidation)
|
||
│ └── contact/ # Contact form submission
|
||
├── privacy/, terms/ # Legal pages
|
||
└── fonts/ # Local font files (Geist Sans/Mono)
|
||
```
|
||
|
||
### Archiving Convention
|
||
When replacing a page or component with a new version, move the old one to an `_archive/` subdirectory (e.g., `src/app/(marketing)/_archive/` for old homepage iterations). The archive is excluded from TypeScript compilation via `tsconfig.json`.
|
||
|
||
### Dark Mode
|
||
Dark mode uses the `data-theme="dark"` HTML attribute (not Tailwind's `dark:` class). Tailwind is configured with `darkMode: ['variant', '[data-theme="dark"] &']` — use the **variant** form, not `['selector', ...]`, because the selector form is accepted by the config but JIT emits zero `dark:` rules under Turbopack + Next.js 16.
|
||
|
||
**Three-state preference model** (`src/lib/theme.ts` is the single source of truth):
|
||
|
||
| Stored value (`localStorage['novalon-theme']`) | Meaning |
|
||
|---|---|
|
||
| `'light'` / `'dark'` | Explicit user choice — always wins over the OS |
|
||
| `'system'` / absent | Follow OS `prefers-color-scheme` (**default for new visitors**) |
|
||
|
||
- `src/app/layout.tsx` has an inline `<script>` in `<head>` that resolves the preference and sets `data-theme` **before first paint** (FOUC prevention). It must stay semantically in sync with `src/lib/theme.ts` — change both together.
|
||
- `src/components/theme/theme-toggle.tsx` cycles `跟随系统 → 浅色 → 深色`. It listens to `matchMedia('(prefers-color-scheme: dark)')` for live OS changes (only effective in the `system` state) and to `storage` events for cross-tab sync.
|
||
- `html[data-theme]` is an **output** of preference resolution — never read it back as the user's preference, or the `system` state collapses into `dark` on dark-OS machines and the cycle deadlocks.
|
||
|
||
### HSI Information Architecture
|
||
The site follows a **Hub-Spoke-Independent** model (see `CONTEXT.md` and ADR-0002):
|
||
- **Hub**: `/products` — product catalog, split into "企业套装" (6 enterprise products) and "专业产品" (standalone)
|
||
- **Spoke**: `/solutions` — industry scenarios, each recommending product combinations from the Hub
|
||
- **Independent**: Standalone products in the specialized zone have their own narrative path
|
||
|
||
### Four-Layer Narrative Model
|
||
Every detail page (product/service/solution/standalone) follows the same four-layer structure:
|
||
1. **L1 Hero**: Emotional entry with visual, title, value prop, status badge
|
||
2. **L2 Value Rationale**: Product-specific — features/benefits for products, pain-points→architecture for solutions, challenges→results for services
|
||
3. **L3 Trust Proof**: Case studies, data proofs, certifications, testimonials (currently stubbed — company is pre-launch)
|
||
4. **L4 CTA Conversion**: Primary action + secondary action + cross-recommendations
|
||
|
||
The detail components implementing this live in `src/components/detail/`:
|
||
- `detail-hero.tsx`, `detail-product-value.tsx`, `detail-trust-section.tsx`, `detail-cta-section.tsx`
|
||
- `solution-value.tsx`, `service-value.tsx` (type-specific L2 variants)
|
||
- `detail-cross-recommend.tsx` (cross-links between products↔solutions↔services)
|
||
|
||
### CMS Content Architecture
|
||
`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 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:
|
||
- **Colors**: `--color-ink` (deep charcoal #0A0E14), `--color-brand` (vermilion #C41E3A), accent colors for service-coding (blue, teal, amber, purple), functional colors (success, warning, error, info), dark-section overrides
|
||
- **Typography**: `--font-size-*`, `--line-height-*`, `--letter-spacing-*` all mapped to Tailwind's `fontSize`/`lineHeight`/`letterSpacing` scales
|
||
- **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`):
|
||
- **Dimension 1 — Design System**: Quantifiable tokens (colors, typography, spacing, radius, shadows, motion, components)
|
||
- **Dimension 2 — Design Style**: Qualitative perception (atmosphere, visual language, composition, imagery, brand tone)
|
||
- **Dimension 3 — Visual Effects**: Scroll animations, micro-interactions, parallax, SVG effects
|
||
|
||
The **Consulting Professional** aesthetic (inspired by Accenture + Bain) is the primary skeleton. **Ink cultural elements** (水墨) are secondary decorations limited to ≤6 locations (logo, dividers, transition animations, footer texture).
|
||
|
||
**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). 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
|
||
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
|
||
- `navigation.ts` — Nav structure + mega dropdown data
|
||
- `company.ts`, `stats.ts`, `team.ts`, `news.ts`, `methodology.ts`
|
||
- `hero-themes.ts` — Per-product hero visual theme variants
|
||
- `cross-references.ts` — Cross-links between products/solutions/services
|
||
|
||
Each product/solution/service implements the `Product`/`Solution`/`Service` interface which includes `caseStudies[]`, `dataProofs[]`, `certifications[]`, etc. for the four-layer narrative.
|
||
|
||
### Backend Layer (Prisma + API Routes)
|
||
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 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
|
||
```
|
||
src/components/
|
||
├── ui/ # Base: Button, Card, Input, Badge, ScrollReveal, AnimatedCounter, etc.
|
||
├── 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
|
||
```
|
||
> 口径核对(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 **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/` (`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.
|
||
|
||
### Submission Flow (PR-First)
|
||
`dev`/`main` are only reached via feature branch + PR: sync `origin/dev` → rebase → push → PR gate → merge by **Rebase** (linear history, no merge commit).
|
||
`bash scripts/check-pr-checklist.sh <pr-description-file>` must pass before opening a PR — it verifies `.gitea/PULL_REQUEST_TEMPLATE.md` has the three required sections (全链路检查 / 测试分层检查 / 质量门禁) with ≥20 checklist items, and that every item in the PR body is checked (mark irrelevant ones as `N/A:<reason>`).
|
||
Rebase rewrites hashes: an already-pushed branch may only be updated with `--force-with-lease`. Always rebase onto `origin/dev`, never a possibly-stale local `dev`.
|
||
|
||
### Component Versioning History
|
||
The project has gone through multiple design iterations. Older component versions:
|
||
- `components/detail-v2/` — previous iteration (deleted per git status, migration to `detail/` completed)
|
||
- `components/detail-v3/` — another iteration (deleted, same reason)
|
||
- `home-content-v2.tsx` through `home-content-v11.tsx` — archived homepage iterations in `(marketing)/_archive/`
|
||
- The current canonical components are in `components/detail/` and `home-content-cms.tsx`
|