feat(ui): 营销站 UI/UE/UX 治理 P0→P3 收官

复评(impeccable critique)全部 Priority Issues 与 Minor Observations 落地:
- P0 数字口径:MetricsBasisNote 单一真源 + metrics-basis.test fs 防线,接入
  products/solutions/services/home/about;纯渲染端加角注,不动 DB/seed 值
- P1 转化断头路:敬请期待态 + 404 语境出口 + services 空态双出口
- P1 首页并卡:TrustSection 早鸟横幅并入 CasesSection 单张深色共创卡
- P2 CTA 治理:CONSULT_CTA_ALLOWLIST 白名单 + 逐字符扫描器,/contact CTA 全归一
- P3 polish:footer 补 服务/公司列、contact 死三元/空槽/toast、GlobalErrorTracker 幂等日志
- 文档同源:PRODUCT/CONTEXT 动效带统一 180–280ms;DESIGN.md 立 The 10px Floor;
  font-floor.test 守卫禁止再引入 <10px(保留 10/11px 有意 Bain 微标签)

Gates: type-check 0 · jest 1581/132 · lint 0e/126w · contrast 7/7 · headings 10/10
This commit is contained in:
2026-09-20 09:17:07 +08:00
parent ec56a2211f
commit 611b6b87fe
66 changed files with 1448 additions and 1313 deletions
+3 -3
View File
@@ -145,7 +145,7 @@ All visual tokens are defined as **CSS custom properties** in `src/app/globals.c
- **Colors**: `--color-ink` (deep charcoal #0A0E14), `--color-brand` (vermilion #C41E3A), accent colors for service-coding (blue, teal, amber, purple), functional colors (success, warning, error, info), dark-section overrides
- **Typography**: `--font-size-*`, `--line-height-*`, `--letter-spacing-*` all mapped to Tailwind's `fontSize`/`lineHeight`/`letterSpacing` scales
- **Spacing, radius, shadows** all tokenized through CSS variables
- **Transitions**: `--transition-fast/normal/slow`, `--ease-ink` (cubic-bezier), `--ease-sharp`, `--ease-spring-soft`
- **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
### Design DNA Framework
@@ -156,7 +156,7 @@ The site follows a three-dimensional design system (see `CONTEXT.md`):
The **Consulting Professional** aesthetic (inspired by Accenture + Bain) is the primary skeleton. **Ink cultural elements** (水墨) are secondary decorations limited to ≤6 locations (logo, dividers, transition animations, footer texture).
**Brand red (#C41E3A) usage rule**: Every page must have ≥3 brand-red touchpoints. It must never be used as paragraph text color, large backgrounds, or alongside accent colors in the same card. Area ≤10%.
**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.
@@ -182,7 +182,7 @@ The project has a backend layer for admin/auth/CMS functionality:
```
src/components/
├── ui/ # Base: Button, Card, Input, Badge, ScrollReveal, AnimatedCounter, etc.
├── layout/ # Header, Footer, MobileTabBar, Breadcrumb, MegaDropdown, MobileMenu
├── layout/ # Header, Footer, MobileTabBar, Breadcrumb, MegaDropdownMobileMenu 已删除,抽屉内置于 Header
├── sections/ # Page section components: HeroSectionV2, ServiceGrid, CTASection, etc.
├── detail/ # Four-layer narrative: DetailHero, ProductValueSection, DetailTrustSection, etc.
├── seo/ # Structured data (OrganizationSchema, WebsiteSchema, etc.)
+1 -1
View File
@@ -73,7 +73,7 @@ Switch / Slider 的白底圆钮。
### 动效设计四原则 (Motion Design Principles)
基于 Porsche Consulting "用动效象征变革"理念,统一全站动效语言:
1. **Purposeful(有目的)**:每个动效必须服务于内容传达,不做纯装饰动效
2. **Fast(快节奏)**:入场 200-300mshover 150ms,反馈 100ms
2. **Fast(快节奏)**:入场 180280ms`--transition-fast``--transition-normal`hover 150ms,反馈 100ms
3. **Natural(自然)**:统一使用 `ease-ink` [0.22, 1, 0.36, 1] 作为默认缓动曲线
4. **Layered(分层)**:子元素入场 stagger 30-60msSection 间 stagger 100-150ms
+241
View File
@@ -0,0 +1,241 @@
---
name: Novalon Website
description: 数字化转型咨询公司的可审计宣言 — Bain 式深红陈述、数据先行、零编造信任语法
colors:
cinnabar: "#C41E3A"
cinnabar-deep: "#A01830"
cinnabar-ember: "#E04A68"
cinnabar-blush: "#FEF2F4"
crimson-veil: "#8B1530"
cinnabar-light: "#F87171"
ink: "#0A0E14"
ink-rise: "#0F1419"
ink-pillar: "#151B23"
paper: "#FFFFFF"
mist: "#F8FAFC"
slate-cool: "#F1F5F9"
graphite: "#334155"
graphite-soft: "#475569"
haze: "#64748B"
hairline: "#EEF2F7"
rule: "#CBD5E1"
typography:
display:
fontFamily: "Geist Sans, PingFang SC, Hiragino Sans GB, Microsoft YaHei, sans-serif"
fontSize: "clamp(2.25rem, 5vw + 1rem, 6rem)"
fontWeight: 900
lineHeight: 0.92
letterSpacing: "-0.03em"
headline:
fontFamily: "Geist Sans, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "clamp(1.875rem, 2vw + 1rem, 3.75rem)"
fontWeight: 800
lineHeight: 0.95
letterSpacing: "-0.02em"
title:
fontFamily: "Geist Sans, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "1.25rem1.5rem"
fontWeight: 700
lineHeight: 1.35
body:
fontFamily: "Geist Sans, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "1rem"
fontWeight: 400
lineHeight: 1.65
label:
fontFamily: "Geist Sans, PingFang SC, Microsoft YaHei, sans-serif"
fontSize: "0.8125rem"
fontWeight: 500
lineHeight: 1.4
letterSpacing: "0.08em0.15em"
mono:
fontFamily: "Geist Mono, SFMono-Regular, Menlo, monospace"
fontSize: "0.75rem"
fontWeight: 400
letterSpacing: "0.2em"
rounded:
xs: "3px"
sm: "6px"
md: "8px"
lg: "12px"
xl: "18px"
2xl: "24px"
full: "9999px"
spacing:
xs: "4px"
sm: "8px"
md: "16px"
lg: "24px"
xl: "32px"
2xl: "48px"
3xl: "64px"
4xl: "96px"
5xl: "128px"
6xl: "192px"
components:
cta-primary:
backgroundColor: "{colors.cinnabar}"
textColor: "{colors.paper}"
rounded: "{rounded.full}"
padding: "16px 40px"
cta-primary-hover:
backgroundColor: "{colors.cinnabar-deep}"
cta-secondary:
textColor: "{colors.ink}"
rounded: "{rounded.full}"
padding: "16px 40px"
cta-dark:
textColor: "#F8FAFC"
rounded: "{rounded.full}"
padding: "16px 40px"
card-bain:
backgroundColor: "{colors.paper}"
rounded: "{rounded.lg}"
padding: "24px"
input-field:
backgroundColor: "{colors.paper}"
rounded: "{rounded.sm}"
size: "height 44px"
badge-pill:
backgroundColor: "{colors.cinnabar-blush}"
textColor: "{colors.cinnabar}"
rounded: "{rounded.full}"
---
# Design System: Novalon Website
## Overview
**Creative North Star: "The Crimson Ledger · 可审计的宣言"**
Novalon 的界面是一份可以逐条审计的经营宣言。它模仿顶级咨询公司(Bain 系)的陈述方式:先给答案,再给论证 —— 巨型黑体断言占据首屏,三条实测数字紧随其后,朱砂红只落在结论、行动与强调上。整个系统的情绪是「沉稳、精致、可信赖」:不是高高在上的专家,而是坐下来一起想办法的同行者。
第二支柱是「诚实语法」:不虚构案例、不伪造数据。未发布的内容明说「内测中 / 首批共创 / 敬请期待」,每个数字带来源角注。这份克制本身被当作差异化资产,视觉系统配合它:宁可有大面积留白与「暂未发布」,不放一张假图、一句空话。
深色模式不是附属品而是一等公民:主题由 `html[data-theme]` 驱动,仅覆盖 CSS 变量,每个文字令牌附带实测对比度注释(全部达 WCAG AA),品牌红走「底色 / 文字」双通道以保证反色后仍达标。
**Key Characteristics:**
- 答案先行的宣言 Hero,按页型分三档音量:首页全幅宣言 / hub 紧凑引言 / 信任页降档宣言
- 朱砂红是「结论电压」:每页 ≥3 处触达点、面积 ≤10%
- 柔和阴影分层替代硬边框分界;细发线(1px)只做区域呼吸线
- 明暗双主题逐令牌审校,对比度实测值写进代码注释
- 动效 180280ms、ease-ink 曲线、reduced-motion 全链路守卫
## Colors
中性色是纸与墨的冷调 slate 系,全部色彩张力由一个声音承担 —— 朱砂。
### Primary
- **Cinnabar Signal 朱砂信号** (`{colors.cinnabar}` #C41E3A): 品牌红。CTA 填充、标题关键词下划线、active 导航下划线、数据数字、卡片顶部 2px 红条、hover 焦点环。
- **Cinnabar Deep** (`{colors.cinnabar-deep}` #A01830): 品牌红 hover 态。
- **Cinnabar Ember** (`{colors.cinnabar-ember}` #E04A68): 亮部变体,用于装饰性强调。
- **Cinnabar Blush** (`{colors.cinnabar-blush}` #FEF2F4): 红底最浅层,badge 背景、错误提示底。
- **Crimson Veil** (`{colors.crimson-veil}` #8B1530): 深红全幅区块背景(Bain 式引述区/CTA 区),其上文字用 #F8FAFC
### Neutral
- **Ink** (`{colors.ink}` #0A0E14): 近黑的冷墨。主文字、深色页底、描边按钮文字。暗黑模式下语义整体反转(ink 变浅、paper 变深),但「深色区块」类令牌(`--color-dark-*`、overlay、footer)显式引用、不随反色。
- **Ink Rise / Ink Pillar** (`{colors.ink-rise}` / `{colors.ink-pillar}`): 暗黑模式的两级深底。
- **Paper** (`{colors.paper}` #FFFFFF): 主底与卡片底。
- **Mist** (`{colors.mist}` #F8FAFC): 交替 section 底、次级面。
- **Slate Cool** (`{colors.slate-cool}` #F1F5F9): 三级底、hover 底。
- **Graphite** (`{colors.graphite}` #334155): 正文次级文字(AA 达标)。
- **Haze** (`{colors.haze}` #64748B): 辅助文字与占位符下限(4.76:1);更浅的 `--color-text-hint` #7C8CA5 仅允许出现在 ≥24px 装饰大字。
- **Hairline** (`{colors.hairline}` #EEF2F7): 卡片描边、gap-px 网格线 —— 刻意淡到近乎不可见,分界交给阴影。
- **Rule** (`{colors.rule}` #CBD5E1): 可见分隔线、次级边框。
### Functional
- success/warning/error/info 各带「本体 + 文字专用」双令牌(如 `--color-success` 仅 3.30:1,正文必须用 `--color-success-text` #15803D)。错误色直接复用品牌红。
### 编码辅色(非装饰)
蓝/青/琥珀/紫/青柠/玫红/靛 7 个 accent 仅用于服务与行业编码(图标底、solution 卡 accent),一律 12% 软底变体成对出现。**它们永远不组合成渐变,也不承担全局氛围。**
### Named Rules
**The One Voice Rule.** 一屏之内,色彩主张只允许朱砂一个声音发言;accent 编码色是分类标签,不是情绪表达。
**The Two-Channel Red Rule.** 底色用 `--color-brand`(暗黑不翻),文字用 `--color-brand-ink`(暗黑翻至 #F87171)。合并成一条是 bug 的源头——红底白字按钮与深底红字要同时达标。
**The Ledger Budget Rule.** 品牌红触达每页 ≥3 处、面积 ≤10%。红色稀缺才值钱。
## Typography
**Display/Body Font:** Geist Sans(本地 woff2next/font),中文逐字符回退 PingFang SC / 微软雅黑
**Label/Mono Font:** Geist Mono(编号、digest、eyebrow 英文装饰位)
**Legacy Exception:** `--font-brand` 楷体(STKaiti/KaiTi)仅存于遗留页 `/about/brand`**新页面禁用**。
**Character:** 无衬线黑体的极端粗细跨度(400 正文 ↔ 900 断言)就是层级本身;中文靠字族回退保持现代感,不用书法制造「文化氛围」。
### Hierarchy
- **Display** (900, 3rem→6rem 经 `--font-size-7xl` 变量随断点放大, leading 0.920.95, tracking -0.03em): 首屏宣言本体,第二行常染品牌红或加红下划线。注意:本项目 fontSize 在 `theme.extend` 中自定义到 7xl(≥1024px 时 6rem)Tailwind 默认 `text-8xl` 与它同值(6rem)——历史遗留的 `xl:text-8xl` 是无效层级,子页 Hero 已全部移除。
- **Headline** (800/900, 1.875rem→3.75rem, leading ~0.95): Section 标题。
- **Title** (600700, 1.1251.5rem): 卡片标题。
- **Body** (400, 1rem/1.0625rem, leading 1.651.85): 正文;`text-text-secondary` 为默认灰度。
- **Label** (500, 0.8125rem, tracking 0.080.15em, 可 uppercase): eyebrow、badge、导航眉标。
- **Mono Caption** (0.75rem, tracking 0.2em uppercase): 编号、digest、技术注记。
### Named Rules
**The Answer-First Rule.** 标题写结论断言,不写问句或口号铺垫;紧随其后的数字条是证据。
**The -0.04em Floor.** 追踪最紧 -0.04em(现用最紧 -0.03em);display 不超过 6rem。
**The 10px Floor.** 1011px 大写 tracking 微标签(eyebrow、digest、case 分类签)是 Bain 账本风的有意下限,全站保留、不上坡;绝对像素字号不得 <10px,由 `src/lib/constants/font-floor.test.ts` 机械守卫禁止再引入(rem 因 18px 根字号歧义不纳入)。
## Layout
12 列网格心智(`--grid-columns: 12`),容器上限 1360px`--container-max`),水平内边距 16→40px 随断点。Mobile FirstTailwind 默认断点(sm 640 / md 768 / lg 1024 / xl 1280)。
- 纵向节奏:section 默认 `--section-padding-y` = 96px`py-20 md:py-28` 一族),呼吸区 `section-breathing` 128→192px,数据密集区 `section-compact` 48→64px。
- Bain 式不对称栅格是主力构图:`asymmetric-wide-narrow` 5fr/3fr、`asymmetric-narrow-wide` 3fr/5fr、`asymmetric-three` 2fr/1fr/1frlg 起生效)。
- 卡片密集区用 gap-px + 发线底色(`bg-border-primary`)的无缝网格,而非卡片间留大缝。
- Section 背景在 Paper ↔ Mist 间交替,上下以 1px 发线收边;深色/深红区块作为全幅打断。
- Hero 四档音量(2026-09-19 layout+distill 定型,critique 复评补第四档):**首页** `min-h-[90vh]`+粒子场,全站唯一全幅宣言档;**hub 目录页**products/solutions/services)与**叶子详情**service-detail)为紧凑引言:无 min-h、`py-16 sm:py-20 lg:py-28`、标题封顶 `lg:text-6xl xl:text-7xl`,其下重复的 Hero 数据条已删;**信任页**about/team`min-h-[60vh]``lg:py-40`、标题封顶 `lg:text-7xl`,保留宣言气场与 CMS 驱动数据条;**独立内容页**(cases `70vh` / news `75vh` / methodology `60vh` / product-detail·solution-detail `80vh`)允许中等音量引言,但**转化页不得用**——/contact 已改紧凑档(无 min-h、`py-16 sm:py-20 lg:py-28`,表单回到首屏视野)。/about/brand 为遗产豁免(90vh,随页面退役一并处理)。
## Elevation & Depth
分层靠「几乎看不见的边框 + 真实柔和的阴影」完成:`--color-border-primary` 被刻意软化到 #EEF2F7,卡片默认 `--shadow-sm`/card-shadow 贴地,hover 升至 `--card-shadow-hover` 并 translateY(-4px)。阴影全部从墨色 rgba(10,14,20,α) 取用,不用纯黑。
### Shadow Vocabulary
- **xsxl 层级带** (`0 1px 1px α0.03``0 24px 56px α0.1`): 微边界 → 卡片 → 下拉 → 模态 → 全屏浮层。
- **Brand Halo** (`0 6px 24px rgba(196,30,58,0.22)`hover 加深至 0.32): 仅 CTA/品牌强调元素的有色阴影。
- **Inset / Glow** (`inset 0 2px 4px`; `0 0 40px rgba(196,30,58,0.15)`): 凹陷输入与慎用光晕。
### Named Rules
**The Quiet Border Rule.** 边框只负责「有没有」,深度全部交给阴影;禁止用重边框线做层级。
## Shapes
柔边体系:徽章/焦点元素 3px、按钮/输入 6px、卡片/弹窗 8px、大卡 12px`--card-border-radius`)、英雄卡 18px、全屏弹窗 24px;CTA 与 badge 一律全圆角 pill。特色几何:区块顶/底 0.5–2px 朱砂条带、`corner-frame` L 形角框(16px 红色直角)、`clip-slant` 斜切(Accenture 遗产,仅装饰带使用)。卡片在 1px 淡边框与阴影之间只声明一次 elevation(边框为 hairline 级)。
## Components
### ButtonsCTAButton,全站行动召唤)
- **Shape:** 全圆角 pillrounded-full),内置 ArrowRight 箭头签名,hover 位移 translate-x。
- **Primary:** 朱砂填充 + 白字(16px 40px),hover 转 Cinnabar Deep**Secondary:** 墨色 20% 描边透明底;**Dark:** 深色区块专用浅描边;**Inline:** 透明小箭头文字链,用于卡内「了解更多」。
- 交互 Buttonui/button.tsx)另带 6 档 size 与 ripple,触控下限 44×44px。
### Cards / Containersbain-card
- **Corner Style:** 12px 柔边;**Background:** Paper**Border:** 1px hairline**Shadow:** card-shadow → hover 抬升 4px + card-shadow-hover;内边距 24px(大 40px)。
### Inputs / Fields
- Paper 底、hairline 描边、6px 圆角、44px 高;focus 转 `border-brand/50`;标签常驻(placeholder 仅作示例);错误文案内联于字段下。
### Navigation
- 白色 sticky 头部,active 项朱砂下划线;桌面 hover 展开 mega dropdown(分组 + 描述 + 状态 badge);移动端汉堡 + 底部 tab bar 双轨(在办:收敛为一套);skip-link 常驻。
### 数据条(Stats Strip
- 3 项为宜:数字用 display 级 font-black + `tabular-nums` + 品牌红,label 用小灰字。这是「答案先行」的结构件,但仅在数据不被本页其它区域重复时放置(首页成果带、信任页 Hero);hub 页 Hero 的数据条因与下方网格逐字重复已移除。
### Badge / 状态签
- Pill12% 软底或 blush 底 + 品牌/编码色文字 + 20% 同色描边;「内测中 / 敬请期待」等诚实状态以此呈现。
## Do's and Don'ts
### Do:
- **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)`、180280msstagger 用 `--stagger-*` 令牌;reduced-motion 时退为静态。
- **Do** 未发布内容使用 `ContentUnavailableState`(带导航出口),空/错/成功态永远给出下一步。
### Don't:
- **Don't** 用多色渐变、玻璃拟态或彩色光晕制造「科技感」;科技感到数字和排版为止。
- **Don't** 在新页面使用楷体 `--font-brand`(例外遗产,仅 /about/brand)。
- **Don't** 把内容入场做成 spring 回弹(`--ease-spring*` 死令牌已于 2026-09-19 polish 批次删除;原则保留,防止回归)。
- **Don't** 用 `--color-text-hint` (#7C8CA5) 写正文/占位符(仅 ≥24px 装饰大字)。
- **Don't** 虚构案例、客户 logo、产品截图;宁可用 badge 承认「内测中」。
- **Don't** 让 CTA 文案自由发挥:预约动作全站统一「预约咨询」。
+19 -58
View File
@@ -3,73 +3,34 @@
### Users
- 中国企业决策者(CEO/CIO/CTO),寻求数字化转型服务
- 使用场景:评估技术供应商、了解解决方案、发起咨询
- 期望感受:专业可信、文化共鸣、技术前沿
- 期望感受:专业可信、决断清晰、技术前沿
### Brand Personality
- 沉稳 · 精致 · 可信赖
- 东方水墨美学 + 现代科技感
- 咨询顾问式的断言表达:答案先行、有立场、有依据
- 不是高高在上的"专家",而是坐下来一起想办法的"同行者"
### Aesthetic Direction
- **风格**: 水墨雅致 — 以留白和排版取胜,特效点到为止
- **参考**: Apple 中国官网(极致留白、精准排版、微妙动效
- **核心视觉**: 墨韵流光(旋转渐变边框 + 鼠标跟随光晕)— 全站统一
- **反参考**: 过度装饰、花哨动画、多色渐变、拥挤布局
- **风格**: The Crimson Ledger(可审计的宣言)— 大字排印、细线分栏、克制的深红信号,像顶级咨询机构的账本页面
- **参考**: Bain / McKinsey 官网(答案先行的陈述式标题、数据条、结构化留白
- **核心视觉**: 深红断言 Hero + hairline 账本分栏 + bain-card 细线卡片 + 编号数据条
- **反参考**: 过度装饰、花哨动画、多色渐变、拥挤布局;旧水墨体系(旋转渐变边框、鼠标跟随光晕)已退役
### Design Principles
1. **留白即力量**内容呼吸,不拥挤。section 间距 generous,卡片内部留白充足
2. **墨韵流光统一** — 所有卡片共享 ink-glow-border + mouse-follow 系统,但参数克制
3. **朱砂点睛**品牌红 #C41E3A 仅作点缀,不作为主色调。标题中关键词用 font-calligraphy 突出
4. **层次分明**通过字重和间距建立层级,而非颜色多样性
5. **克制动效** — 动效服务于信息传达,不炫技。hover 效果统一:translateY(-4px) + shadow + glow
1. **答案先行**标题直接给出结论与价值判断,不做悬案式文案;副标题与数据负责佐证
2. **朱砂信号(Cinnabar Signal** — 品牌红 #C41E3A 是唯一信号色:每页 ≥3 处触达点、面积 ≤10%;用于强调,绝不作装饰主色
3. **账本骨架**层次由 hairline 分隔线、对齐网格与字号字重建立,而非颜色多样性或装饰性边框
4. **安静底面**背景为白 / mist #F8FAFC 交替分区;深红区块与 ink #0A0E14 深色只留给 CTA 与收束区块
5. **克制动效** — 动效服务于信息传达180280ms、`--ease-ink`、hover 轻 lift + shadow,不做循环装饰动画
### Design Tokens
### 视觉令牌
#### Colors (Strict)
| Token | Value | Usage |
|-------|-------|-------|
| ink | #1C1C1C | 主文字、深色背景 |
| ink-light | #595959 | 次要文字(唯一值) |
| ink-muted | #A3A3A3 | 辅助文字(唯一值) |
| cinnabar | #C41E3A | 品牌强调色,仅点缀 |
| paper | #FFFFFF | 主背景 |
| paper-warm | #FAFAFA | 交替 section 背景 |
| ink-dark | #0A0A0A | CTA 深色背景 |
令牌权威来源为 [DESIGN.md](DESIGN.md)(含机器可读 YAML 令牌层与 `.impeccable/design.json` 侧车)。本文件不再罗列具体令牌值,仅保留产品级永久约束:
#### Typography
| Level | Size | Weight | Usage |
|-------|------|--------|-------|
| H1 | text-5xl sm:text-6xl lg:text-7xl | font-normal (brand) | Hero 品牌名 |
| H2 | text-3xl sm:text-4xl | font-semibold | Section 标题 |
| H3 | text-lg sm:text-xl | font-semibold | Card 标题 |
| Body | text-base | normal | 正文描述 |
| Small | text-sm | normal | 辅助信息 |
| Mono | text-xs font-mono | normal | 编号标签 |
#### Spacing
| Token | Value | Usage |
|-------|-------|-------|
| section-y | py-20 md:py-28 | Section 纵向间距 |
| card-p | p-6 md:p-8 | 卡片内边距 |
| grid-gap | gap-6 md:gap-8 | 网格间距 |
#### Card System
- 所有卡片: `ink-glow-border rounded-2xl` + mouse-follow + hover translateY(-4px)
- 鼠标光晕: `radial-gradient(400px circle, rgba(accent, 0.04), transparent 40%)`
- 阴影: hover `0 16px 32px rgba(0,0,0,0.08)` / default `0 1px 3px rgba(0,0,0,0.04)`
- 边框: ink-glow-border 旋转渐变
#### Section Backgrounds (Alternating)
1. Hero: paper (#FFFFFF)
2. Social Proof: paper-warm (#FAFAFA)
3. Product Matrix: paper (#FFFFFF)
4. Challenge: paper-warm (#FAFAFA)
5. Services: paper (#FFFFFF)
6. Methodology: paper-warm (#FAFAFA)
7. Home Solutions: paper (#FFFFFF)
8. Testimonials: paper-warm (#FAFAFA)
9. Team: paper (#FFFFFF)
10. About: paper-warm (#FAFAFA)
11. News: paper (#FFFFFF)
12. CTA: ink-dark (#1C1C1C)
- 品牌红 #C41E3A 每页 ≥3 触达点、面积 ≤10%
- 字体全部使用本地文件(Geist woff2 + PingFang 回退),禁止外部字体 CDN
- 动效时长 180280ms`--transition-fast``--transition-normal`),缓动 `cubic-bezier(0.22, 1, 0.36, 1)``--ease-ink`
- 字距下限 -0.04em,不得更紧
- 暗黑模式由 `html[data-theme='dark']` 覆盖 CSS 变量驱动,组件不得写死颜色字面量
- 楷体(`--font-brand`)为遗留例外,仅存量页面保留,新页面禁用
+4 -3
View File
@@ -27,9 +27,10 @@ NEXT_PUBLIC_GA_MEASUREMENT_ID=G-LGTLCR15KM
文件位置: `src/components/analytics/GoogleAnalytics.tsx`
**功能:**
- 自动初始化 GA4
- 配置全局追踪
- 在所有页面启用追踪
- 单次初始化 GA4inline snippet 内唯一一次 `gtag('config')` 负责首屏 pageview(含 consent default
- SPA 路由追踪:仅当 pathname/searchParams 变化且 URL 与上次不同时,effect 再发 `config`Google SPA 文档口径)
- 禁止在 snippet 与 effect 中重复 config(复评曾检出「GA4 初始化 2 次」,勿回退;有单测防线)
- 在所有页面启用追踪(根布局 `GoogleAnalyticsWrapper` 动态挂载,SSR 关闭)
### 3. 根布局集成
-1
View File
@@ -103,7 +103,6 @@ src/components/
├── layout/ # 布局组件
│ ├── header.tsx # 页头导航
│ ├── footer.tsx # 页脚
│ ├── mobile-menu.tsx # 移动端菜单
│ ├── mobile-tab-bar.tsx # 移动端底部导航
│ └── breadcrumb.tsx # 面包屑
-8
View File
@@ -184,14 +184,6 @@ import { Footer } from '@/components/layout/footer';
- 联系方式
- 微信公众号二维码
#### MobileMenu 移动端菜单
```tsx
import { MobileMenu } from '@/components/layout/mobile-menu';
// 由 Header 内部调用
```
#### MobileTabBar 移动端底部导航
```tsx
+3 -3
View File
@@ -26,7 +26,7 @@
| `src/lib/analytics.ts` | **新建** | Analytics 核心模块,包含所有追踪函数 |
| `src/components/analytics/GlobalErrorTracker.tsx` | **新建** | 全局错误追踪器组件 |
| `src/app/layout.tsx` | **修改** | 集成 GlobalErrorTracker |
| `src/app/test-error-tracking/page.tsx` | **新建** | 错误监控测试页面 |
| `src/app/test-error-tracking/page.tsx` | **新建** | 错误监控测试页面(后于 2026-09-19 polish 删除) |
### 辅助文件
| 文件 | 操作 | 说明 |
@@ -93,7 +93,7 @@ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
npm run dev
```
2. **访问测试页面**
2. **访问测试页面**(⚠️ `/test-error-tracking` 调试路由已于 2026-09-19 polish 批次删除,本地验证可临时自建同名路由或用控制台 `throw` 触发)
```
http://localhost:3000/test-error-tracking
```
@@ -330,7 +330,7 @@ A: GA4 免费版每个属性每月最多 1000 万次事件。如果超出:
✅ **已完成**:GA4 错误监控完全集成
✅ **已验证**TypeScript 检查通过,构建成功
✅ **可测试**:提供专用测试页面 `/test-error-tracking`
✅ **可测试**提供专用测试页面 `/test-error-tracking`2026-09-19 polish 已删除,防线上暴露)
**可扩展**:支持自定义错误过滤和上下文
**零成本**:利用现有 GA4 账户,无需额外费用
+12 -13
View File
@@ -2,6 +2,8 @@
import { motion } from 'framer-motion';
import { CTAButton } from '@/components/ui/cta-button';
import { MetricsBasisNote } from '@/components/ui/metrics-basis-note';
import { ContentUnavailableState } from '@/components/ui/content-unavailable';
import {
Trophy,
Shield, Award, Zap,
@@ -48,8 +50,8 @@ function HeroSection({ data }: { data: AboutData }) {
const titleLines = (data.heroTitle || '').split('\n');
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<section className="relative min-h-[60vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-32 lg:py-40">
<div className="max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -64,7 +66,7 @@ function HeroSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.92] tracking-tightest mb-8 sm:mb-10 md:mb-12"
>
{titleLines.map((line, i) => (
<div key={i} className={`block ${i > 0 ? 'mt-4 sm:mt-6' : ''}`}>
@@ -92,7 +94,7 @@ function HeroSection({ data }: { data: AboutData }) {
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
>
{data.heroDescription || ''}
</motion.p>
@@ -103,10 +105,10 @@ function HeroSection({ data }: { data: AboutData }) {
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
>
<CTAButton href={data.heroPrimaryCtaHref || '#'} className="min-h-[52px] touch-manipulation">
<CTAButton href={data.heroPrimaryCtaHref || '/contact'} className="min-h-[52px] touch-manipulation">
{data.heroPrimaryCtaLabel || '预约咨询'}
</CTAButton>
<CTAButton href={data.heroSecondaryCtaHref || '#'} variant="secondary" className="min-h-[52px] touch-manipulation">
<CTAButton href={data.heroSecondaryCtaHref || '/methodology'} variant="secondary" className="min-h-[52px] touch-manipulation">
{data.heroSecondaryCtaLabel || '了解更多'}
</CTAButton>
</motion.div>
@@ -145,6 +147,7 @@ function MetricsStrip({ data }: { data: AboutData }) {
</motion.div>
))}
</div>
<MetricsBasisNote className="mt-8 text-center lg:text-left" />
</div>
</section>
);
@@ -421,10 +424,10 @@ function CTASection({ data }: { data: AboutData }) {
{data.ctaDescription || ''}
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<CTAButton href={data.ctaPrimaryHref || '#'} className="min-h-[52px] touch-manipulation">
<CTAButton href={data.ctaPrimaryHref || '/contact'} className="min-h-[52px] touch-manipulation">
{data.ctaPrimaryLabel || '预约咨询'}
</CTAButton>
<CTAButton href={data.ctaSecondaryHref || '#'} variant="secondary" className="min-h-[52px] touch-manipulation">
<CTAButton href={data.ctaSecondaryHref || '/methodology'} variant="secondary" className="min-h-[52px] touch-manipulation">
{data.ctaSecondaryLabel || '了解更多'}
</CTAButton>
</div>
@@ -439,11 +442,7 @@ export default function AboutContentV4({ data }: { data: Record<string, unknown>
// Empty state when no CMS data is available
if (!data || Object.keys(data).length === 0) {
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center">
<div className="text-text-muted text-lg"></div>
</div>
);
return <ContentUnavailableState pageName="关于我们" />;
}
return (
@@ -66,7 +66,7 @@ const brandStats = [
export default function BrandContent() {
return (
<main className="min-h-screen bg-bg-primary overflow-x-hidden">
<div className="min-h-screen bg-bg-primary overflow-x-hidden">
{/* ============ L1: Hero 品牌情感入口 ============ */}
<section className="relative min-h-[90vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
@@ -91,7 +91,7 @@ export default function BrandContent() {
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl text-ink leading-[1.05] tracking-tight mb-8 sm:mb-10"
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl text-ink leading-[1.05] tracking-tight mb-8 sm:mb-10"
>
<span className="sr-only"></span>
<BrandCalligraphyName
@@ -363,6 +363,6 @@ export default function BrandContent() {
</ScrollReveal>
</div>
</section>
</main>
</div>
);
}
+108 -52
View File
@@ -7,6 +7,7 @@ import { Building2, ArrowUpRight, Target, Lightbulb, Filter } from 'lucide-react
import {
type CaseStudy,
} from '@/lib/constants/cases';
import { EARLY_ACCESS } from '@/lib/constants/company';
import { cn } from '@/lib/utils';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
@@ -119,6 +120,10 @@ export default function CasesContentV3({
return allCases.filter(c => c.industry === selectedIndustry);
}, [selectedIndustry, casesProp]);
// critique P0(2026-09-19)CMS 零案例时「全部案例/按行业筛选成功故事」是空头承诺,
// 且空态无任何出口。整段改为共创承诺卡,文案承认现状并给出预约/方法论两条去路。
const hasAnyCases = (casesProp ?? []).length > 0;
return (
<div className="min-h-screen bg-bg-primary text-ink overflow-x-hidden">
{/* Hero Section */}
@@ -138,7 +143,7 @@ export default function CasesContentV3({
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
@@ -226,64 +231,115 @@ export default function CasesContentV3({
{/* Cases List Section */}
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 mb-12 sm:mb-16 md:mb-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="lg:max-w-2xl"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
All Cases
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
{filterTitle}
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
{filterDescription}
</p>
</motion.div>
{hasAnyCases ? (
<>
<div className="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-8 mb-12 sm:mb-16 md:mb-20">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="lg:max-w-2xl"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
All Cases
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
{filterTitle}
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
{filterDescription}
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }}
>
<div className="flex items-center gap-2 flex-wrap">
<Filter className="w-4 h-4 text-text-muted" />
{industries.map((industry) => (
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }}
>
{/* 原生 button + aria-pressed:互斥筛选 chip 的可访问语义。
role="radio" 需 radiogroup 父级 + roving tabindex 才合法,
此处保留按钮原生键盘行为,避免半成品 ARIA。 */}
<div className="flex items-center gap-2 flex-wrap" role="group" aria-label="按行业筛选">
<Filter className="w-4 h-4 text-text-muted" />
{industries.map((industry) => (
<button
key={industry.id}
type="button"
aria-pressed={selectedIndustry === industry.id}
onClick={() => setSelectedIndustry(industry.id)}
className={cn(
'px-4 py-2 text-xs font-medium transition-all duration-300',
selectedIndustry === industry.id
? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-ink'
)}
>
{industry.label}
</button>
))}
</div>
</motion.div>
</div>
{filteredCases.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary">
{filteredCases.map((caseItem, i) => (
<CaseCard key={caseItem.id} caseItem={caseItem} index={i} />
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-text-secondary text-lg mb-6"></p>
<button
key={industry.id}
type="button"
role="radio"
aria-checked={selectedIndustry === industry.id}
onClick={() => setSelectedIndustry(industry.id)}
className={cn(
'px-4 py-2 text-xs font-medium transition-all duration-300',
selectedIndustry === industry.id
? 'bg-brand text-white'
: 'bg-bg-secondary text-text-secondary hover:bg-bg-tertiary hover:text-ink'
)}
onClick={() => setSelectedIndustry('all')}
className="min-h-11 px-4 text-sm font-semibold text-brand-ink underline underline-offset-4 hover:text-brand-hover transition-colors duration-200"
>
{industry.label}
</button>
))}
</div>
)}
</>
) : (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="relative overflow-hidden rounded-[2rem] bg-dark-bg p-10 sm:p-12 shadow-card"
>
{/* 背景纹理与出口结构沿用首页共创深色卡(home-content-v15 Cases 区) */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.05]"
style={{
backgroundImage: 'radial-gradient(circle at 75% 25%, rgba(255,255,255,0.5) 1px, transparent 1px)',
backgroundSize: '48px 48px',
}}
/>
<div className="relative z-10 flex flex-col lg:flex-row lg:items-end lg:justify-between gap-10">
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-px bg-brand" />
<span className="text-[13px] tracking-[0.12em] font-semibold uppercase text-brand-light">
Case Studies
</span>
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-black text-white leading-[1.05] mb-6">
{EARLY_ACCESS.label}
</h2>
<p className="text-white/70 leading-relaxed">{EARLY_ACCESS.desc}</p>
</div>
<div className="flex flex-col sm:flex-row gap-4 shrink-0">
<CTAButton href="/contact" dataTestId="cases-empty-primary-cta">
</CTAButton>
<CTAButton href="/methodology" variant="dark" dataTestId="cases-empty-secondary-cta">
</CTAButton>
</div>
</div>
</motion.div>
</div>
{filteredCases.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary">
{filteredCases.map((caseItem, i) => (
<CaseCard key={caseItem.id} caseItem={caseItem} index={i} />
))}
</div>
) : (
<div className="text-center py-20">
<p className="text-text-secondary text-lg"></p>
</div>
)}
</div>
</section>
@@ -283,7 +283,7 @@ describe('ContactContentV3 - 联系表单集成测试', () => {
it('should render CTA section', async () => {
await renderContactContent();
expect(screen.getByText('准备好开启数字化转型了吗?')).toBeInTheDocument();
expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
expect(screen.getByText('预约咨询')).toBeInTheDocument();
expect(screen.getByText('了解更多')).toBeInTheDocument();
});
@@ -1,6 +1,6 @@
'use client';
import { useState, Suspense } from 'react';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { z } from 'zod';
import { motion } from 'framer-motion';
@@ -9,6 +9,9 @@ import { LabeledInput as Input } from '@/components/ui/labeled-input';
import { LabeledTextarea as Textarea } from '@/components/ui/labeled-textarea';
import { Toast } from '@/components/ui/toast';
import { CTAButton } from '@/components/ui/cta-button';
import { canonicalCtaLabel } from '@/lib/constants/cta-labels';
import { StaticLink } from '@/components/ui/static-link';
import { ContentUnavailableState } from '@/components/ui/content-unavailable';
import {
Mail,
MapPin,
@@ -78,9 +81,8 @@ function ContactFormContent({ data }: { data: ContactData }) {
const [toastMessage, setToastMessage] = useState(
isSuccessFromRedirect ? '表单提交成功!我们会尽快与您联系。' : ''
);
const [toastType, setToastType] = useState<'success' | 'error'>(
isSuccessFromRedirect ? 'success' : 'success'
);
// 初值仅在 ?success=true 重定向时随 showToast/isSubmitted 一同生效,故恒为 success。
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(isSuccessFromRedirect);
const [formData, setFormData] = useState<ContactFormData>({
@@ -92,6 +94,14 @@ function ContactFormContent({ data }: { data: ContactData }) {
});
const [errors, setErrors] = useState<FormErrors>({});
// 刷新不应重放成功态:读取 ?success=true 后立即从 URL 中移除
useEffect(() => {
if (!isSuccessFromRedirect) return;
const url = new URL(window.location.href);
url.searchParams.delete('success');
window.history.replaceState(null, '', url.pathname + url.search + url.hash);
}, [isSuccessFromRedirect]);
const emailAddress = data.email ?? '';
const addressText = data.address ?? '';
@@ -201,7 +211,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
}
}
const heroTitleLines = (data.heroTitle || '').split('\n');
const heroTitleLines = (data.heroTitle || '随时欢迎\n与您聊聊需求').split('\n');
const commitmentItems = data.commitmentItems ?? [];
return (
@@ -211,29 +221,32 @@ function ContactFormContent({ data }: { data: ContactData }) {
<Toast
message={toastMessage}
type={toastType}
duration={toastType === 'error' ? 0 : undefined}
duration={toastType === 'error' ? 6000 : undefined}
onClose={() => setShowToast(false)}
/>
)}
{/* Hero Section */}
<section className="relative min-h-[80vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
{/* Hero Section —— critique P2(2026-09-19)80vh 巨幅把转化表单推出首屏,
转化页按 DESIGN.md 紧凑档处理:无 min-h、py 收窄,表单回到首屏视野。 */}
<section className="relative flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 lg:py-28">
<div className="max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.15em] text-text-muted mb-8"
>
{data.heroSubtitle || ''}
</motion.div>
{data.heroSubtitle && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1, ease: EASE_OUT }}
className="text-sm font-mono tracking-[0.15em] text-text-muted mb-8"
>
{data.heroSubtitle}
</motion.div>
)}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.88] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
{heroTitleLines.map((line, i) => (
<div key={i} className={`block ${i > 0 ? 'mt-4 sm:mt-6' : ''}`}>
@@ -257,14 +270,16 @@ function ContactFormContent({ data }: { data: ContactData }) {
))}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
>
{data.heroDescription || ''}
</motion.p>
{data.heroDescription && (
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
>
{data.heroDescription}
</motion.p>
)}
</div>
</div>
</section>
@@ -282,7 +297,9 @@ function ContactFormContent({ data }: { data: ContactData }) {
className="lg:col-span-2 space-y-8"
>
<div>
<h2 className="text-xl font-bold text-ink mb-8">{data.contactTitle || ''}</h2>
{data.contactTitle && (
<h2 className="text-xl font-bold text-ink mb-8">{data.contactTitle}</h2>
)}
<div className="space-y-6" data-testid="contact-info">
<div className="flex items-start gap-4" data-testid="email-info">
<div className="w-12 h-12 border border-border-primary bg-bg-elevated flex items-center justify-center shrink-0" aria-hidden="true">
@@ -378,7 +395,23 @@ function ContactFormContent({ data }: { data: ContactData }) {
<CheckCircle2 className="w-10 h-10 text-white" />
</div>
<h4 className="text-2xl font-bold text-ink mb-3">{data.successTitle || '提交成功'}</h4>
<p className="text-text-secondary">{data.successMessage || '感谢您的留言,我们会尽快与您联系。'}</p>
<p className="text-text-secondary mb-10">{data.successMessage || '感谢您的留言,我们会尽快与您联系。'}</p>
<div className="flex flex-col items-center justify-center gap-5 mb-10">
<CTAButton href="/methodology"></CTAButton>
<StaticLink
href="/products"
className="text-sm text-text-secondary underline-offset-4 transition-colors hover:text-brand-ink hover:underline"
>
</StaticLink>
</div>
<button
type="button"
onClick={() => setIsSubmitted(false)}
className="text-sm text-text-muted transition-colors hover:text-text-secondary"
>
</button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6" noValidate>
@@ -529,12 +562,16 @@ function ContactFormContent({ data }: { data: ContactData }) {
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 justify-center">
<CTAButton href={data.ctaPrimaryHref || '#'} className="px-12 py-6 font-bold">
{data.ctaPrimaryLabel || ''}
</CTAButton>
<CTAButton href={data.ctaSecondaryHref || '#'} variant="secondary" className="px-12 py-6 font-bold">
{data.ctaSecondaryLabel || ''}
</CTAButton>
{data.ctaPrimaryLabel && data.ctaPrimaryHref && (
<CTAButton href={data.ctaPrimaryHref} className="px-12 py-6 font-bold">
{canonicalCtaLabel(data.ctaPrimaryLabel)}
</CTAButton>
)}
{data.ctaSecondaryLabel && data.ctaSecondaryHref && (
<CTAButton href={data.ctaSecondaryHref} variant="secondary" className="px-12 py-6 font-bold">
{data.ctaSecondaryLabel}
</CTAButton>
)}
</div>
</motion.div>
</div>
@@ -544,11 +581,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
}
function EmptyState() {
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center">
<div className="text-text-muted text-lg"></div>
</div>
);
return <ContentUnavailableState pageName="联系我们" />;
}
export default function ContactContentV3({ data }: { data: Record<string, unknown> }) {
@@ -139,10 +139,12 @@ describe('HomeContentV15 - 信任层与章节式叙事', () => {
expect(screen.getAllByText(/数据来源:/i).length).toBeGreaterThanOrEqual(4);
});
it('renders an early-access co-creation label instead of fabricated testimonials', () => {
it('consolidates the co-creation pitch into the cases dark card (no duplicated TrustSection banner)', () => {
render(<HomeContentV15 />);
expect(screen.getByTestId('early-access-banner')).toBeInTheDocument();
// 复评 P1:早鸟横幅曾与案例深色卡、洞察卡三处重复共创主张,现收敛为唯一深色卡
expect(screen.queryByTestId('early-access-banner')).not.toBeInTheDocument();
expect(screen.getByTestId('cases-co-creation-cta')).toBeInTheDocument();
expect(screen.getAllByText('首批客户共创中').length).toBeGreaterThanOrEqual(1);
});
+23 -74
View File
@@ -10,7 +10,10 @@ import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, C
import { cn } from '@/lib/utils';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { CTAButton } from '@/components/ui/cta-button';
import { MetricsBasisNote } from '@/components/ui/metrics-basis-note';
import { COMPANY_INFO, TRUST_SIGNALS, EARLY_ACCESS } from '@/lib/constants';
import { canonicalCtaLabel, CTA_LABELS } from '@/lib/constants/cta-labels';
import { canonicalTrustSource } from '@/lib/constants/company';
import { useSiteConfig } from '@/lib/site-config';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { type NewsItem } from '@/lib/constants/news';
@@ -100,13 +103,6 @@ const FALLBACK_INSIGHTS = [
date: '2026-07',
href: '/services/software',
},
{
tag: '共创',
title: '首批客户共创计划:与早期客户共同打磨产品',
desc: '以真实运营数据验证业务改善,成果在获得客户授权后公开。',
date: '2026-06',
href: '/contact',
},
];
// ===== 创始人观点兜底(对标 Accenture CEO Quote,人格化背书)=====
@@ -171,7 +167,7 @@ function HeroSection({ heroData }: {
const heading = heroData?.heading || heroData?.headingBottom || '让每一家企业都拥有数据驱动的决策能力';
const subheading = heroData?.subheading || heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaLabel = canonicalCtaLabel(heroData?.ctaLabel) || CTA_LABELS.consult;
const ctaHref = heroData?.ctaHref || '/contact';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '查看产品矩阵';
const secondaryCtaHref = heroData?.secondaryCtaHref || '/products';
@@ -300,28 +296,17 @@ function HeroStatsBand({ stats }: { stats?: Record<string, any>[] }) {
))}
</div>
</ScrollReveal>
<MetricsBasisNote className="mt-6" />
</div>
</section>
);
}
// ===== 信任/成果区:可验证信任信号 + 首批客户共创标签 =====
// 共创计划默认状态条目(如实呈现,无真实案例时不虚构客户)
const DEFAULT_EARLY_ACCESS_STATUS = [
{ label: '共创进行中', desc: '我们正与首批客户共同打磨产品与方案,以真实运营数据验证业务改善。' },
{ label: '产品内测中', desc: '自研产品(如睿视 NovaVis)处于内测阶段,成熟后正式发布。' },
{ label: '成果授权公开', desc: '案例与可量化成果在获得客户授权后公示——经得起衡量。' },
];
// ===== 信任/成果区:可验证信任信号 =====
function TrustSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
const eyebrow = pageCopy?.trustEyebrow || '值得信赖';
const titleLines = (pageCopy?.trustTitle || '以可衡量的成果\n赢得长期信任').split('\n');
const signals = (pageCopy?.trustSignals?.length ? pageCopy.trustSignals : TRUST_SIGNALS) as typeof TRUST_SIGNALS;
const earlyLabel = pageCopy?.earlyAccessLabel || EARLY_ACCESS.label;
const earlyDesc = pageCopy?.earlyAccessDesc || EARLY_ACCESS.desc;
const earlyTitle = (pageCopy?.earlyAccessTitle as string) || '首批客户共创计划';
const earlyCtaLabel = (pageCopy?.earlyAccessCtaLabel as string) || '成为共创客户';
const earlyStatus = (pageCopy?.earlyAccessStatus?.length ? pageCopy.earlyAccessStatus : DEFAULT_EARLY_ACCESS_STATUS) as typeof DEFAULT_EARLY_ACCESS_STATUS;
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary" data-testid="trust-section">
@@ -350,44 +335,12 @@ function TrustSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
</div>
<p className="text-sm text-text-secondary leading-relaxed mb-4 flex-1">{item.desc}</p>
<div className="text-xs text-text-muted border-t border-border-primary pt-3">
{item.source}
{canonicalTrustSource(item.source)}
</div>
</div>
</StaggerReveal>
))}
</div>
{/* 首批客户共创计划:如实呈现共创/内测/授权公开状态,不虚构客户 */}
<StaggerReveal staggerDelay={0.05}>
<div className="p-6 sm:p-8 bg-brand-bg border border-[rgba(196,30,58,0.15)]" data-testid="early-access-banner">
<div className="flex items-center gap-3 mb-4">
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand-ink">
{earlyLabel}
</span>
</div>
<h3 className="text-xl sm:text-2xl font-bold text-ink mb-3">
{earlyTitle}
</h3>
<p className="text-sm text-text-secondary leading-relaxed mb-6 max-w-2xl">
{earlyDesc}
</p>
<div className="grid sm:grid-cols-3 gap-4 mb-8">
{earlyStatus.map((s) => (
<div key={s.label} className="p-4 bg-bg-primary border border-brand/10">
<div className="text-sm font-semibold text-brand-ink mb-1">{s.label}</div>
<div className="text-xs text-text-secondary leading-relaxed">{s.desc}</div>
</div>
))}
</div>
<StaticLink
href="/contact"
className="group inline-flex shrink-0 items-center min-h-6 gap-2 text-sm font-semibold text-ink hover:text-brand-ink transition-colors"
>
<span>{earlyCtaLabel}</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</StaticLink>
</div>
</StaggerReveal>
</div>
</section>
);
@@ -538,7 +491,7 @@ function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
</p>
<div className="pt-6 border-t border-border-primary">
<span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors">
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</span>
</div>
@@ -596,13 +549,16 @@ function ServicesSection({ services, pageCopy }: { services?: Record<string, any
))}
</div>
{(service as any).metrics && (
<div className="flex gap-6 mb-8 pb-8 border-b border-border-primary">
{(service as any).metrics.map((m: any, j: number) => (
<div key={j}>
<div className="text-2xl sm:text-3xl font-black text-ink leading-none mb-1">{m.value}</div>
<div className="text-xs text-text-muted font-medium">{m.label}</div>
</div>
))}
<div className="mb-8 pb-8 border-b border-border-primary">
<div className="flex gap-6 mb-2">
{(service as any).metrics.map((m: any, j: number) => (
<div key={j}>
<div className="text-2xl sm:text-3xl font-black text-ink leading-none mb-1">{m.value}</div>
<div className="text-xs text-text-muted font-medium">{m.label}</div>
</div>
))}
</div>
<MetricsBasisNote />
</div>
)}
<div className="mt-auto pt-2">
@@ -700,16 +656,9 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05] mb-8">
<StyledTitle lines={casesTitleLines} />
</h2>
<p className="text-text-secondary leading-relaxed text-lg mb-8">
<p className="text-text-secondary leading-relaxed text-lg">
{casesDesc}
</p>
<StaticLink
href="/contact"
className="group inline-flex items-center min-h-6 gap-3 text-ink font-semibold text-sm"
>
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand-ink" />
</StaticLink>
</ScrollReveal>
<ScrollReveal delay={0.15}>
<div className="relative overflow-hidden rounded-[2rem] bg-dark-bg p-10 sm:p-12 h-full flex flex-col shadow-card">
@@ -748,7 +697,7 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
</ol>
<div className="mt-auto">
<CTAButton href="/contact" dataTestId="cases-co-creation-cta">
{CTA_LABELS.consult}
</CTAButton>
</div>
</div>
@@ -980,7 +929,7 @@ function NewsSection({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: HomePag
function CTASection({ heroData, pageCopy }: { heroData?: Record<string, any> | null; pageCopy?: HomePageCopy | null }) {
const ctaTitleLine1 = heroData?.ctaTitleLine1 || pageCopy?.ctaTitleLine1 || '准备好开始了吗?';
const ctaTitleLine2 = heroData?.ctaTitleLine2 || pageCopy?.ctaTitleLine2 || '让数据真正转化为业务增长';
const ctaLabel = heroData?.ctaLabel || pageCopy?.ctaLabel || '预约咨询';
const ctaLabel = canonicalCtaLabel(heroData?.ctaLabel || (pageCopy?.ctaLabel as string)) || CTA_LABELS.consult;
const ctaHref = heroData?.ctaHref || '/contact';
return (
@@ -1009,7 +958,7 @@ export default function HomeContentV15({ services, cases, stats, heroData, news,
pageCopy?: HomePageCopy | null;
}) {
return (
<main>
<div>
{/* 1. Hero:浅色 editorial 画布 + 品牌红单电压(参考 Accenture Radical Relevance */}
<HeroSection heroData={heroData} />
@@ -1039,6 +988,6 @@ export default function HomeContentV15({ services, cases, stats, heroData, news,
{/* 9. CTA:克制强调,品牌红仅用于按钮 */}
<CTASection heroData={heroData} pageCopy={pageCopy} />
</main>
</div>
);
}
+3 -1
View File
@@ -13,7 +13,9 @@ export default function MarketingLayout({
<div className="min-h-screen flex flex-col">
<Header />
<ErrorBoundary>
<div className="flex-1 pt-16" role="main" aria-label="页面主体内容">
{/* 单一 main 原则:全局 <main id="main-content">ClientLayout)是唯一 landmark
此处仅是布局容器,不得再声明 role="main"2026-09-19 polish 收敛三重 main)。 */}
<div className="flex-1 pt-16">
{children}
</div>
</ErrorBoundary>
@@ -3,6 +3,7 @@
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { CheckCircle2 } from 'lucide-react';
@@ -26,7 +27,7 @@ export function MethodologyContent({ data }: MethodologyContentProps) {
const phases: MethodologyPhase[] = Array.isArray(data?.phases) ? data.phases : [];
return (
<main>
<div>
{/* Hero:品牌化定位,轻量 CTA */}
<section className="relative min-h-[60vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-28 lg:py-32">
@@ -155,12 +156,11 @@ export function MethodologyContent({ data }: MethodologyContentProps) {
<div className="flex flex-wrap justify-center gap-6">
<CTAButton href="/services"></CTAButton>
<CTAButton href="/contact" variant="secondary">
</CTAButton>
</div>
{CTA_LABELS.consult}
</CTAButton> </div>
</ScrollReveal>
</div>
</section>
</main>
</div>
);
}
@@ -2,6 +2,7 @@
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card } from '@/components/ui/card';
@@ -141,7 +142,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</Button>
</StaticLink>
<CTAButton href="/contact" dataTestId="news-slug-detail-cta" className="px-8 py-3 text-base font-medium">
{CTA_LABELS.consult}
</CTAButton>
</div>
</motion.div>
+3 -2
View File
@@ -6,6 +6,7 @@ import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Button } from '@/components/ui/button';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Search, Calendar, ArrowRight, Newspaper, ChevronLeft, ChevronRight } from 'lucide-react';
@@ -129,7 +130,7 @@ export default function NewsContentV3({ news, pageCopy }: { news?: NewsItem[]; p
</ScrollReveal>
<ScrollReveal delay={0.1}>
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-bold leading-[0.92] tracking-tighter mb-10 sm:mb-12 md:mb-16">
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold leading-[0.92] tracking-tighter mb-10 sm:mb-12 md:mb-16">
{heroTitleLines.map((line, i) => (
<span key={i} className={i === heroTitleLines.length - 1 ? 'block mt-4 text-brand-ink' : 'block'}>
{line}
@@ -252,7 +253,7 @@ export default function NewsContentV3({ news, pageCopy }: { news?: NewsItem[]; p
</p>
<div className="mt-12 flex flex-col sm:flex-row justify-center gap-4 sm:gap-6">
<CTAButton href="/contact" dataTestId="news-bottom-cta">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/about" variant="secondary" className="px-12 py-6">
@@ -3,6 +3,7 @@
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Badge } from '@/components/ui/badge';
import { Calendar, ArrowLeft, Newspaper } from 'lucide-react';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
@@ -155,7 +156,7 @@ export default function NewsDetailContentV3({ news, relatedNews = [] }: NewsDeta
</StaticLink>
<CTAButton href="/contact" dataTestId="news-detail-bottom-cta" className="px-8 py-4 font-medium text-base">
{CTA_LABELS.consult}
</CTAButton>
</div>
</ScrollReveal>
@@ -154,7 +154,7 @@ describe('ProductDetailPage', () => {
const page = await ProductDetailPage({ params: Promise.resolve({ id: 'test-product' }) });
render(page);
const appointmentLinks = screen.getAllByRole('link', { name: /预约体验/i });
const appointmentLinks = screen.getAllByRole('link', { name: /预约咨询/i });
expect(appointmentLinks.length).toBeGreaterThan(0);
});
});
@@ -2,6 +2,7 @@ import { Metadata } from 'next';
import { getProductBySlug } from '@/lib/cms/data-server';
import { COMPANY_INFO } from '@/lib/constants';
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { DetailHero } from '@/components/detail/detail-hero';
import { ProductValueSection } from '@/components/detail/detail-product-value';
import { DetailTrustSection } from '@/components/detail/detail-trust-section';
@@ -65,7 +66,7 @@ export default async function ERPUpgradeV3Page() {
title={title}
subtitle={description}
description={overview}
primaryAction={{ label: '预约体验', href: '/contact' }}
primaryAction={{ label: CTA_LABELS.consult, href: '/contact' }}
secondaryAction={{ label: '查看方案', href: '/solutions' }}
/>
@@ -83,8 +84,7 @@ export default async function ERPUpgradeV3Page() {
theme={theme}
title={`探索 ${title} 的无限可能`}
description="立即联系我们,获取专属方案和报价"
primaryAction={{ label: '预约体验', href: '/contact' }}
secondaryAction={{ label: '下载产品手册', href: '#' }}
primaryAction={{ label: CTA_LABELS.consult, href: '/contact' }}
/>
</div>
);
@@ -4,6 +4,7 @@ import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { ArrowRight, Package, Check, Settings, Users, FileText, Cpu, TrendingUp } from 'lucide-react';
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import type { Product } from '@/lib/constants/products';
@@ -91,7 +92,7 @@ function DetailHero({ product }: DetailHeroProps) {
className="flex flex-wrap gap-6"
>
<CTAButton href="/contact" dataTestId="product-detail-hero-primary">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/products" variant="secondary" dataTestId="product-detail-hero-secondary">
@@ -286,7 +287,7 @@ function CTASection({ product }: CTASectionProps) {
<div className="flex flex-wrap justify-center gap-6">
<CTAButton href="/contact" dataTestId="product-detail-bottom-primary">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/products" variant="secondary" dataTestId="product-detail-bottom-secondary">
@@ -318,7 +319,7 @@ interface ProductDetailContentV3Props {
export default function ProductDetailContentV3({ product }: ProductDetailContentV3Props) {
return (
<main>
<div>
<DetailHero product={product} />
<FeaturesSection features={product.features} />
{product.methodology && product.methodology.length > 0 && (
@@ -359,6 +360,6 @@ export default function ProductDetailContentV3({ product }: ProductDetailContent
/>
)}
<CTASection product={product} />
</main>
</div>
);
}
@@ -310,21 +310,41 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
expect(screen.getByText('推荐产品组合')).toBeInTheDocument();
});
it('should render CTA section', async () => {
it('should render CTA section with consult primary routing to contact', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
expect(screen.getByText('联系我们')).toBeInTheDocument();
const consultButtons = screen.getAllByText('预约咨询');
expect(consultButtons.length).toBeGreaterThanOrEqual(2); // hero + 底部主行动
const heroPrimary = document.querySelector('[data-testid="products-hero-primary"]');
expect(heroPrimary?.getAttribute('href')).toBe('/contact');
const bottomPrimary = document.querySelector('[data-testid="products-cta-section-primary"]');
expect(bottomPrimary?.getAttribute('href')).toBe('/contact');
expect(screen.getByText('查看行业方案')).toBeInTheDocument();
});
it('should render stats section with product count', async () => {
it('should badge pre-release products and annotate metrics as targets', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
expect(screen.getByText('产品线')).toBeInTheDocument();
expect(screen.getByText('自研率')).toBeInTheDocument();
expect(screen.getByText('技术覆盖')).toBeInTheDocument();
expect(screen.getAllByText('研发中').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('目标口径,非既成结果')).toBeInTheDocument();
});
it('should not annotate metrics for published products only', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={mockEnterpriseProducts} />);
expect(screen.queryByText('目标口径,非既成结果')).not.toBeInTheDocument();
});
it('should not render hero stats band (distilled: duplicates hero copy and product grid)', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
expect(screen.queryByText('产品线')).not.toBeInTheDocument();
expect(screen.queryByText('自研率')).not.toBeInTheDocument();
expect(screen.queryByText('技术覆盖')).not.toBeInTheDocument();
});
it('should handle empty products list gracefully', async () => {
@@ -1,15 +1,15 @@
'use client';
import { useRef, useState, useEffect } from 'react';
import { motion, useInView } from 'framer-motion';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { MetricsBasisNote } from '@/components/ui/metrics-basis-note';
import { BentoItem } from '@/components/sections/bento-grid';
import { ArrowRight, ArrowUpRight, Package, Cpu, TrendingUp, Users, Settings, FileText } from 'lucide-react';
import type { Product } from '@/lib/constants/products';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { cn } from '@/lib/utils';
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
@@ -25,79 +25,25 @@ const PRODUCT_ICONS: Record<string, React.ReactNode> = {
'睿新供应链数字化平台': <Cpu className="w-5 h-5" />,
};
function useCountUp(target: number, start: boolean, duration: number = 2000) {
// 初始值设为 target,避免动画触发前或用户偏好减少动效时显示 0
const [count, setCount] = useState(target);
const prefersReducedMotion = useReducedMotion();
useEffect(() => {
if (!start || prefersReducedMotion) {
setCount(target);
return;
}
const startTime = performance.now();
let animationId: number;
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
setCount(Math.floor(target * easeOutQuart));
if (progress < 1) {
animationId = requestAnimationFrame(animate);
} else {
setCount(target);
}
};
animationId = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationId);
}, [target, start, duration, prefersReducedMotion]);
return count;
}
function StatItem({ value, suffix, label, start, delay }: { value: number; suffix: string; label: string; start: boolean; delay: number }) {
const count = useCountUp(value, start, 2000);
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={start ? { opacity: 1, y: 0 } : {}}
transition={{ delay, duration: 0.3, ease: EASE_OUT }}
>
<div className="text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-none mb-2">
{count}{suffix}
</div>
<div className="text-sm text-text-muted">{label}</div>
</motion.div>
);
}
function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const statsRef = useRef(null);
const isInView = useInView(statsRef, { once: true, margin: '-100px' });
const heroTitle1 = (pageCopy?.productsHeroTitle1 as string) || '自研产品矩阵';
const heroTitle2 = (pageCopy?.productsHeroTitle2 as string) || '支撑企业';
const heroTitle3 = (pageCopy?.productsHeroTitle3 as string) || '数字化核心系统';
const heroDescriptionLines = ((pageCopy?.productsHeroDescription as string) ||
'从业务系统到数据智能,深度自研、互为补充。\n6 大企业套装与专业产品线,覆盖企业数字化核心场景。').split('\n');
const ctaPrimary = (pageCopy?.productsCtaPrimary as string) || '了解我们的服务';
// Hero 主行动固定「预约咨询」→ /contact(复评 P1#2:产品 hub 不得把主 CTA 导去 /services
// 不读 CMS productsCtaPrimary,避免旧文案「了解我们的服务」配 /contact 造成标签/路由不一致)
const ctaSecondary = (pageCopy?.productsCtaSecondary as string) || '查看行业方案';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-28 md:py-32 lg:py-40">
<section className="relative overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 lg:py-28">
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 70 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.15, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-ink leading-[0.92] tracking-tighter mb-8 sm:mb-10 md:mb-12"
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tightest mb-8 sm:mb-10 md:mb-12"
>
<div className="block">{heroTitle1}</div>
<div className="block mt-5">
@@ -119,7 +65,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.45, ease: EASE_OUT }}
className="text-lg sm:text-xl lg:text-2xl text-text-secondary max-w-2xl leading-relaxed mb-8 sm:mb-10 md:mb-12"
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
>
{heroDescriptionLines.map((line, i) => (
<span key={i}>
@@ -135,24 +81,13 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
transition={{ duration: 0.3, delay: 0.6, ease: EASE_OUT }}
className="flex flex-wrap gap-6"
>
<CTAButton href="/services" dataTestId="products-hero-primary">
{ctaPrimary}
<CTAButton href="/contact" dataTestId="products-hero-primary">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/solutions" variant="secondary" dataTestId="products-hero-secondary">
{ctaSecondary}
</CTAButton>
</motion.div>
<div ref={statsRef} className="grid grid-cols-3 gap-12 mt-24 pt-16 border-t border-border-primary max-w-2xl">
<StatItem value={6} suffix="+" label="产品线" start={isInView} delay={0.1} />
<StatItem value={100} suffix="%" label="自研率" start={isInView} delay={0.2} />
<div>
<div className="text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-none mb-2">
<span className="text-brand-ink"></span>
</div>
<div className="text-sm text-text-muted"></div>
</div>
</div>
</div>
</div>
</section>
@@ -181,14 +116,14 @@ function ProductCard({ product, size = 'small' }: { product: Product; size?: 'la
>
<div className={cn('p-6 sm:p-7 lg:p-8 flex flex-col flex-1', isLarge && 'lg:p-10')}>
<div className="flex items-start justify-between mb-4">
<div>
<div className="text-xs text-text-muted mb-1">{bundleLabel}</div>
<h3 className={cn('font-bold text-text-primary', isLarge ? 'text-2xl lg:text-3xl' : 'text-lg')}>
{product.title}
</h3>
</div>
<h3 className={cn('font-bold text-text-primary', isLarge ? 'text-2xl lg:text-3xl' : 'text-lg')}>
{product.title}
</h3>
<div className="flex items-center gap-2 shrink-0">
<span className="px-2 py-1 text-xs bg-brand/10 text-brand-ink" aria-hidden="true">{bundleLabel}</span>
{product.status !== '已发布' && (
<span className="px-2 py-1 text-xs bg-accent-blue-soft text-accent-blue">{product.status}</span>
)}
<span className="px-2 py-1 text-xs bg-brand/10 text-brand-ink">{bundleLabel}</span>
{isExternal && (
<span className="inline-flex items-center gap-1 px-2 py-1 text-xs border border-border-primary text-text-secondary">
<ArrowUpRight className="w-3 h-3" aria-hidden="true" />
@@ -210,6 +145,9 @@ function ProductCard({ product, size = 'small' }: { product: Product; size?: 'la
</div>
))}
</div>
{product.metrics && product.metrics.length > 0 && product.status !== '已发布' && (
<MetricsBasisNote className="-mt-2 mb-4" />
)}
<div className="flex flex-wrap gap-2">
{product.capabilities?.map((cap) => (
@@ -226,8 +164,8 @@ function ProductCard({ product, size = 'small' }: { product: Product; size?: 'la
}
function BundleOverview({ products, pageCopy }: { products: Product[]; pageCopy?: PageCopy | null }) {
const enterpriseProducts = products.filter((p) => p.bundle === 'enterprise');
const standaloneProducts = products.filter((p) => p.bundle === 'standalone');
const enterpriseCount = products.filter((p) => p.bundle === 'enterprise').length;
const standaloneCount = products.filter((p) => p.bundle === 'standalone').length;
const bundleTitle = (pageCopy?.productsBundleTitle as string) || '两套产品体系\n覆盖不同场景';
const bundleTitleLines = bundleTitle.split('\n');
const bundleDescription = (pageCopy?.productsBundleDescription as string) || '企业套装以组合形式解决核心管理问题,专业产品聚焦独立场景深度赋能。';
@@ -257,24 +195,11 @@ function BundleOverview({ products, pageCopy }: { products: Product[]; pageCopy?
<Package className="w-5 h-5" />
</div>
<h3 className="text-xl font-bold text-ink"></h3>
<span className="px-2 py-1 text-xs bg-brand/10 text-brand-ink">6 </span>
<span className="px-2 py-1 text-xs bg-brand/10 text-brand-ink">{enterpriseCount} </span>
</div>
<p className="text-sm text-text-secondary leading-relaxed mb-6">
<p className="text-sm text-text-secondary leading-relaxed">
</p>
<div className="space-y-3">
{enterpriseProducts.map((p) => (
<div key={p.id} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-bg-elevated border border-border-primary flex items-center justify-center text-text-secondary">
{PRODUCT_ICONS[p.title] || <Package className="w-4 h-4" />}
</div>
<div>
<div className="text-sm font-medium text-ink">{p.title}</div>
<div className="text-xs text-text-muted">{p.scenario}</div>
</div>
</div>
))}
</div>
</div>
<div className="bg-bg-elevated p-8 sm:p-10 lg:p-12">
@@ -283,24 +208,11 @@ function BundleOverview({ products, pageCopy }: { products: Product[]; pageCopy?
<Cpu className="w-5 h-5" />
</div>
<h3 className="text-xl font-bold text-ink"></h3>
<span className="px-2 py-1 text-xs bg-accent-blue-soft text-text-primary"></span>
<span className="px-2 py-1 text-xs bg-accent-blue-soft text-text-primary">{standaloneCount} </span>
</div>
<p className="text-sm text-text-secondary leading-relaxed mb-6">
<p className="text-sm text-text-secondary leading-relaxed">
</p>
<div className="space-y-3">
{standaloneProducts.map((p) => (
<div key={p.id} className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center text-text-secondary">
{PRODUCT_ICONS[p.title] || <Cpu className="w-4 h-4" />}
</div>
<div>
<div className="text-sm font-medium text-ink">{p.title}</div>
<div className="text-xs text-text-muted">{p.scenario}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
@@ -494,11 +406,11 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
</p>
<div className="flex flex-wrap justify-center gap-6">
<CTAButton href="/services" dataTestId="products-cta-section-primary">
<CTAButton href="/contact" dataTestId="products-cta-section-primary">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/contact" variant="secondary" dataTestId="products-cta-section-secondary">
<CTAButton href="/services" variant="secondary" dataTestId="products-cta-section-secondary">
</CTAButton>
</div>
</ScrollReveal>
@@ -511,13 +423,13 @@ export default function ProductsContentV3({ products: productsProp, pageCopy }:
const products = productsProp ?? [];
return (
<main>
<div>
<HeroSection pageCopy={pageCopy} />
<BundleOverview products={products} pageCopy={pageCopy} />
<EnterpriseProductsSection products={products} />
<SpecializedProductsSection products={products} />
<SuiteCombosSection products={products} />
<CTASection pageCopy={pageCopy} />
</main>
</div>
);
}
@@ -2,6 +2,7 @@
import { getHeroTheme } from '@/lib/constants/hero-themes';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { DetailHero } from '@/components/detail/detail-hero';
import { DetailTrustSection } from '@/components/detail/detail-trust-section';
import { DetailCTASection } from '@/components/detail/detail-cta-section';
@@ -270,8 +271,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
theme={theme}
title={`了解更多关于${title}`}
description="联系我们,获取详细的产品资料和方案报价"
primaryAction={{ label: '预约演示', href: '/contact' }}
secondaryAction={{ label: '下载产品手册', href: '#' }}
primaryAction={{ label: CTA_LABELS.consult, href: '/contact' }}
/>
</>
)}
@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { CheckCircle2, Zap, Clock, Users, Award, TrendingUp, Shield } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { Service } from '@/lib/constants/services';
@@ -15,14 +16,14 @@ function DetailHero({ service }: { service: Service }) {
const firstBenefit = service.benefits?.[0] || '效率提升 40%';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<section className="relative overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 lg:py-28">
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12 md:mb-16"
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black text-ink leading-[0.95] tracking-tightest mb-6 sm:mb-8"
>
<div className="block">{service.title}</div>
<div className="block mt-4 sm:mt-6">
@@ -45,7 +46,7 @@ function DetailHero({ service }: { service: Service }) {
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
>
{service.description}
</motion.p>
@@ -54,36 +55,10 @@ function DetailHero({ service }: { service: Service }) {
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }}
className="grid grid-cols-3 gap-8 mb-12 sm:mb-16 max-w-xl"
>
<div>
<div className="text-3xl sm:text-4xl font-black text-brand-ink tracking-tight leading-none mb-2">
10+
</div>
<div className="text-sm text-text-muted"></div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
6
</div>
<div className="text-sm text-text-muted"></div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
100%
</div>
<div className="text-sm text-text-muted"></div>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.9, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
>
<CTAButton href="/contact" dataTestId="service-detail-hero-primary" className="min-h-[52px]">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="#cases" variant="secondary" className="min-h-[52px] touch-manipulation">
@@ -276,7 +251,7 @@ function CTASection() {
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<CTAButton href="/contact" dataTestId="service-detail-bottom-primary" className="min-h-[52px]">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/services" variant="secondary" dataTestId="service-detail-bottom-secondary" className="min-h-[52px]">
@@ -290,7 +265,7 @@ function CTASection() {
export default function ServiceDetailContentV4({ service }: { service: Service }) {
return (
<main className="min-h-screen bg-bg-primary text-ink">
<div className="min-h-screen bg-bg-primary text-ink">
<DetailHero service={service} />
<OverviewSection service={service} />
<FeaturesSection service={service} />
@@ -298,6 +273,6 @@ export default function ServiceDetailContentV4({ service }: { service: Service }
<L3EmptyFallback caseStudies={service.caseStudies} />
<CaseStudiesSection caseStudies={service.caseStudies} id="cases" heading="真实案例,可衡量的成果" />
<CTASection />
</main>
</div>
);
}
@@ -103,4 +103,18 @@ describe('ServicesContentV3 - 非对称服务卡片', () => {
expect(screen.getByText('数字化转型战略咨询')).toBeInTheDocument();
expect(screen.getByText('数据智能与 AI 应用')).toBeInTheDocument();
});
it('annotates service metrics with the target-basis note (P0)', () => {
render(<ServicesContentV3 services={mockServices} />);
expect(screen.getAllByTestId('metrics-basis-note').length).toBeGreaterThan(0);
});
it('gives the empty state a dual exit instead of bare text (P1 断头路)', () => {
render(<ServicesContentV3 services={[]} pageCopy={{ servicesEmptyState: '暂无服务数据' }} />);
expect(screen.getByText('暂无服务数据')).toBeInTheDocument();
expect(screen.getByTestId('services-empty-primary-cta')).toBeInTheDocument();
expect(screen.getByTestId('services-empty-secondary-cta')).toBeInTheDocument();
});
});
@@ -4,6 +4,8 @@ import { motion } from 'framer-motion';
import { ArrowUpRight, CheckCircle2 } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { MetricsBasisNote } from '@/components/ui/metrics-basis-note';
import { CTA_LABELS, canonicalCtaLabel } from '@/lib/constants/cta-labels';
import type { Service } from '@/lib/constants/services';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
@@ -77,18 +79,18 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const heroTitle2 = (pageCopy?.servicesHeroTitle2 as string) || '全程陪伴';
const heroDescriptionLines = ((pageCopy?.servicesHeroDescription as string) ||
'不只是技术供应商,更是您数字化转型的同行者。\n从第一次沟通到系统稳定运行,我们始终在您身边。').split('\n');
const ctaPrimary = (pageCopy?.servicesCtaPrimary as string) || '聊聊您的需求';
const ctaPrimary = canonicalCtaLabel(pageCopy?.servicesCtaPrimary as string) || CTA_LABELS.consult;
const ctaSecondary = (pageCopy?.servicesCtaSecondary as string) || '查看产品矩阵';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<section className="relative overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 lg:py-28">
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12 md:mb-16"
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tightest mb-8 sm:mb-10 md:mb-12"
>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
@@ -111,7 +113,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
>
{heroDescriptionLines.map((line, i) => (
<span key={i}>
@@ -134,32 +136,6 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
{ctaSecondary}
</CTAButton>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.9, ease: EASE_OUT }}
className="grid grid-cols-3 gap-8 mt-20 pt-12 border-t border-border-primary max-w-xl"
>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
4<span className="text-brand-ink"></span>
</div>
<div className="text-sm text-text-muted"></div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
3<span className="text-brand-ink"></span>
</div>
<div className="text-sm text-text-muted"></div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
<span className="text-brand-ink"></span>
</div>
<div className="text-sm text-text-muted"></div>
</div>
</motion.div>
</div>
</div>
</section>
@@ -175,8 +151,22 @@ function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pag
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 text-center">
<p className="text-text-muted text-lg">{emptyState}</p>
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
{/* 空态给出诚实说明 + 双出口,避免访客被困在无出口的空白区(复制 cases 空态卡范式) */}
<div className="max-w-2xl mx-auto text-center">
<h2 className="text-2xl sm:text-3xl font-bold text-text-primary mb-4">{emptyState}</h2>
<p className="text-text-secondary leading-relaxed mb-8">
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<CTAButton href="/contact" dataTestId="services-empty-primary-cta">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/solutions" variant="secondary" dataTestId="services-empty-secondary-cta">
</CTAButton>
</div>
</div>
</div>
</section>
);
@@ -233,7 +223,7 @@ function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pag
))}
</ul>
<div className="flex gap-6 pt-5 border-t border-border-primary mb-6">
<div className="flex gap-6 pt-5 border-t border-border-primary mb-2">
{service.metrics.map((m) => (
<div key={m.label}>
<div className="text-xl font-bold text-ink">{m.value}</div>
@@ -241,6 +231,7 @@ function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pag
</div>
))}
</div>
{service.metrics.length > 0 && <MetricsBasisNote className="mb-6" />}
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand-ink transition-colors duration-300">
<span></span>
@@ -359,7 +350,7 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<CTAButton href="/contact" dataTestId="services-bottom-primary" className="min-h-[52px]">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/team" variant="secondary" dataTestId="services-bottom-secondary" className="min-h-[52px]">
@@ -373,11 +364,11 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
export default function ServicesContentV3({ services, pageCopy }: { services?: Service[]; pageCopy?: PageCopy | null }) {
return (
<main className="bg-bg-primary text-ink">
<div className="bg-bg-primary text-ink">
<HeroSection pageCopy={pageCopy} />
<ServicesGridSection services={services} pageCopy={pageCopy} />
<ProcessModesSection />
<CTASection pageCopy={pageCopy} />
</main>
</div>
);
}
@@ -3,6 +3,7 @@
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { CTAButton } from '@/components/ui/cta-button';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Factory, ShoppingCart, Heart, GraduationCap, Lightbulb, Settings, Users, TrendingUp, Package, BookOpen, MessageSquare, Calendar, Smartphone, Route, Shield, Truck } from 'lucide-react';import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
import type { Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
@@ -84,7 +85,7 @@ function DetailHero({ solution }: DetailHeroProps) {
className="flex flex-wrap gap-6"
>
<CTAButton href="/contact" dataTestId="solution-detail-hero-primary">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/products" variant="secondary" dataTestId="solution-detail-hero-secondary">
@@ -390,7 +391,7 @@ interface SolutionDetailContentV3Props {
export default function SolutionDetailContentV3({ solution, products = [] }: SolutionDetailContentV3Props) {
return (
<main>
<div>
<DetailHero solution={solution} />
<ChallengesSection challenges={solution.challenges} />
<SolutionsSection solutions={solution.solutions} />
@@ -400,6 +401,6 @@ export default function SolutionDetailContentV3({ solution, products = [] }: Sol
<CaseStudiesSection caseStudies={solution.caseStudies} heading="同行业落地案例" />
<CertificationsSection certifications={solution.certifications} />
<CTASection solution={solution} />
</main>
</div>
);
}
@@ -4,6 +4,8 @@ import { motion } from 'framer-motion';
import { ArrowUpRight, Factory, ShoppingCart, Heart, GraduationCap, Shield, Truck, type LucideIcon } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { MetricsBasisNote } from '@/components/ui/metrics-basis-note';
import { CTA_LABELS, canonicalCtaLabel } from '@/lib/constants/cta-labels';
import { type Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
@@ -25,18 +27,18 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const heroTitle2 = (pageCopy?.solutionsHeroTitle2 as string) || '解决方案';
const heroDescriptionLines = ((pageCopy?.solutionsHeroDescription as string) ||
'端到端交付可量化的业务成果。\n基于自研产品矩阵,为制造、零售、教育、医疗、金融、物流六大行业沉淀最佳实践。').split('\n');
const ctaPrimary = (pageCopy?.solutionsCtaPrimary as string) || '立即咨询';
const ctaPrimary = canonicalCtaLabel(pageCopy?.solutionsCtaPrimary as string) || '预约咨询';
const ctaSecondary = (pageCopy?.solutionsCtaSecondary as string) || '浏览产品';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<section className="relative overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 lg:py-28">
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12 md:mb-16"
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tightest mb-8 sm:mb-10 md:mb-12"
>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
@@ -59,7 +61,7 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10 sm:mb-12"
>
{heroDescriptionLines.map((line, i) => (
<span key={i}>
@@ -82,32 +84,6 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
{ctaSecondary}
</CTAButton>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.9, ease: EASE_OUT }}
className="grid grid-cols-3 gap-8 mt-20 pt-12 border-t border-border-primary max-w-xl"
>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
6<span className="text-brand-ink"></span>
</div>
<div className="text-sm text-text-muted"></div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
<span className="text-brand-ink"></span>
</div>
<div className="text-sm text-text-muted"></div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
<span className="text-brand-ink"></span>
</div>
<div className="text-sm text-text-muted"></div>
</div>
</motion.div>
</div>
</div>
</section>
@@ -158,6 +134,9 @@ function SolutionCard({ solution, index, products, featured = false }: { solutio
</div>
))}
</div>
{solution.outcomes && solution.outcomes.length > 0 && (
<MetricsBasisNote className="-mt-2 mb-4" />
)}
<div className="pt-4 border-t border-border-primary">
<div className="text-xs text-text-muted mb-2"></div>
@@ -235,7 +214,7 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<CTAButton href="/contact" dataTestId="solutions-bottom-primary" className="min-h-[52px]">
{CTA_LABELS.consult}
</CTAButton>
<CTAButton href="/products" variant="secondary" dataTestId="solutions-bottom-secondary" className="min-h-[52px]">
@@ -251,10 +230,10 @@ export default function SolutionsContentV3({ solutions: solutionsProp, products,
const solutions = solutionsProp ?? [];
return (
<main className="bg-bg-primary text-ink">
<div className="bg-bg-primary text-ink">
<HeroSection pageCopy={pageCopy} />
<SolutionsGridSection solutions={solutions} products={products} pageCopy={pageCopy} />
<CTASection pageCopy={pageCopy} />
</main>
</div>
);
}
+7 -10
View File
@@ -3,6 +3,7 @@
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { CTAButton } from '@/components/ui/cta-button';
import { ContentUnavailableState } from '@/components/ui/content-unavailable';
import { Shield, Building2, Users, Code, Target, Layers, BookOpen, Sparkles, type LucideIcon } from 'lucide-react';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { useReducedMotion } from '@/hooks/use-reduced-motion';
@@ -135,18 +136,14 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
// Empty state when no CMS data is available
if (!data || Object.keys(data).length === 0) {
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center">
<div className="text-text-muted text-lg"></div>
</div>
);
return <ContentUnavailableState pageName="核心团队" />;
}
return (
<div className="min-h-screen bg-bg-primary text-ink overflow-x-hidden">
{/* ============ L1: Hero 团队情感入口 ============ */}
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<section className="relative min-h-[60vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-24 sm:py-32 lg:py-40">
<div className="max-w-4xl">
{/* 眉标 Badge */}
<motion.div
@@ -168,7 +165,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12"
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[0.92] tracking-tightest mb-8 sm:mb-10"
>
{(teamData.heroTitle || '').split('\n').map((line, i) => (
<span key={i}>
@@ -221,8 +218,8 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
<CTAButton href={teamData.heroPrimaryCtaHref || '/contact'}>
{teamData.heroPrimaryCtaLabel || '加入我们'}
</CTAButton>
{teamData.heroSecondaryCtaLabel && (
<CTAButton href={teamData.heroSecondaryCtaHref || '#'} variant="secondary">
{teamData.heroSecondaryCtaLabel && teamData.heroSecondaryCtaHref && (
<CTAButton href={teamData.heroSecondaryCtaHref} variant="secondary">
{teamData.heroSecondaryCtaLabel}
</CTAButton>
)}
+7 -10
View File
@@ -29,24 +29,21 @@ export default function Error({
</div>
<h1 className="text-3xl font-bold text-[var(--color-text-primary)] mb-4">
</h1>
<p className="text-lg text-[var(--color-text-placeholder)] mb-6 leading-relaxed">
</p>
{error.message && (
{error.digest && (
<div className="bg-[var(--color-bg-section)] border border-[var(--color-border-primary)] rounded-lg p-4 mb-8 text-left">
<p className="text-sm text-[var(--color-text-placeholder)] font-mono">
: {error.message}
ID: {error.digest}
</p>
<p className="text-xs text-[var(--color-text-placeholder)] mt-2">
ID
</p>
{error.digest && (
<p className="text-xs text-[var(--color-text-placeholder)] mt-2 font-mono">
ID: {error.digest}
</p>
)}
</div>
)}
+2 -6
View File
@@ -261,9 +261,6 @@ body {
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
/* 加速:元素退场(轻柔开始,快速结束) */
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
/* 弹性:有生命感的交互(按钮按压、卡片回弹) */
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-spring-soft: cubic-bezier(0.34, 1.2, 0.64, 1);
/* 锐利:精确、专业的感觉(数据变化、指标跳动) */
--ease-sharp: cubic-bezier(0.65, 0, 0.35, 1);
/* 水墨:柔和、有呼吸感(背景过渡、光晕变化) */
@@ -1318,9 +1315,8 @@ p, li, span {
/* MobileTabBar 是 fixed bottom-0 高度 64px,需给 main 与 footer 都留出安全区
防止正文/页脚被遮挡("联系我们"等页脚内链接被截问题)。
使用 calc + env(safe-area-inset-bottom) 兼容刘海屏/小白条。
选择器同时匹配 <main> 与 div[role="main"]marketing layout 当前用后者)。 */
main,
[role="main"] {
全站单一 <main>ClientLayout2026-09-19 polish 三重 main 收敛后)。 */
main {
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
}
footer {
+1
View File
@@ -167,6 +167,7 @@ export default async function RootLayout({
<html
lang="zh-CN"
className={`scroll-smooth ${geistSans.variable} ${geistMono.variable}`}
data-scroll-behavior="smooth"
suppressHydrationWarning
>
<head>
-163
View File
@@ -1,163 +0,0 @@
'use client';
import { useState } from 'react';
import { trackError } from '@/lib/analytics';
export default function TestErrorTrackingClient() {
const [testResults, setTestResults] = useState<string[]>([]);
const addResult = (message: string) => {
setTestResults((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
};
const testJavaScriptError = () => {
try {
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
trackError('javascript_error', errorMessage, false, {
test_id: 'test_js_error',
filename: 'test-error-page.tsx',
lineno: 20,
});
addResult('✅ JavaScript 错误已发送到 GA4');
}
};
const testPromiseRejection = () => {
addResult('🧪 测试 2: 触发 Promise 未捕获异常...');
Promise.reject(new Error('测试错误:Promise 故意拒绝'));
addResult('✅ Promise 异常已触发(由 GlobalErrorTracker 自动捕获)');
};
const testReactError = () => {
addResult('🧪 测试 3: 触发 React 渲染错误...');
trackError('react_error', '组件渲染失败:测试故意错误', true, {
component: 'TestComponent',
stack_trace: 'Error: 测试错误\n at TestComponent...',
});
addResult('✅ React 错误已发送到 GA4(标记为 fatal');
};
const testNetworkError = () => {
addResult('🧪 测试 4: 模拟网络请求错误...');
trackError('network_error', 'Failed to fetch: https://api.example.com/data', false, {
url: 'https://api.example.com/data',
status_code: 0,
request_method: 'GET',
});
addResult('✅ 网络错误已发送到 GA4');
};
const clearResults = () => {
setTestResults([]);
};
return (
<div className="min-h-screen bg-bg-primary p-8">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-text-primary mb-8">
🧪 GA4
</h1>
<div className="bg-bg-elevated rounded-lg shadow-md p-6 mb-6">
<h2 className="text-xl font-semibold text-text-secondary mb-4">
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={testJavaScriptError}
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
📛 JavaScript
</button>
<button
onClick={testPromiseRejection}
className="px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
>
Promise
</button>
<button
onClick={testReactError}
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
💥 React
</button>
<button
onClick={testNetworkError}
className="px-6 py-3 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors"
>
🌐
</button>
</div>
<button
onClick={clearResults}
className="mt-4 px-6 py-2 bg-bg-secondary text-text-secondary rounded-lg hover:bg-bg-tertiary transition-colors"
>
🗑
</button>
</div>
{testResults.length > 0 && (
<div className="bg-dark-bg rounded-lg shadow-md p-6">
<h2 className="text-xl font-semibold text-green-400 mb-4">
📋
</h2>
<div className="space-y-2 font-mono text-sm">
{testResults.map((result, index) => (
<div key={index} className="text-green-300">
{result}
</div>
))}
</div>
</div>
)}
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
💡 GA4
</h3>
<ol className="list-decimal list-inside space-y-2 text-yellow-700">
<li></li>
<li>
{' '}
<a
href="https://analytics.google.com/"
target="_blank"
rel="noopener noreferrer"
className="underline font-semibold"
>
Google Analytics
</a>
</li>
<li> </li>
<li> <code className="bg-yellow-100 px-1">exception</code></li>
<li> 30 </li>
</ol>
</div>
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-blue-800 mb-2">
🔧
</h3>
<p className="text-blue-700 mb-2">
F12
</p>
<pre className="bg-blue-900 text-green-300 p-4 rounded-lg overflow-x-auto text-sm">
{`[GA4] Error tracked: {
description: "[javascript_error] 测试错误:这是一个故意的 JavaScript 错误",
fatal: "false",
url: "http://localhost:3000/test-error-tracking",
timestamp: "2025-01-15T10:30:00.000Z",
test_id: "test_js_error",
filename: "test-error-page.tsx",
lineno: 20
}`}
</pre>
</div>
</div>
</div>
);
}
-12
View File
@@ -1,12 +0,0 @@
import { Metadata } from 'next';
import TestErrorTrackingClient from './page-client';
// QA 内部测试页:禁止搜索引擎收录(robots.txt 未屏蔽,需 metadata 层 noindex
export const metadata: Metadata = {
title: '错误监控测试面板(内部)',
robots: { index: false, follow: false },
};
export default function TestErrorTrackingPage() {
return <TestErrorTrackingClient />;
}
+1 -1
View File
@@ -277,7 +277,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
</header>
{/* Page content */}
<main className="p-4 lg:p-6">{children}</main>
<div className="p-4 lg:p-6">{children}</div>
</div>
</div>
);
@@ -19,6 +19,9 @@ function shouldIgnoreError(message: string): boolean {
return IGNORED_ERRORS.some((pattern) => pattern.test(message));
}
// StrictMode 下 effect 双跑会让开发日志重复;用模块级标志保证只打一次。
let initLogEmitted = false;
export function GlobalErrorTracker() {
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -66,7 +69,8 @@ export function GlobalErrorTracker() {
window.addEventListener('unhandledrejection', handleUnhandledRejection);
document.addEventListener('error', handleResourceError, true);
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === 'development' && !initLogEmitted) {
initLogEmitted = true;
console.log('[GA4] Global error tracker initialized');
}
@@ -29,7 +29,7 @@ jest.mock('next/script', () => {
const MockScript = (props: { id?: string; src?: string; children?: string; strategy?: string; [key: string]: unknown }) => {
const { children, src, ...rest } = props;
if (children) {
return <script data-testid="next-script" data-inline="true" {...rest} />;
return <script data-testid="next-script" data-inline="true" {...rest} dangerouslySetInnerHTML={{ __html: children }} />;
}
return <script data-testid="next-script" src={src} {...rest} />;
};
@@ -87,36 +87,87 @@ describe('GoogleAnalytics', () => {
});
});
describe('gtag configuration', () => {
it('should call gtag config on mount with pathname and searchParams', () => {
describe('single gtag initialization', () => {
it('should send exactly one config from the inline snippet, with default pageview enabled', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { container } = render(<GoogleAnalytics />);
const inline = container.querySelector('script[data-inline="true"]');
const snippet = inline?.textContent ?? '';
expect(snippet).toContain("gtag('config', 'G-TEST123'");
expect(snippet.match(/gtag\('config'/g)).toHaveLength(1);
// 复评 P2snippet config 承担首屏 pageview,不得再出现 send_page_view:false + effect 双发
expect(snippet).not.toContain('send_page_view');
});
it('should NOT call config on initial mount (snippet config owns the load pageview)', () => {
setupGtag();
mockPathname.mockReturnValue('/products');
mockSearchParams.mockReturnValue(new URLSearchParams('page=1'));
const { GoogleAnalytics } = require('./GoogleAnalytics');
render(<GoogleAnalytics />);
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', {
page_path: '/products?page=1',
page_title: document.title,
page_location: window.location.origin + '/products?page=1',
});
expect(gtagMock).not.toHaveBeenCalled();
});
it('should call gtag config on mount without searchParams when empty', () => {
it('should call config only when navigating to a different URL', () => {
setupGtag();
mockPathname.mockReturnValue('/about');
const { GoogleAnalytics } = require('./GoogleAnalytics');
render(<GoogleAnalytics />);
const { rerender } = render(<GoogleAnalytics />);
expect(gtagMock).not.toHaveBeenCalled();
mockPathname.mockReturnValue('/contact');
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(1);
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', {
page_path: '/about',
page_path: '/contact',
page_title: document.title,
page_location: window.location.origin + '/about',
page_location: window.location.origin + '/contact',
});
});
it('should include searchParams in navigation page_path', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
mockPathname.mockReturnValue('/news');
mockSearchParams.mockReturnValue(new URLSearchParams('page=2'));
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledWith('config', 'G-TEST123', expect.objectContaining({
page_path: '/news?page=2',
}));
});
it('should skip duplicate config when URL is unchanged across re-renders', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
mockPathname.mockReturnValue('/cases');
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(1);
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(1);
});
});
describe('rendering guards', () => {
it('should not call gtag when gtag is not available on window', () => {
const { GoogleAnalytics } = require('./GoogleAnalytics');
render(<GoogleAnalytics />);
@@ -125,7 +176,7 @@ describe('GoogleAnalytics', () => {
it('should not call gtag when GA_MEASUREMENT_ID is empty', () => {
// The component checks for GA_MEASUREMENT_ID at the top level
// When it's empty, the component returns null and no scripts render
// When it is empty, the component returns null and no scripts render
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { container } = render(<GoogleAnalytics />);
@@ -135,29 +186,4 @@ describe('GoogleAnalytics', () => {
expect(scripts.length).toBeGreaterThan(0);
});
});
describe('Pathname changes', () => {
it('should re-call gtag config when pathname changes', () => {
setupGtag();
const { GoogleAnalytics } = require('./GoogleAnalytics');
const { rerender } = render(<GoogleAnalytics />);
// Initial call
expect(gtagMock).toHaveBeenCalledTimes(1);
// Change pathname
mockPathname.mockReturnValue('/contact');
act(() => {
rerender(<GoogleAnalytics />);
});
expect(gtagMock).toHaveBeenCalledTimes(2);
expect(gtagMock).toHaveBeenLastCalledWith('config', 'G-TEST123', {
page_path: '/contact',
page_title: document.title,
page_location: window.location.origin + '/contact',
});
});
});
});
+11 -2
View File
@@ -2,7 +2,7 @@
import Script from 'next/script';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, Suspense, useSyncExternalStore } from 'react';
import { useEffect, useRef, Suspense, useSyncExternalStore } from 'react';
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
@@ -21,12 +21,22 @@ function GoogleAnalyticsContent() {
const pathname = usePathname();
const searchParams = useSearchParams();
const mounted = useIsMounted();
// null = 尚未导航过:首屏 pageview 由 snippet 内唯一一次 config 发送(GA 用真实 URL)。
// effect 只在「路由变化且 URL 与上次不同」时再发 config,消除复评指出的双 config。
const lastNavigationUrlRef = useRef<string | null>(null);
useEffect(() => {
if (!GA_MEASUREMENT_ID || !mounted || typeof window === 'undefined') {return;}
const url = pathname + (searchParams.toString() ? `?${searchParams.toString()}` : '');
if (lastNavigationUrlRef.current === null) {
lastNavigationUrlRef.current = url;
return;
}
if (lastNavigationUrlRef.current === url) {return;}
lastNavigationUrlRef.current = url;
if (window.gtag) {
window.gtag('config', GA_MEASUREMENT_ID, {
page_path: url,
@@ -60,7 +70,6 @@ function GoogleAnalyticsContent() {
});
gtag('config', '${GA_MEASUREMENT_ID}', {
send_page_view: false,
anonymize_ip: true,
allow_google_signals: true,
allow_ad_personalization_signals: false,
+29 -18
View File
@@ -13,24 +13,35 @@ export function CertificationList({ certifications }: CertificationListProps) {
return (
<div className="flex flex-wrap gap-3">
{certifications.map((cert, index) => (
<motion.a
key={cert.name}
href={cert.link || '#'}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true, margin: '-50px' }}
transition={{ duration: 0.3, delay: index * 0.05 }}
className="inline-flex items-center gap-2 rounded-full border border-border-primary bg-bg-elevated px-4 py-2 text-sm text-text-secondary shadow-sm transition-all hover:border-border-secondary hover:shadow-md"
>
<Award className="h-4 w-4 text-brand-ink" />
<span className="font-medium">{cert.name}</span>
<span className="text-xs text-text-muted">{cert.issuer}</span>
{cert.link && cert.link !== '#' && (
<ExternalLink className="h-3 w-3 text-text-muted" />
)}
</motion.a>
))}
{certifications.map((cert, index) => {
const link = cert.link && cert.link !== '#' ? cert.link : null;
const className =
'inline-flex items-center gap-2 rounded-full border border-border-primary bg-bg-elevated px-4 py-2 text-sm text-text-secondary shadow-sm transition-all hover:border-border-secondary hover:shadow-md';
const motionProps = {
initial: { opacity: 0, scale: 0.95 },
whileInView: { opacity: 1, scale: 1 },
viewport: { once: true, margin: '-50px' },
transition: { duration: 0.3, delay: index * 0.05 },
className,
};
const inner = (
<>
<Award className="h-4 w-4 text-brand-ink" />
<span className="font-medium">{cert.name}</span>
<span className="text-xs text-text-muted">{cert.issuer}</span>
{link && <ExternalLink className="h-3 w-3 text-text-muted" />}
</>
);
return link ? (
<motion.a key={cert.name} href={link} {...motionProps}>
{inner}
</motion.a>
) : (
<motion.span key={cert.name} {...motionProps}>
{inner}
</motion.span>
);
})}
</div>
);
}
+29
View File
@@ -63,6 +63,16 @@ jest.mock('@/lib/site-config', () => ({
],
},
],
services: [
{
id: 'professional-services',
title: '专业服务',
items: [
{ id: 'consulting', title: '战略咨询', description: '数字化转型咨询规划', href: '/services/consulting' },
{ id: 'software', title: '企业软件', description: 'ERP/CRM/BI 系统实施', href: '/services/software' },
],
},
],
},
}),
SiteConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
@@ -155,6 +165,25 @@ describe('Footer', () => {
expect(screen.getByText('零售业')).toBeInTheDocument();
});
it('should render services column from megaDropdown.services (P3 footer)', () => {
render(<Footer />);
expect(screen.getByTestId('card-services')).toBeInTheDocument();
expect(screen.getByText('服务', { exact: true })).toBeInTheDocument();
expect(screen.getByText('战略咨询')).toBeInTheDocument();
expect(screen.getByText('企业软件')).toBeInTheDocument();
});
it('should render company column with verified routes (P3 footer)', () => {
render(<Footer />);
const company = screen.getByTestId('card-company');
expect(company).toBeInTheDocument();
expect(screen.getByText('公司', { exact: true })).toBeInTheDocument();
expect(screen.getByText('关于我们').closest('a')).toHaveAttribute('href', '/about');
expect(screen.getByText('方法论').closest('a')).toHaveAttribute('href', '/methodology');
expect(screen.getByText('案例').closest('a')).toHaveAttribute('href', '/cases');
expect(screen.getByText('新闻动态').closest('a')).toHaveAttribute('href', '/news');
});
it('should render contact details', () => {
render(<Footer />);
expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
+106 -60
View File
@@ -5,6 +5,14 @@ import Image from 'next/image';
import { Mail, MapPin } from 'lucide-react';
import { useSiteConfig } from '@/lib/site-config';
// 公司栏目为固定信息架构,路由均已验证存在(/about · /methodology · /cases · /news),与 NAVIGATION_V2 对齐。
const COMPANY_LINKS: ReadonlyArray<{ id: string; label: string; href: string }> = [
{ id: 'about', label: '关于我们', href: '/about' },
{ id: 'methodology', label: '方法论', href: '/methodology' },
{ id: 'cases', label: '案例', href: '/cases' },
{ id: 'news', label: '新闻动态', href: '/news' },
];
export function Footer() {
const config = useSiteConfig();
const productItems = (config.megaDropdown.products ?? [])
@@ -12,13 +20,18 @@ export function Footer() {
.filter(item => item.href !== '#');
const solutionItems = (config.megaDropdown.solutions ?? [])
.flatMap(group => group.items);
.flatMap(group => group.items)
.filter(item => item.href !== '#');
const serviceItems = (config.megaDropdown.services ?? [])
.flatMap(group => group.items)
.filter(item => item.href !== '#');
return (
<footer className="bg-dark-bg text-white relative overflow-hidden [&_a]:inline-flex [&_a]:items-center [&_a]:min-h-6" data-testid="footer" role="contentinfo">
<footer className="bg-dark-bg text-dark-text-primary relative overflow-hidden [&_a]:inline-flex [&_a]:items-center [&_a]:min-h-6" data-testid="footer" role="contentinfo">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-12 gap-10 lg:gap-12 py-16 lg:py-20">
<div data-testid="card-brand" className="col-span-2 lg:col-span-4">
<div data-testid="card-brand" className="col-span-2 md:col-span-4 lg:col-span-4">
<div className="mb-6">
<StaticLink href="/" className="inline-block group" aria-label="返回首页">
<Image
@@ -32,24 +45,70 @@ export function Footer() {
/>
</StaticLink>
</div>
<p className="text-gray-300 text-sm leading-relaxed max-w-md">
<p className="text-dark-text-secondary text-sm leading-relaxed max-w-md">
{config.slogan && (
<>
<span className="block text-gray-200 font-medium mb-2">{config.slogan}</span>
<span className="block text-dark-text-primary font-medium mb-2">{config.slogan}</span>
</>
)}
{config.description}
</p>
<div data-testid="card-contact" className="mt-8">
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase"></div>
<ul className="space-y-4 mb-8 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-dark-text-muted mt-0.5 shrink-0" />
<span className="text-dark-text-secondary text-sm leading-relaxed">{config.address}</span>
</li>
<li className="flex items-start gap-3">
<Mail className="w-4 h-4 text-dark-text-muted mt-0.5 shrink-0" />
<a
href={`mailto:${config.email}`}
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm"
>
{config.email}
</a>
</li>
</ul>
<div className="flex gap-6">
<div>
<p className="text-xs text-dark-text-muted mb-3 tracking-wide"></p>
<div className="inline-block p-2 border border-dark-border bg-dark-bg-secondary">
<Image
src="/images/qrcode.webp"
alt="微信公众号二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
<div>
<p className="text-xs text-dark-text-muted mb-3 tracking-wide"></p>
<div className="inline-block p-2 border border-dark-border bg-dark-bg-secondary">
<Image
src="/images/wechat-business-qr.webp"
alt="业务咨询微信二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
</div>
</div>
</div>
<div data-testid="card-products" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></div>
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase"></div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{productItems.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm whitespace-nowrap"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.title}
</StaticLink>
@@ -59,13 +118,13 @@ export function Footer() {
</div>
<div data-testid="card-solutions" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></div>
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase"></div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{solutionItems.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm whitespace-nowrap"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.title}
</StaticLink>
@@ -74,64 +133,51 @@ export function Footer() {
</ul>
</div>
<div data-testid="card-contact" className="col-span-2 lg:col-span-4">
<div className="font-semibold text-sm mb-5 text-white tracking-widest uppercase"></div>
<ul className="space-y-4 mb-8 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
<li className="flex items-start gap-3">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
<span className="text-gray-300 text-sm leading-relaxed">{config.address}</span>
</li>
<li className="flex items-start gap-3">
<Mail className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
<a
href={`mailto:${config.email}`}
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm"
>
{config.email}
</a>
</li>
</ul>
<div className="flex gap-6">
<div>
<p className="text-xs text-gray-400 mb-3 tracking-wide"></p>
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
<Image
src="/images/qrcode.webp"
alt="微信公众号二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
<div>
<p className="text-xs text-gray-400 mb-3 tracking-wide"></p>
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
<Image
src="/images/wechat-business-qr.webp"
alt="业务咨询微信二维码"
width={96}
height={96}
className="w-24 h-24"
loading="lazy"
/>
</div>
</div>
{serviceItems.length > 0 && (
<div data-testid="card-services" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase"></div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{serviceItems.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.title}
</StaticLink>
</li>
))}
</ul>
</div>
)}
<div data-testid="card-company" className="lg:col-span-2">
<div className="font-semibold text-sm mb-5 text-dark-text-primary tracking-widest uppercase"></div>
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
{COMPANY_LINKS.map((item) => (
<li key={item.id}>
<StaticLink
href={item.href}
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 text-sm whitespace-nowrap"
>
{item.label}
</StaticLink>
</li>
))}
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-6 pb-[calc(8rem+env(safe-area-inset-bottom,0px))] md:pb-8">
<div className="border-t border-dark-border pt-6 pb-[calc(8rem+env(safe-area-inset-bottom,0px))] md:pb-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-6">
<p className="text-gray-300 text-xs">
<p className="text-dark-text-secondary text-xs">
© {new Date().getFullYear()} {config.name}. .
</p>
<div className="flex gap-6">
<StaticLink href="/privacy" className="text-gray-300 hover:text-white text-xs transition-colors duration-200">
<StaticLink href="/privacy" className="text-dark-text-secondary hover:text-dark-text-primary text-xs transition-colors duration-200">
</StaticLink>
<StaticLink href="/terms" className="text-gray-300 hover:text-white text-xs transition-colors duration-200">
<StaticLink href="/terms" className="text-dark-text-secondary hover:text-dark-text-primary text-xs transition-colors duration-200">
</StaticLink>
</div>
@@ -142,16 +188,16 @@ export function Footer() {
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
className="text-gray-300 hover:text-white transition-colors duration-200 inline-flex items-center"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 inline-flex items-center"
>
{config.icp}
</a>
<span className="hidden sm:inline text-text-hint">|</span>
<span className="hidden sm:inline text-dark-text-muted">|</span>
<a
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
target="_blank"
rel="noreferrer"
className="text-gray-300 hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
className="text-dark-text-secondary hover:text-dark-text-primary transition-colors duration-200 inline-flex items-center gap-1.5"
>
<Image
src="/images/beian-icon.png"
+8 -8
View File
@@ -51,10 +51,10 @@ function HeaderContent() {
const config = useSiteConfig();
const handleNavClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
e.preventDefault();
const handleNavClick = useCallback(() => {
// StaticLink 新契约:onClick 先于导航执行,不调 preventDefault 即由
// next/link 完成客户端跳转,此处只负责关闭抽屉。
setIsOpen(false);
window.location.href = item.href;
}, []);
const isActive = useCallback((item: NavigationItem) => {
@@ -138,7 +138,7 @@ function HeaderContent() {
<div key={item.id} className="relative -mx-2 px-2 py-2">
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
onClick={handleNavClick}
className={cn(
'relative inline-flex items-center px-3 py-2 text-sm font-medium',
'transition-all duration-300 ease-out',
@@ -171,9 +171,9 @@ function HeaderContent() {
asChild
className=""
>
<StaticLink href="/contact" data-testid="consult-button" aria-label="立即咨询">
<StaticLink href="/contact" data-testid="consult-button" aria-label="预约咨询">
<MessageCircle className="w-4 h-4" />
<span className="hidden lg:inline"></span>
<span className="hidden lg:inline"></span>
<span className="lg:hidden" aria-hidden="true"></span>
</StaticLink>
</Button>
@@ -231,7 +231,7 @@ function HeaderContent() {
>
<StaticLink
href={item.href}
onClick={(e) => handleNavClick(e, item)}
onClick={handleNavClick}
className={`
block px-4 py-4 text-base font-medium
transition-all duration-300 ease-out
@@ -254,7 +254,7 @@ function HeaderContent() {
>
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
<MessageCircle className="w-4 h-4" />
</StaticLink>
</Button>
</div>
-234
View File
@@ -1,234 +0,0 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MobileMenu } from './mobile-menu';
jest.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
jest.mock('@/lib/site-config', () => ({
useSiteConfig: () => ({
mainNav: [
{ id: 'products', label: '产品', href: '/products', hasDropdown: true, dropdownKey: 'products' },
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
{ id: 'services', label: '服务', href: '/services' },
{ id: 'about', label: '关于我们', href: '/about' },
{ id: 'contact', label: '联系我们', href: '/contact' },
],
megaDropdown: {
products: [
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
],
solutions: [
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
],
},
}),
SiteConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
jest.mock('@/lib/constants', () => ({
NAVIGATION_V2: [
{ id: 'products', label: '产品', href: '/products', hasDropdown: true, dropdownKey: 'products' },
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
{ id: 'services', label: '服务', href: '/services' },
{ id: 'about', label: '关于我们', href: '/about' },
{ id: 'contact', label: '联系我们', href: '/contact' },
],
MEGA_DROPDOWN_DATA: {
products: [
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
],
solutions: [
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
],
},
}));
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
jest.mock('framer-motion', () => ({
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
motion: {
div: ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => (
<div className={className} {...props}>{children}</div>
),
},
}));
jest.mock('lucide-react', () => ({
ChevronDown: () => <span data-testid="chevron-down" />,
Menu: () => <span data-testid="menu-icon" />,
X: () => <span data-testid="x-icon" />,
}));
jest.mock('@/components/ui/static-link', () => ({
StaticLink: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
jest.mock('@/lib/utils', () => ({
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
describe('MobileMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Rendering', () => {
it('should render menu button', () => {
render(<MobileMenu />);
expect(screen.getByRole('button', { name: '打开菜单' })).toBeInTheDocument();
});
it('should render menu icon when closed', () => {
render(<MobileMenu />);
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('should not render menu panel when closed', () => {
render(<MobileMenu />);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('Opening Menu', () => {
it('should open menu when button clicked', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('should change button label when open', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('button', { name: '关闭菜单' })).toBeInTheDocument();
});
it('should render navigation items', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByText('产品')).toBeInTheDocument();
expect(screen.getByText('服务')).toBeInTheDocument();
});
});
describe('Closing Menu', () => {
it('should close menu when button clicked again', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
const closeButton = screen.getByRole('button', { name: '关闭菜单' });
fireEvent.click(closeButton);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('should close menu when overlay clicked', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
const overlay = document.querySelector('.fixed.inset-0');
if (overlay) {
fireEvent.click(overlay);
}
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('Keyboard Navigation', () => {
it('should open menu with button click', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
it('should not double-toggle menu on Enter or Space keydown', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
// Enter/Space 由原生 button 的 click 处理,keydown 不应再次 toggle
fireEvent.keyDown(button, { key: 'Enter' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
fireEvent.keyDown(button, { key: ' ' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('should close menu with Escape key', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
fireEvent.keyDown(button, { key: 'Escape' });
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have aria-expanded attribute', () => {
render(<MobileMenu />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-expanded', 'false');
});
it('should update aria-expanded when open', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
it('should have aria-controls attribute', () => {
render(<MobileMenu />);
const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-controls', 'mobile-menu-panel');
});
it('should have navigation role', () => {
render(<MobileMenu />);
const button = screen.getByRole('button', { name: '打开菜单' });
fireEvent.click(button);
const nav = screen.getByRole('navigation');
expect(nav).toHaveAttribute('aria-label', '移动端导航');
});
});
describe('Styling', () => {
it('should have responsive visibility', () => {
const { container } = render(<MobileMenu />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('lg:hidden');
});
it('should apply custom className', () => {
const { container } = render(<MobileMenu className="custom-class" />);
const wrapper = container.firstChild as HTMLElement;
expect(wrapper).toHaveClass('custom-class');
});
});
});
-129
View File
@@ -1,129 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Menu, X, ChevronDown } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { useSiteConfig } from '@/lib/site-config';
import { cn } from '@/lib/utils';
import { useFocusTrap } from '@/hooks/use-focus-trap';
interface MobileMenuProps {
className?: string;
}
export function MobileMenu({ className }: MobileMenuProps) {
const config = useSiteConfig();
const [isOpen, setIsOpen] = useState(false);
const [expandedDropdown, setExpandedDropdown] = useState<string | null>(null);
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
const handleKeyDown = (event: React.KeyboardEvent) => {
// Escape 关闭菜单;Enter/Space 已由原生 button 的 onClick 处理,
// 此处不再拦截,避免键盘操作时重复触发 toggle。
if (event.key === 'Escape' && isOpen) {
event.preventDefault();
setIsOpen(false);
}
};
const toggleDropdown = (key: string) => {
setExpandedDropdown(expandedDropdown === key ? null : key);
};
return (
<div className={cn('lg:hidden', className)} ref={focusTrapRef}>
<button
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
className="p-3 rounded-md hover:bg-[var(--color-brand-bg)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
aria-expanded={isOpen}
aria-controls="mobile-menu-panel"
>
{isOpen ? (
<X className="w-6 h-6 text-[var(--color-text-primary)]" />
) : (
<Menu className="w-6 h-6 text-[var(--color-text-primary)]" />
)}
</button>
{isOpen && (
<>
<div
className="fixed inset-0 bg-[var(--color-brand)]/20 backdrop-blur-sm z-40"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<nav
id="mobile-menu-panel"
className="fixed top-16 left-0 right-0 bg-[var(--color-bg-primary)] border-b border-[var(--color-border-primary)] z-50 shadow-lg max-h-[calc(100vh-4rem)] overflow-y-auto"
role="navigation"
aria-label="移动端导航"
>
<div className="container-wide py-4">
<ul className="space-y-1" role="list">
{config.mainNav.map((item) => (
<li key={item.id}>
{item.hasDropdown && item.dropdownKey ? (
<div>
<button
onClick={() => toggleDropdown(item.dropdownKey!)}
className="flex items-center justify-between w-full text-left px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-bg)] hover:text-[var(--color-brand)] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-inset min-h-[48px]"
aria-expanded={expandedDropdown === item.dropdownKey}
>
{item.label}
<ChevronDown
className={cn(
'w-4 h-4 transition-transform duration-200',
expandedDropdown === item.dropdownKey && 'rotate-180'
)}
/>
</button>
{expandedDropdown === item.dropdownKey && (
<ul className="pl-4 space-y-1 mt-1 mb-2" role="list">
{(config.megaDropdown[item.dropdownKey] ?? []).flatMap(group => group.items).filter(sub => sub.href !== '#').map((sub) => (
<li key={sub.id}>
<StaticLink
href={sub.href}
onClick={() => setIsOpen(false)}
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand)] hover:bg-[var(--color-brand-bg)] rounded-md transition-colors"
>
<span className="font-medium text-[var(--color-text-primary)]">{sub.title}</span>
<span className="block text-xs text-[var(--color-text-muted)] mt-0.5">{sub.description}</span>
</StaticLink>
</li>
))}
</ul>
)}
</div>
) : (
<StaticLink
href={item.href}
onClick={() => setIsOpen(false)}
className="block px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-bg)] hover:text-[var(--color-brand)] rounded-md transition-colors min-h-[48px]"
>
{item.label}
</StaticLink>
)}
</li>
))}
</ul>
</div>
</nav>
</>
)}
</div>
);
}
@@ -41,7 +41,7 @@ describe('MobileTabBar', () => {
expect(screen.getByText('首页')).toBeInTheDocument();
expect(screen.getByText('产品')).toBeInTheDocument();
expect(screen.getByText('方案')).toBeInTheDocument();
expect(screen.getByText('关于')).toBeInTheDocument();
expect(screen.getByText('案例')).toBeInTheDocument();
expect(screen.getByText('联系')).toBeInTheDocument();
});
@@ -86,10 +86,10 @@ describe('MobileTabBar', () => {
expect(productsLink).toHaveAttribute('href', '/products');
});
it('should have correct href for about', () => {
it('should have correct href for cases', () => {
render(<MobileTabBar />);
const aboutLink = screen.getByText('关于').closest('a');
expect(aboutLink).toHaveAttribute('href', '/about');
const casesLink = screen.getByText('案例').closest('a');
expect(casesLink).toHaveAttribute('href', '/cases');
});
it('should have correct href for contact', () => {
+10 -13
View File
@@ -2,15 +2,17 @@
import { StaticLink } from '@/components/ui/static-link';
import { usePathname } from 'next/navigation';
import { Home, Lightbulb, Package, FileText, User } from 'lucide-react';
import { Home, Lightbulb, Package, FolderOpen, User } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
// 关于页让位给案例页:转化路径上「看实证」优先于「看介绍」,
// 关于/新闻等仍可通过汉堡抽屉(header)到达。
const tabs = [
{ id: 'home', label: '首页', href: '/', icon: Home },
{ id: 'products', label: '产品', href: '/products', icon: Package },
{ id: 'solutions', label: '方案', href: '/solutions', icon: Lightbulb },
{ id: 'about', label: '关于', href: '/about', icon: FileText },
{ id: 'cases', label: '案例', href: '/cases', icon: FolderOpen },
{ id: 'contact', label: '联系', href: '/contact', icon: User },
];
@@ -30,8 +32,8 @@ export function MobileTabBar() {
if (id === 'solutions') {
return pathname === '/solutions' || pathname.startsWith('/solutions/');
}
if (id === 'about') {
return pathname === '/about';
if (id === 'cases') {
return pathname === '/cases' || pathname.startsWith('/cases/');
}
return false;
};
@@ -50,13 +52,6 @@ export function MobileTabBar() {
className="flex flex-col items-center justify-center flex-1 h-full relative group min-h-12"
>
<div className="relative flex flex-col items-center justify-center py-2">
{active && (
<motion.div
layoutId="activeTabTop"
className="absolute -top-0 w-6 h-[3px] bg-brand"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
<Icon
className={cn(
'w-6 h-6 transition-colors duration-300',
@@ -72,10 +67,12 @@ export function MobileTabBar() {
{tab.label}
</span>
{active && (
/* critique P2(2026-09-19)线 tab
spring ease-ink 200ms */
<motion.div
layoutId="activeTabBottom"
layoutId="activeTab"
className="absolute -bottom-1 w-8 h-0.5 bg-brand"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
)}
</div>
+5 -4
View File
@@ -4,6 +4,7 @@ import { motion, useScroll, useTransform } from 'framer-motion';
import { useRef, useState, useEffect } from 'react';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { StaticLink } from '@/components/ui/static-link';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { SectionHeader } from '@/components/sections/section-header';
import { ServiceCard } from '@/components/sections/service-card';
import { ScrollProgress } from '@/components/ui/scroll-progress';
@@ -177,7 +178,7 @@ function CaseDetailHero({ data }: CaseDetailHeroProps) {
>
<Button size="lg" asChild>
<StaticLink href="/contact">
{CTA_LABELS.consult}
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
@@ -514,7 +515,7 @@ function CTASection() {
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Button size="lg" asChild>
<StaticLink href="/contact">
{CTA_LABELS.consult}
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
@@ -530,7 +531,7 @@ function CTASection() {
export default function CaseDetailPage({ data }: { data: CaseDetailData }) {
return (
<main className="min-h-screen">
<div className="min-h-screen">
<ScrollProgress />
<CaseDetailHero data={data} />
<ChallengeSection data={data} />
@@ -538,6 +539,6 @@ export default function CaseDetailPage({ data }: { data: CaseDetailData }) {
<ResultsSection data={data} />
<RelatedServicesSection services={data.services} />
<CTASection />
</main>
</div>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@
import { useRef } from 'react';
import { motion } from 'framer-motion';
import { StaticLink } from '@/components/ui/static-link';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Button } from '@/components/ui/button';
import { BrandStamp } from '@/components/ui/brand-visuals';
import { ArrowRight, Sparkles, MessageCircle, Clock, ShieldCheck } from 'lucide-react';
@@ -20,7 +21,7 @@ interface CTASectionProps {
export function CTASection({
title = '一起聊聊您的数字化需求',
description = `无论您处于数字化转型的哪个阶段,我们都愿意坐下来一起想办法。首次咨询免费,无任何销售压力。`,
primaryLabel = '预约免费咨询',
primaryLabel = CTA_LABELS.consult,
primaryHref = '/contact',
secondaryLabel = '了解我们的方法',
secondaryHref = '/team',
+2 -1
View File
@@ -3,6 +3,7 @@
import { useCallback, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { StaticLink } from '@/components/ui/static-link';
import { CTA_LABELS } from '@/lib/constants/cta-labels';
import { Button } from '@/components/ui/button';
import { COMPANY_INFO } from '@/lib/constants';
@@ -93,7 +94,7 @@ export function HeroSectionV2() {
>
<Button size="lg" asChild className="min-h-[52px] px-8 text-base font-semibold shadow-lg shadow-[var(--color-brand)]/20">
<StaticLink href="/contact">
{CTA_LABELS.consult}
<ArrowRight className="w-4 h-4 ml-2" />
</StaticLink>
</Button>
+2 -2
View File
@@ -296,7 +296,7 @@ describe('CTASection', () => {
const { CTASection } = await import('./cta-section');
render(<CTASection />);
expect(screen.getByText('一起聊聊您的数字化需求')).toBeInTheDocument();
expect(screen.getByText('预约免费咨询')).toBeInTheDocument();
expect(screen.getByText('预约咨询')).toBeInTheDocument();
});
it('should render custom title and description', async () => {
@@ -336,7 +336,7 @@ describe('HeroSectionV2', () => {
const { HeroSectionV2 } = await import('./hero-section-v2');
render(<HeroSectionV2 />);
expect(screen.getByText('企业数字化转型服务商')).toBeInTheDocument();
expect(screen.getByText('免费获取定制方案')).toBeInTheDocument();
expect(screen.getByText('预约咨询')).toBeInTheDocument();
expect(screen.getByText('探索产品')).toBeInTheDocument();
});
+4 -2
View File
@@ -97,10 +97,12 @@ export function ThemeToggle({ className }: { className?: string }) {
const mounted = resolved !== null;
const Icon = preference === 'light' ? Sun : preference === 'dark' ? Moon : Monitor;
// 显式判空而非复用 mounted 布尔值:TS 无法从 mounted 反推出 preference/resolved 非空
// 显式判空而非复用 mounted 布尔值:TS 无法从 preference/resolved 反推非空
// 「显示」与「偏好」分开表述:跟随系统且系统恰为浅色时,旧文案会出现
// 「当前浅色,点击切换为浅色」的歧义(偏好变了但画面不变)。
const label =
preference && resolved
? `主题:${THEME_PREFERENCE_LABELS[preference]}当前${RESOLVED_THEME_LABELS[resolved]}),点击切换为${THEME_PREFERENCE_LABELS[nextPreference(preference)]}`
? `主题偏好${THEME_PREFERENCE_LABELS[preference]}页面当前显示${RESOLVED_THEME_LABELS[resolved]}),点击将偏好切换为${THEME_PREFERENCE_LABELS[nextPreference(preference)]}`
: '切换主题';
return (
+47
View File
@@ -0,0 +1,47 @@
'use client';
import { CTAButton } from '@/components/ui/cta-button';
import { StaticLink } from '@/components/ui/static-link';
const SUGGESTED_LINKS = [
{ label: '解决方案', href: '/solutions' },
{ label: '方法论', href: '/methodology' },
{ label: '联系我们', href: '/contact' },
];
/**
* CMS +
* 访
*/
export function ContentUnavailableState({ pageName }: { pageName: string }) {
return (
<div className="min-h-screen bg-bg-primary flex items-center justify-center">
<div className="max-w-xl mx-auto px-4 py-20 text-center">
<h1 className="text-2xl sm:text-3xl font-bold text-ink mb-4"></h1>
<p className="text-base text-text-secondary leading-relaxed mb-10">
{pageName}
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
<CTAButton href="/"></CTAButton>
<CTAButton href="/products" variant="secondary">
</CTAButton>
</div>
<nav aria-label="推荐入口" className="flex flex-wrap justify-center gap-x-8 gap-y-3 text-sm">
{SUGGESTED_LINKS.map((link) => (
<StaticLink
key={link.href}
href={link.href}
className="text-brand-ink font-medium transition-colors hover:text-brand"
>
{link.label}
</StaticLink>
))}
</nav>
</div>
</div>
);
}
+3 -2
View File
@@ -3,6 +3,7 @@
import * as React from 'react';
import { ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import { StaticLink } from '@/components/ui/static-link';
/**
* CTAButton
@@ -65,7 +66,7 @@ export function CTAButton({
}: CTAButtonProps) {
const isPill = variant !== 'inline';
return (
<a
<StaticLink
href={href}
data-testid={dataTestId}
className={cn(
@@ -77,7 +78,7 @@ export function CTAButton({
>
<span className={cn(innerClassName)}>{children}</span>
<ArrowRight className={ARROW_STYLES[variant]} />
</a>
</StaticLink>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { cn } from '@/lib/utils';
import { METRICS_BASIS_NOTE } from '@/lib/constants/metrics-basis';
/**
* / /
* METRICS_BASIS_NOTE solutions / services / home / about / products
*/
export function MetricsBasisNote({ className }: { className?: string }) {
return (
<p className={cn('text-xs text-text-muted', className)} data-testid="metrics-basis-note">
{METRICS_BASIS_NOTE}
</p>
);
}
+79 -31
View File
@@ -1,17 +1,24 @@
// @ts-nocheck
import { describe, it, expect, jest } from '@jest/globals';
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { StaticLink } from './static-link';
// Note: jsdom's window.location is non-configurable, so we cannot use
// jest.spyOn or Object.defineProperty to mock the entire location object.
// However, window.location.href IS writable in jsdom, but navigation
// (setting href to a different page) is not implemented — see skipped tests below.
// next/link mock:断言「站内无 hash 链接交给 next/link 客户端导航」,
// jsdom 无法真实执行 RSC 导航,mock 为透传 props 的 <a>。
jest.mock('next/link', () => {
const MockLink = ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>
{children}
</a>
);
MockLink.displayName = 'MockLink';
return MockLink;
});
import { StaticLink } from './static-link';
describe('StaticLink', () => {
beforeEach(() => {
// Reset location to a known state before each test
window.location.href = '/';
});
@@ -26,29 +33,64 @@ describe('StaticLink', () => {
expect(link).toHaveAttribute('href', '/products/erp');
});
// ── 导航行为测试(jsdom 限制跳过) ──────────────────────────────
//
// 以下测试涉及 window.location.href 赋值导航,在 jsdom 中不可用:
// Error: Not implemented: navigation (except hash changes)
//
// 后期补全方案(任选其一):
// A. 升级至 @playwright/test 做 E2E 验证(推荐 — 可真实模拟浏览器导航)
// B. 在 jest 中 mock window.location(需升级 jsdom 或使用 jest-environment-jsdom 29+
// C. 将 StaticLink 的导航逻辑抽取为独立函数,单独测试该函数
it.skip('should navigate to internal link on click', () => {
it('should route internal no-hash links through next/link (client nav + prefetch)', () => {
render(<StaticLink href="/about"></StaticLink>);
fireEvent.click(screen.getByText('关于'));
expect(window.location.href).toBe('/about');
// MockLink 渲染的 <a> 即代表走了 next/link 分支(而非 window.location 兜底)
expect(screen.getByText('关于').closest('a')).toHaveAttribute('href', '/about');
});
it.skip('should handle hash link (same page scroll)', () => {
// hash 导航中 scrollIntoView 部分可在 jsdom 中测试,但需先 mock
// Element.prototype.scrollIntoView = jest.fn()
render(<StaticLink href="/about#section">Hash Link</StaticLink>);
fireEvent.click(screen.getByText('Hash Link'));
// 期望:window.location.href 被设置为 '/about#section'
// 但 jsdom 导航未实现,此断言无法通过
it('should call custom onClick handler for internal links', () => {
const handleClick = jest.fn<() => void>();
render(
<StaticLink href="/about" onClick={handleClick as any}>
Click
</StaticLink>
);
fireEvent.click(screen.getByText('Click'));
expect(handleClick).toHaveBeenCalled();
});
it('should smooth-scroll for same-page hash links and prevent default', () => {
const target = document.createElement('div');
target.id = 'cases';
const scrollIntoView = jest.fn();
target.scrollIntoView = scrollIntoView;
document.body.appendChild(target);
let prevented = false;
const { container } = render(
<div onClick={(e) => { prevented = e.defaultPrevented; }}>
<StaticLink href="#cases"></StaticLink>
</div>
);
fireEvent.click(screen.getByText('看案例'));
expect(prevented).toBe(true);
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' });
container.remove();
});
it('should not navigate for bare href="#" placeholder', () => {
let prevented = false;
const { container } = render(
<div onClick={(e) => { prevented = e.defaultPrevented; }}>
<StaticLink href="#"></StaticLink>
</div>
);
fireEvent.click(screen.getByText('占位'));
expect(prevented).toBe(true);
container.remove();
});
it('should leave cross-page hash links to browser default navigation', () => {
let prevented = true;
const { container } = render(
<div onClick={(e) => { prevented = e.defaultPrevented; }}>
<StaticLink href="/about#section"></StaticLink>
</div>
);
fireEvent.click(screen.getByText('跳板块'));
expect(prevented).toBe(false);
container.remove();
});
it('should add noopener noreferrer for external links', () => {
@@ -57,11 +99,17 @@ describe('StaticLink', () => {
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
it('should call custom onClick handler', () => {
it('should not intercept external link clicks (caller onClick still fires)', () => {
const handleClick = jest.fn<() => void>();
render(<StaticLink href="/about" onClick={handleClick as any}>Click</StaticLink>);
fireEvent.click(screen.getByText('Click'));
render(
<StaticLink href="https://example.com" onClick={handleClick as any}>
External
</StaticLink>
);
fireEvent.click(screen.getByText('External'));
expect(handleClick).toHaveBeenCalled();
const link = screen.getByText('External');
expect(link).toHaveAttribute('href', 'https://example.com');
});
it('should render with custom className', () => {
@@ -76,4 +124,4 @@ describe('StaticLink', () => {
expect(link).toHaveAttribute('href', 'mailto:test@test.com');
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
});
});
});
+37 -40
View File
@@ -1,20 +1,25 @@
'use client';
import NextLink from 'next/link';
import { useCallback, type AnchorHTMLAttributes, type MouseEventHandler, type ReactNode } from 'react';
/**
* StaticLink -
* StaticLink -
*
* output: 'export' Next.js <a>
* RSC "Failed to fetch RSC payload"
* output: 'export' window.location.href
* Next.js RSC payload https://github.com/vercel/next.js/issues/85374)。
* output: 'standalone' hash
* next/link + RSC
*
* e.preventDefault() Next.js
* - onClick onClick
* - /
* - Hash
* - window.location.href
*
* - hashnext/link
* - hash preventDefault + hash
* RSC
* - http/https/mailto/tel target="_blank" <a>
* rel="noopener noreferrer"
*
* @see https://github.com/vercel/next.js/issues/85374
* onClick onClick e.preventDefault()
* preventDefault
*/
interface StaticLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
children: ReactNode;
@@ -26,47 +31,39 @@ function isExternalLink(href: string): boolean {
}
export function StaticLink({ children, href, onClick, target, rel, ...props }: StaticLinkProps) {
const external = isExternalLink(href) || target === '_blank';
const hasHash = href.includes('#');
const useClientNav = !external && !hasHash;
const handleClick: MouseEventHandler<HTMLAnchorElement> = useCallback(
(e) => {
// 外部链接或新窗口打开:不拦截,保持默认行为
if (isExternalLink(href) || target === '_blank') {
onClick?.(e);
return;
}
onClick?.(e);
if (e.defaultPrevented) return;
// 阻止 Next.js 客户端路由拦截
e.preventDefault();
if (external) return;
// 如果有外部 onClick,由它完全控制导航行为
if (onClick) {
onClick(e);
return;
}
// Hash 链接:平滑滚动
if (href.includes('#')) {
if (hasHash) {
const [path, hash] = href.split('#');
if (path && path !== window.location.pathname) {
window.location.href = href;
} else if (hash) {
const el = document.getElementById(hash);
if (el) {
el.scrollIntoView({ behavior: 'smooth' });
}
if (!path || path === window.location.pathname) {
e.preventDefault();
if (hash) document.getElementById(hash)?.scrollIntoView({ behavior: 'smooth' });
}
return;
// 跨页 hash:不拦截,浏览器默认整页导航并在加载后滚动到锚点
//(RSC 渲染完成前锚点不存在,客户端跳转不可靠)
}
// 站内页面链接:完整页面导航
window.location.href = href;
},
[href, onClick, target]
[href, onClick, external, hasHash]
);
// 外部链接自动添加安全属性
const linkRel = isExternalLink(href)
? 'noopener noreferrer'
: rel;
if (useClientNav) {
return (
<NextLink href={href} onClick={onClick} target={target} {...props}>
{children}
</NextLink>
);
}
const linkRel = isExternalLink(href) ? 'noopener noreferrer' : rel;
return (
<a href={href} onClick={handleClick} target={target} rel={linkRel} {...props}>
+15 -1
View File
@@ -108,10 +108,24 @@ export const TRUST_SIGNALS = [
value: '共创',
label: '首批客户共创',
desc: '与早期客户共同打磨产品,成果可验证',
source: '如实标注',
source: '公司公开声明',
},
] as const;
/**
* TRUST_SIGNALS.source CMS pageCopy.trustSignals seed
*
* admin 线
*/
const STALE_TRUST_SOURCES: Record<string, string> = {
'如实标注': '公司公开声明',
};
export function canonicalTrustSource(source?: string | null): string | undefined {
if (!source) return undefined;
return STALE_TRUST_SOURCES[source.trim()] ?? source;
}
/**
* quote
* quote / `src/components/content/testimonials.tsx` Testimonial
+106
View File
@@ -0,0 +1,106 @@
/**
* CTA 线2026-09-19 critique P1
* DESIGN.md
* src/app + src/components .ts/.tsx _archive
*/
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { CTA_LABELS, canonicalCtaLabel, CONSULT_CTA_ALLOWLIST } from './cta-labels';
import { canonicalTrustSource } from './company';
const FORBIDDEN_CTA_VARIANTS = /预约演示|预约体验|预约免费咨询|立即咨询|咨询专家/;
const SOURCE_ROOTS = ['src/app', 'src/components'];
function collectSourceFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (entry === '_archive') continue;
const stat = statSync(full);
if (stat.isDirectory()) {
collectSourceFiles(full, acc);
} else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
acc.push(full);
}
}
return acc;
}
describe('CTA 文案单一真源防线', () => {
it('CTA_LABELS.consult 固定为「预约咨询」', () => {
expect(CTA_LABELS.consult).toBe('预约咨询');
});
it('生产源码中不存在预约动作历史变体', () => {
const violations: string[] = [];
for (const root of SOURCE_ROOTS) {
for (const file of collectSourceFiles(root)) {
const lines = readFileSync(file, 'utf-8').split('\n');
lines.forEach((line, i) => {
if (FORBIDDEN_CTA_VARIANTS.test(line)) {
violations.push(`${file}:${i + 1}`);
}
});
}
}
expect(violations).toEqual([]);
});
});
describe('CMS 旧值渲染入口消毒', () => {
it('canonicalCtaLabel 精确匹配历史变体时归一为「预约咨询」', () => {
expect(canonicalCtaLabel('预约免费咨询')).toBe(CTA_LABELS.consult);
expect(canonicalCtaLabel('聊聊您的需求')).toBe(CTA_LABELS.consult);
expect(canonicalCtaLabel(' 预约演示 ')).toBe(CTA_LABELS.consult);
});
it('canonicalCtaLabel 放行正常/长文案与空值', () => {
expect(canonicalCtaLabel('成为首批共创客户')).toBe('成为首批共创客户');
expect(canonicalCtaLabel('让我们聊聊您的需求,看看能做什么')).toBe('让我们聊聊您的需求,看看能做什么');
expect(canonicalCtaLabel(undefined)).toBeUndefined();
expect(canonicalCtaLabel('')).toBeUndefined();
});
it('canonicalTrustSource 修复「如实标注」批注外泄', () => {
expect(canonicalTrustSource('如实标注')).toBe('公司公开声明');
expect(canonicalTrustSource('产品规格')).toBe('产品规格');
expect(canonicalTrustSource(undefined)).toBeUndefined();
});
});
describe('/contact 预约 CTA 白名单强制(复评 P2)', () => {
// 逐字符扫描:定位 href="/contact",跳过开始标签(正确处理 onClick={() => ...} 里的 `=>`),
// 取「开始标签结束 `>`」与「首个 `<`」之间的文本子节点作为按钮文案。
const HREF = 'href="/contact"';
function scan(text: string, file: string, violations: string[]) {
let from = 0;
let idx: number;
while ((idx = text.indexOf(HREF, from)) !== -1) {
from = idx + HREF.length;
// 找到闭合开始标签的 `>`(前一个字符不是 `=`,以跳过箭头函数 `=>`
let j = from;
while (j < text.length && !(text[j] === '>' && text[j - 1] !== '=')) j++;
if (j >= text.length) break;
let k = j + 1;
while (k < text.length && text[k] !== '<') k++;
const raw = text.slice(j + 1, k).trim();
if (raw.startsWith('{')) continue; // 表达式文案,运行时收敛
const label = raw.replace(/\s+/g, '');
if (!label) continue; // 图标优先子节点或自闭合
if (!CONSULT_CTA_ALLOWLIST.has(label)) {
const line = text.slice(0, idx).split('\n').length;
violations.push(`${file}:${line} → 「${label}`);
}
}
}
it('href="/contact" 的 CTA 字面文案必须等于规范值或在白名单内', () => {
const violations: string[] = [];
for (const root of SOURCE_ROOTS) {
for (const file of collectSourceFiles(root)) {
scan(readFileSync(file, 'utf-8'), file, violations);
}
}
expect(violations).toEqual([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* CTA_LABELS
*
* DESIGN.md CTA
* 2026-09-19 critique P1 / / /
* 线 cta-labels.test.ts
*/
export const CTA_LABELS = {
consult: '预约咨询',
} as const;
/**
* CMS pageCopy seed
* /
* admin 线
*/
const STALE_CONSULT_VARIANTS = new Set([
'预约演示',
'预约体验',
'预约免费咨询',
'立即咨询',
'咨询专家',
'聊聊您的需求',
]);
export function canonicalCtaLabel(label?: string | null): string | undefined {
if (!label) return undefined;
return STALE_CONSULT_VARIANTS.has(label.trim()) ? CTA_LABELS.consult : label;
}
/**
* /contact CTA P2
*
*
*
* - 线
* - /ERP offer
* -
* -
* href="/contact" CTA CTA_LABELS.consult
* 线 cta-labels.test.ts
*/
export const CONSULT_CTA_ALLOWLIST: ReadonlySet<string> = new Set([
CTA_LABELS.consult,
'获取上线通知',
'免费诊断咨询',
'预约免费诊断',
'联系我们的技术团队',
'了解共创合作',
]);
+74
View File
@@ -0,0 +1,74 @@
/**
* 线2026-09-19 P3 ·
* 10/11px tracking Bain
* 线 <10px 的字号防止未来漂移
* src/app + src/components .ts/.tsx/.css _archive
* px `text-[Npx]` / `font-size: Npx` rem
* html 18pxrempx /px detect
*/
import { readdirSync, readFileSync, statSync, existsSync } from 'fs';
import { join } from 'path';
const SOURCE_ROOTS = ['src/app', 'src/components'];
const FONT_FLOOR_PX = 10;
const PATTERNS: RegExp[] = [
/text-\[(\d+(?:\.\d+)?)px\]/g,
/font-size:\s*(\d+(?:\.\d+)?)px/g,
];
/** 返回该文本内所有 <10px 字号字面量(`行号: 命中串`)。抽出为纯函数以便单元测试其活性。 */
export function findSubTenPxLines(text: string): string[] {
const hits: string[] = [];
text.split('\n').forEach((line, i) => {
for (const re of PATTERNS) {
re.lastIndex = 0;
for (const m of line.matchAll(re)) {
const px = Number.parseFloat(m[1] ?? '');
if (Number.isFinite(px) && px < FONT_FLOOR_PX) hits.push(`${i + 1}: ${m[0]}`);
}
}
});
return hits;
}
function collectSourceFiles(dir: string, acc: string[] = []): string[] {
if (!existsSync(dir)) return acc;
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (entry === '_archive') continue;
const stat = statSync(full);
if (stat.isDirectory()) {
collectSourceFiles(full, acc);
} else if (/\.(ts|tsx|css)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
acc.push(full);
}
}
return acc;
}
describe('字号下限防线(禁止再引入 <10px', () => {
it('检测器能命中 <10px 字面量,且放行 10/11px 有意下限(防匹配零的假绿)', () => {
expect(findSubTenPxLines('className="text-[9px]"')).toHaveLength(1);
expect(findSubTenPxLines('base { font-size: 8px; }')).toHaveLength(1);
expect(findSubTenPxLines('text-[10px] text-[11px] font-size: 12px')).toHaveLength(0);
});
it('检测器确实覆盖仓库现存的 10/11px 微标签(证明模式在真实源码中匹配而非空转)', () => {
const files = SOURCE_ROOTS.flatMap((root) => collectSourceFiles(root));
const tenOrEleven = files.some((f) =>
/text-\[1[01]px\]/.test(readFileSync(f, 'utf-8')),
);
expect(tenOrEleven).toBe(true);
});
it('生产源码不得存在 <10px 字号字面量', () => {
const violations: string[] = [];
for (const file of SOURCE_ROOTS.flatMap((root) => collectSourceFiles(root))) {
for (const hit of findSubTenPxLines(readFileSync(file, 'utf-8'))) {
violations.push(`${file}:${hit}`);
}
}
expect(violations).toEqual([]);
});
});
+65
View File
@@ -0,0 +1,65 @@
/**
* 线2026-09-19 critique P0
* METRICS_BASIS_NOTE
* MetricsBasisNote
* src/app + src/components .ts/.tsx _archive
*/
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
import { METRICS_BASIS_NOTE } from './metrics-basis';
const SOURCE_ROOTS = ['src/app', 'src/components'];
/** 呈现结果型数字、必须接入口径角注的页面/组件源文件。 */
const REQUIRED_ANNOTATED_SOURCES = [
'src/app/(marketing)/products/products-content-v3.tsx',
'src/app/(marketing)/solutions/solutions-content-v3.tsx',
'src/app/(marketing)/services/services-content-v3.tsx',
'src/app/(marketing)/home-content-v15.tsx',
];
function collectSourceFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (entry === '_archive') continue;
const stat = statSync(full);
if (stat.isDirectory()) {
collectSourceFiles(full, acc);
} else if (/\.(ts|tsx)$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
acc.push(full);
}
}
return acc;
}
describe('结果型数字口径角注防线', () => {
it('METRICS_BASIS_NOTE 固定为「目标口径,非既成结果」', () => {
expect(METRICS_BASIS_NOTE).toBe('目标口径,非既成结果');
});
it('生产源码不得硬编码口径角注字面量(必须引用单一真源)', () => {
const violations: string[] = [];
for (const root of SOURCE_ROOTS) {
for (const file of collectSourceFiles(root)) {
if (file.endsWith('lib/constants/metrics-basis.ts')) continue;
const lines = readFileSync(file, 'utf-8').split('\n');
lines.forEach((line, i) => {
if (line.includes(METRICS_BASIS_NOTE)) {
violations.push(`${file}:${i + 1}`);
}
});
}
}
expect(violations).toEqual([]);
});
it('结果型数字页面均接入 MetricsBasisNote', () => {
for (const file of REQUIRED_ANNOTATED_SOURCES) {
const src = readFileSync(file, 'utf-8');
expect({ file, used: src.includes('<MetricsBasisNote') }).toEqual({
file,
used: true,
});
}
});
});
+9
View File
@@ -0,0 +1,9 @@
/**
* METRICS_BASIS_NOTE
*
* 2026-09-19 critique P0solutions / services / home / about 30%
* 99.9% /
* CTA_LABELS 线
* admin 线
*/
export const METRICS_BASIS_NOTE = '目标口径,非既成结果';
-2
View File
@@ -210,8 +210,6 @@ module.exports = {
accelerate: 'var(--ease-accelerate)',
'ease-out': 'var(--ease-ink)',
'ease-in-out': 'var(--ease-sharp)',
'ease-spring': 'var(--ease-spring)',
'ease-spring-soft': 'var(--ease-spring-soft)',
sharp: 'var(--ease-sharp)',
ink: 'var(--ease-ink)',
},