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:
@@ -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, MegaDropdown(MobileMenu 已删除,抽屉内置于 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
@@ -73,7 +73,7 @@ Switch / Slider 的白底圆钮。
|
||||
### 动效设计四原则 (Motion Design Principles)
|
||||
基于 Porsche Consulting "用动效象征变革"理念,统一全站动效语言:
|
||||
1. **Purposeful(有目的)**:每个动效必须服务于内容传达,不做纯装饰动效
|
||||
2. **Fast(快节奏)**:入场 200-300ms,hover 150ms,反馈 100ms
|
||||
2. **Fast(快节奏)**:入场 180–280ms(`--transition-fast` … `--transition-normal`),hover 150ms,反馈 100ms
|
||||
3. **Natural(自然)**:统一使用 `ease-ink` [0.22, 1, 0.36, 1] 作为默认缓动曲线
|
||||
4. **Layered(分层)**:子元素入场 stagger 30-60ms,Section 间 stagger 100-150ms
|
||||
|
||||
|
||||
@@ -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.25rem–1.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.08em–0.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)只做区域呼吸线
|
||||
- 明暗双主题逐令牌审校,对比度实测值写进代码注释
|
||||
- 动效 180–280ms、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(本地 woff2,next/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.92–0.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** (600–700, 1.125–1.5rem): 卡片标题。
|
||||
- **Body** (400, 1rem/1.0625rem, leading 1.65–1.85): 正文;`text-text-secondary` 为默认灰度。
|
||||
- **Label** (500, 0.8125rem, tracking 0.08–0.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.** 10–11px 大写 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 First,Tailwind 默认断点(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/1fr(lg 起生效)。
|
||||
- 卡片密集区用 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
|
||||
- **xs–xl 层级带** (`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
|
||||
|
||||
### Buttons(CTAButton,全站行动召唤)
|
||||
- **Shape:** 全圆角 pill(rounded-full),内置 ArrowRight 箭头签名,hover 位移 translate-x。
|
||||
- **Primary:** 朱砂填充 + 白字(16px 40px),hover 转 Cinnabar Deep;**Secondary:** 墨色 20% 描边透明底;**Dark:** 深色区块专用浅描边;**Inline:** 透明小箭头文字链,用于卡内「了解更多」。
|
||||
- 交互 Button(ui/button.tsx)另带 6 档 size 与 ripple,触控下限 44×44px。
|
||||
|
||||
### Cards / Containers(bain-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 / 状态签
|
||||
- Pill,12% 软底或 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)`、180–280ms,stagger 用 `--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
@@ -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. **克制动效** — 动效服务于信息传达:180–280ms、`--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
|
||||
- 动效时长 180–280ms(`--transition-fast` … `--transition-normal`),缓动 `cubic-bezier(0.22, 1, 0.36, 1)`(`--ease-ink`)
|
||||
- 字距下限 -0.04em,不得更紧
|
||||
- 暗黑模式由 `html[data-theme='dark']` 覆盖 CSS 变量驱动,组件不得写死颜色字面量
|
||||
- 楷体(`--font-brand`)为遗留例外,仅存量页面保留,新页面禁用
|
||||
|
||||
@@ -27,9 +27,10 @@ NEXT_PUBLIC_GA_MEASUREMENT_ID=G-LGTLCR15KM
|
||||
文件位置: `src/components/analytics/GoogleAnalytics.tsx`
|
||||
|
||||
**功能:**
|
||||
- 自动初始化 GA4
|
||||
- 配置全局追踪
|
||||
- 在所有页面启用追踪
|
||||
- 单次初始化 GA4:inline snippet 内唯一一次 `gtag('config')` 负责首屏 pageview(含 consent default)
|
||||
- SPA 路由追踪:仅当 pathname/searchParams 变化且 URL 与上次不同时,effect 再发 `config`(Google SPA 文档口径)
|
||||
- 禁止在 snippet 与 effect 中重复 config(复评曾检出「GA4 初始化 2 次」,勿回退;有单测防线)
|
||||
- 在所有页面启用追踪(根布局 `GoogleAnalyticsWrapper` 动态挂载,SSR 关闭)
|
||||
|
||||
### 3. 根布局集成
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ src/components/
|
||||
├── layout/ # 布局组件
|
||||
│ ├── header.tsx # 页头导航
|
||||
│ ├── footer.tsx # 页脚
|
||||
│ ├── mobile-menu.tsx # 移动端菜单
|
||||
│ ├── mobile-tab-bar.tsx # 移动端底部导航
|
||||
│ └── breadcrumb.tsx # 面包屑
|
||||
│
|
||||
|
||||
@@ -184,14 +184,6 @@ import { Footer } from '@/components/layout/footer';
|
||||
- 联系方式
|
||||
- 微信公众号二维码
|
||||
|
||||
#### MobileMenu 移动端菜单
|
||||
|
||||
```tsx
|
||||
import { MobileMenu } from '@/components/layout/mobile-menu';
|
||||
|
||||
// 由 Header 内部调用
|
||||
```
|
||||
|
||||
#### MobileTabBar 移动端底部导航
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -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 账户,无需额外费用
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>(ClientLayout,2026-09-19 polish 三重 main 收敛后)。 */
|
||||
main {
|
||||
padding-bottom: calc(64px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
footer {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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);
|
||||
// 复评 P2:snippet 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 完整页面导航
|
||||
* 跳转策略:
|
||||
* - 站内无 hash:next/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}>
|
||||
|
||||
@@ -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 区块替换本标签。
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
'获取上线通知',
|
||||
'免费诊断咨询',
|
||||
'预约免费诊断',
|
||||
'联系我们的技术团队',
|
||||
'了解共创合作',
|
||||
]);
|
||||
|
||||
@@ -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 根为 18px,rem→px 换算存在歧义,纳入会造成误报/漏报。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([]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* METRICS_BASIS_NOTE — 全站「结果型数字」口径角注单一真源。
|
||||
*
|
||||
* 2026-09-19 critique P0:solutions / services / home / about 把「30% 库存周转提升」
|
||||
* 「99.9% 可用性」这类目标值/能力值以既成结果口吻呈现,缺出处即构成误导。
|
||||
* 与 CTA_LABELS 同属渲染入口的诚实性防线:数字保留,但强制附口径说明。
|
||||
* 根治方式是补充真实案例数据或 admin 修正内容,此处为最后一道防线。
|
||||
*/
|
||||
export const METRICS_BASIS_NOTE = '目标口径,非既成结果';
|
||||
@@ -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)',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user