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
+1 -1
View File
@@ -42,7 +42,7 @@
## 质量门禁
- [ ] `npm run test:unit` 全部通过
- [ ] `npm run test:coverage` 通过 `jest.config.js` 的 `coverageThreshold`(global:branches 30% / functions 25% / lines 32% / statements 30%),且**未下调阈值**
- [ ] `npm run test:coverage` 通过 `config/test/jest.config.js` 的 `coverageThreshold`(**该文件是阈值唯一真源**,根 `jest.config.js` 仅转发;global:branches 82 / functions 75 / lines 75 / statements 75),且**未下调阈值**
- [ ] `npm run test`(Playwright E2E)全部通过
- [ ] `npm run check:contrast` 与 `npm run check:headings` 通过(WCAG 2.1 AA)
- [ ] `npm run lighthouse` 满足 `lhci` 断言(性能/CSP/可访问性预算未回退)
+4
View File
@@ -97,6 +97,10 @@ e2e-tests/reports/
# ============================================================
*.db
*.db-journal
# SQLite WAL 边车文件(WAL 模式下与 *.db 同目录生成;此前只挡了 *.db 与 *.db-journal,
# 导致 prisma/dev.db-shm / dev.db-wal 处于未跟踪且未忽略状态,`git add -A` 会把它们带进提交)
*.db-shm
*.db-wal
data.db
data/
+232
View File
@@ -0,0 +1,232 @@
# 系统性 Code Review / 全面测试 / 验收报告 — 2026-09-21
- 分支:`refactor/optimize-ui`(领先 `origin/main` 30 个提交;`src/` 改动 120 文件,+3248 / −3959)
- 环境:Next.js 16.3.0 (Turbopack) / React 18.3 / TypeScript 5 strict / Prisma 6.19 + SQLite / Tailwind 3.4
- 方法:4 路并行模块审查(API+鉴权 / lib+hooks / 组件+页面 / 数据+配置+测试基建),所有结论由主代理逐条复核证据后定级;无法证实的假设已撤回(见 §7)
- 实测门禁:type-check / lint / 单测+覆盖率 / build / **E2E 三浏览器全量** / check:contrast / check:headings / 安全响应头 / **Lighthouse 7 URL** / **变异测试(quick 作用域)** —— 全部由本次亲跑取回原始输出,未引用任何历史报告(详见 §2)
## 1. 验收结论
**不通过验收(REJECTED)。**
阻断项 5 类:①生产级安全漏洞(权限提升 ×2、存储型 XSS ×2);②E2E 92 例失败(3 浏览器一致复现,含 6 例 `@critical`);③CI 用 `|| echo` 吞掉全部功能性测试,绿色徽章不证明任何行为正确性;④本分支引入 2 处用户可见 UI 回归;⑤**可访问性门禁双重失效**(A-11)导致一个**全站 62 页**的 WCAG AA 对比度失败(A-12 Cookie 条隐私政策链接 3.31:1)在 `check:contrast` 与 Lighthouse 两道门禁下同时报绿。
> 关键判据不是「失败数」而是「门禁是否可信」。当前 `test:all` 与 Jenkins 绿灯所覆盖的范围,与 AGENTS.md §5 声称的质量门禁之间存在实质落差;本次实测中**两道 a11y 门禁双双通过,却被两个独立引擎各测出 critical 级失败**,即为该落差的最强证据。
## 2. 门禁执行结果(本次实测,非引用)
| 门禁 | 命令 | 结果 | 判定 |
|---|---|---|---|
| 类型检查 | `npm run type-check` | 0 error,EXIT=0 | 通过 |
| Lint | `npm run lint` | 0 error / **128 warning** | **不达 AGENTS.md「无警告」** |
| 单元测试 | `npm run test:unit` | **132 套件 / 1594 例全通过**,23.9s | 通过 |
| 覆盖率 | `npm run test:coverage` | stmts 75.9 / branch 84.28 / func 75.46 / line 75.9 | 通过(但门禁形同虚设,见 §3.3) |
| 生产构建 | `npm run build` | EXIT=0,62 静态页生成 | 通过 |
| E2E 三浏览器 | `playwright test --project=chromium,firefox,webkit` | **710 通过 / 92 失败 / 8 跳过**,17.2min,EXIT=1 | **失败** |
| 色彩对比度 | `npm run check:contrast` | 7/7 通过 | 通过(**但仅 7 组硬编码浅色对,盲区见 §3.4**) |
| 标题层级 | `npm run check:headings` | 10/10 页 0 问题 | 通过 |
| 安全响应头 | `npm run test:security:headers` | 6 通过 / 2 警告 | 通过(**但打的是线上 novalon.cn,不验证本分支**) |
| Lighthouse | `npx lighthouse@13`(对 `lighthouserc.json` 全部 7 个 URL,desktop preset) | **性能全绿**(perf 99-100 / FCP 246-250ms / LCP 790-896ms / CLS **0.000** / TBT 0ms / SI 248-414ms);**a11y 92-97、7 页全部含 contrast 失败节点,共 10 节点** | **断言 0 违规 → 门禁"通过",但门禁太松看不见真实 a11y 缺陷,见 §3.4** |
| 变异测试 | `npx stryker run --inPlace --mutate 'src/lib/utils.ts'`(`test:mutation:quick`) | **91.18%**(31 killed / 3 survived / 0 no-cov / 0 error),≥ `break:50`;**全量作用域未跑**(外推需数小时) | 通过(阈值达标),但暴露 2 处真实断言缺陷 + 作用域排除「零编造」核心,见 §3.5 |
| 压测 | `npm run test:performance` | **不可运行**:`k6` 是 v0.0.0 占位包(`node_modules/k6/package.json`:"Dummy package for autocompleting k6 scripts"),无 `node_modules/.bin/k6` | **失效** |
Lint 128 warning 构成:`no-explicit-any` 49 · `react-hooks/set-state-in-effect` 12 · `next/no-img-element` 10 · 其余 57。
## 3. 阻断级问题(P0)
### 3.1 权限提升与账号接管(已逐行复核)
**A-1 `content_admin` → `super_admin` 自主提权** — `src/app/api/admin/users/route.ts:90`(POST)/ `:166`(PUT)放行 `content_admin`;`:135` `const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly']` 直接取请求体,`:139`/`:214` 仅校验「角色是否存在于 DB」,不校验「调用者是否有权授予」。`:201-208` 只阻止移除**自己**的 super_admin,授予方向无任何 allowlist。
攻击:`POST /api/admin/users {username,password,roleCodes:["super_admin"]}`。
修复:两个 handler 均将 `body.roleCodes` 与调用者角色集求交,非 `super_admin` 不得授予 `super_admin`。
**A-2 `content_admin` 可重置任意用户密码 → 接管超管** — `users/route.ts:189-191` `if (body.password) updateData.password = await hashPassword(body.password)` → `:193` `prisma.user.update({ where: { id: userId } })`,`userId` 来自 `:172` 攻击者可控的 query 参数,未校验目标是否持有 `super_admin`。同理 `:188` 可将超管 `status` 置 0 致其失联。
攻击:`PUT /api/admin/users?id=<superadmin_cuid> {password:"x"}` 后登录。
修复:改密/停用他人须 `super_admin`;目标持有 `super_admin` 时同样要求调用者为 `super_admin`。
架构成因:`src/proxy.ts:104` `matcher: ['/admin/:path*']` 且 `:81` 显式 `!pathname.startsWith('/api/')` —— **proxy 完全不覆盖 `/api/*`**,每个 route 必须自守卫。(Next 16 已将 `middleware.ts` 更名 `proxy.ts`,此处用法正确。)
### 3.2 存储型 XSS ×2(已验证数据通路)
**A-3 CMS 富文本未净化直出公开页** — `src/app/terms/page.tsx:212` 与 `src/app/privacy/page.tsx:267`:`dangerouslySetInnerHTML={{ __html: cmsContent }}`,`cmsContent` 来自 `getPublishedItems('legal-page')` → `item.data.content`(`terms/page.tsx:181`)。字段类型 `richtext`,描述即「支持 HTML 标签」(`content-types.ts:1563`)。**全仓无净化器**(`DOMPurify` / `sanitize-html` 检索 0 命中);`RichTextEditor.tsx:38-46` 的 TipTap `Link` 未配置 `protocols`。
影响:任何可编辑 legal-page 的账号(或经 A-1 提权者)即可在 `/terms`、`/privacy` 对**全部访客**注入脚本。
修复:渲染端统一 `sanitize-html`(白名单标签/属性/协议),并在 `uploadMedia`/items 写入侧再校验一次。
**A-4 上传文件同源分发且安全头丢失** — `src/app/api/admin/media/route.ts:79-84` 将客户端可控的 `file.type`/`file.name` 原样传入;`src/lib/media/media-service.ts:36-87` `uploadMedia` **无扩展名/MIME 白名单**(`:44` `isImage()` 只门控缩略图,不门控落盘),`generateUniqueFileName` 保留原扩展名 → `evil.html` 存入 `public/uploads/`。`nginx-static-production.conf:163-168` 的 `location /uploads/` 自带 `add_header`,按 nginx 语义**不再继承** server 级 `X-Content-Type-Options: nosniff`(`:42`)与 CSP(`:128`)→ 以 `text/html` 同源渲染。叠加 CSP 含 `script-src 'unsafe-inline' 'unsafe-eval'`(`next.config.mjs:40`),失去兜底。
修复:`uploadMedia` 增白名单 + magic-byte 校验;`/uploads/` 内重新 `add_header nosniff/CSP` 并对非图片强制 `Content-Disposition: attachment`;移除 `unsafe-eval`。
### 3.3 测试基建不可信(最高价值发现)
**A-5 CI 功能性测试全部被 `|| echo` 吞掉** — `Jenkinsfile:179` `playwright test --grep "@smoke|@critical" || echo "⚠️ …继续执行"`;`:181` journey、`:214` 视觉回归、`:236` `npm audit`、`:238` 安全头 同法吞没。真门禁仅 lint(:129)/type-check(:136)/coverage(:148)/build(:174,254)。**AGENTS.md §5 列为必须的 lighthouse、`check:contrast`、`check:headings`、mutation、k6、integration 在流水线中完全缺席。**
结论:「CI 绿」当前只证明 lint + tsc + 「覆盖率≥30%」+ build 成功,不证明任何用户行为。
修复:去掉功能性 stage 的 `|| echo`,把缺失门禁纳入流水线。
**A-6 `@critical` GA4 测试是自证式空测** — `e2e/ga4-event-tracking.spec.ts:47,75,165,235`(4 例,标签 `@critical`):`beforeEach:24-31` 注入 mock `window.gtag`,测试体 `:56-62` **自行调用** `gtag('config','G-TEST123',…)`,再于 `:65-70` 断言 `__gtagCalls` 中存在该调用。TC-GA4-003 `:184-205` 甚至不点击按钮,只判可见性后自调用;且 `if (isCtaVisible) {} else {}` 两分支均自调用 → **CTA 不存在也通过**。断言的 `G-TEST123` 由测试自己提供,与应用无关。
影响:`npm run test:critical` 对分析埋点的绿灯为 0 信息量。
修复:改为拦截真实网络请求(`gtag/js` 的 `page_path`/事件参数),或让 `trackEvent` 走可注入 sink 并断言应用调用。
**A-7 覆盖率门禁形同虚设 + 三处文档口径互斥** — 实际生效门禁用 `jest.config.js` global `branches 30 / functions 25 / lines 32 / statements 30`,而实测为 `84.28 / 75.46 / 75.9 / 75.9`,**低于实测约 45 个百分点**;目录级阈值多数不可约束:`seo` branches 门限 0(实测 100)、`content` branches 门限 4(实测 100)、`ui` functions 门限 10(实测 81)。文档互斥:`CLAUDE.md:39,196` 称阈值 80% 且路径写作 `config/test/jest.config.js`(**路径错误**,真实配置在仓库根;`config/test/jest.config.js` 仅 Stryker 使用);`docs/development/quality-gates.md:82-85` 称四项均 ≥70%。
修复:阈值上调至「实测 −5pp」的棘轮值;统一三处文档并修正配置路径。
**A-8 单测不接触真实数据层** — `jest.setup.js:12-27` 全局 mock `PrismaClient`(`findMany → []`),`:29-50` 整体 mock `@/lib/cms/data-server`。因此 §4 所有数据层缺陷(无事务、TOCTOU、JSON.parse 崩溃)**没有任何测试能发现**。`npm run test:integration`(`--testPathPatterns='src/app/api/'`)跑的仍是 mock 版 `route.test.ts`(如 `items/route.test.ts:14` mock `@/lib/db`),命名误导。
另有空洞断言:`src/lib/db.test.ts:6-9` `expect(prisma).toBeDefined()` 对全局 mock 永真;`colors.test.ts`(≈18)、`constants.test.ts`(≈24)、`design-system.test.ts`(21) 大量 `toBeDefined()` 静态常量。
**A-9 视觉基线已「追认现状」,且 9 张近空白** — 提交 `f543e47` 自述「87 例失败…均为尺寸级不匹配」后重生成 86/115 基线 ⇒ 重生时点已存在的回归**被固化为参照**,该套件此后无法再发现它。基线总数 107(5 project × 21,齐全),但 `visual-{chromium,firefox,webkit}-desktop/…/button-{default,hover,focus}-*.png` 为 **912 / 1111 / 1961–2030 字节**,日期 Jul 26 与 Jul 6(**在 9 月重生成之外**)⇒ 近空白区域仍算「比对通过」。容差偏松:`playwright.config.ts:38-41` `maxDiffPixels:200`、`maxDiffPixelRatio:0.005`、`threshold:0.3`。部分断言条件化(`visual-regression.spec.ts:73,91,101,111,130,182` 元素缺失即 0 断言通过)或为永真(`:199` `color||fontFamily`、`:217` `brand||ink||bg` `toBeTruthy()`)。
**A-10 E2E 从不验证交付物** — `playwright.config.ts:130` `command: 'npm run dev'` + `:132 reuseExistingServer: true`,全部规格跑在 **dev server**(甚至可能是上一轮残留进程)而非 `output:'standalone'` 构建产物。构建期才暴露的问题(预渲染、ISR、production header)永不被测。
修复:新增 `--project=production` 指向 `npm run start` 的 webServer。
### 3.4 可访问性门禁的双重失效与已证实的全站缺陷
**A-11 两个对比度门禁同时看不见同一个真实违规** — 两条独立通路各自漏检:
1. **`check:contrast` 静态漏检** — `scripts/utils/check-color-contrast.ts:10-18` 的 `criticalColorPairs` 是 **7 组硬编码十六进制**(全部 `#FFFFFF` 底),既不读 `tailwind.config.js`/`globals.css`,也**不含任何暗色模式配对**,更不覆盖 alpha 修饰类。而本分支主题提交(`4b8500a`、`846585a`)恰好把暗色模式与 `--color-brand` 双通道拆分作为主战场 —— 门禁与其要保护的对象完全脱节,令牌一旦改动它仍对着陈旧色值报绿。
2. **Lighthouse 阈值漏检** — `lighthouserc.json` 对 accessibility 断言 **≥0.9**,而含 axe critical 失败的实际得分是 **92-97**:`products` 92 分(7 个 `aria-required-parent` critical 节点)、其余 6 页 96-97 分(每页 1-4 个 `color-contrast` 节点)。**critical 级 WCAG 失败被折算成分数后落在门禁线之上**,故 `npm run lighthouse` 会显示全绿。
**A-12 Cookie 同意条的「隐私政策」链接在全部 62 页对比度不达标(已双引擎证实)** — `src/components/analytics/CookieConsent.tsx:152` `text-[var(--color-brand)]`(#C41E3A)落在同文件 `:141` 的 `bg-[var(--color-bg-primary)]`(暗色 #0A0E14)上:
- Lighthouse/axe 实测 **3.3:1**(15.75px normal,要求 4.5:1);我按 `globals.css:23/416` 令牌值独立算得 **3.31:1**,两法吻合。
- 该组件挂在**根布局** `src/app/layout.tsx:223` ⇒ 7/7 被测页各命中 1 次,即**全站每一页**都失败(10 个失败节点中的 7 个来自此处,余 3 个见 §4.3)。
- 根因是**违反已写明的设计契约**:DESIGN.md:154「**The Two-Channel Red Rule.** 底色用 `--color-brand`(暗黑不翻),文字用 `--color-brand-ink`(暗黑翻至 #F87171)。合并成一条是 bug 的源头」、DESIGN.md:232「用 `text-brand-ink` 写红色文字…**永不混用**」。此处的合规写法应为 `text-brand-ink`。
- 讽刺点:这是**隐私/Cookie 同意 UI**里指向隐私政策的链接,属合规可见路径。
**同类面**:全仓 **67 处** `text-[var(--color-brand)]`(23 个文件,含 `CookieConsent`、`error.tsx`、`not-found-content.tsx`、`cta-section`、`hero-section-v2`、`product-card`、`service-card` 等)对比 233 处合规的 `text-brand-ink`。这些站点在浅色面(#FFFFFF 上 **5.84:1**)偶然达标,一旦位于暗色面即跌到 **3.31:1**(`--color-brand-bg` #2A1418 上更仅 **2.97:1**)—— 而本分支暗色为默认。**修复应整族收敛而非逐点打补丁**:以 `text-brand-ink` 替换全部 67 处,并加一条 grep 门禁禁止 `text-[var(--color-brand)]`。
> 本项由 Playwright+axe(移动)与 Lighthouse+axe(桌面)**两个独立引擎**分别复现,非源码推断 —— 这是本次验收中证据强度最高的一类结论。
### 3.5 变异测试(本次实跑,唯一真正量化「测试有没有断言」的门禁)
`npm run test:mutation:quick`(作用域 `src/lib/utils.ts`):**91.18%**(31 killed / 3 survived / 0 no-coverage / 0 error,均值 14.97 tests/mutant,74s)≥ `stryker.config.json` 的 `break: 50` ⇒ 阈值达标。但 3 个存活变异体经逐个复核后,**2 个是本项目的真实测试缺陷**:
**A-13 `lerp` 的全部测试用例都以 `start = 0` 输入 ⇒ `start` 偏移量从未被检验** — `src/lib/utils.ts:49` `start + (end - start) * t` 被改为 `start + (end + start) * t` 后仍全绿。原因(`src/lib/utils.test.ts:120-128` 四例逐一验算):`lerp(0,10,0.5)`、`lerp(0,100,0.25)`、`lerp(0,10,0)`、`lerp(0,10,1)` —— **`start` 恒为 0**,而 `end - 0` 与 `end + 0` 数值相同,故该变异在数学上不可观测。这是**测试数据选择缺陷**:函数唯一独有的参数(`start`)恰好是唯一没被非零值覆盖的那个。
**A-14 `randomBetween` 只断言边界,检不出算子错误** — `utils.ts:45` `Math.random() * (max - min) + min` 改为 `/` 后仍全绿。`test.ts:106-115` 只做 `toBeGreaterThanOrEqual(1)` / `toBeLessThanOrEqual(10)`;变异实现给出 `rand/9 + 1 ∈ [1, 1.89]`,负数例给出 `∈ [-1, -0.89]` —— 均落在断言区间内。分布被压到区间一端 11% 的长度而测试无法察觉,因为**没有任何一例检验取值是否覆盖全区或分布是否均匀**。
**(反向校准)第 3 个存活体不是缺陷** — `utils.ts:25` 的 `if (timeout)` → `if (true)`:`clearTimeout(null)` 在 Node/浏览器均为合法 no-op,故该变异体与原实现**语义等价**,存活属正常,不计入测试质量问题。列出以示本次定级未把噪声当发现。
**A-15 变异门禁的作用域把「零编造」核心排除在外** — `stryker.config.json:20` 的 `mutate` 含排除项 `"!src/lib/constants/**"`,而 `src/lib/constants/metrics-basis.ts`(`resolveMetricBasis` / `weakestBasis` / `FORBIDDEN_PROOF_PHRASES`,即项目 AGENTS.md §3「零编造」原则的唯一机械载体)**正在该目录内**。后果:那个「未声明口径必须保守回落到 `target`」的回落逻辑,若被改坏(例如回落到 `verified`)**不会有任何变异测试发现** —— 与 B-3「`basis` 仅类型约定、写入侧无校验」构成同一处治理缺口的两半。
**门禁自身的两点风险(实测记录)**:
1. `npm run test:mutation` 与 `:quick` 均带 `--inPlace`,Stryker 会**直接改写工作树**(其日志自述 "In place mode is enabled, Stryker will be overriding YOUR files")。本次运行期间 `git status` 一度显示 10+ 个文件为 ` M`(Stryker 为注入覆盖率而临时修补 jest/babel 配置),结束后由 `.stryker-tmp/backup-*` 复原,**我已核实工作树恢复到运行前状态**(仅本报告与 `deliverables/` 两个未跟踪项)。但这意味着 CI 中一旦该 job 中途崩溃,仓库将留下**被变异过的源码**且无 `git checkout` 提示 —— 建议 CI 改用沙箱模式(去掉 `--inPlace`)。
2. 顺带证伪了一个可疑点:我曾怀疑 Stryker 用的 `config/test/jest.config.js` 与根 `jest.config.js` 存在测试发现差异(其 dry-run 报 "Ran 668 tests" 而 `test:unit` 为 1594)。实测 `npx jest --config config/test/jest.config.js` ⇒ **132 套件 / 1594 例全通过**,两配置的 `testMatch`/`roots`/`setupFilesAfterEnv` 一致,无结果分歧,故**不列为缺陷**(668 与 ✘ 标记是 Stryker perTest 覆盖分析的呈现方式,✘ = 该测试未覆盖当前变异体,**不是失败**)。唯一真实差异仍是 A-7 已记的**阈值不同**(此配置 global 为 70/55/55/55,根配置为 30/25/32/30)—— 即两份 jest 配置近重复却配着互不相同的门禁值。
## 4. 高危问题(P1)
### 4.1 本分支引入的 UI 回归(验收主要风险)
**R-1 双重移动端安全区留白 ≈190px** — `globals.css:1321-1323`(本分支审计修复 `152eef9` 新增)`footer { padding-bottom: calc(64px + env(safe-area-inset-bottom,0px)) }` 叠加 `src/components/layout/footer.tsx:171` 的 `pb-[calc(8rem+env(safe-area-inset-bottom,0px))]` ⇒ iPhone SE/15 上备案行下方 128+64+2×inset ≈ **192–226px** 空深色带,**全部 31 页**。
修复:二选一(建议只保留 CSS 侧,并删除组件 `pb-[…]`)。
**R-2 动效时长收敛到错误基准** — `CONTEXT.md:76` 与 `DESIGN.md:233` 规定入场 **180–280ms**,`--transition-normal: 280ms`(`globals.css:252`);分支却收敛到 300ms:`src/components/ui/scroll-reveal.tsx:73` `duration = 0.3` 且注释**错误引用契约为「200-300ms」**;另有 `duration-300` ×108、`duration: 0.3` ×179 未令牌化。
修复:以 `duration-normal/fast` 令牌替换字面量,修正注释;或正式修订 CONTEXT.md。同类:`--stagger-*` 令牌(`globals.css:272-276`)**零采用**(`var(--stagger` 检索 0 命中),实散为 `detail-trust-section.tsx:108` `index*0.06`、`header.tsx:230` `index*0.05`、`contact-content-v3.tsx:262` `delay: 1.2`(**1200ms**,远超 150ms 段间上限)。
**R-3 暗色模式新闻页对比度不达标** — `src/app/(marketing)/news/[slug]/NewsDetailClient.tsx:47` `bg-[var(--color-brand-bg)] text-[var(--color-brand)]`(另 `:34`、`:124`)误用**设计上不翻转**的 `--color-brand` #C41E3A 作文字色,违反 DESIGN.md:154 双通道红规则。暗色实测 **3.31:1**(`--color-bg-primary` #0A0E14 上)与 **2.97:1**(`--color-brand-bg` #2A1418 上)。`news-detail-content-v3.tsx:29,105`、`news-content-v3.tsx:37` 同病。同处 `hover:bg-[var(--color-brand)]/20`、`text-[var(--color-brand)]/30` 在构建产物中**不生成任何 CSS**(对照 `border-brand/30` 可正常编译)。
**本项是 §3.4 A-12 全站缺陷的一个局部实例** —— 同一契约违反在全仓共 67 处,故修复须按 A-12 整族收敛,只改新闻页会留下 Cookie 条等仍在失败。
修复:文字改用 `text-brand-ink`。同族缺陷另见 `content-unavailable.tsx:38`(本分支新增,暗色 3.31:1)。
**R-4 审计 §10.1 修复 #4 只落一半** — `src/components/detail/solution-service-card.tsx:128` `shadow-blue-500/20 → shadow-brand` 已做,同行保留 `bg-gradient-to-br from-[var(--color-accent-blue)] to-[#1d4ed8]`(硬编码 hex + 双色渐变),`:127` `hover:border-blue-300`、`:134` `group-hover:text-blue-600` 绕过 `accent-blue` 令牌且暗色不翻转 ⇒ **蓝色块配品牌红光晕**,违反 DESIGN.md:150/153「One Voice」与 CONTEXT.md:45「红与强调色不同卡」。
**R-5 `prefers-reduced-motion` 未被 framer-motion 尊重** — 全仓 **0 处 `MotionConfig`**(`grep MotionConfig src` 无命中),`globals.css:755-764` 的 CSS 守卫(`animation/transition-duration: 0.01ms !important`)**管不到 framer-motion 的 JS-rAF 内联样式**。仅部分文件单独调用 `useReducedMotion()`,未接线者(如 `detail-trust-section.tsx:80-83` 的 `y:24→0`、`detail-hero.tsx`、`product-detail-content-v3.tsx`)在暗色+动效默认开启的本分支上,对前庭敏感用户仍产生大位移。与 DESIGN.md:122「reduced-motion 全链路守卫」不符。
修复:根布局包 `<MotionConfig reducedMotion="user">`,一处收口。
### 4.2 逻辑与安全(非本分支引入,但在验收范围内)
**B-1 创建接口绕过发布工作流** — `src/app/api/admin/items/route.ts:130` `status: status || 'draft'` + `:135` `publishedAt: status==='published' ? new Date() : null`,仅 `:107` `requirePermission(…,'create')` 守卫;PUT 侧 `:184-186` 明确拒改 status 并要求走 workflow 接口 —— 即 `POST {status:'published'}` 可跳过 submit/approve 与 `publish` 权限直接上线。且任意 status 字符串可入库,之后 `workflow.ts:42` 对所有动作返回 `false`,条目永久卡死且无反馈。
**B-2 登录接口用户枚举 + 零限流** — `src/app/api/auth/login/route.ts:20-22` 在 `:24` `verifyPassword` **之前**返回 `'账号已被禁用,请联系管理员'`,未知用户则为 `:17` `'用户名或密码错误'`;即便消息相同,`user` 不存在时不跑 bcrypt 也留下可靠的时间侧信道。全仓唯一限流在 `src/app/api/contact/route.ts:14`,登录裸奔(`nginx-static-production.conf:177` `rate=100r/s` 仍允许约 860 万次/日)。修复:禁用检查移到验密之后;对 `/api/auth/login` 加 per-IP+per-username 计数或上游独立 `limit_req`。
**B-3 `basis` 仅类型约定,非结构强制**(直接对应「零编造」原则)— 类型侧全部可选:`products.ts:15`、`services.ts:40`、`solutions.ts:27`、`sections.tsx:323`、`about-content-v4.tsx:39` 皆 `basis?: MetricBasis`;运行侧 `items/route.ts:131` `JSON.stringify(data || {})` **完全不按 `FieldDefinition` 校验**(`cms/types.ts:50-55` 的 `validation {min,max,pattern}` 全仓从未执行,zod 只在 `api/contact/route.ts` 使用)。故 `POST {data:{metrics:[{value:'99.9%',basis:'verified'}]}}` 会渲染「已有可核验的实测出处」。机械门禁 `metrics-basis.test.ts:53-64,170-182` 读的是**导入的 seed 字面量**、只扫 `src/app`+`src/components`,`prisma/` 与 `/admin` 编辑后的库内容从不复检。
缓解事实:`resolveMetricBasis`/`weakestBasis`(`metrics-basis.ts:34-47`)保守回落 + `content-types.ts:8-18` 的 `metricBasisField` 明示「留空按目标口径处理」—— 兜底方向正确,`FORBIDDEN_PROOF_PHRASES` 扫描也确属严格(含 `length>40` 防空跑)。真正的漏洞在**写入侧无校验**,以及 `home-content-v15.tsx:510,552,555,562` 用 `Record<string, any>` / `as any` 把 CMS 载荷从类型系统里放行。
**B-4 `data:null` 写库即打挂整页** — `items/route.ts:197` `if (data !== undefined) updateData.data = JSON.stringify(data)`(PUT 缺 POST 那样的 `|| {}` 兜底)→ `data-server.ts:20` `JSON.parse(item.data)` 得 `null` → `terms/page.tsx:180` `items.find(i => i.data.pageType === 'terms')` 抛 `TypeError`。同类:`data-server.ts:20,75-77,89-91,106` 的 `JSON.parse` **无 try/catch**(对照 `media-service.ts:133-139 parseDerivatives` 已正确守卫),单个脏列即可中断页面/SSG。
**B-5 角色权限重写无事务** — `src/app/api/admin/roles/route.ts:81` 先 `deleteMany` 全部权限再 `:85-97` 循环 `create`,**全仓应用代码零 `$transaction`**。中途失败即留下**权限为空/半权限**的角色(提权/降权双向风险)。同因:`cms/workflow.ts:59→66→80` `version: item.version+1` 读-改-写在事务外(`items/route.ts:192` 却用了正确的 `{ increment: 1 }`,同规则两实现)→ 并发审批丢更新 + status TOCTOU;`workflow.ts:80→90→102` update/audit/notify 三连 await 无原子性,通知失败会把成功审批变成 500。SQLite 侧 `src/lib/db.ts:7` 未配 `journal_mode=WAL`/`busy_timeout`(`DATABASE_URL` 无查询参数),默认 rollback journal + `busy_timeout=0` 下并发写直接 `SQLITE_BUSY`。
**B-6 `/api/cms/draft/enable` 密钥缺失即放行 + 开放重定向** — `route.ts:15` `if (expectedSecret && secret !== expectedSecret)` 为 **fail-open**;`CMS_PREVIEW_SECRET` 经全仓检索**不在 `.env` / `.env.local` / `.env.production` / `.env.example` 任何一处**,故线上恒为未配置,任意请求可 `draft.enable()`,并直达 `:25-27` `NextResponse.redirect(new URL(redirect, request.url))`,`redirect` 取请求体、协议相对形式 `//evil.com` 即跳出站外。(内容面影响当前为零:`draftMode()` 除这两个路由外无人读取,`data-server.ts:38,49,195,219` 硬过滤 `status:'published'`。对照 `/api/cms/revalidate/route.ts:40-45` 未配置即 500,写法正确 —— 应统一为 fail-closed。)
**B-7 刷新令牌无轮换/吊销** — `auth/refresh/route.ts:16` 仅验签名,`:21-25` 直接用 `payload` 重签,不加载用户 ⇒ 被禁用/删除的用户 7 天内持续换取访问令牌,绕过 `login/route.ts:20` 的禁用拦截;`:24` `role: payload.role` 沿用旧 claim,降权用户保留高权标识(`permissions.ts:38-56` 按 `UserRole` 实查故服务端不破,但 `admin-layout.tsx:241`、`auth/me/route.ts:15` 会显示陈旧角色)。`logout/route.ts:5-10` 只清 cookie,`prisma/schema.prisma` 无 jti/tokenVersion 表 ⇒ 失窃 refresh token 登出后仍有效。
**B-8 Bento 网格 ARIA 角色无父(3 页 × 3 浏览器 = 9 例失败的真实根因)** — `src/components/sections/bento-grid.tsx:23` 把 `role="list"` 放在 `BentoGrid`、`:47` 子项 `role="listitem"`;但 `src/app/(marketing)/products/products-content-v3.tsx:9` **只 import 了 `BentoItem`**,卡片直接落在无 `role="list"` 的 `div.grid lg:grid-cols-2 gap-px`(`:~193`)上 ⇒ axe `aria-required-parent`(critical)。
**已由 Lighthouse 桌面端独立复现并逐节点确认**:`/products` 命中 **7 个** critical 节点,`data-testid` 分别为 `bento-product-card-{erp,crm,cms,bi,sds,oa,novavis}` —— 与我按源码推断的「7 张 BentoItem 卡」精确一致,故根因不是假设。该页 a11y 得分因此降至 **92**(仍高于 0.9 门禁,见 A-11)。
修复:改用 `BentoGrid` 包裹,或去掉 `BentoItem` 的 `role="listitem"`。
**B-9 `tracking-tightest` 是空类名,10 个 H1 丢失展示级字距** — `tailwind.config.js:157-165` 的 `letterSpacing` 仅 `tighter/tight/normal/wide/wider/widest/eyebrow`,**无 `tightest`**;构建产物 `dist/static/chunks/0o3c09ni2y1ao.css` 内只有 `tracking-tighter`/`tracking-tight` 两条规则,**不存在 `.tracking-tightest`** ⇒ 该类在 10 处标题(`contact-content-v3.tsx:249`、`about-content-v4.tsx:70`、`cases-content-v3.tsx:146`、`team-content-v3.tsx:168` 等)**静默失效**,全部大字标题以 0em 字距呈现,与 DESIGN.md:28 的 −0.03em 相悖。与 config 自述(`:130-133`)已发生过的 `font-calligraphy` 同族缺陷。
**B-10 mega 下拉键盘不可关闭(WCAG 1.4.13 / 2.1.2)** — `src/components/layout/mega-dropdown.tsx:37` `onMouseEnter` 开、`:28-30` 仅 `onMouseLeave` 关,无 Escape 路径;`header.tsx:28-32` 的全局 Escape 只看 `isOpen`(移动抽屉),从不看 `openDropdown`。键盘用户在「产品」上回车后必须动鼠标才能关闭。
### 4.3 E2E 真实失败分类(92 = 约 30 例 × 3 浏览器,非抖动)
| 失败簇 | 例数 | 定性 |
|---|---|---|
| `ga4-event-tracking` TC-GA4-001..004 `@critical` | 12 | **测试自身缺陷**(A-6 空测)+ 断言环境不成立 |
| `mobile-accessibility` axe `aria-required-parent` `/products` | 3 | **真实缺陷** B-8(Lighthouse 桌面端独立复现同样 7 节点,见 B-8) |
| `mobile-accessibility` axe `color-contrast` `/about`、`/team` | 6 | **需产品裁决**:节点为 `about-content-v4.tsx:217`、`team-content-v3.tsx:85,118` 的 `text-text-muted/30|/10` **`aria-hidden="true"` 装饰性序号**(Lighthouse 桌面端在 `/about` 复现同样 3 个 `aria-hidden` 节点,类名一致);axe 按视觉可见性仍会报。建议按 WCAG「incidental/decorative」显式豁免,或提高 alpha。真正该修的是**正文** `text-text-secondary/80`(`brand-content.tsx:129`、`team-content-v3.tsx:205`)。**注意:Lighthouse 另在 7/7 页各报 1 例装饰节点之外的真缺陷,即 A-12 Cookie 条链接 —— 移动套件反而没抓到它** |
| 触摸目标 ≥44px(首页,`@accessibility`+`@performance`) | 6 | 真实移动可用性风险,需按元素定位 |
| `p1-brand-visual-audit`「不应出现可见 novalon」× 首页/全站 | 6 | **测试过期**(见 §7),`CONTEXT.md:194` 已批准该文案 |
| `uj-11b` 共创旅程 `@critical` | 6 | **真实可测性缺陷**:`uj-11-home-conversion.spec.ts:117` 依赖 `data-testid="early-access-banner"`,而首页 HTML 中该 testid **0 命中**;`:131` 期望文案「成为首批共创客户」HTML 亦 0 命中(「首批客户共创中」「共创进行时」均存在)。实现未落测试钩子 |
## 5. 中危(P2)摘要
- `header.tsx:187` `aria-controls="mobile-menu"` 指向 `{isOpen && …}` 内才存在的 `id`(`:219`)——关闭时 AT 解析落空。
- `contact-content-v3.tsx:111` `setErrors(prev => ({…prev,[field]:undefined}))` 不删键 ⇒ `:376` `Object.keys(errors).length` 与 `:379`「请修正以下 N 项」长期错误,字段全修完后仍残留带空 `<li>` 的 `role="alert"` 红框。
- `header.tsx:167` `<div className="hidden md:flex">` 使 `ThemeToggle` **桌面专属**,移动抽屉(`:224-261`)从不渲染 ⇒ 系统深色下的手机用户无法切浅色。本分支暗色默认开启,影响放大。
- `header.tsx:87` 高度 80/64 动画 vs `layout.tsx:18` 固定 `pt-16`(64px) ⇒ `scrollY===0` 时首屏顶部 16px 内容压在固定头下方。
- `api-crypto.ts:49-50` 以 `content-length` 判定有无请求体,chunked/H2 下**静默跳过解密**并把 `{data:"<base64>"}` 交给 handler(`:93`),管理端保存即写入密文;`admin-api.ts:44-49` `catch {}` 在非安全上下文(`crypto.subtle` 不可用)**降级明文**;`:85` 将 `e.message` 回显客户端。密钥为 `NEXT_PUBLIC_*` 且盐硬编码 ⇒ 属混淆而非加密,无任何逻辑把它当鉴权用(此点正确)。
- `analytics.ts:178-188` `trackOutboundLink` 同时发 `outbound_click` 与 `click`,外跳统计**双计**;`:172` `value || 1` 吞掉 0;`:18-23` 默认 `analytics: true` 与 `:29-42` 无形状合并的 `JSON.parse` 可能违背用户实际同意。
- `use-focus-trap.ts:43-46` Escape 恒 `preventDefault` 并归还焦点却不通知持有者(焦点可逃出仍开启的 trap);`:13` 的 `[tabindex]:not([tabindex="-1"])` 只约束末项,`<button tabIndex={-1}>`/`<input type=hidden>` 被当可聚焦;`:64` 无条件 `overflow='unset'`,层叠弹窗互相解锁滚动。`use-keyboard-shortcuts.ts:34-36` `preventDefault()` 后调可能未接线的 `onSkipToContent` ⇒ **Tab 键对键盘用户死路**。
- `media-service.ts:113-117` 先删文件后删 DB 记录,DB 失败即留下指向已删文件的资产;`:109-111` 空 `catch {}` 使畸形 derivatives 的文件永不被删。`media/storage.ts:29` `fs.unlink` 未做 `resolve`+前缀校验(当前 `deleteMedia` 只接受库内路径)。
- 死代码(grep 复核零引用):`ui/loading-skeleton.tsx`、`cms/RichTextEditor.tsx`、`examples/ContactFormAnalyticsExample.tsx`、`content/testimonials.tsx`、`lib/gradients.ts`(+仅被它引用的 `lib/colors.ts`;且 `getGlowStyle` 把 `primary` 映射为调色板中不存在的蓝 `'0, 94, 184'`)、`ui/metric-card.tsx`、`ui/stats-showcase.tsx`、`sections/stats-bar.tsx`。汇总桶 `ui/index.ts`(201 行)、`sections/index.ts`、`layout/index.ts` **无任何 importer** ⇒ 经其可达的 `metric-card/stats-showcase/milestone-timeline/flip-clock/page-nav/list-page-hero/accordion/tabs/dialog/select` 全为死接线。这解释了 §4 中若干「渲染缺陷」实为潜伏缺陷。
- `products/[id]/page.tsx:9-14`(及 `solutions/[id]`、`services/[id]`、`news/[slug]`)同时声明 `generateStaticParams` 与 `export const dynamic='force-dynamic'` —— 后者胜出 ⇒ 前者是死码,且这 4 类详情页退出全站 `revalidate=3600` ISR 策略。`next.config.mjs:4` 实为 `output:'standalone'`,而 `:10` 注释仍以「静态导出限制」为 `unoptimized:true` 辩护,AGENTS.md/CLAUDE.md 亦仍称静态导出。
- 配置/文档矛盾:`Dockerfile` 与 `docker-compose.yml` 把 `dist` 当静态 HTML 根拷贝、`Dockerfile.static` 拷贝**不存在的 `html/`** ⇒ 用基础 Dockerfile 部署即白屏,仅 `Dockerfile.prod`(`COPY dist/standalone` + `node server.js`)与配置模式相符。`next.config.mjs:36` `X-Frame-Options: DENY` 与 nginx `SAMEORIGIN`(`:40,124,140,182,194`)**双重且互斥**,并产生重复 CSP —— 这正是 `test:security:headers` 报 2 警告、且 `X-Frame-Options` 实际值为 `"DENY, SAMEORIGIN"` 的来由。Sentry 未用 `withSentryConfig` 包裹 ⇒ **不上传 source map**,线上堆栈为压缩态。`tsconfig.json` 仍排除已不存在的 `src/app/(marketing)/_archive`(AGENTS.md/CLAUDE.md 亦仍描述 `_archive/` 约定)。
- `data-server.ts:182-210` 只解析 `zi.itemId`、忽略 `ContentZoneItem.item`,且条目全部未发布的 zone 不写 key(返回 `undefined` 而非 `[]`)。`workflow.ts:94` 把 `submit`/`reject` 一律记为 `action:'update'`(驳回与编辑在审计日志中不可分),且 `'approve'|'archive'` 越出 `cms/types.ts:196` 联合类型,仅靠 `:50` 的 `as unknown as` 通过编译。
## 6. 已核实为正确(避免无谓返工)
`$queryRaw/$executeRaw` 应用层零使用(仅 Prisma 生成类型含);Prisma 写入无 `...body` 质量赋值;`media-service.ts:9` `path.basename` 先于 `storage.ts:19` `path.join`,遍历已消;通知路由按会话 `userId` 收口(`notifications.ts:80,90`),无 IDOR;`roles/route.ts:18,59` 正确限 `super_admin` 且 `:73` 保护该角色自身;`revalidate` fail-closed;cookie `HttpOnly; SameSite=Lax` + 条件 `Secure`;`auth.ts:10-15` 缺密钥即抛(无硬编码兜底);三个 `.env*` 均已 gitignore(`git ls-files` 仅 `.env.example` 占位)。
迁移无漂移(5 个 migration 与 `schema.prisma` 对齐,RBAC 表已在 `prisma/dev.db` 落地);seed 中 55 处 `basis` 一律取最弱 `'target'`、无一处声称 `'verified'`。
`verifyAccessTokenEdge`(`proxy.ts:34-67`)以 HMAC 重算比对,`alg:none` 无法伪造;`color-contrast.ts:21-28` WCAG sRGB 亮度与 0.03928 阈值正确;`use-reduced-motion.ts`、`animated-counter.tsx:59`、`stats-showcase.tsx:48` 等 observer/listener 清理齐备;`theme-toggle.tsx` 是 SSR 安全实现范本;`static-link.tsx` 外链 `rel="noopener noreferrer"` 正确;`footer.tsx:195` 暗色对比度实测 **7.54:1**(审计 P1-2 确已修复);`webpackBuildWorker` 未出现在 `experimental`(符合项目铁律)。
Jest 侧无 `.only` / `.skip` / `.todo` / `xit` / 盲写快照;`playwright.config.ts` `forbidOnly:!!CI`、`retries: CI?2:0` 配置正确,仅 2 处合理的按浏览器条件跳过。
**性能预算实测达标且余量充足(7/7 页,desktop preset,`lighthouserc.json` 全部 URL)**:performance **99-100**、FCP **246-250ms**(预算 ≤2000)、LCP **790-896ms**(预算 ≤3000)、CLS **0.000**(预算 ≤0.1)、TBT **0ms**(预算 ≤300)、SI **248-414ms**(预算 ≤3000)、best-practices **100**、SEO **100**。`lighthouserc.json` 的 6 项性能断言 + 4 项分类断言**零违规**,且本分支未引入性能退化 —— 这是少数几个「AGENTS.md §5 声称的门禁经实测确实成立」的项。故上表把 Lighthouse 判为「门禁太松」仅指 **accessibility 子项的 0.9 阈值容下了 critical 失败**(A-11-2),性能维度本身可信。
## 7. 复核中主动撤回的判断(保持报告可信)
1. ~~「首页服务区忽略 CMS,恒渲染兜底数据」~~ —— 我据 `content-types.ts:88` 的 `serviceFields` 未声明 `subtitle/highlights/href` 推断该假设,随后以三重证据否证:`prisma/dev.db` 的 service 行实测**含** `subtitle/href/highlights(4)/metrics`,且首页 HTML 中 CMS 文案(「战略咨询」「数字化成熟度评估」)命中 3/2/2 次、兜底专属串(「Strategy Consulting」「数字化转型战略咨询」)命中 **0** 次。CMS 通路正常。
2. ~~「seed 指标缺 `basis`,违反结构强制」~~ —— `content-types.ts:8-18` 的 `metricBasisField.description` 明示「留空按目标口径处理并自动标注」,`metrics-basis.ts:33-38` 亦为刻意保守回落。这是设计而非缺陷。
3. ~~「`prisma/dev.db` 与 schema 漂移,缺 RBAC 表」~~ —— 我误查了仓库根的**陈旧残留** `./dev.db`(Jul 6) 与 0 字节 `./data.db`;真实库 `.env:8` 指向 `prisma/dev.db`,RBAC 四表与 5 个 migration 俱在。
4. ~~「`Novalon 创始团队` 是品牌一致性回归」~~(6 例 E2E 失败的定性)—— `CONTEXT.md:194`(✅ 2026-08-31 确认)在「零编造」条款下**明确批准** FounderQuote 兜底署名「Novalon 创始团队」,`prisma/seed.ts:961`、`home-content-v15.tsx:115` 与单测 `home-content-v15.test.tsx:181` 一致。故 `p1-brand-visual-audit.spec.ts:30` 的 `FORBIDDEN_TEXT=/novalon/i` 前提已过期,应改测试而非改文案。
5. `stats-bar.tsx:88` `parseInt("99.9")→99`(99.9% 永久显示 99%)与 `:71` 运行期插值类 `lg:grid-cols-${items.length}`(Tailwind 不生成,布局静默停在 `md:grid-cols-3`)、`animated-counter.tsx:62` 先显终值再跳 0 起算 —— 缺陷成立但**当前不可达**(组件经无 importer 的死汇总桶暴露,§5 死代码清单);列为修复时必须一并删除的死码,而非线上问题。`animated-counter.test.tsx:5-12` mock `useCountUp→500` 且 `value={500}`,两分支同值故永不能观测此问题。
6. ~~「两份 jest 配置测试发现不一致,变异门禁只看到 42% 的用例」~~ —— 我据 Stryker dry-run 报 "Ran 668 tests"(而 `test:unit` 为 1594)提出该怀疑,实测 `npx jest --config config/test/jest.config.js` 得 **132 套件 / 1594 例全通过**,两配置 `testMatch`/`roots`/`setupFilesAfterEnv` 完全一致 ⇒ 不成立。同时纠正了对 Stryker 报告的误读:其 "All tests" 树中的 **✘ 标记含义是「该测试未覆盖当前变异体」(后缀 `(covered 0)`),不是测试失败**(✓=killed、~=covered 但未杀)。真实差异只有阈值数值不同,已归入 A-7。
7. ~~「Stryker 报告的 3 个存活变异体全为测试缺陷」~~ —— 其中 `utils.ts:25` `if (timeout)`→`if (true)` 因 `clearTimeout(null)` 本身是 no-op 而**语义等价**,存活属正常。仅 A-13、A-14 两项计为缺陷。
## 8. 修复优先级与放行条件
| 顺序 | 动作 | 理由 |
|---|---|---|
| 1 | A-1 + A-2 角色 allowlist 与改密鉴权;A-3 渲染端净化;A-4 上传白名单 + `/uploads/` 头 | 生产可利用,且 A-1→A-3/A-4 构成完整提权-存储 XSS 链 |
| 2 | A-5 摘掉 Jenkins `|| echo` 并补齐缺失 stage;A-10 E2E 改打构建产物 | 先让门禁**可信**,否则后续一切绿灯无意义 |
| 3 | **A-12 + R-3 + B-9 一并处理:67 处 `text-[var(--color-brand)]` → `text-brand-ink`,并新增 grep 门禁禁该模式** | 全站 62 页的确定性合规失败,且已有明文契约(DESIGN.md:154/232)背书;逐点修必然漏 |
| 4 | R-1 双安全区;R-2 动效回到 280ms;R-5 `MotionConfig`;B-8 BentoGrid;B-10 下拉 Escape | 用户可见 / 本分支核心意图 |
| 5 | A-6 GA4 重写为真实拦截;A-9 基线在干净参照点重采 + 删 9 张近空白基线;**A-11 `check:contrast` 改为读令牌表并补暗色/alpha 组,Lighthouse a11y 断言由 0.9 改为「critical 失败数 = 0」** | 恢复测试信号有效性;否则第 3 步修完仍无守卫 |
| 6 | A-7 阈值棘轮 + 三文档统一;A-8 加真库集成层;B-1/B-5 写入校验与 `$transaction`+WAL | 结构性 |
| 7 | B-2/B-6/B-7、§4.2 其余、§5 P2、死码清理 | 常规 |
| 8 | **A-13/A-14 补 `lerp` 非零 `start` 用例与 `randomBetween` 分布断言;A-15 把 `src/lib/constants/**` 从 `stryker.config.json` 的 `mutate` 排除中移出;`test:mutation` 去 `--inPlace`** | 变异门禁是本次唯一直接度量「断言是否有内容」的手段,且已实测可在 74s 内跑完单文件,成本极低 |
**放行条件(全部满足方可验收通过)**:①§3.1/§3.2 四项 P0 安全关闭并有回归测试;②E2E 92 → 0(若按 §7-4 判定品牌测试过期,须同时修订 `CONTEXT.md` 或测试并在提交信息留痕);③Jenkins 无 `|| echo` 后连续两次全绿;④AGENTS.md §5 列出的 lighthouse / contrast / headings 至少各执行一次并留结果;⑤A-12 全站对比度关闭后,以两个引擎各复跑一次并留 axe 节点计数 = 0 的证据;⑥R-1/R-3 在真机(iPhone SE + 系统深色)截图复核。
> 注:本次性能门禁的 0 违规**不构成本项豁免**——性能维度已实测达标(§6),但 a11y 维度是「断言通过而实际失败」,二者性质不同。
## 9. 本次未覆盖(诚实标注)
- **未做真机/真浏览器人工走查**:R-1 双安全区、触摸目标 <44px 的具体元素,均由构建产物 CSS、axe 节点选择器与 HTML 计数推断,未经肉眼截图确认。(A-12 / B-8 例外 —— 二者已由 axe 引擎在真浏览器中直接报出节点,非推断。)
- **`npm run lighthouse`(lhci autorun)未直接执行**,原因有两条,均为**独立缺陷**:
1. `config/test/lighthouserc.json` 配 `upload.target: "temporary-public-storage"` ⇒ 一旦运行即把含站点结构的结果上传至第三方公共存储(Lighthouse 官方示例报告库),对未发布站点属外泄风险。故本次改为逐 URL 调 `npx lighthouse@13` 复现同一断言集。建议改为 `upload: {target:'filesystem'}`。
2. lighthouserc 的 `startServerCommand: "npm run start"` **与 `output:'standalone'` 不兼容** —— 本次以 `next start` 启动 :3100 时 Next 自身告警 `"next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.`。即 `package.json` 的 `start`/`preview` 与 lighthouse 的起服命令在 standalone 模式下**是半失效的**,与 §5 中 Dockerfile 仍假设静态导出同源。这解释了 Jenkins 为何从未跑 lighthouse —— 跑不起来。
- **k6 四个性能脚本未执行**(依赖为占位包),仓库内 `tests/performance/*-summary.json`(Aug 12)是手工产物而非 CI 结果。
- **变异测试仅跑了 `quick` 作用域(`src/lib/utils.ts`,34 变异体 / 74s)**;`stryker.config.json` 声明的全量作用域(`src/lib/**`、`src/hooks/**` 及 7 个组件目录)**未执行** —— 按单文件外推需数小时,且 `--inPlace` 在全量下会长时间改写整个工作树,不宜在验收轮次内冒险。故 §3.5 的 91.18% **只代表 utils.ts 一个文件**,不可作为全仓变异得分引用。
- **渗透测试 / 授权验证矩阵**:仅静态审计 A-1/A-2/B-1,未真实构造请求验证提权链可用性。
- **Lighthouse 仅测 desktop preset、每 URL 单次运行**(`lighthouserc.json` 配 `numberOfRuns: 3`);未测移动网络节流下的性能,也未做 3 次取中位以消除抖动。
- **运维观察(实测泄漏,已在本报告提交前清理)**:`scripts/utils/check-heading-hierarchy.ts:47` 与 `scripts/accessibility-test.js:31` 均以 `spawn('npm', ['run','preview'])` 起服,而 `accessibility-test.js:7` 的自述是「扫描完成后关闭服务」。实测本次跑完 `check:headings` 后,:3000 上仍残留一个 PPID=**1** 的 `next-server (v16.3.0)`(父为已 orphan 的 `npm run preview`)—— 因为脚本终止的是 `npm` 包装进程,真正 LISTEN 的 `next-server` 孙进程被 init 收养而继续占端口,且存活 **39 分钟**。这不是环境噪声而是**门禁脚本的进程回收缺陷**:它与 A-10 的 `reuseExistingServer: true` 叠加后,会让后续 E2E / Lighthouse 静默复用一个陈旧构建的服务,从而使「测的是当前代码」这一前提失效。
- 附带发现:`CLAUDE.md:15` 称 `npm run preview` 是「Serve dist/ on port 3000 (npx serve)」,实际 `package.json` 为 `next start -p 3000`;`CLAUDE.md:197` 称 Playwright「Auto-starts `npm run preview`」,实际 `playwright.config.ts:130` 用 `npm run dev`。两处文档与实现相反,属 §5 配置/文档矛盾族。
- 仍有 2 处歧义未能闭合:`mega-dropdown` 在 `md:`(768px) 的 `w-[640px]` 是否于真实平板宽度溢出;`useFocusTrap` 的 `offsetParent` 过滤对 `position:fixed` 抽屉是否如预期工作。
+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.
+30 -2
View File
@@ -140,7 +140,7 @@ Novalon 的 6 个核心产品,互为互补关系,常以组合形式出现在
当前状态:近期有规划,需在本次重构中预留完整的模板体系
### 特效组件 (Effects)
`src/components/effects/` 目录下的 24 个视觉特效组件。当前状态:**大部分未被首页使用**,属于技术债务。
**该目录已不存在**(口径核对 2026-09-23):`src/components/effects/` 在提交 `37296b5`(2026-05-10「三轮视觉改造与页面过渡动画」)中被**整体删除 25 个文件**,`git ls-tree -r HEAD` 与该目录的 `ls` 均为空。本节原文("24 个视觉特效组件……大部分未被首页使用,属于技术债务")是**重构前的现状盘点**,只作历史保留,不得再当作可寻址的目录引用。动效能力现在的落点是 `src/components/ui/`(`scroll-reveal.tsx`、`animated-counter.tsx`、`brand-visuals.tsx`)与 `src/components/sections/`(`hero-particle-field.tsx` + 其引擎)。
### Design Tokens
`.impeccable.md` 中定义的设计令牌系统,包含颜色、排版、间距、卡片系统、Section 背景交替规则。当前状态:**文档已定义,代码中 globals.css 有对应 CSS 变量,但组件层未完全落地**。
@@ -191,7 +191,35 @@ Novalon 的 6 个核心产品,互为互补关系,常以组合形式出现在
| 品牌主口号 | **智连未来 · 成长伙伴 —— 您的数字化转型同行者**(整句全站统一采用)。品牌叙事内核(定位声明 / 承诺 / L0 价值主张 / L1 三支柱 / 语气语调)见 `docs/brand-narrative-core.md`,作为全站文案统一唯一锚点 | ✅ 2026-08-19 确认 |
| 品牌叙事 CMS 化 | 品牌叙事内核全部下沉到 CMS `site-config` 模型(`slogan` / `valueProposition` / `positioningStatement` / `brandPromise` / `toneOfVoice` / `pillars`),后台可编辑;代码侧 `COMPANY_INFO` 与 `BRAND_NARRATIVE`(`src/lib/constants/company.ts`)作为未配置 CMS 时的兜底唯一真源,`layout.tsx` 读取后经 `SiteConfigProvider` 注入全站 | ✅ 2026-08-19 确认 |
| 结构性文案 CMS 化 | 新增 `page-copy` 内容模型承载各业务页面章节标题/眉标/描述/CTA/空状态,覆盖首页 + 服务/方案/产品/案例/新闻列表页,seed 写入 6 条(home/services/solutions/products/cases/news)。实现为「CMS 优先 + 硬编码兜底」:CMS 未配置时回退 `TRUST_SIGNALS`/`EARLY_ACCESS`/`NARRATIVE_ACTS` 等常量,保证任何情况不白屏。数据层新增 `getPageCopy(pageCode)` / `getHomePageCopy()`,各 `page.tsx` 读取后以 `pageCopy` props 传入组件。详情页小节标题、页面内嵌业务常量(服务流程/合作模式/产品组合)、Header/Footer 等 UI 外壳按 UI 职责保留代码。数据实体(服务/产品/方案/案例/新闻/指标/Hero)仍由 `data-server` 从 Prisma 读取 | ✅ 2026-08-19 确认 |
| 首页对标埃森哲重构(scene#16) | **视觉基调:局部深色 Hero(推荐项)**——保持浅色咨询风为底,仅 Hero 升级为全出血深色画布(近黑底 `bg-ink` + 白色 Logo `/logo-white.svg` + 品牌红 #C41E3A 单电压 ≤10%),制造埃森哲式戏剧感;**实施范围:Phase A+B 全量**——①内容支柱补齐:新增 Insights 行业洞察(2×2 gap-px 卡片网格 + 方法论/观点/共创类型标签)、FounderQuote 创始人观点(深红区块 + 白色点阵 + 大引言)、News 新闻动态(最近 3 条 + 查看全部)三个对标埃森哲思想领导力支柱的区块;②Hero 深色化;③CTA 签名符号:全站 Button 组件 `rounded-md` → `rounded-full`(Pill 胶囊化)+ ArrowRight 签名箭头。零编造内容原则:公司 2026-01-15 成立,Insights 兜底 4 条均为可验证方法论内容(不虚构研究报告/人名),FounderQuote 署名兜底「Novalon 创始团队」不虚构具体人名。实现:`home-content-v15.tsx` 替换 v14(9 区块顺序 Hero→Trust→Narrative→Insights→Services→FounderQuote→Cases→News→CTA),CMS 优先 + FALLBACK_INSIGHTS/NEWS 常量兜底;seed page-copy 新增 insights*/founderQuote*/news* 字段(upsert 幂等)。验证:tsc 0 errors / eslint 0 errors / Jest 128 套件 1621 通过 / Playwright 视觉回归 22 passed(macOS 11 限制下用 Chromium 117 兼容配置生成快照) | ✅ 2026-08-31 确认 |
| 首页对标埃森哲重构(scene#16) | **视觉基调:局部深色 Hero(推荐项)**——保持浅色咨询风为底,仅 Hero 升级为全出血深色画布(近黑底 `bg-ink` + 白色 Logo `/logo-white.svg` + 品牌红 #C41E3A 单电压 ≤10%),制造埃森哲式戏剧感;**实施范围:Phase A+B 全量**——①内容支柱补齐:新增 Insights 行业洞察(2×2 gap-px 卡片网格 + 方法论/观点/共创类型标签)、FounderQuote 创始人观点(深红区块 + 白色点阵 + 大引言)、News 新闻动态(最近 3 条 + 查看全部)三个对标埃森哲思想领导力支柱的区块;②Hero 深色化;③CTA 签名符号:全站 Button 组件 `rounded-md` → `rounded-full`(Pill 胶囊化)+ ArrowRight 签名箭头。零编造内容原则:公司 2026-01-15 成立,Insights 兜底 4 条均为可验证方法论内容(不虚构研究报告/人名),FounderQuote 署名兜底「Novalon 创始团队」不虚构具体人名。实现:`home-content-v15.tsx` 替换 v14(9 区块顺序 Hero→Trust→Narrative→Insights→Services→FounderQuote→Cases→News→CTA),CMS 优先 + FALLBACK_INSIGHTS/NEWS 常量兜底;seed page-copy 新增 insights*/founderQuote*/news* 字段(upsert 幂等)。验证:tsc 0 errors / eslint 0 errors / Jest 128 套件 1621 通过 / Playwright 视觉回归 22 passed(macOS 11 限制下用 Chromium 117 兼容配置生成快照) | ✅ 2026-08-31 确认(署名口径已被 2026-09-21 行修订)|
| FounderQuote 兜底署名改回法定主体名(修订上行) | 上行的「Novalon 创始团队」与 `p1-brand-visual-audit.spec.ts:30` 的 `FORBIDDEN_TEXT=/novalon/i` 冲突,且对外可见文案的署名应是法定主体「四川睿新致远科技有限公司」的简称而非英文品牌名。裁定:**改文案不改测试**——`FALLBACK_FOUNDER_QUOTE.name`(`home-content-v15.tsx:115`)与 `prisma/seed.ts:961` 同步为「睿新致远创始团队」,单测 `home-content-v15.test.tsx:181` 随动。DB 侧 `page-copy/home` 的 `founderQuoteName` 实测为空,页面走兜底分支,无需授权写库;`curl localhost:3000/` 复核渲染值为「睿新致远创始团队」且 0 处 "Novalon 创始团队"。零编造原则不变:仍不虚构具体人名 | ✅ 2026-09-21 确认(验收 §7-4 / 放行条件② 留痕)|
| 装饰大字对比度口径 | 装饰性大字号文字(序号、客户名首字占位)不享有对比度豁免:axe-core 4.11.4 的 `color-contrast` 只看 DOM 可见文字颜色,`aria-hidden` / `role="none"` / CSS `opacity` 均不豁免(变体矩阵实测,仅 SVG `<text>`、CSS mask、`::before` 等非文本编码可绕)。全站统一用大字档令牌 `text-text-hint`(#7C8CA5 / 深色 #94A3B8,对各级卡片底色 ≥3:1,满足 WCAG 1.4.3 大字 AA),不再用 `text-text-muted/10` 之类低 alpha 淡化;口径由 `scripts/utils/check-color-contrast.ts` 的「大号装饰文本 × 各级卡片底色」契约组固化 | ✅ 2026-09-21 确认(验收 A-12 清零)|
| 触摸目标口径 | 硬门禁 = WCAG 2.2 **AA** SC 2.5.8 **≥24×24px**(含 Spacing/Equivalent/Inline 例外,正文内联链接按 `display:inline` 排除);SC 2.5.5 的 44×44px 属 **AAA**,在 `e2e/touch-targets.ts` 中只作为建议清单打印、不使构建失败。此前测试把 44px 标为 AA 是标准档位错用 | ✅ 2026-09-21 确认 |
| GA4 SPA pageview 标题时序 | Next.js App Router 的 `<title>` 在路由 commit 之后 2–5 帧才写入(生产实测 chromium-mobile 第 2 帧、桌面 chromium 第 3–5 帧),因此 `GoogleAnalytics.tsx` 必须带帧预算(`MAX_TITLE_SETTLE_FRAMES = 10`)轮询等待标题变化后再 `gtag('config')`;超预算兜底发送(不丢计数),新导航前补发未送出的上一条 pageview(不送上一页标题)| ✅ 2026-09-21 确认(验收 A-17)|
| iPhone SE 复核口径(放行条件⑥) | 375×667 @2x + `colorScheme` 强制深/浅 × 同意条 pending/dismissed × 首页/新闻详情/新闻列表/联系页 = 16 组,证据(32 图 + `measurements.json` + 探针脚本)落在 `docs/acceptance/2026-09-21-iphone-se/`。实测:16/16 `scrollWidth == clientWidth == 375`(无横向溢出)、`footerBottomGap = 64`(验收 R-1 记录为 192–226 的双安全区空带)、滚到底后 `footerBottomVsViewport = -128` 一致;同意条在 pending 态以 `bannerTop=461` 覆盖页脚——这是 `fixed bottom-16` 浮层的设计行为,可关闭后消失。**边界**:这是浏览器仿真,不等同真机走查,真机确认仍需人工 | ✅ 2026-09-22 确认(验收 §8-⑥)|
| `-[var(--color-*)]/<alpha>` 死样式族的收口边界 | 编译产物实测:src 下 22 处 / 17 个唯一类在 CSS 中 0 产出(Tailwind v3 不能对纯 hex 变量套 alpha,见 `tailwind.config.js:12-15`)。按**渲染证据**分级后只修 2 处「活组件 + 仅 hover」:`NewsDetailClient.tsx:49` → `hover:bg-brand-soft`、`service-card.tsx:33` → `hover:border-brand/20`(hover 不入基线,零像素影响,已确认三条规则在编译 CSS 中产出)。注意点亮后的合成底色须重算对比度:`hover:bg-brand/20` 在浅色主题下使 `text-brand-ink` 降到 4.18:1(破 AA 4.5:1),且 axe 忽略 `:hover`、`check:contrast` 只断言令牌配对,两道门禁均测不到,故取 12% 的 `brand-soft`(浅色 4.80:1 / 深色 6.60:1)。**不做**:`/privacy`、`/terms` 首屏 `via-[var(--color-brand)]/80` 一旦被"点亮"会在已审批基线里画出半透明暗坑,属设计判断;3 处需先在 `globals.css` + config 补 `--color-bg-section-rgb` / `--color-brand-bg-rgb` 通道;其余 14 处位于无任何路由渲染的 barrel-only 死代码。是否扩 `check-brand-text-token.ts` 到全族由用户定范围 | ⏳ 2026-09-22 待裁定(残留项,不属验收 §8 六条放行条件)|
| 英文 wordmark 的受控口径(续上上行 §7-4) | 全仓唯一的字面 `NOVALON` 位于 `public/logo.svg:72`、`public/logo-white.svg:72`、`public/logo-calligraphy.svg:72` 的 `<text>` 元素,且各自行前注释 `<!-- NOVALON - 英文 -->` 自述为设计意图,三处 `<text>` 实测内容恰为 `NOVALON`(`letter-spacing="4"`,仅 `fill` 随底色不同:#0A0E14 / #FFFFFF)。提交 `8d3bd72` 另把书法人名统一到 header/footer/品牌页(新增 `BrandCalligraphyName`,改 `public/logo.svg`)。裁定:**wordmark 属图形资产内的受控内容,不是文案回归**;对外可见**文本**署名一律「睿新致远」(:195),`src` 下其余 `novalon` 串为技术上下文(`novalon.cn` 域名、`novalon_admin_token` 等存储键、PBKDF2 盐、管理端 `Novalon CMS` 界面)。据此把 `e2e/p1-brand-visual-audit.spec.ts` 从"零断言"改为三条真不变量:①两张 logo 资产经 `page.request.get` 取回后,每个 `<text>` 内容须恰为 `NOVALON`(改名、夹带或漏字即失败);②header/footer/首页的可见文本、`alt`、`title`、hover 后文本零 `novalon`(技术上下文除外);③凡"遍历后无命中即通过"的用例补 `expect(checked/hovered/withAlt).toBeGreaterThan(0)`,避免选择器失配伪装成绿灯。因 wordmark 以 `<img>` 加载、不成 DOM 文本,`:173-177` 的首页 Hero 豁免分支在生产 HTML 上当前不触发(改为内联 SVG 时会生效)——保留但不依赖。放行条件② 的「修订 `CONTEXT.md` 或测试」两条路径此处**同时**满足 | ✅ 2026-09-22 确认(验收 §7-4 / 放行条件②)|
| 边界页(404 / 根错误兜底)的可测口径 | ⑤ 的扫描分母从 `sitemap.xml`(29 条)扩为 **sitemap ∪ 预渲染产物 ∪ 站内链接 BFS = 35 条**后,首轮即 `passed=false`(`docs/acceptance/2026-09-21-axe/axe-evidence.json`,双引擎双主题共 8 条 bad rows):`/_not-found` 的 h1「404」用 `text-brand-ink` + **`opacity-20`** ⇒ 有效 alpha 20%,`color-contrast` 必破;`/_global-error` 因项目无 `global-error.tsx`,由 Next 内置文档壳顶替 root layout ⇒ `<html id="__next_error__">` 无 `lang`,且本站主题是 `html[data-theme='dark']` 属性驱动(`globals.css:396` 明示非 `.dark` class),内置壳读不到令牌,深色落到浏览器默认 `rgb(10,10,10)`。裁定:**边界页属全站范围,不豁免**——404 与根错误页是每个访客都可能落到的页面,且是唯一的"无 CMS 数据兜底路径"。修复:404 标题改 `text-brand-ink/80`(120px 属大号文本,AA 需 3:1;沿用 §5.22 `/products/erp-upgrade` 水印数字的同一口径与同一档位),新增 `src/app/global-error.tsx` 自带 `<html lang="zh-CN">` + `<title>`(错误边界是 Client Component,不支持 `metadata` 导出)+ 与 `globals.css` 同值的 OS 主题内联样式(CSP `style-src 'self' 'unsafe-inline'` 允许)。**约束**:①可见文本一律不得用 `opacity-*` 暗化,装饰性大字用可编译的主题令牌 + `/80` 档;②新增 `app/` 顶层边界文件须自带文档壳与主题样式,且本版本 Next 的 `global-error` props 是 **`retry`**(`node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/error.md`),不是旧 API 的 `reset`;③任何"全站 = 0"型门禁的分母必须是三源并集清单,`sitemap.xml` 只覆盖"希望被收录的页",单独使用即为口径错误。配套回归:`src/app/global-error.test.tsx`(3 例:`lang`、`retry` 接线、digest 展示) | ✅ 2026-09-22 修复并复扫通过:chain5/chain6 最终树 34 路由 × chromium/firefox × 深/浅 的 `contrastNodes / violationNodes / themeMismatch / bgMismatch` 全为 0、`rows=136`、`passed=true`(`docs/acceptance/2026-09-21-axe/axe-evidence.json`,详见 §5.24 与 `docs/acceptance/2026-09-21-gates/final-tree-results.md` ⑤)|
| refresh token 的轮换/吊销:本轮**有意延后**(需 schema 变更 = 需授权动作) | `src/app/api/auth/refresh/route.ts` 只验签名即用旧 payload 重签,意味着失窃的 refresh token 在到期前可无限换取 access token。**闭合它需要 `jti` / `tokenVersion` 一类的服务端状态,即 Prisma schema 迁移**——按 `AGENTS.md` §5.1「破坏性/共享状态动作须单独取得授权」,DB 迁移不在常规变更里夹带,故本轮明确延后并留痕(源码注释同处 `:33-34` 已写明 backlog)。**已随车交付的缓解**(不需迁移):①按 `payload.userId` 实查 `User`,账号不存在或 `status === 0` 一律 401,堵住「被禁用/删除账号仍能在 7 天有效期内换新令牌」(B-7);②角色不按令牌里的旧 claim 沿用,而是走 `permissions` 层的同源查询 `UserRole` 关联表后**重签**,无关联角色的历史账号回落到与 login 同源的 `User.role` 标量,避免降权后仍持旧角色。边界:这两条只保证「账号状态实时」,**不**构成令牌吊销——已签发且账号仍启用的旧 refresh token 依旧可用到过期 | ✅ 2026-09-23 确认(延后为有意决策,非遗漏;轮换/吊销待授权迁移)|
| CMS 写入侧字段校验的边界(承接「数字口径 basis 结构强制」行) | 出路由 `src/lib/cms/validate-content-data.ts` 承载,挂在 `POST /api/admin/items`(`:132`)与 `PATCH`(`:215`)上,**只执行 `ContentModel.fields` 已声明的约束**:`options` 即视为枚举(`select`/`dropdown` 与带 `options` 的 `text` 在 `content-types.ts` 同义)、`min`/`max`/`pattern`;违规返回 `validationError('内容字段校验未通过', { fields })` 并附每条 `path`。刻意保持宽松的两处:**未声明的键一律放行、未填的键不判必填**,以免改变既有载荷形态(seed 与页面组件依赖宽松结构);`loadDeclaredFields` 在模型缺失或 `fields` 不可解析时回落 `[]`,即**fail-open**(无声明 = 无约束),因此「后台有校验」不等于「值一定合法」,新增约束的正解是补 `ContentModel.fields` 而非在路由里加特例。`basis` 的强制仍在那一行的口径里:渲染端 `metrics-basis-note.tsx:11` 缺省回落 `target`,`metrics-basis.test.ts` 机械校验 seed 全量条目显式声明并禁止无据自称 `verified`;字段定义改动须重跑 seed 同步 `ContentModel.fields` 后 admin 才出现输入项 | ✅ 2026-09-23 确认(写入侧只判已声明约束 + fail-open 为有意取舍)|
| 边界页(`/_not-found` / `/_global-error`)**不带站点 Header/Footer** | 这是结构结果而非疏漏,本轮核实并固化为口径:`src/app/not-found.tsx` 位于 `app/` 根、**不在 `(marketing)` 路由组内**,而 Header/Footer 只挂在 `(marketing)/layout.tsx:14,22`,故 404 页由 root layout 提供公共外壳(主题内联脚本、GA、CookieConsent、MobileTabBar)但**无导航与页脚**;`NotFoundContent` 自带恢复动线(返回首页 CTA + `history.back()` + 产品/方案/关于/联系四枚入口)。`src/app/global-error.tsx` 更彻底:按 Next 契约它**自带 `<html lang="zh-CN">` + `<title>` + 与 `globals.css` 同值的 OS 主题内联样式并顶替 root layout**(`node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/error.md:163,165` 明写"必须自带文档壳、不含全局样式、`data-theme` 到不了它"),因此两页都**不能**指望复用 Header/Footer 或品牌红规则;错误边界是 Client Component,`metadata` 导出无效,标题只能用 React `<title>`。**约束**:新增 `app/` 顶层边界文件须自带文档壳与主题样式,且本版本 props 是 `retry` 不是 `reset`;给这两页补导航/页脚需先决定是否把 root layout 变成可复用外壳,属设计判断而非机械改动 | ✅ 2026-09-23 确认(口径固化,配套回归 `src/app/global-error.test.tsx`)|
| lint warning 的处置口径:**0 error 判门禁,warning 不判红也不清零** | 现行裁定:`npm run lint` 通过标准是 **0 error**;warning 属**已知存量**并只登记分布、不阻塞合入(数量口径的**版本库内权威记录位**是 `docs/development/quality-gates.md` §1:2026-09-23 **第二周期**最终树复跑 `npm run lint` ⇒ **104 problems / 0 errors / EXIT=0**,分项 55 `no-console` + 25 `no-explicit-any` + **11** `set-state-in-effect` + 10 `no-img-element` + 各 1 `no-sync-scripts`/`no-html-link-for-pages`/死抑制(同日第一周期为 105,差值唯一来自后台编辑器"挂载期消费守卫"那个效果被删)。⚠ 不要再指向 `AGENTS.md` §5——该文件被 `.gitignore` 有意排除、由 `next dev` 再生,不可审也不可回滚,见验收 N-16)。分级由 `eslint.config.mjs` 声明而非随手 `--fix`:`@typescript-eslint/no-explicit-any`(`:52`)、`no-console`(`:54`,`allow: ['warn','error']`)、`react-hooks/set-state-in-effect`(`:61`)为 `warn`;`react/no-unescaped-entities`、`eqeqeq`、`prefer-const` 等是 `error`。按路径降档只有三处 override(`e2e/**/*.ts` 关 `no-console`、`**/*.test.{ts,tsx}` 与 `**/__tests__/**` 关 `no-explicit-any`、`tests/performance/*.js`),**`prisma/seed.ts` 不在其中**——它的进度日志是"有意容忍的 warning",不是配置豁免。**禁止的处置方式**:为凑绿把规则调成 `off`、加行内 `eslint-disable` 而不写理由、把 `Unused eslint-disable directive`(死抑制)当噪声忽略——死抑制本身按 §「门禁断言的有效性口径」第 1 条归入"什么都没测"。需要静音时须附来源注释 | ✅ 2026-09-23 确认(warning 存量制,error 零容忍)|
| `duration-300` → 动效令牌 与 死样式收口:**本轮为 in flight,不得当作已完成** | 两条已知残留:①`duration-300` 一类 Tailwind 预设时长绕过 `--transition-fast/normal/slow`(`globals.css:255-258` 的 180/280/450/700ms)且 300ms 超出动效四原则「入场 180–280ms」的档位;②`-[var(--color-*)]/<alpha>` 静默不产出 CSS 的家族(见上方 §5.20 与「死样式族的收口边界」行)。**2026-09-23 文档同步时不写结论**:另一执行体正在同一批 `src/**/*.tsx` 上作业(写入瞬间 `src` 下 `duration-300` 命中 13 处、死样式族命中 8 处,均在实时变动中),任何计数此刻都不可引用。**约束(替换时才成立)**:动效时长走令牌、点亮死样式属设计判断、hover 合成底色须重算对比度(axe 与 `check:contrast` 都测不到 `:hover`)。复算命令:`grep -rn "duration-300" src` 与 `grep -rno "\-\[var(--color-[a-z-]*)\]/[0-9]*" src` | ⏳ in flight(2026-09-23 观察:他人正在编辑相关文件;未落地,不作为已确认决策引用)|
| E2E 目标与 skipped 口径 / `check:axe*` 的落地状态(2026-09-23) | ①**口径**:`npm run test`(含 `test:all` 里的 `test:e2e:fast`)的 webServer 是 `npm run dev`(`e2e/playwright.config.ts:130`),GA4 的 4 个 `@critical` 用例 × 4 project = **16 个 skipped**(`.env.production` 独占 GA ID → dev 下不注入 gtag → 用例内 `test.skip`);产物口径只有 `npm run test:e2e:prod`(CI 在 `Jenkinsfile`「🌐 E2E 测试」仅 main 分支执行,且须先 build)。裁定:**引用 GA4/生产头覆盖必须指名 prod 目标**,"全绿"不等于"全断言"。②**静态 a11y 门禁已成型**:`check:a11y` = `check:contrast` + `check:headings` + `check:brand-token`,并已并入 `test:all`。③**已闭合(2026-09-23 同日落地)**:`package.json` 的 `check:axe` / `check:axe:routes` 指向的 `scripts/accessibility/{axe-node-count,crawl-routes}.mjs` 现已存在(由 `docs/acceptance/2026-09-21-axe/` 的一次性 harness 平移而来,最终树 34 路由 × 4 组合 `passed=true`,证据 JSON 仍留原目录),判定改为退出码(0 通过 / 1 判红 / 2 清单缺失或 0 条),并由 `Jenkinsfile`「♿♿ 全站 axe 节点计数」阶段(仅 main,服务用 `node dist/standalone/server.js`,按记录 PID 收服)自动执行 —— axe 节点计数自此**是**自动化门禁;引用其结果时须同时给出 `axe-evidence.json` 的 `routeCount` 与分母断言,只报 `passed=true` 不报分母即为口径错误。④文档同步另纠正三处:`npm run preview` 实为 `next start -p 3000`(非 `npx serve`,standalone 下会打警告)、渲染模式措辞由「静态导出」改「standalone 混合渲染」、`docs/testing.md` 的 dev 指示器机制改回 `e2e/fixtures.ts` 的真实实现 | ✅ 2026-09-23 确认(③已于同日落地为常驻门禁,④为该轮文档纠正;③的漂移记录见 `docs/lessons-learned.md` §5.26,其修复标注在同一行)|
#### 门禁断言的有效性口径(chain6/chain7 新增)
验收期间发现 `config/test/lighthouserc.json` 有 3 条 `warn` 级断言(`autocomplete-valid` / `presentation-role-conflict` / `svg-img-alt`)**在 Lighthouse 里根本没有对应审计项**:把 chain6 全部 21 份报告的 `audits` 键取并集(175 个 id)比对 62 个非 `categories:` 断言键,只有这 3 条缺席;`@lhci/cli@0.15.1` 用的是它自带的 `lighthouse@12.6.1`(不是顶层的 13.4.1),其 `core/audits/accessibility/` 64 个审计文件里没有这三个。它们每轮稳定打印 `"…" is not a known audit. found: 0`,因为是 warn 级永不判红 —— 于是"57 项 a11y 断言全过"里混进了 3 项什么都没测的条目。
**约束(今后一律适用)**:
1. **门禁里的每一条断言必须映射到真实存在的测量项**。删除或新增断言都要留下"这项由谁兜底"的说明;`warn` 级、恒 0、无对照的断言按"假绿"处理,不得当作覆盖。
2. **拆门之前先证明覆盖还在别处**。用与 ⑤ 门禁同款的 `runOnly: {type:'tag', values:['wcag2a','wcag2aa','wcag21a','wcag21aa']}` 做正/负对照(`docs/acceptance/2026-09-21-axe/probe-axe-rule-coverage.mjs`,每条规则各造一个必然违规的最小节点):`autocomplete-valid`、`svg-img-alt` 确在 axe 通道内(各抓到 1 node);但 `presentation-role-conflict` 属 `best-practice` tag,**axe 门禁的 tag 集合根本不跑它**。因此处理不是"删了事",而是删死断言 + 在 `axe-contrast-evidence.mjs`(现 `scripts/accessibility/axe-node-count.mjs`)增加第二次 `runOnly: {type:'rules', values:EXTRA_RULES}` 运行。
3. **规则级通道也要断言分母**:每页 `extraRulesChecked === EXTRA_RULES.length`(计 `extraRulePagesUnderCovered`)+ 违规节点 0。axe 对未知 rule id 会抛错,这一条同时防"将来某条规则被 axe 改名后静默消失"——与本节第 1 条是同一种病。
4. **异常快的绿灯必须自证**。chain6 `type-check` 只花 2s,用注入 `const probe: number = "not a number"` 的正对照确认 `tsc` 能报错(冷缓存 11.1s)、删除后 2.9s 干净,成因是 `tsconfig.compilerOptions.incremental=true` 命中 `.tsbuildinfo` ⇒ 门禁有效。**"很快/很慢"只是怀疑的起点,能造正对照就不要靠推断背书**(与记忆「绿灯≠达标」同源)。
5. **文档里的数字要被复算,否则就从证据退化成传说**:该口径本身成立,但**它的示范当时写歪了**——原文称「`AGENTS.md` §5 记的 lint『实测 105 条 warning』在 chain4/chain6 实测均为 **106**,已按实测改文并注明分布(56 `no-console`/25/12/10/其余 3 条零散)」,而 2026-09-23 本树复跑 `npm run lint` 实为 **105 problems / 0 errors / EXIT=0**、`no-console` **55** 条,且"改文"落在未被 git 跟踪的 `AGENTS.md` 上(验收 N-16:由 `next dev` 再生,随时可丢)。裁定:**warning 计数按轮次漂移,不作为常量引用**;权威分布记录位改到 `docs/development/quality-gates.md` §1,并在其后附可复制的复算命令(含分项管道)。本条教训从"文档数字落后于实测"升级为"**文档数字被写进了不可审的载体**"。
来源:`docs/acceptance/2026-09-21-gates/final-tree-results.md` ④ 与「Lighthouse 的 3 条死断言」小节、`docs/lessons-learned.md §5.25`。
## 页面类型与四层映射
+1 -1
View File
@@ -230,7 +230,7 @@ Novalon 的界面是一份可以逐条审计的经营宣言。它模仿顶级咨
- **Do** 让每个 section 以可验证的陈述开场,数据、来源角注跟上(The Answer-First Rule)。
- **Do** 新文字令牌落地前先在代码注释写实测对比度比值(沿现制度:如 5.74:1、AA 判定)。
- **Do** 用 `text-brand-ink` 写红色文字、`bg-brand` 做红色填充,永不混用(双通道)。
- **Do** 入场动效统一 ease-ink `cubic-bezier(0.22,1,0.36,1)`、180–280ms,stagger 用 `--stagger-*` 令牌;reduced-motion 时退为静态。
- **Do** 入场动效统一 ease-ink `cubic-bezier(0.22,1,0.36,1)`、180–280ms;stagger 在 JS 侧以秒数值表达(Section 间 100–150ms、子元素卡片 30–60ms;原 `--stagger-*` CSS 令牌因 framer-motion 读不到自定义属性且零消费已删除);`delay` 属调度不属时长,>150ms 的字面 delay 为已批准保留值;reduced-motion 时退为静态。
- **Do** 未发布内容使用 `ContentUnavailableState`(带导航出口),空/错/成功态永远给出下一步。
### Don't:
+52 -6
View File
@@ -1,3 +1,23 @@
# 构建 + 运行一体镜像。
#
# 服务契约来源(交叉验证):
# - node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/output.md:
# output: 'standalone' 产出可独立部署的目录 + 精简 server.js;该 server **默认不含** public
# 与 static,需手工拷到 <standalone>/public 与 <standalone>/.next/static(本项目 distDir=dist,
# 实测为 <standalone>/dist/static,见下);监听地址/端口由 HOSTNAME / PORT 环境变量决定。
# - 本仓库 dist/ 实测:dist/standalone/server.js、dist/standalone/dist/{BUILD_ID,server,...}、
# dist/static/{chunks,media,<buildId>}。
#
# 与 Dockerfile.prod 的分工(避免第三种变体):
# - Dockerfile.prod:直接复用**宿主机已构建**的 dist/standalone,宿主为 darwin,
# 因此需要 sharp-deps 阶段补 linux-musl 的 @img 二进制。
# - 本文件:镜像内完成 npm ci + next build(依赖原生装到 linux-musl),无需覆盖 @img。
#
# 历史缺陷(ACCEPTANCE_REVIEW_2026-09-21 §5):旧版把 /app/dist 当静态 HTML 根
# `COPY --from=builder /app/dist /usr/share/nginx/html` 交给 nginx,standalone 产物里没有
# 可直服的站点根目录(HTML 在 dist/standalone/dist/server/app/*.html,且需运行时渲染),
# 用本镜像部署即白屏/404。
FROM node:20-alpine AS builder
WORKDIR /app
@@ -9,13 +29,39 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 先删除构建上下文里带进来的宿主产物:.dockerignore 为 Dockerfile.prod 放行了
# dist/standalone 与 dist/static,而 next build 不会清空目标目录,残留的
# dist/standalone/node_modules/@img/*-darwin-* 会被下面的 runner 原样拷进 linux 镜像。
RUN rm -rf "${NEXT_DIST_DIR:-dist}" && npm run build
FROM nginx:alpine
FROM node:20-alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-static.conf /etc/nginx/nginx.conf
WORKDIR /app
EXPOSE 80
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
# standalone server 只绑 HOSTNAME,默认 localhost 在容器外不可达
ENV HOSTNAME="0.0.0.0"
CMD ["nginx", "-g", "daemon off;"]
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Next.js 16 standalone 输出:server.js 位于 dist/standalone 根目录(与 Dockerfile.prod 一致)
# 注:以下三个路径按 next.config.mjs 的默认 distDir=dist 硬编码;若 CI 用 NEXT_DIST_DIR 改目录,
# 需同步这里的 --from 路径(当前 Jenkinsfile 未设置该变量)。
COPY --from=builder /app/dist/standalone ./
# 客户端静态资源,standalone 内的 distDir 影子目录为 dist/
COPY --from=builder /app/dist/static ./dist/static
# 公共静态资源(图片、字体、favicon、uploads)
COPY --from=builder /app/public ./public
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
# 应用层安全响应头由 next.config.mjs 的 headers() 发出,本镜像不再叠加 nginx 层,
# 因此容器直连(无 nginx)时头部依然完整。
CMD ["node", "server.js"]
-8
View File
@@ -1,8 +0,0 @@
FROM nginx:alpine
COPY html /var/www/novalon
COPY nginx-internal.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]
Vendored
+147 -17
View File
@@ -162,6 +162,8 @@ pipeline {
}
// ====== L3: E2E + 用户旅程测试 ======
// 历史上这些命令带 `|| echo`,失败被吞掉、绿灯不代表任何行为正确(验收 A-5)。
// 现统一 set -e:任一例失败即整个 stage 失败。
stage('🌐 E2E 测试') {
when {
branch 'main'
@@ -170,21 +172,16 @@ pipeline {
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
echo "🔨 构建用于 E2E 测试..."
set -e
echo "🔨 构建生产产物(验收 A-10:E2E 打构建产物,dev server 下预渲染/ISR/生产响应头永不被测)..."
npm run build
echo "🚀 启动预览服务器..."
npx serve dist -l 3000 &
sleep 5
echo "🧪 运行 E2E 快速测试(@smoke + @critical)..."
cd e2e && npx playwright test --grep "@smoke|@critical" || echo "⚠️ E2E 测试部分失败,继续执行"
echo "🧪 运行用户旅程测试..."
npx playwright test --grep @journey || echo "⚠️ 用户旅程测试部分失败,继续执行"
echo "🚀 运行 E2E(@smoke + @critical + @journey × 三浏览器,next start 由 Playwright webServer 起)..."
npm run test:e2e:prod
'''
}
}
post {
always {
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
publishHTML(target: [
allowMissing: true,
reportDir: 'e2e/playwright-report',
@@ -207,22 +204,80 @@ pipeline {
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
echo "🚀 启动预览服务器..."
npx serve dist -l 3000 &
sleep 5
set -e
echo "🧪 运行视觉回归测试..."
cd e2e && npx playwright test visual-regression.spec.ts --project=visual-chromium-desktop || echo "⚠️ 视觉回归测试失败,请检查基线是否需要更新"
npm run test:visual
'''
}
}
post {
always {
sh 'kill $(lsof -t -i:3000) 2>/dev/null || true'
archiveArtifacts artifacts: 'e2e/test-results/**/*.png', allowEmptyArchive: true
}
}
}
// ====== L4.5: 可访问性与设计契约门禁 ======
stage('♿ 可访问性门禁') {
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
set -e
echo "🎨 令牌对比度(读令牌表,含暗色与 alpha 组)..."
npm run check:contrast
echo "🔴 双通道红契约(禁 text-[var(--color-brand)])..."
npm run check:brand-token
echo "🧱 标题层级..."
npm run check:headings
'''
}
}
}
stage('⚡ Lighthouse 性能与无障碍') {
when {
branch 'main'
beforeAgent true
}
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
set -e
echo "🔨 构建用于 Lighthouse..."
npm run build
echo "🚦 运行 lhci(断言含 axe critical 失败数 = 0)..."
npm run lighthouse
'''
}
}
post {
always {
archiveArtifacts artifacts: 'lighthouse-reports/**/*.report.html, lighthouse-reports/**/*.report.json', allowEmptyArchive: true
}
}
}
stage('🧬 变异测试') {
when {
branch 'main'
beforeAgent true
}
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
set -e
npm run test:mutation:quick
'''
}
}
post {
always {
// 沙箱模式下 Stryker 不改写工作树;此清理仅兜底残留临时目录
sh 'rm -rf .stryker-tmp || true'
}
}
}
// ====== L5: 安全扫描 ======
stage('🔒 安全扫描') {
when {
@@ -232,10 +287,11 @@ pipeline {
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
set -e
echo "🔒 运行依赖安全审计..."
npm audit --audit-level=high || echo "⚠️ 存在高危依赖漏洞,请检查"
echo "🔒 检查安全响应头..."
npm run test:security:headers -- --url https://novalon.cn || echo "⚠️ 安全头检查未通过,请检查 Nginx 配置"
npm audit --audit-level=high
echo "🔒 检查本分支构建产物的安全响应头..."
npm run test:security:headers
'''
}
}
@@ -274,6 +330,80 @@ pipeline {
}
}
// ====== 验收 §8-⑤:全站 axe 节点计数(三规则级覆盖 + 分母闭合的唯一真实来源) ======
// 复用上一个阶段刚产出的 standalone 产物,不重复构建。跑在 main 分支(与 E2E / Lighthouse 同档:
// 34 路由 × 双引擎 × 双主题 = 136 页扫描,约 14 分钟);令牌级 a11y 门禁仍在每个分支跑。
// 服务用 `node dist/standalone/server.js`,不用 `npm run start`——Next 对 output:'standalone' 下
// 的 next start 会直接告警不支持(佐证见 docs/acceptance/2026-09-21-gates/ga4-production-run.txt)。
stage('♿♿ 全站 axe 节点计数') {
when {
branch 'main'
beforeAgent true
}
steps {
nodejs(nodeJSInstallationName: "${NODE_VERSION}") {
sh '''
set -e
AXE_PORT="${AXE_PORT:-3100}" # 专用端口:不复用 :3000(chain2 事故就是端口被占 + 静默复用)
ROUTES=/tmp/axe-routes.xml
echo "📦 把浏览器静态资源并入 standalone 根目录(与 Dockerfile.prod / scripts/deploy.sh 同一套装配)..."
echo " standalone 产物不含 dist/static 与 public,缺了它们 /_next/static/** 全 404 ⇒"
echo " 页面落在默认黑白底上,对比度会「意外达标」;harness 的 bgMismatch 判据会抓到,但别拿它当门禁目的。"
mkdir -p dist/standalone/dist/static dist/standalone/public
cp -R dist/static/. dist/standalone/dist/static/
cp -R public/. dist/standalone/public/
echo "🚀 启动 standalone 服务 :$AXE_PORT ..."
PORT="$AXE_PORT" HOSTNAME=127.0.0.1 node dist/standalone/server.js > /tmp/axe-server.log 2>&1 &
AXE_PID=$!
# 只收服本轮自己起的进程(记录 PID)。历史事故:只 kill 包装进程会留下 next-server
# 孤儿继续占端口,下一轮 E2E / Lighthouse 通过 reuseExistingServer 静默复用它 —— 整轮验证被毒化。
cleanup() {
if kill -0 "$AXE_PID" 2>/dev/null; then
kill "$AXE_PID" 2>/dev/null || true
i=0
while kill -0 "$AXE_PID" 2>/dev/null && [ "$i" -lt 20 ]; do i=$((i+1)); sleep 1; done
kill -0 "$AXE_PID" 2>/dev/null && kill -9 "$AXE_PID" 2>/dev/null || true
fi
}
# rc=$? 先行捕获:EXIT/INT/TERM 处理器必须原样带回门禁的退出码,
# 否则「trap 里最后一条命令的状态」会把判红变成判绿(POSIX trap 语义的坑)。
on_exit() { rc=$?; cleanup; exit "$rc"; }
trap on_exit EXIT INT TERM
CODE=000
i=0
while [ "$CODE" != "200" ] && [ "$i" -lt 40 ]; do
i=$((i+1))
CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$AXE_PORT/" || true)
sleep 2
done
if [ "$CODE" != "200" ]; then
echo "❌ standalone 服务未就绪(HTTP $CODE),日志:"
tail -40 /tmp/axe-server.log
exit 1
fi
export BASE="http://127.0.0.1:$AXE_PORT"
echo "🗺️ 生成全站路由清单(sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)..."
OUT="$ROUTES" npm run check:axe:routes
echo "🔎 双引擎(chromium/firefox)× 双主题(light/dark)逐页 axe 节点计数 + 规则级通道..."
SITEMAP="$ROUTES" npm run check:axe
'''
}
}
post {
always {
// 失败时这份证据就是判红依据(harness 失败也会落盘,含逐条 failures),必须留档
archiveArtifacts artifacts: 'docs/acceptance/2026-09-21-axe/axe-evidence.json', allowEmptyArchive: true
}
failure {
sh 'tail -40 /tmp/axe-server.log || true'
}
}
}
stage('🚀 部署到生产环境') {
when {
allOf {
+65 -31
View File
@@ -31,7 +31,7 @@
- [x] CMS 全覆盖:法律页、新闻、团队、案例、服务、方案、产品、独立产品、首页运营位(ContentZone)、RBAC/工作流/媒体/通知全部从 CMS 读取并启用 ISR(3600s)
- [x] CMS 管理后台:角色权限界面(`/admin/roles`)、内容模型 × 操作权限矩阵、super_admin 锁定
- [x] 全量测试体系:单元 ~1600 / 功能 E2E ~800 / 视觉回归三端 / 用户旅程 UJ-01~UJ-11 / Lighthouse / k6 负载压力 / 安全扫描,全部通过
- [x] 全量测试体系:单元 **134 suites / 1697 例** / 功能 E2E **1080 例**(270 例 × chromium + chromium-mobile + firefox + webkit 四个 project)/ 视觉回归 **125 例**(25 例 × 桌面·平板·移动·firefox·webkit 五个 project)/ 用户旅程 UJ-01~UJ-11 / Lighthouse / k6 负载压力 / 安全扫描,全部通过。**计数为 2026-09-23 本树复跑实测**(`npm run test:coverage` ⇒ `Test Suites: 134 passed` / `Tests: 1697 passed` / EXIT=0;`cd e2e && npx playwright test --list` ⇒ `Total: 1205 tests in 22 files` = 1080 + 125),非估算;计数随树漂移,引用请附复算命令
- [x] **官网产品模块定位(IHG/字节式品牌矩阵,2026-08-19)**:官网品牌宣传为主,产品矩阵页为聚合入口;成熟产品 `externalUrl` 外链独立站(NovaVis → novavis.p.novalon.cn);未成熟产品详情页占位,独立站上线后切换外链
- [x] **结构性文案全站 CMS 化(2026-08-19)**:新增 `page-copy` 内容模型承载全部营销页面章节标题/眉标/描述/CTA/空状态(首页 + 服务/方案/产品/案例/新闻列表页),「CMS 优先 + 硬编码兜底」不白屏;seed 写入 6 条,本地 db:seed 已生效
- [x] **信任证据补齐·阶段 0(成立 <1 年策略,2026-08-20)**:信任策略从「结果证据」转向「可验证的过程 + 能力 + 治理证据」——首页「首批客户共创计划」板块(共创进行中/产品内测中/成果授权公开三档如实状态 + 招募 CTA)+ 关于页资质「建设中」如实空态,零编造
@@ -87,9 +87,20 @@ npm run build
### 预览生产版本
```bash
npm run preview
npm run build # 先产出 dist/
npm run preview # == npm run start == `next start -p 3000`(不是 `npx serve`)
```
`preview` 是 `start` 的同义别名,两者都是 `next start -p 3000`。项目为 `output: 'standalone'`,Next 16 在此组合下会打印
`"next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.`
——这是**警告而非抛错**,服务仍会起来,但走的是**不受支持的降级路径**,因此它已不再被任何门禁当作产物口径:
- 受支持的启动方式是 `node dist/standalone/server.js`(静态资源由 `package.json:9` 的 `postbuild` 在 `npm run build` 末尾自动拷入 `dist/standalone/`,standalone 产物本身不含 `dist/static` 与 `public`,缺了它们 `/_next/static/**` 全 404)。该启动方式**尚未做成 npm script**,目前由 `check:axe`、`check:headings`、`e2e/playwright.config.ts:134`(`E2E_TARGET=production`)与 `Jenkinsfile` 各自直接 `node` 起。
- `npm run test:e2e:prod` 走的是上面这条受支持路径(`e2e/playwright.config.ts:132-135`),**不是** `next start`。
- `npm run check:headings` 存在产物时直起 standalone,仅在产物缺失时回退 `npm run preview` 并打印告警(`scripts/utils/check-heading-hierarchy.ts:52-67`,验收 N-24③)。
详见 `CLAUDE.md`「Build & Preview」。
## 项目结构
```
@@ -98,31 +109,43 @@ novalon-website/
│ ├── app/ # Next.js App Router
│ │ ├── (marketing)/ # 营销页面路由组
│ │ │ ├── page.tsx # 首页
│ │ │ ├── home-content-v15.tsx # 首页内容装配(V1.5)
│ │ │ ├── about/ # 关于我们
│ │ │ ├── team/ # 团队
│ │ │ ├── cases/ # 成功案例
│ │ │ ├── contact/ # 联系我们
│ │ │ ├── methodology/ # 方法论
│ │ │ ├── news/ # 新闻动态
│ │ │ ├── products/ # 产品服务
│ │ │ ├── services/ # 核心业务
│ │ │ └── solutions/ # 解决方案
│ │ ├── admin/ # CMS 管理后台
│ │ ├── api/ # 服务端接口(表单、CMS、鉴权)
│ │ ├── privacy/ # 隐私政策
│ │ ├── terms/ # 服务条款
│ │ ├── layout.tsx # 根布局
│ │ ├── error.tsx # 错误页面
│ │ ├── error.tsx # 根段错误页面
│ │ ├── global-error.tsx # 全局错误边界
│ │ └── not-found.tsx # 404 页面
│ ├── components/ # React 组件
│ │ ├── ui/ # 基础 UI 组件
│ │ ├── layout/ # 布局组件
│ │ ├── sections/ # 页面区块组件
│ │ ├── effects/ # 视觉效果组件
│ │ ├── content/ # 内容渲染组件(`sections.tsx`)
│ │ ├── detail/ # 详情页组件
│ │ ├── admin/ # 后台组件
│ │ ├── theme/ # 主题组件
│ │ ├── seo/ # SEO 组件
│ │ └── analytics/ # 分析组件
│ ├── hooks/ # 自定义 Hooks
│ └── contexts/ # React Context
├── e2e/ # E2E 测试
├── tests/ # 测试文件
│ ├── performance/ # 性能测试
│ └── styles/ # 样式测试
│ ├── lib/ # 工具与领域逻辑(含 `cms/`、`constants/`、`media/`)
│ └── types/ # 类型声明
├── e2e/ # Playwright E2E + 视觉回归(**无独立 package.json**,依赖装在根目录)
├── tests/ # 非 Playwright 资产
│ ├── performance/ # k6 负载/压力脚本(由 k6 CLI 跑,不在 npm scripts 内)
│ ├── lib/ # 集成测试
│ ├── styles/ # 样式测试
│ └── screenshots/ # 截图基线
├── docs/ # 项目文档
├── scripts/ # 脚本文件
├── config/ # 配置文件
@@ -157,31 +180,38 @@ novalon-website/
| `npm start` | 启动生产服务器 |
| `npm run lint` | 运行 ESLint 检查 |
| `npm run type-check` | TypeScript 类型检查 |
| `npm run test` | 运行 E2E 测试 |
| `npm run test:unit` | 运行单元测试(Jest,120 suites / 1509 tests) |
| `npm run test:coverage` | 运行测试覆盖率(当前 Branches 82.38%, Lines 73.59%) |
| `npm run test` | 全链路 E2E:`test:functional`(4 project ⇒ **1080 实例**)→ `test:visual:all`(5 project ⇒ **125 实例**)串行,`--list` 合计 **1205**。**webServer 默认是 dev server**,见 `test:e2e:prod` |
| `npm run test:unit` | 运行单元测试(Jest,2026-09-23 本树复跑实测 **134 suites / 1697 tests**) |
| `npm run test:coverage` | 运行测试覆盖率。**阈值**单一真源 `config/test/jest.config.js` 的 `coverageThreshold`(全局 statements/lines/functions ≥ 75%、branches ≥ 82%);**实测值**单一记录位见 [docs/development/quality-gates.md](docs/development/quality-gates.md) §3,本文件不再复制百分比以免再次互斥 |
| `npm run test:e2e:fast` | 运行 E2E 快速测试(@smoke + @critical 标签) |
| `npm run test:e2e:standard` | 运行 E2E 标准回归测试(@regression 标签) |
| `npm run test:smoke` | 运行 E2E 冒烟测试(@smoke 标签) |
| `npm run test:critical` | 运行 E2E 关键路径测试(@critical 标签) |
| `npm run test:e2e:journey` | 运行用户旅程测试(@journey 标签,UJ-01/UJ-02) |
| `npm run test:e2e:mobile` | 运行移动端 E2E 测试(@mobile 标签,53 个测试) |
| `npm run test:e2e:prod` | **产物目标** E2E(`E2E_TARGET=production` → harness 新起 `node dist/standalone/server.js`,**不是** `npm run start`——standalone 下 `next start` 不受支持;`@smoke\|@critical\|@journey` × 4 project)。**只有 GA4 的 16 例在这里真正断言**;生产响应头不在此 grep 集合内(它属 `@security` 标签与 `test:security:headers`,勿混)(2026-09-23 实测:排除 2 个写库 spec 后 92 例通过、0 skipped);须先 `npm run build`(`postbuild` 负责装配 `dist/static` + `public`) |
| `npm run test:e2e:mobile` | 运行移动端 E2E 测试(`@mobile` 标签:**55 个用例**(16 `mobile.spec.ts` + 5 `mobile-user-journeys.spec.ts` + 14 `mobile-accessibility.spec.ts` + 18 `mobile-performance.spec.ts` + 2 `uj-11-home-conversion.spec.ts`),4 个功能 project 全跑 ⇒ **220 个实例**;`cd e2e && npx playwright test --list --grep @mobile` 实测 `Total: 220 tests in 5 files`) |
| `npm run test:e2e:mobile:performance` | 运行移动端性能基线测试(FCP/LCP/加载时间) |
| `npm run test:e2e:mobile:accessibility` | 运行移动端可访问性测试(axe-core WCAG 2.1 AA) |
| `npm run test:mutation` | 运行变异测试(Stryker,评估测试质量,当前 36.98%) |
| `npm run test:mutation:quick` | 快速变异测试(仅 utils.ts,91.18%) |
| `npm run test:mutation` | 运行变异测试(Stryker,评估测试质量)。**分数不在此声明**:全量轮耗时以十分钟计且 `--inPlace` 会改动工作树,本轮未复跑;最近一次落盘记录为整体 **36.98%**(`docs/test-strategy-plan.md` P4.5 / 验收清单条目,2026-08 轮次),引用时须附该出处与 `npm run test:mutation` 复算命令 |
| `npm run test:mutation:quick` | 快速变异测试(`--mutate 'src/lib/utils.ts'`,同一落盘记录 **91.18%**,出处同上) |
| `npm run test:security` | 安全扫描(npm audit + 安全响应头检查) |
| `npm run test:security:headers` | 安全响应头检查(X-Content-Type-Options, CSP, HSTS 等) |
| `npm run test:all` | 全量门禁检查(type-check + lint + coverage + fast E2E + security headers) |
| `npm run check:a11y` | 可访问性静态门禁伞:`check:contrast`(令牌对比度,只读 `globals.css`)+ `check:headings`(标题层级,有产物直起 standalone、缺产物才回退 preview)+ `check:brand-token`(双通道红) |
| `npm run check:axe:routes` | 生成全站路由清单:sitemap ∪ 预渲染产物 ∪ 站内链接 BFS → `/tmp/axe-routes.xml`(`scripts/accessibility/crawl-routes.mjs`;退出码 0/2) |
| `npm run check:axe` | 双引擎 × 双主题逐页 axe **节点计数** + 三条规则级通道的分母断言(`scripts/accessibility/axe-node-count.mjs`);退出码 0 通过 / 1 判红 / 2 清单缺失。**需先有 `node dist/standalone/server.js` 在 :3100**;不在 `test:all` 内,CI 由 `Jenkinsfile`「♿♿」阶段(仅 main)执行 |
| `npm run test:all` | 全量门禁检查,`package.json:48` 原文七段:type-check + lint + coverage + **test:integration:real** + check:a11y + fast E2E + security headers;其 E2E 段仍是 dev 目标,GA4 `@critical` 在其中 skipped,需另跑 `test:e2e:prod` |
| `npm run lighthouse` | 运行 Lighthouse 性能测试 |
## 代码质量门禁
项目配置了自动化质量门禁,确保代码提交前通过所有质量检查:
- **ESLint**: 代码风格检查
- **commitlint**: 提交信息规范
- **Jest**: 代码覆盖率检查
- **ESLint**: 代码风格检查(pre-commit 经 husky + lint-staged 跑 `eslint --fix`,CI 跑全仓 `npm run lint`;判定口径 0 error,warning 不判红)
- **commitlint**: 提交信息规范(`.husky/commit-msg`)
- **Jest**: 代码覆盖率检查(阈值单一真源 `config/test/jest.config.js`)
- **可访问性**: `npm run check:a11y`(对比度 / 标题层级 / 双通道红),CI 在「♿ 可访问性门禁」阶段逐条执行同一组
- **TypeScript**: `npm run type-check` —— **不在 pre-commit**(`.lintstagedrc.json` 只跑 `eslint --fix`),推送前须自行执行
- **E2E 目标**: `npm run test` 打 dev server;GA4 的 16 个 `@critical` 实例在该轮为 skipped,产物口径见 `npm run test:e2e:prod`(详见 [docs/development/quality-gates.md](docs/development/quality-gates.md) §5–§7)
### 提交规范
@@ -209,12 +239,12 @@ novalon-website/
| 工具 | 用途 | 配置 |
|------|------|------|
| **Jest** | 单元测试(120 suites / 1509 tests) | `config/test/jest.config.js` |
| **Playwright** | E2E / 视觉回归 / 用户旅程测试 | `e2e/playwright.config.ts` |
| **Jest** | 单元测试(2026-09-23 本树复跑:134 suites / 1697 tests) | `config/test/jest.config.js` |
| **Playwright** | E2E / 视觉回归 / 用户旅程测试(`--list` 实测 1205 例 = 功能 1080 + 视觉 125) | `e2e/playwright.config.ts` |
| **Stryker** | 变异测试(评估测试质量) | `stryker.config.json` |
| **Allure** | 测试报告可视化 | Allure Playwright reporter |
| **k6** | 负载/压力/API 性能测试 | `tests/performance/` |
| **Lighthouse CI** | 性能/可访问性/SEO 审计 | `lighthouserc.js` |
| **Lighthouse CI** | 性能/可访问性/SEO 审计 | `config/test/lighthouserc.json` |
### E2E 测试标签体系
@@ -225,35 +255,39 @@ novalon-website/
| `@smoke` + `@critical` | 快速回归(<5min) | `npm run test:e2e:fast` |
| `@regression` | 全量回归(<15min) | `npm run test:e2e:standard` |
| `@journey` | 用户旅程(UJ-01/UJ-02) | `npm run test:e2e:journey` |
| `@mobile` | 移动端专项测试(53 个:16 基础 + 5 用户旅程 + 14 可访问性 + 18 性能) | `npm run test:e2e:mobile` |
| `@visual` | 视觉回归(105 snapshots × 5 browsers) | `npm run test:visual:all` |
| `@mobile` | 移动端专项测试(55 个用例:16 基础 + 5 用户旅程 + 14 可访问性 + 18 性能 + 2 UJ-11c;× 4 project = 220 实例) | `npm run test:e2e:mobile` |
| (无标签) | 视觉回归:`@visual` 标签**并不存在**(`e2e/` 全量 grep 为 0 命中),视觉用例由 5 个 `visual-*` project 通过 `testMatch` 选中,且只跑 `visual-regression.spec.ts`(25 例 × 5 project = 125 实例) | `npm run test:visual:all` |
### 运行测试
```bash
# 单元测试
npm run test:unit # 1509 tests
npm run test:unit # 134 suites / 1697 tests(2026-09-23 本树复跑)
# 测试覆盖率
npm run test:coverage # Branches 82.38%, Lines 73.59%
npm run test:coverage # 阈值: 全局 statements/lines/functions ≥75%、branches ≥82%
# 实测值见 docs/development/quality-gates.md §3(此处不复制百分比)
# E2E 测试
npm run test # 全量 E2E(631 passed)
npm run test # 全量 E2E = 功能 1080 + 视觉 125 = 1205 实例
# 落盘通过数引自 docs/acceptance/2026-09-21-gates/skipped-tests-final-tree.json
# (totalResults 1195 = 1167 passed / 28 skipped,生成于 2026-09-22 的 chain6 最终树);
# 本轮未重跑全量 E2E(需 dev server + 4 引擎 + 视觉基线,且 28 条 skip 需按该文件逐条解释)
npm run test:e2e:fast # 快速回归(@smoke + @critical)
npm run test:e2e:standard # 标准回归(@regression)
npm run test:e2e:journey # 用户旅程(@journey)
# 移动端测试
npm run test:e2e:mobile # 全量移动端(53 个 @mobile 测试)
npm run test:e2e:mobile # 全量移动端(55 个 @mobile 用例 × 4 project = 220 实例)
npm run test:e2e:mobile:performance # 移动端性能基线
npm run test:e2e:mobile:accessibility # 移动端可访问性
# 变异测试
# 变异测试(分数只引 docs/test-strategy-plan.md 的落盘记录,本仓未复跑)
npm run test:mutation # 全量变异测试
npm run test:mutation:quick # 快速变异(仅 utils.ts)
# 全量门禁检查
npm run test:all # type-check + lint + coverage + fast E2E
npm run test:all # type-check + lint + coverage + integration:real + check:a11y + fast E2E(dev 目标) + security headers
```
## 部署
@@ -263,7 +297,7 @@ npm run test:all # type-check + lint + coverage + fast E2E
项目发布统一通过 `scripts/deploy.sh` 完成,包含构建、发布、回滚、状态查看:
```bash
# 构建静态产物
# 构建产物(`next build`,output=standalone;脚本再把 public/ 同步进 dist、把 dist/static 复制为 dist/_next/static 供 Nginx 直服)
./scripts/deploy.sh build
# 构建并发布到生产服务器(默认命令,等价于 ./scripts/deploy.sh deploy)
+3 -1
View File
@@ -18,7 +18,9 @@ services:
- .env.production
volumes:
- ./data:/app/data
- ./uploads:/app/uploads
# 落盘根是 cwd/public/uploads(src/lib/media/storage.ts:141,LOCAL_UPLOAD_DIR 未在任何 .env 中设置),
# 挂到 /app/uploads 会让上传写进容器可写层并在重启后丢失,挂载点则一直是空目录。
- ./uploads:/app/public/uploads
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
interval: 30s
+33 -5
View File
@@ -1,4 +1,9 @@
version: "3.8"
# 单机应用容器:跑 Next.js 16 standalone 服务(不是静态 HTML 站点)。
# 服务契约见 Dockerfile 顶部注释(output: 'standalone' → node server.js + HOSTNAME=0.0.0.0)。
# TLS/HTTP→HTTPS 跳转/HSTS/gzip/limit_req 属于边缘,见 nginx-static.conf。
#
# 历史缺陷:本文件曾用 `Dockerfile`(把 dist/ 当静态 HTML 交给 nginx、暴露 80/443 并挂载
# nginx-static.conf + ./ssl),在 standalone 产物下部署即白屏/404(ACCEPTANCE_REVIEW §5)。
services:
novalon-website:
@@ -9,8 +14,31 @@ services:
container_name: novalon-website
restart: unless-stopped
ports:
- "80:80"
- "443:443"
# 容器内 server.js 监听 0.0.0.0:3000;边缘 nginx 的 upstream 默认写 127.0.0.1:3000,
# 若把本服务加入 nginx 所在网络,可改用容器名 novalon-website:3000。
- "3000:3000"
environment:
- NODE_ENV=production
- HOSTNAME=0.0.0.0
- PORT=3000
# .dockerignore 排除了 .env*,镜像内没有 env 文件,数据库路径必须显式给出
- DATABASE_URL=file:/app/data/prod.db
# 无需 Dockerfile.prod 里的 PRISMA_QUERY_ENGINE_LIBRARY 覆盖:那条是为「宿主机(darwin)
# 构建的 dist 被直接 COPY」准备的;本镜像在 alpine 内 npm ci + build,query engine
# 本身就是 linux-musl 版本并被 standalone 追踪进 /app/src/generated/prisma/。
env_file:
# 与 docker-compose.server.yml 保持一致(CDN_DOMAIN / NEXT_PUBLIC_* 等)
- .env.production
volumes:
- ./nginx-static.conf:/etc/nginx/nginx.conf:ro
- ./ssl:/etc/nginx/ssl:ro
# SQLite 落盘
- ./data:/app/data
# 媒体上传目录:src/lib/media/storage.ts 默认写 <cwd>/public/uploads
# (对照:docker-compose.server.yml 挂的是 ./uploads:/app/uploads,与应用实际读取的
# public/uploads 不一致,属该文件的问题,本次未改——不在本次授权范围内)
- ./uploads:/app/public/uploads
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import { chromium } from 'playwright';
import path from 'path';
const ROOT = '/Users/zhangxiang/Codes/Novalon/novalon-website';
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
// 每条规则各造一个必然违规的最小节点(正对照)+ 一个正常页(负对照)
const cases = {
'autocomplete-valid': '<form><label for=x>n</label><input id=x autocomplete="bogus-value"></form>',
'presentation-role-conflict': '<div role="button" aria-label="x" tabindex="0" style="display:none">h</div><img alt="" role="presentation" tabindex="0" src="data:,"><h1>t</h1><main>m</main>',
'svg-img-alt': '<svg role="img" width="10" height="10"><rect width="10" height="10"></rect></svg><h1>t</h1><main>m</main>',
};
const b = await chromium.launch();
for (const [rule, html] of Object.entries(cases)) {
const p = await b.newPage();
await p.setContent(`<!doctype html><html lang="zh-CN"><head><title>t</title></head><body><main>${html}</main></body></html>`);
await p.addScriptTag({ path: path.join(ROOT, 'node_modules/axe-core/axe.min.js') });
const res = await p.evaluate((tags) => window.axe.run(document, { runOnly: { type: 'tag', values: tags } }), TAGS);
const v = res.violations.find((x) => x.id === rule);
const ran = [...res.violations, ...res.passes, ...res.incomplete].some((x) => x.id === rule);
console.log(`${rule.padEnd(28)} tagIncluded=${ran ? 'YES' : 'NO '} violation=${v ? v.nodes.length + ' nodes' : '0'}`);
await p.close();
}
await b.close();
@@ -0,0 +1,72 @@
#!/bin/bash
# 第七轮(死断言处理后的复验):
# A. axe harness 扩到「tag 通道 + 规则级通道」——先 2 路由 sanity,再全站 34 路由 × 4 组合
# B. Lighthouse 去掉 3 条死断言后重跑,证明 EXIT=0 且 0 warning
set -u
cd /Users/zhangxiang/Codes/Novalon/novalon-website || exit 1
LOG=/tmp/final-chain7.log
HARNESS=docs/acceptance/2026-09-21-axe/axe-contrast-evidence.mjs
CRAWLER=docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs
PORTS() { lsof -nP -iTCP:3000 -iTCP:3100 -sTCP:LISTEN 2>/dev/null | grep -c LISTEN; }
echo "=== chain7 started $(date -u +%FT%TZ) ===" > "$LOG"
echo "PREFLIGHT_LISTENERS=$(PORTS) $(date -u +%FT%TZ)" >> "$LOG"
npx next start -p 3100 > /tmp/axe-server8.log 2>&1 &
SRV=$!
code=000
for i in $(seq 1 40); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3100/ 2>/dev/null || true)
[ "$code" = "200" ] && break
sleep 3
done
echo "AXE_SERVER_UP=$code $(date -u +%FT%TZ)" >> "$LOG"
# 全站清单(口径与 chain6 相同:sitemap ∪ 预渲染产物 ∪ 站内链接 BFS)
BASE=http://localhost:3100 SEED=/tmp/axe-sitemap2.xml \
MANIFEST=dist/standalone/dist/server/app OUT=/tmp/axe-sitemap-all5.xml \
node --input-type=module -e "$(cat $CRAWLER)" > /tmp/crawl6.log 2>&1
echo "EXIT_CRAWL=$? $(date -u +%FT%TZ)" >> "$LOG"
grep -a "^crawled" /tmp/crawl6.log >> "$LOG"
# sanity:只用 2 条路由跑新代码路径,避免 14 分钟后才发现探针本身有 bug
python3 - <<'PY'
import re
src = open('/tmp/axe-sitemap-all5.xml', encoding='utf8').read()
blocks = re.findall(r'<url>.*?</url>', src, re.S)
open('/tmp/axe-sitemap-sanity.xml', 'w', encoding='utf8').write(
'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n'
+ "\n".join(blocks[:2]) + "\n</urlset>\n")
print('sanity urls:', len(blocks[:2]))
PY
echo "EXIT_SANITY_CROP=$?" >> "$LOG"
SITEMAP=/tmp/axe-sitemap-sanity.xml BASE=http://localhost:3100 OUT=/tmp/axe-sanity-out \
node --input-type=module -e "$(cat $HARNESS)" > /tmp/axe-sanity.log 2>&1
echo "EXIT_SANITY=$? $(date -u +%FT%TZ)" >> "$LOG"
sed -e 's/\x1b\[[0-9;]*m//g' /tmp/axe-sanity.log | grep -aE "^(chromium|firefox)/|^PASSED|^SUMMARY|FATAL|Error" | head -14 >> "$LOG"
# 保留 chain6 证据,再产出最终树的新证据
cp docs/acceptance/2026-09-21-axe/axe-evidence.json \
docs/acceptance/2026-09-21-axe/axe-evidence-chain6-tagonly.json 2>/dev/null
SITEMAP=/tmp/axe-sitemap-all5.xml BASE=http://localhost:3100 \
OUT=docs/acceptance/2026-09-21-axe \
node --input-type=module -e "$(cat $HARNESS)" > /tmp/axe-sweep8.log 2>&1
echo "EXIT_AXE_FULL=$? $(date -u +%FT%TZ)" >> "$LOG"
node -e "const j=require('./docs/acceptance/2026-09-21-axe/axe-evidence.json');console.log('AXE routeCount='+j.routeCount+' rows='+j.rows.length+' passed='+j.passed+' extraRules='+JSON.stringify(j.extraRules)+' summary='+JSON.stringify(j.summary))" >> "$LOG" 2>&1
kill "$SRV" 2>/dev/null
pkill -f 'next start -p 3100' 2>/dev/null
sleep 3
echo "POST_AXE_LISTENERS=$(PORTS)" >> "$LOG"
{
echo
echo "===== chain7 lighthouse(移除 3 条死断言后)$(date -u +%FT%TZ) ====="
npm run lighthouse 2>&1
} | sed -e 's/\x1b\[[0-9;]*m//g' >> docs/acceptance/2026-09-21-gates/lighthouse.txt 2>&1
echo "EXIT_LIGHTHOUSE=${PIPESTATUS[0]} $(date -u +%FT%TZ)" >> "$LOG"
# 只统计本轮(chain7 段)残留的死断言,历史段保留作对照
awk '/chain7 lighthouse/{f=1} f' docs/acceptance/2026-09-21-gates/lighthouse.txt \
| grep -ac "not a known audit" | sed 's/^/CHAIN7_DEAD_AUDIT_WARNINGS=/' >> "$LOG"
sleep 3
echo "POST_FINAL_LISTENERS=$(PORTS)" >> "$LOG"
echo "=== chain7 done $(date -u +%FT%TZ) ===" >> "$LOG"
@@ -0,0 +1,60 @@
#!/bin/bash
# 第八轮(补 chain6/7 未覆盖的两项证据):
# A. npm run test:coverage —— A-7 阈值棘轮在最终树上是否仍成立
# B. iPhone SE 口径复跑(含新增的 404 边界页),证据写入新目录,不覆盖 09-21 的旧证据
set -u
cd /Users/zhangxiang/Codes/Novalon/novalon-website || exit 1
LOG=/tmp/final-chain8.log
SE_OUT=docs/acceptance/2026-09-22-iphone-se
PORTS() { lsof -nP -iTCP:3000 -iTCP:3100 -sTCP:LISTEN 2>/dev/null | grep -c LISTEN; }
echo "=== chain8 started $(date -u +%FT%TZ) ===" > "$LOG"
while ! grep -q "chain7 done" /tmp/final-chain7.log 2>/dev/null; do sleep 20; done
echo "CHAIN7_TAIL: $(grep -aE 'EXIT_AXE_FULL|AXE routeCount|EXIT_LIGHTHOUSE|CHAIN7_DEAD|^crawled|EXIT_SANITY' /tmp/final-chain7.log | tr '\n' '|')" >> "$LOG"
echo "PREFLIGHT_LISTENERS=$(PORTS) $(date -u +%FT%TZ)" >> "$LOG"
npm run test:coverage > /tmp/coverage-final8.log 2>&1
echo "EXIT_COVERAGE=$? $(date -u +%FT%TZ)" >> "$LOG"
{
echo "# test:coverage 最终树复跑 $(date -u +%FT%TZ)"
sed -e 's/\x1b\[[0-9;]*m//g' /tmp/coverage-final8.log | grep -aE "All files|Jest:|Tests:|Test Suites:|threshold|% (Stmts|Branch)" | head -20
} > docs/acceptance/2026-09-21-gates/coverage-final-tree.txt 2>&1
sleep 2
echo "POST_COVERAGE_LISTENERS=$(PORTS)" >> "$LOG"
mkdir -p "$SE_OUT"
npx next start -p 3000 > /tmp/se-server.log 2>&1 &
SRV=$!
code=000
for i in $(seq 1 40); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/ 2>/dev/null || true)
[ "$code" = "200" ] && break
sleep 3
done
echo "SE_SERVER_UP=$code $(date -u +%FT%TZ)" >> "$LOG"
BASE=http://localhost:3000 OUT="$SE_OUT" \
node --input-type=module -e "$(cat docs/acceptance/2026-09-21-iphone-se/iphone-se-audit.mjs)" \
> "$SE_OUT/audit.log" 2>&1
echo "EXIT_SE_AUDIT=$? $(date -u +%FT%TZ)" >> "$LOG"
kill "$SRV" 2>/dev/null
pkill -f 'next start -p 3000' 2>/dev/null
sleep 3
node -e '
const j = require("./'"$SE_OUT"'/measurements.json");
const pending = j.rows.filter((r) => /pending/.test(r.tag));
const overlap = pending.filter((r) => r.bannerOverlapsFooter);
const nf = j.rows.filter((r) => /not-found/.test(r.tag));
const bad = j.rows.filter((r) => r.docScrollW > r.clientW || r.theme === null);
console.log("SE rows=" + j.rows.length
+ " overflow=" + j.rows.filter((r) => r.docScrollW > r.clientW).length
+ " themeNull=" + j.rows.filter((r) => r.theme === null).length
+ " pendingRows=" + pending.length
+ " pendingBannerOverlapFooter=" + overlap.length
+ " notFoundRows=" + nf.length
+ " notFoundGaps=" + JSON.stringify(nf.map((r) => r.footerBottomGap))
+ " footerGaps=" + JSON.stringify([...new Set(j.rows.map((r) => r.footerBottomGap))]));
console.log("SE PASSED =", j.rows.length === 20 && bad.length === 0);
' >> "$LOG" 2>&1
echo "POST_FINAL_LISTENERS=$(PORTS)" >> "$LOG"
echo "=== chain8 done $(date -u +%FT%TZ) ===" >> "$LOG"
@@ -0,0 +1,36 @@
#!/bin/bash
# 第九轮(把 §7 覆盖缺口的 Lighthouse 半边补上后复跑):
# lighthouserc 的 collect.url 由 7 条扩到 9 条(补 /products/erp-upgrade、/about/brand),
# 同时重跑静态门禁,确认本轮改 config/docs/probe 未触碰 src 行为。
set -u
cd /Users/zhangxiang/Codes/Novalon/novalon-website || exit 1
LOG=/tmp/final-chain9.log
PORTS() { lsof -nP -iTCP:3000 -iTCP:3100 -sTCP:LISTEN 2>/dev/null | grep -c LISTEN; }
echo "=== chain9 started $(date -u +%FT%TZ) ===" > "$LOG"
while ! grep -q "chain8 done" /tmp/final-chain8.log 2>/dev/null; do sleep 20; done
echo "CHAIN8_TAIL: $(grep -aE 'EXIT_COVERAGE|SE PASSED|SE rows|EXIT_SE_AUDIT' /tmp/final-chain8.log | tr '\n' '|')" >> "$LOG"
echo "PREFLIGHT_LISTENERS=$(PORTS) $(date -u +%FT%TZ)" >> "$LOG"
for g in type-check lint test:unit check:contrast check:headings check:brand-token; do
npm run "$g" > "/tmp/c9-$(echo "$g" | tr ':' '-').log" 2>&1
echo "EXIT_$(echo "$g" | tr 'a-z-' 'A-Z')=$? $(date -u +%FT%TZ)" >> "$LOG"
done
sed -e 's/\x1b\[[0-9;]*m//g' /tmp/c9-lint.log | grep -a "problems" | tail -1 >> "$LOG"
sleep 2
echo "POST_STATIC_LISTENERS=$(PORTS)" >> "$LOG"
URLS=$(node -e 'const c=require("./config/test/lighthouserc.json");console.log(c.ci.collect.url.length)' 2>/dev/null)
echo "LIGHTHOUSE_URLS=$URLS" >> "$LOG"
{
echo
echo "===== chain9 lighthouse(9 URL × 3 次 = 27 运行,补 erp-upgrade / about-brand)$(date -u +%FT%TZ) ====="
npm run lighthouse 2>&1
} | sed -e 's/\x1b\[[0-9;]*m//g' >> docs/acceptance/2026-09-21-gates/lighthouse.txt 2>&1
echo "EXIT_LIGHTHOUSE=${PIPESTATUS[0]} $(date -u +%FT%TZ)" >> "$LOG"
awk '/chain9 lighthouse/{f=1} f' docs/acceptance/2026-09-21-gates/lighthouse.txt \
| grep -ac "not a known audit" | sed 's/^/CHAIN9_DEAD_AUDIT_WARNINGS=/' >> "$LOG"
awk '/chain9 lighthouse/{f=1} f' docs/acceptance/2026-09-21-gates/lighthouse.txt \
| grep -aE "✗|✓ \[" | tail -12 >> "$LOG"
sleep 3
echo "POST_FINAL_LISTENERS=$(PORTS)" >> "$LOG"
echo "=== chain9 done $(date -u +%FT%TZ) ===" >> "$LOG"
@@ -0,0 +1,404 @@
# check:contrast 与 check:headings —— 空闲机器复跑(无并发 E2E/Lighthouse)
# 生成于 2026-09-21T21:51:32Z
## npm run check:contrast
> ruixin-website-react@1.0.0-phase1 check:contrast
> tsx scripts/utils/check-color-contrast.ts
🎨 LIGHT 主题(:root,246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 19.34:1
✅ --color-text-primary on --color-bg-secondary — 18.48:1
✅ --color-text-primary on --color-bg-tertiary — 17.65:1
✅ --color-text-secondary on --color-bg-primary — 10.35:1
✅ --color-text-secondary on --color-bg-secondary — 9.90:1
✅ --color-text-tertiary on --color-bg-primary — 7.58:1
✅ --color-text-muted on --color-bg-primary — 4.76:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 5.84:1
✅ --color-brand-ink on --color-bg-secondary — 5.58:1
✅ --color-brand-ink on --color-brand-bg — 5.35:1
✅ --color-brand-ink-hover on --color-bg-primary — 7.87:1
✅ --color-brand-ink-hover on --color-brand-bg — 7.20:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 5.74:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 3.41:1
✅ --color-text-hint on --color-bg-secondary — 3.26:1
✅ --color-text-hint on --color-bg-tertiary — 3.12:1
✅ --color-text-hint on --color-bg-elevated — 3.41:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
🎨 DARK 主题(html[data-theme='dark'],246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 18.48:1
✅ --color-text-primary on --color-bg-secondary — 17.69:1
✅ --color-text-primary on --color-bg-tertiary — 16.55:1
✅ --color-text-secondary on --color-bg-primary — 13.03:1
✅ --color-text-secondary on --color-bg-secondary — 12.47:1
✅ --color-text-tertiary on --color-bg-primary — 7.54:1
✅ --color-text-muted on --color-bg-primary — 7.54:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:headings
> ruixin-website-react@1.0.0-phase1 check:headings
> tsx scripts/utils/check-heading-hierarchy.ts
🚀 启动预览服务...
(node:52518) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
# check:contrast 与 check:headings —— 最终树空闲复跑
# 生成于 2026-09-21T22:47:59Z
## npm run check:contrast
> ruixin-website-react@1.0.0-phase1 check:contrast
> tsx scripts/utils/check-color-contrast.ts
🎨 LIGHT 主题(:root,246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 19.34:1
✅ --color-text-primary on --color-bg-secondary — 18.48:1
✅ --color-text-primary on --color-bg-tertiary — 17.65:1
✅ --color-text-secondary on --color-bg-primary — 10.35:1
✅ --color-text-secondary on --color-bg-secondary — 9.90:1
✅ --color-text-tertiary on --color-bg-primary — 7.58:1
✅ --color-text-muted on --color-bg-primary — 4.76:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 5.84:1
✅ --color-brand-ink on --color-bg-secondary — 5.58:1
✅ --color-brand-ink on --color-brand-bg — 5.35:1
✅ --color-brand-ink-hover on --color-bg-primary — 7.87:1
✅ --color-brand-ink-hover on --color-brand-bg — 7.20:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 5.74:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 3.41:1
✅ --color-text-hint on --color-bg-secondary — 3.26:1
✅ --color-text-hint on --color-bg-tertiary — 3.12:1
✅ --color-text-hint on --color-bg-elevated — 3.41:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
🎨 DARK 主题(html[data-theme='dark'],246 个令牌)
正文层级 × 页面底色
✅ --color-text-primary on --color-bg-primary — 18.48:1
✅ --color-text-primary on --color-bg-secondary — 17.69:1
✅ --color-text-primary on --color-bg-tertiary — 16.55:1
✅ --color-text-secondary on --color-bg-primary — 13.03:1
✅ --color-text-secondary on --color-bg-secondary — 12.47:1
✅ --color-text-tertiary on --color-bg-primary — 7.54:1
✅ --color-text-muted on --color-bg-primary — 7.54:1
品牌红文字通道(text-brand-ink 及 hover 档)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:headings
> ruixin-website-react@1.0.0-phase1 check:headings
> tsx scripts/utils/check-heading-hierarchy.ts
🚀 启动预览服务...
(node:61061) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
## npm run check:brand-token
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 414
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
# ===== 最终树空闲复跑(chain3,含门禁脚本进程回收修复后)2026-09-22T02:42:16Z =====
## npm run check:contrast (EXIT=0)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:headings (EXIT=0)
(node:58761) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
## npm run check:brand-token (EXIT=0)
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 414
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
# ===== 最终树复跑(chain4:404 水印 /80 + global-error 兜底页修复后)2026-09-22T03:19:50Z =====
## npm run check:contrast (EXIT=0)
✅ --color-brand-ink on --color-bg-primary — 6.99:1
✅ --color-brand-ink on --color-bg-secondary — 6.69:1
✅ --color-brand-ink on --color-brand-bg — 6.27:1
✅ --color-brand-ink-hover on --color-bg-primary — 10.19:1
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:brand-token (EXIT=0)
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 416
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
## npm run check:headings (EXIT=0)
(node:41979) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
(Use `node --trace-deprecation ...` to show where the warning was created)
🔍 检查页面标题层级...
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
# ===== 最终树复跑(chain6:header 函数式 setState + 移动端性能计时修复后)2026-09-22T03:51:41Z =====
## npm run check:contrast (EXIT=0)
✅ --color-brand-ink-hover on --color-brand-bg — 9.13:1
✅ #FFFFFF on --color-brand — 5.84:1
alpha 修饰正文(带 /80 等修饰类的正文)
✅ --color-text-secondary on --color-bg-primary @80% — 8.52:1
大号装饰文本(text-text-hint)× 各级卡片底色
✅ --color-text-hint on --color-bg-primary — 7.54:1
✅ --color-text-hint on --color-bg-secondary — 7.22:1
✅ --color-text-hint on --color-bg-tertiary — 6.75:1
✅ --color-text-hint on --color-bg-elevated — 6.75:1
深色区块(不随主题翻转的独立 token)
✅ --color-dark-text-primary on --color-dark-bg — 18.48:1
✅ --color-dark-text-secondary on --color-dark-bg — 13.03:1
✅ --color-dark-text-muted on --color-dark-bg — 7.54:1
✅ --color-brand-section-text on --color-brand-section — 8.93:1
📊 合计 44 组:✅ 44 / ❌ 0 不达标 / ⚠️ 0 令牌缺失
✅ 全部令牌配对满足 WCAG 2.1 AA
## npm run check:brand-token (EXIT=0)
> ruixin-website-react@1.0.0-phase1 check:brand-token
> tsx scripts/utils/check-brand-text-token.ts
🔴 Two-Channel Red Rule 检查 — 文字通道禁用 --color-brand
📊 检查摘要
扫描目录: src
扫描文件: 416
违规处数: 0
✅ 文字通道全部使用 --color-brand-ink,双通道契约未被违反!
## npm run check:headings (EXIT=0)
✅ 首页 (0 个问题)
✅ 关于我们 (0 个问题)
✅ 产品中心 (0 个问题)
✅ 产品详情-ERP (0 个问题)
✅ 解决方案 (0 个问题)
✅ 解决方案详情-制造 (0 个问题)
✅ 服务列表 (0 个问题)
✅ 服务详情-软件开发 (0 个问题)
✅ 新闻列表 (0 个问题)
✅ 联系我们 (0 个问题)
📊 检查摘要
扫描页面: 10
通过页面: 10
失败页面: 0
问题总数: 0
📝 详细报告已保存: /Users/zhangxiang/Codes/Novalon/novalon-website/heading-hierarchy-report.json
@@ -0,0 +1,10 @@
# test:coverage 最终树复跑 2026-09-22T04:50:44Z
✓ does not refresh when pull distance is below threshold (2 ms)
✓ should call onSwipeLeft when progress exceeds threshold (2 ms)
✓ should call onSwipeRight when progress exceeds threshold
✓ should not trigger callbacks when progress is below threshold (1 ms)
✓ should not call vibrate when progress is below threshold (2 ms)
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
All files | 77.92 | 84.6 | 77.64 | 77.92 |
Test Suites: 137 passed, 137 total
Tests: 1659 passed, 1659 total
@@ -0,0 +1,68 @@
// 从 chain6 的 allure-results 里取出「本轮真正跑过的用例」按状态分组,
// 目的:给 ② 的 28 skipped 一份**具名**证据(哪些用例、在哪个 project、以什么理由跳过),
// 而不是只引用汇总行里的计数。汇总计数只能证明「数量」,证明不了「没有把失败洗成跳过」。
import { readdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
const DIR = resolve(process.cwd() ?? '.', 'e2e/allure-results');
// chain6 功能阶段:03:52:08Z → 04:20:43Z;视觉阶段紧接其后。留出余量按 UTC 当日 03:50–04:25 过滤。
const FROM = Date.parse('2026-09-22T03:50:00Z');
const TO = Date.parse('2026-09-22T04:25:00Z');
const files = readdirSync(DIR).filter((f) => f.endsWith('-result.json'));
const rows = [];
for (const f of files) {
const p = resolve(DIR, f);
const m = statSync(p).mtimeMs;
if (m < FROM || m > TO) continue;
let j;
try {
j = JSON.parse(readFileSync(p, 'utf8'));
} catch {
continue;
}
const labels = {};
for (const l of j.labels || []) labels[l.name] = l.value;
rows.push({
file: f,
mtime: new Date(m).toISOString(),
status: j.status,
reason: j.statusDetails?.message || j.statusDetails?.trace || '',
project: labels.device || labels.suiteParent || '',
suite: labels.suite || '',
fullName: j.fullName || '',
name: j.name || '',
});
}
const byStatus = {};
for (const r of rows) byStatus[r.status] = (byStatus[r.status] || 0) + 1;
const skipped = rows.filter((r) => r.status === 'skipped').sort((a, b) => (a.fullName + a.project).localeCompare(b.fullName + b.project));
const out = {
generatedAt: new Date().toISOString(),
source: 'e2e/allure-results(chain6 最终代码树 `npm run test` 功能+视觉两阶段的 allure 落盘)',
window: { from: new Date(FROM).toISOString(), to: new Date(TO).toISOString() },
totalResults: rows.length,
byStatus,
skippedCount: skipped.length,
// 按 (suite, reason) 聚合,便于核对「每处 test.skip 条件命中了多少条」
skipTally: Object.entries(
skipped.reduce((acc, r) => {
const k = `${r.suite} :: ${r.reason.slice(0, 80)}`;
acc[k] = (acc[k] || 0) + 1;
return acc;
}, {}),
).map(([k, n]) => ({ group: k, count: n })),
skipped,
};
writeFileSync(
resolve(process.cwd() ?? '.', 'docs/acceptance/2026-09-21-gates/skipped-tests-final-tree.json'),
JSON.stringify(out, null, 2),
);
console.log(`window results=${rows.length} byStatus=${JSON.stringify(byStatus)}`);
console.log(`skipped=${skipped.length}`);
for (const t of out.skipTally) console.log(` x${t.count} ${t.group}`);
const nonPass = rows.filter((r) => r.status !== 'passed' && r.status !== 'skipped');
console.log(`otherNonPassed=${nonPass.length}`);
for (const r of nonPass.slice(0, 10)) console.log(` ${r.status} ${r.project} ${r.fullName} :: ${r.reason.slice(0, 120)}`);
@@ -0,0 +1,112 @@
# 验收 §8 放行条件复跑结果(最终代码树)
生成时间:2026-09-22(UTC)。命令在**同一棵最终代码树**上串行执行,每步前后打印 :3000/:3100 端口占用数(0 才可用),
证据链编号:chain1(静态 + Lighthouse + GA4 生产复跑)→ chain2(作废,孤儿服务复用毒化)→ chain3(进程回收修复后复跑)
→ chain4/chain5/chain6(404 水印、`global-error` 兜底页、header 函数式 setState、移动端性能计时修复后,`EXIT_TEST=0` 归零轮)
→ chain7(Lighthouse 死断言处理 + axe 规则级通道)→ chain8(覆盖率棘轮 + iPhone SE 口径复跑)→ chain9(静态门禁与 Lighthouse 的第二次连续全绿)。
复跑脚本随证据存放于本目录:`chain7-dead-audit-and-rule-coverage.sh`、`chain8-coverage-and-iphone-se.sh`、`chain9-lighthouse-9urls.sh`。
## ② E2E 归零(`npm run test` = `test:functional && test:visual:all`)
| 轮次 | 结果 | 说明 |
|---|---|---|
| chain3 | 功能 `1048 passed / 4 failed / 28 skipped`(19.0m),端口预检 0 | 4 例均为**满负载竞态**(隔离复跑 `/tmp/repro-4.log` 33 例全过):p2 hover 下拉链接点击、p3 `isVisible→boundingBox` 两段式解析、uj-11 `scrollIntoViewIfNeeded` 撞 detach、p4 把 `waitForTimeout(1500)` 计入加载耗时 |
| chain4 | 功能 `1049 passed / 3 failed / 28 skipped`(15.7m) | 上述 4 例全过;新暴露同类 3 例:移动端 LCP 2736ms(dev 现场编译计入计时)、`website-acceptance.spec.ts:121` 与 `mobile.spec.ts:101` 找不到 `[data-testid="mobile-navigation"]` |
| chain6 | 功能 `1052 passed / 0 failed / 28 skipped`(17.4m)→ 视觉 `115 passed / 0 failed`(3.7m);`EXIT_TEST=0 2026-09-22T04:20:43Z` | **已归零**。chain4 的 3 例(移动端 LCP、两例 `mobile-navigation`)全部通过;视觉阶段真正执行(`/tmp/e2e-final9.log` 末段逐条列出 `visual-webkit-desktop` 的 115 例,含 L1 13 路由 + L2 组件态 + L3 排版色彩 + 浅色/深色主题各一)。全程未使用 `--update-snapshots`,基线未被改写。`POST_TEST_LISTENERS=0` |
chain4 的移动端两例经**根因定位后按产品缺陷修复**:`src/components/layout/header.tsx:184` 原为 `setIsOpen(!isOpen)`,
读取渲染闭包里的 `isOpen`,与抽屉的 `AnimatePresence` 退出动画(0.2s + 0.25s)叠加时高负载下丢点击 ⇒ 用户连点汉堡按钮可能打不开抽屉。
改为函数式 `setIsOpen((prev) => !prev)`。同文件同类的另外两处(`admin/login/page.tsx:80`、`admin/notifications/page.tsx:167`)
未在失败用例链路上,记为残留项交范围裁定。视觉基线未被改写(全程未使用 `--update-snapshots`)。
## ④ 静态门禁(chain6 最终树)
| 门禁 | 命令 | 结果 |
|---|---|---|
| 类型 | `npm run type-check` | chain6 `EXIT_TYPECHECK=0`(2026-09-22T03:51:14Z,2s)。**2s 触发怀疑,已用正/负对照自证不是空跑**:临时写入 `src/__typecheck-probe.ts`(`const probe: number = "not a number"`)⇒ `tsc` 报 `error TS2322`(11.1s,冷缓存);删除后复跑干净(2.9s,`tsconfig.compilerOptions.incremental=true` 命中 `.tsbuildinfo`)。探针文件已删除,未进入代码树 |
| 单元 | `npm run test:unit` | chain6 `EXIT_UNIT=0`:**137 suites / 1659 tests 全通过**(`Time: 6.936 s`,含新增 `src/app/global-error.test.tsx` 3 例) |
| Lint | `npm run lint` | chain6 `EXIT_LINT=0`:**0 error / 106 warning**(chain4 同为 106)。⚠️ [AGENTS.md](../../../AGENTS.md) §5 记的「实测 105 条」已过期,需同步;warning 是否清零由范围裁定,见 CONTEXT.md 决策表。其中 1 条是 `Unused eslint-disable directive`(死抑制,非真问题但可清) |
| 品牌文本通道 | `npm run check:brand-token` | chain6 `EXIT=0`,双通道契约未违反 |
| 对比度(令牌配对) | `npm run check:contrast` | chain6 **合计 44 组:✅ 44 / ❌ 0 / ⚠️ 0** → `contrast-and-headings.txt`(chain3/4/6 各一段) |
| 标题层级 | `npm run check:headings` | chain6 逐页 **0 问题**,`EXIT=0` |
| 构建 | `npm run build` | chain6 `EXIT_BUILD=0`(8s 增量,路由表与 24 个 html 产物 mtime 新于 03:50 已核对,非空跑) |
| Lighthouse | `npm run lighthouse` | chain6 `EXIT=0`(21 运行,但含 3 条死断言)→ **chain7 `EXIT_LIGHTHOUSE=0`(04:50:08Z)与 chain9 `EXIT_LIGHTHOUSE=0`(05:10:58Z)连续两次:9 URL × 3 次 = 27 运行、0 error、0 条 `not a known audit`**。URL 表已补 `/products/erp-upgrade`、`/about/brand`(§7 缺口的 Lighthouse 半边),两条新路由四类目均满分(perf 100 / a11y 1.0 / bp 1.0 / seo 1.0,LCP 786ms 与 794ms) |
| axe 节点计数(⑤) | 见下节 | `passed=true` |
**chain9 二次确认(05:03–05:11Z)**:处理完死断言、改过 `config/test/lighthouserc.json` 与文档/探针之后,把 `type-check`、`lint`、`test:unit`、`check:contrast`、`check:headings`、`check:brand-token` 全量重跑,**6 项 `EXIT=0`**(lint 仍 0 error / 106 warning),前后 `LISTENERS=0`。chain9 同时是 Lighthouse 在 9 URL 配置下的第二次连续全绿。
### Lighthouse 的 3 条死断言(chain6 新发现,已在最终树处理)
`config/test/lighthouserc.json:95-97` 的 `autocomplete-valid` / `presentation-role-conflict` / `svg-img-alt` 三条 `warn` 断言,
每轮都稳定打印 `"…" is not a known audit. expected: >=1 found: 0`(chain4+chain6 合计 42 条 = 3 条 × 7 URL × 2 轮)。
两个独立信源确认它们是**恒不成立的死断言**,而非「页面恰好没问题」:
1. **运行时报告**:最新 JSON 报告(`lighthouse-reports/contact-2026_09_22_04_25_34.report.json`,`lighthouseVersion 12.6.1`)的 `audits` 键集合里没有这三个 id,而同批断言的 `color-contrast`/`heading-order`/`target-size`/`aria-conditional-attr`/`skip-link` 均在列。
2. **审计注册表**:`@lhci/cli@0.15.1` 自带的是 `lighthouse@12.6.1`(不是顶层的 13.4.1 —— 这层版本裂差本身就是「为什么本地包里没有该审计」的答案),其 `core/audits/accessibility/` 共 64 个审计文件,无此三者。
覆盖率影响并非对等,因此不能简单删掉了事:用与门禁同款的 `runOnly: {type:'tag', values:['wcag2a','wcag2aa','wcag21a','wcag21aa']}`
做正/负对照探针(`docs/acceptance/2026-09-21-axe/probe-axe-rule-coverage.mjs`,每条规则各造一个必然违规的最小节点):
| 规则 | axe 4.11.4 是否在门禁 tag 内 | 探针是否抓到违规 |
|---|---|---|
| `autocomplete-valid` | 在 | ✅ 1 node |
| `svg-img-alt` | 在 | ✅ 1 node |
| `presentation-role-conflict` | **不在**(属 `best-practice` tag) | 规则未参与运行 |
⇒ 前两条的真实兜底是 ⑤ 的 axe 通道(已 34 路由 × 4 组合 = 0 违规);第三条在 axe 通道里也测不到,必须以规则级 `runOnly` 显式补上,
否则「删死断言」会变成「悄悄拆掉一道门」。
**chain7 复验(最终处理)**:`lighthouserc.json` 删掉这 3 条;`axe-contrast-evidence.mjs` 在 tag 通道之外增加第二次
`axe.run(document, {runOnly: {type:'rules', values: EXTRA_RULES}})`,并按页记录 `extraRulesChecked` 与 `extraRuleNodes`,
把 `extraRuleNodes === 0` 与 `extraRulePagesUnderCovered === 0`(即每页三条规则确实都跑了)一并写入 `thresholds` 与 `passed`。
结果:`EXIT_AXE_FULL=0`,`Σ extraRulesChecked = 408 = 34 路由 × 4 组合 × 3 规则`(分母闭合,不是"没测到所以 0")、
`extraRuleNodes = 0`;`EXIT_LIGHTHOUSE=0` 且 chain7 段 `not a known audit` 计数 **0**(chain4+chain6 历史段共 42 条,保留作对照)。
## ⑤ 全站双引擎双主题 axe 节点计数 = 0
**chain7 最终树(`docs/acceptance/2026-09-21-axe/axe-evidence.json`,`generatedAt 2026-09-22T04:43:02Z`,axe-core 4.11.4)**
——同一棵 src 代码树,`EXIT_AXE_FULL=0`;chain6(tag 通道单跑)快照存为 `axe-evidence-chain6-tagonly.json`,chain5 前一刻快照存为 `axe-evidence-chain5-preheader-fix.json`。
| 组合 | pages | contrastNodes | violationNodes | themeMismatch | bgMismatch | extraRuleNodes | extraRulePagesUnderCovered |
|---|---|---|---|---|---|---|---|
| chromium / light | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
| chromium / dark | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
| firefox / light | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
| firefox / dark | 34 | 0 | 0 | 0 | 0 | 0 | 0 |
`routeCount=34 rows=136(= 34 × 4 组合,分母断言)passed=true`;`Σ extraRulesChecked = 408 = 34 × 4 × 3`,
规则级通道覆盖 `autocomplete-valid` / `presentation-role-conflict` / `svg-img-alt`(后一条不在 wcag tag 集合内,只能靠它)。
**口径升级(这是 ⑤ 的关键,不是换实现)**:清单来源从「`/sitemap.xml` 的 29 条」扩为
**sitemap ∪ 预渲染产物(`dist/standalone/dist/server/app/**/*.html`)∪ 站内链接 BFS**(`crawl-sitemap.mjs`,打印 `skippedInternalDocs=1`)。
扩目录首轮(chain3)就**不是 0**:`/_not-found` 的 h1 用 `text-brand-ink` + `opacity-20` ⇒ 双引擎双主题各 1 个 `color-contrast` 节点;
`/_global-error` 是 Next 内置 500 文档(`<html id="__next_error__">` 无 `lang`、深色丢本站令牌)。
两处修复见 `docs/lessons-learned.md §5.24` 与 `CONTEXT.md` 决策行;框架内置文档按**内容**(非名字)排除并留痕,
一旦框架把本站 `global-error.tsx` 预渲染到该路径,条件即不再命中、路由自动回到清单。
历史对照:`axe-evidence-sitemap29.json`(29 页版,`passed=true`,116 行)—— 当时的 0 是真 0,但分母不足以覆盖 404 与兜底页。
## ⑥ 与 A-7 的补充复跑(chain8,最终树)
| 项 | 结果 |
|---|---|
| 覆盖率棘轮(A-7) | `npm run test:coverage` → **`EXIT_COVERAGE=0`**(2026-09-22T04:50:44Z):137 套件 / 1659 例全通过,`All files` = **Stmts 77.92 / Branch 84.6 / Funcs 77.64 / Lines 77.92**,对照 `config/test/jest.config.js` 的 `coverageThreshold`(82 / 75 / 75 / 75)四项均达标。数值已回写 `AGENTS.md` §5(原记 84.71 / 77.55 / 77.89 / 77.89 是 2026-09-21 树,`global-error.tsx` 加入后微移)→ `coverage-final-tree.txt` |
| iPhone SE 口径复跑(⑥ 的仿真部分) | `EXIT_SE_AUDIT=0`(2026-09-22T04:52:07Z),证据写入**新目录** `docs/acceptance/2026-09-22-iphone-se/`(40 图 + `measurements.json` + `audit.log`),不覆盖 09-21 那份 21:07Z 的旧证据。本轮把探针路由从 4 条扩到 5 条(新增 `/no-such-route-404-check`),得 **20 行 = 5 页 × 深/浅 × 同意条 pending/dismissed**:`overflow=0`(`docScrollW == clientW == 375` 全部成立)、`themeNull=0`、`dismissed` 态 `footerBottomGap=64` 一致(R-1 的双安全区空带已消除)。`pendingBannerOverlapFooter=8` 是 `fixed bottom-*` 浮层的既定行为,可关闭后消失,与旧证据同口径。**限制不变**:这是 Playwright 仿真,条件⑥ 要的「真机 iPhone SE + 系统深色」仍只能由交付决策人实机走查 |
**新探针当场挖到的一条观察(不是回归,待裁定)**:404 路由的 `footerBottomGap` 全为 `null` ⇒ 预渲染产物 `dist/standalone/dist/server/app/_not-found.html` 里**没有 `<footer>`**(同一探针下首页有 `<footer data-testid="footer" role="contentinfo">`)。根因不是 bug:`src/app/layout.tsx` 本身不渲染 Header/Footer,页面级外壳由各页自行组合,而 `src/app/not-found.tsx` 只返回 `NotFoundContent`(自带「返回首页 / 搜索」出口)。因此**404 与根错误页无站内导航、无备案页脚**——是否给边界页补齐页头页脚属设计判断,未擅自改动,列入决策清单。
## ① 四项 P0 的回归测试归属(非快照,指向可点开的具体用例)
- A-1/A-2 权限提升与改密鉴权:`src/app/api/admin/users/route.test.ts`(11 例,含「不得借先建号再自我提权绕过」「资料字段不得先落库再被角色校验拒绝」「不得停用/降级超管」)、`src/app/api/admin/roles/route.test.ts`(9 例)、`src/lib/permissions.test.ts`(10 例)。
- A-3 存储型 XSS:`src/lib/sanitize.test.ts`(15 例)。
- A-4 上传白名单 + 魔数:`src/lib/media/upload-policy.test.ts`(扩展名/MIME 申报不符、双扩展名、`.html`/`.svg`/可执行、路径穿越、空与超限)、`src/lib/media/media-service.test.ts:60,69`(**`upload-policy` 未被 mock**,断言「HTML 载荷在落盘前即被拒绝」,因此同时是路由下游的接线回归)、`src/app/api/admin/media/route.test.ts`(12 例:401/403/400/体积/多图)。
## 已知口径限制(不掩饰)
1. **chain2 那次 `805 passed / 263 failed` 作废**:`check:headings`/`check:contrast` 的脚本只 kill 包装进程 ⇒ `next-server` 孤儿占住 :3000,被 Playwright `reuseExistingServer` 静默复用,205 个失败是 `page.goto` 超时而非断言失败。已修(`detached` + 负 PID 杀进程组 + `process.exit` 前显式收服),chain3/4/6 的 `PRE/POST_*_LISTENERS` 均为 0 即为正/负对照。
2. **满负载竞态与"隔离必过"的差别**:本地 `retries: 0`、CI `retries: 2`(`e2e/playwright.config.ts`),且 4 个 project × 多 worker 同机跑 3 个引擎。本轮不做"加 retries 掩盖",而是逐项消除竞态成因(预热、auto-retry 断言、限定 `main` 作用域、函数式 setState)。残留风险:同机并发仍可能出新的竞态例。
3. ③「Jenkins 无 `|| echo` 后连续两次全绿」只能由交付决策人触发;本地已按 Jenkinsfile 原样跑通同一命令序列(`npm run build` + `npm run test:e2e:prod`)。
4. ⑥ 已在最终树上以仿真复跑(chain8,`docs/acceptance/2026-09-22-iphone-se/`,含新增的 404 路由);`measurements.json` 的 `caveat` 仍写明「Playwright 仿真,非真机」,**真机走查只能由交付决策人完成**,本条件不得由本地证据替代。
5. ② 的「提交信息留痕」需一次经授权的提交才能落地;**本轮全部改动(含 `src/app/global-error.tsx`、header setState、4 个 spec 修订、`CONTEXT.md`/`lessons-learned.md`)均未提交**。
6. 28 skipped 的拆解见 §5.23 与下表;其中 16 条 GA4 `@critical` 已在**生产目标**下单独复跑并全部真断言(`ga4-production-run.txt`:`Running 4 tests using 4 workers` → `4 passed (7.2s)`,含 `next start` 与 `output: standalone` 的告警原文)。
7. **覆盖缺口的处理进度**:`/products/erp-upgrade` 与 `/about/brand` 此前既不在 Lighthouse URL 表也不在视觉回归的 13 条路由表里(视觉表有 `/products/erp`,与 `erp-upgrade` 是**不同路由**)——这正是 §5.22 已记过的同型缺口。**Lighthouse 半边已闭合**(chain7 起为 9 URL × 3 = 27 运行,`EXIT=0`);**视觉半边仍未闭合**,把两条路由加进 `e2e/visual-regression.spec.ts` 需要 `--update-snapshots` 重写已审批基线,属破坏性动作,须单独授权。
@@ -0,0 +1,24 @@
=== GA4 生产目标复跑 2026-09-21T22:47:51Z ===
(node:60835) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
[WebServer] ⚠ Warning: Next.js ignored package-lock.json in /Users/zhangxiang because it is outside the current Git repository (/Users/zhangxiang/Codes/Novalon/novalon-website).
[WebServer]  To use this directory, set `outputFileTracingRoot` in your Next.js config.
[WebServer] 
[WebServer] ⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.
Running 4 tests using 4 workers
(node:60886) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:60883) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:60885) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:60884) [DEP0205] DeprecationWarning: `module.register()` is deprecated. Use `module.registerHooks()` instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
✓ 3 [chromium] › ga4-event-tracking.spec.ts:121:7 › GA4 事件追踪验证 › TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config @analytics @regression @critical (2.2s)
✓ 4 [chromium] › ga4-event-tracking.spec.ts:234:7 › GA4 事件追踪验证 › TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config @analytics @regression @critical (2.5s)
✓ 1 [chromium] › ga4-event-tracking.spec.ts:141:7 › GA4 事件追踪验证 › TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件 @analytics @regression @critical (4.1s)
✓ 2 [chromium] › ga4-event-tracking.spec.ts:199:7 › GA4 事件追踪验证 › TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件 @analytics @regression @critical (5.1s)
4 passed (7.2s)
@@ -0,0 +1,564 @@
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 7 URL(s), 21 total run(s)
3 result(s) for http://localhost:3000/ :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/about :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/services :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/products :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/cases :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/news :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/contact :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
All results processed!
Dumping 21 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
===== chain6 lighthouse(最终树)2026-09-22T04:20:43Z =====
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 7 URL(s), 21 total run(s)
3 result(s) for http://localhost:3000/ :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/about :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/services :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/products :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/cases :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/news :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
3 result(s) for http://localhost:3000/contact :
⚠️ autocomplete-valid warning for auditRan assertion
"autocomplete-valid" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ presentation-role-conflict warning for auditRan assertion
"presentation-role-conflict" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
⚠️ svg-img-alt warning for auditRan assertion
"svg-img-alt" is not a known audit.
expected: >=1
found: 0
all values: 0, 0, 0
All results processed!
Dumping 21 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
===== chain7 lighthouse(移除 3 条死断言后)2026-09-22T04:43:05Z =====
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products/erp-upgrade
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about/brand
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 9 URL(s), 27 total run(s)
All results processed!
Dumping 27 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
===== chain9 lighthouse(9 URL × 3 次 = 27 运行,补 erp-upgrade / about-brand)2026-09-22T05:04:19Z =====
> ruixin-website-react@1.0.0-phase1 lighthouse
> lhci autorun --config=config/test/lighthouserc.json
✅ .lighthouseci/ directory writable
✅ Configuration file found
✅ Chrome installation found
Healthcheck passed!
Started a web server with "npm run start"...
Running Lighthouse 3 time(s) on http://localhost:3000/
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/services
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/cases
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/news
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/contact
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/products/erp-upgrade
Run #1...done.
Run #2...done.
Run #3...done.
Running Lighthouse 3 time(s) on http://localhost:3000/about/brand
Run #1...done.
Run #2...done.
Run #3...done.
Done running Lighthouse!
Checking assertions against 9 URL(s), 27 total run(s)
All results processed!
Dumping 27 reports to disk at /Users/zhangxiang/Codes/Novalon/novalon-website/lighthouse-reports...
Done writing reports to disk.
Done running autorun.
@@ -0,0 +1,21 @@
import { test } from '@playwright/test';
// 只打印、不判定:把「fixture browserName 值」与「真实引擎(userAgent)」两件事分开测,
// 因为跳过条件读的是前者,而能力(Touchscreen API)取决于后者。
test('probe browserName identity', async ({ browserName, page }, testInfo) => {
const ua = await page.evaluate(() => navigator.userAgent);
const touchPoints = await page.evaluate(() => 'ontouchstart' in window ? (navigator.maxTouchPoints ?? 0) : -1);
console.log(
JSON.stringify({
project: testInfo.project.name,
fixtureBrowserName: browserName,
useBrowserName: (testInfo.project.use as { browserName?: string }).browserName ?? null,
skipConditionIsTrue: browserName !== 'chromium',
ua: ua.slice(0, 90),
engine: /AppleWebKit\/.*Version\//.test(ua) && !/Chrome\//.test(ua) ? 'webkit'
: /Firefox\//.test(ua) ? 'firefox'
: /Chrome\//.test(ua) ? 'chromium' : 'unknown',
touchPoints,
}),
);
});
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
// 探针:`chromium-mobile` 项目里 browserName 到底是什么、真正跑的是哪个引擎。
// 起因:最终树 28 条 skipped 有 7 条落在 chromium-mobile(cms-workflow ×3 + Touchscreen ×1 + GA4 ×4),
// 而跳过条件写成 `browserName !== 'chromium'` ⇒ 该项目内条件为真。
export default defineConfig({
testDir: __dirname,
fullyParallel: true,
reporter: [['list']],
projects: [
{
name: 'replica-chromium-mobile',
use: {
...devices['iPhone 14'],
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
},
},
{
// 正对照:显式写 browserName,看是否与"仅 spread devices"得到不同的 fixture 值
name: 'explicit-chromium-mobile',
browserName: 'chromium',
use: {
...devices['iPhone 14'],
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
},
},
{ name: 'plain-desktop', use: { ...devices['Desktop Chrome'] } },
],
});
@@ -0,0 +1,310 @@
{
"generatedAt": "2026-09-22T05:18:05.880Z",
"source": "e2e/allure-results(chain6 最终代码树 `npm run test` 功能+视觉两阶段的 allure 落盘)",
"window": {
"from": "2026-09-22T03:50:00.000Z",
"to": "2026-09-22T04:25:00.000Z"
},
"totalResults": 1195,
"byStatus": {
"passed": 1167,
"skipped": 28
},
"skippedCount": 28,
"skipTally": [
{
"group": "cms-workflow.spec.ts :: CMS workflow test runs only in chromium",
"count": 9
},
{
"group": "ga4-event-tracking.spec.ts :: 构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"count": 16
},
{
"group": "p3-compatibility.spec.ts :: Touchscreen API only available in Chromium",
"count": 3
}
],
"skipped": [
{
"file": "2219cf08-fad3-4399-8ec7-90aaf2d53ae7-result.json",
"mtime": "2026-09-22T04:07:45.576Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:265:7",
"name": "admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见"
},
{
"file": "7de4326b-8932-4c95-880a-1e80296196b4-result.json",
"mtime": "2026-09-22T04:13:35.011Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:265:7",
"name": "admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见"
},
{
"file": "ab09e8f1-e6bf-4936-a6a8-330dbb04fc10-result.json",
"mtime": "2026-09-22T04:03:26.179Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:265:7",
"name": "admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见"
},
{
"file": "55ec3c0e-1cce-4e62-b0a1-2812f419c924-result.json",
"mtime": "2026-09-22T04:13:35.012Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:279:7",
"name": "非法状态流转会被拦截"
},
{
"file": "625d1024-b16b-4698-9365-bacbbbad6df8-result.json",
"mtime": "2026-09-22T04:03:26.185Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:279:7",
"name": "非法状态流转会被拦截"
},
{
"file": "8ab7a06e-ec1f-4636-a4ec-f83311a5426e-result.json",
"mtime": "2026-09-22T04:07:45.578Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:279:7",
"name": "非法状态流转会被拦截"
},
{
"file": "42921f30-5ef2-402c-a0cd-c75190f16bf1-result.json",
"mtime": "2026-09-22T04:03:26.185Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:297:7",
"name": "多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见"
},
{
"file": "52d69dbe-2aeb-4a1c-a5b5-857f2f5cf0f1-result.json",
"mtime": "2026-09-22T04:07:45.579Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:297:7",
"name": "多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见"
},
{
"file": "f4d7c0ab-7d86-41bf-9e0c-9dba8af4fcf6-result.json",
"mtime": "2026-09-22T04:13:35.013Z",
"status": "skipped",
"reason": "CMS workflow test runs only in chromium",
"project": "",
"suite": "cms-workflow.spec.ts",
"fullName": "cms-workflow.spec.ts:297:7",
"name": "多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见"
},
{
"file": "08dcaad7-65e9-4f8f-ba09-8ccf9998eea6-result.json",
"mtime": "2026-09-22T04:08:01.577Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "19adc24e-76cd-4c67-86e3-6dc3349ad6c0-result.json",
"mtime": "2026-09-22T04:13:53.422Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "3e38da66-8835-4bd0-bcd5-4f25b9632243-result.json",
"mtime": "2026-09-22T04:03:45.432Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "8e30c9be-b79a-463c-959d-3d89ecc8f79b-result.json",
"mtime": "2026-09-22T04:00:03.361Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:121:7",
"name": "TC-GA4-001: 页面浏览追踪 - 首屏由应用发出唯一一次 config"
},
{
"file": "0e4356a2-c4fe-4992-8152-7a42b02323a5-result.json",
"mtime": "2026-09-22T04:08:06.884Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "19d37331-c2d4-4467-89fe-a3f1979e7a28-result.json",
"mtime": "2026-09-22T04:00:03.245Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "29da23ac-66f4-4bed-b9a7-5680ee020b52-result.json",
"mtime": "2026-09-22T04:13:53.581Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "d74226e3-b1ee-45e8-8ece-dd756bd2428d-result.json",
"mtime": "2026-09-22T04:03:44.905Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:141:7",
"name": "TC-GA4-002: 联系表单提交触发 form_submit 和 conversion 事件"
},
{
"file": "37e77148-e420-4c3f-ba73-e7348fbab00c-result.json",
"mtime": "2026-09-22T04:08:06.810Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "868f0726-a517-48d4-953f-8a226ee4b5aa-result.json",
"mtime": "2026-09-22T04:13:52.839Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "8be78b7c-be72-4d56-a8a7-e2898c54f1cb-result.json",
"mtime": "2026-09-22T04:00:04.472Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "c47b32cd-0f86-46b8-98c1-641664fa84c4-result.json",
"mtime": "2026-09-22T04:03:44.635Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:199:7",
"name": "TC-GA4-003: 同意横幅「接受所有」点击触发 button_click 事件"
},
{
"file": "0c13405f-07ca-465f-babe-7694785d1386-result.json",
"mtime": "2026-09-22T04:00:06.235Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "2a78aee1-234c-4b59-9d03-96e5fb749a93-result.json",
"mtime": "2026-09-22T04:03:48.732Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "5ce72f00-9339-4ed9-9243-376f97720ea1-result.json",
"mtime": "2026-09-22T04:13:55.190Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "be9ce18f-0ab5-4016-9697-047f0208eb00-result.json",
"mtime": "2026-09-22T04:08:08.969Z",
"status": "skipped",
"reason": "构建未注入 GA 脚本(NEXT_PUBLIC_GA_MEASUREMENT_ID 为空)",
"project": "",
"suite": "ga4-event-tracking.spec.ts",
"fullName": "ga4-event-tracking.spec.ts:234:7",
"name": "TC-GA4-004: 页面浏览追踪 - 客户端路由跳转触发带 page_path 的 config"
},
{
"file": "2c6ec67e-4473-4796-a81b-d51ec9bef653-result.json",
"mtime": "2026-09-22T04:05:29.786Z",
"status": "skipped",
"reason": "Touchscreen API only available in Chromium",
"project": "",
"suite": "p3-compatibility.spec.ts",
"fullName": "p3-compatibility.spec.ts:226:7",
"name": "触摸滑动流畅(无卡顿)"
},
{
"file": "50fe3c7c-8d10-49b1-800d-cf504f89511f-result.json",
"mtime": "2026-09-22T04:15:12.200Z",
"status": "skipped",
"reason": "Touchscreen API only available in Chromium",
"project": "",
"suite": "p3-compatibility.spec.ts",
"fullName": "p3-compatibility.spec.ts:226:7",
"name": "触摸滑动流畅(无卡顿)"
},
{
"file": "da7b926e-4c87-4ca5-9b21-4b4d9eb0ff4a-result.json",
"mtime": "2026-09-22T04:10:56.093Z",
"status": "skipped",
"reason": "Touchscreen API only available in Chromium",
"project": "",
"suite": "p3-compatibility.spec.ts",
"fullName": "p3-compatibility.spec.ts:226:7",
"name": "触摸滑动流畅(无卡顿)"
}
]
}
@@ -0,0 +1,125 @@
import { chromium } from 'playwright';
import { mkdirSync, writeFileSync } from 'fs';
const BASE = process.env.BASE || 'http://localhost:3000';
const OUT = process.env.OUT || '/tmp/iphone-se-evidence';
mkdirSync(OUT, { recursive: true });
const PAGES = [
['home', '/'],
['news-detail', '/news/company-founded'],
['news-list', '/news'],
['contact', '/contact'],
// 边界页:404 的 h1 由 opacity-20 改为 text-brand-ink/80,需在 375px 窄屏(`text-8xl` 档)复核
['not-found', '/no-such-route-404-check'],
];
const MEASURE = () => {
const footer = document.querySelector('footer, [data-testid="footer"]');
let lastTextBottom = 0;
let label = '';
if (footer) {
footer.querySelectorAll('*').forEach((el) => {
if (el.children.length || !el.textContent.trim()) return;
const b = el.getBoundingClientRect();
if (b.height > 0 && b.bottom > lastTextBottom) {
lastTextBottom = b.bottom;
label = el.textContent.trim().slice(0, 24);
}
});
}
const fr = footer ? footer.getBoundingClientRect() : null;
const banner = document.querySelector('[data-testid="cookie-consent-banner"]');
const br = banner ? banner.getBoundingClientRect() : null;
return {
theme: document.documentElement.getAttribute('data-theme'),
footerBottomGap: fr && lastTextBottom ? Math.round(fr.bottom - lastTextBottom) : null,
lastText: label,
footerBottomVsViewport: fr ? Math.round(fr.bottom - innerHeight) : null,
bannerTop: br ? Math.round(br.top) : null,
bannerOverlapsFooter: br && fr ? br.top < fr.bottom : false,
docScrollW: document.documentElement.scrollWidth,
clientW: document.documentElement.clientWidth,
};
};
const browser = await chromium.launch();
const rows = [];
for (const theme of ['dark', 'light']) {
for (const consent of ['dismissed', 'pending']) {
const ctx = await browser.newContext({
viewport: { width: 375, height: 667 },
deviceScaleFactor: 2,
isMobile: true,
hasTouch: true,
colorScheme: theme,
...(consent === 'dismissed'
? {
storageState: {
cookies: [],
origins: [
{
origin: BASE,
localStorage: [
{
name: 'novalon-cookie-preferences',
value: JSON.stringify({
necessary: true,
analytics: false,
marketing: false,
functionality: true,
timestamp: 1784284000000,
}),
},
],
},
],
},
}
: {}),
});
const page = await ctx.newPage();
for (const [name, path] of PAGES) {
await page.goto(BASE + path, { waitUntil: 'load', timeout: 45000 });
if (consent === 'pending') await page.waitForTimeout(2600);
else await page.waitForTimeout(900);
const tag = `${theme}-${consent}-${name}`;
await page.screenshot({ path: `${OUT}/se-${tag}.png` });
// 逐帧滚到底:首页有入场分级动画,内容高度在滚动后才增长,
// 单次 scrollTo(scrollHeight) 会停在半页,测出的 footer 位置无效。
await page.evaluate(async () => {
let last = -1;
for (let i = 0; i < 12; i += 1) {
window.scrollTo(0, document.body.scrollHeight);
await new Promise((r) => setTimeout(r, 220));
if (Math.round(window.scrollY) === last) break;
last = Math.round(window.scrollY);
}
});
await page.waitForTimeout(700);
await page.screenshot({ path: `${OUT}/se-${tag}-footer.png` });
const m = await page.evaluate(MEASURE);
rows.push({ tag, ...m });
console.log(tag.padEnd(28), JSON.stringify(m));
}
await ctx.close();
}
}
await browser.close();
writeFileSync(
`${OUT}/measurements.json`,
JSON.stringify(
{
generatedAt: new Date().toISOString(),
target: BASE,
device: 'iPhone SE 口径仿真:viewport 375x667 / deviceScaleFactor 2 / isMobile+hasTouch / colorScheme 由 CDP 强制',
caveat: 'Playwright 仿真,非真机;用于复现 R-1/R-3 的布局与叠层判定,不能替代实机验收',
thresholds: { noHorizontalOverflow: 'docScrollW <= clientW', consentClearsFooter: 'bannerOverlapsFooter === false' },
rows,
},
null,
2,
),
);
console.log(`WROTE ${OUT}/measurements.json (${rows.length} rows)`);
@@ -0,0 +1,188 @@
{
"generatedAt": "2026-09-21T21:07:27.236Z",
"target": "http://localhost:3000",
"device": "iPhone SE 口径仿真:viewport 375x667 / deviceScaleFactor 2 / isMobile+hasTouch / colorScheme 由 CDP 强制",
"caveat": "Playwright 仿真,非真机;用于复现 R-1/R-3 的布局与叠层判定,不能替代实机验收",
"thresholds": {
"noHorizontalOverflow": "docScrollW <= clientW",
"consentClearsFooter": "bannerOverlapsFooter === false"
},
"rows": [
{
"tag": "dark-dismissed-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -0,0 +1,232 @@
{
"generatedAt": "2026-09-22T04:52:07.803Z",
"target": "http://localhost:3000",
"device": "iPhone SE 口径仿真:viewport 375x667 / deviceScaleFactor 2 / isMobile+hasTouch / colorScheme 由 CDP 强制",
"caveat": "Playwright 仿真,非真机;用于复现 R-1/R-3 的布局与叠层判定,不能替代实机验收",
"thresholds": {
"noHorizontalOverflow": "docScrollW <= clientW",
"consentClearsFooter": "bannerOverlapsFooter === false"
},
"rows": [
{
"tag": "dark-dismissed-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-dismissed-not-found",
"theme": "dark",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-home",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-detail",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-news-list",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-contact",
"theme": "dark",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "dark-pending-not-found",
"theme": "dark",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": 461,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-dismissed-not-found",
"theme": "light",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": null,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-home",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-detail",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-news-list",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-contact",
"theme": "light",
"footerBottomGap": 64,
"lastText": "蜀ICP备2026013658号",
"footerBottomVsViewport": -128,
"bannerTop": 461,
"bannerOverlapsFooter": true,
"docScrollW": 375,
"clientW": 375
},
{
"tag": "light-pending-not-found",
"theme": "light",
"footerBottomGap": null,
"lastText": "",
"footerBottomVsViewport": null,
"bannerTop": 461,
"bannerOverlapsFooter": false,
"docScrollW": 375,
"clientW": 375
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Some files were not shown because too many files have changed in this diff Show More