Dev #22

Merged
zhangxiang merged 29 commits from dev into main 2026-08-20 12:16:07 +08:00
120 changed files with 3488 additions and 362 deletions
+10 -1
View File
@@ -29,4 +29,13 @@ reports
scripts.backup
logs
*.tar.gz
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
# --- 构建上下文瘦身 ---
# dist 仅 Dockerfile.prod 需要 standalone 与 static 两个子目录,
# 其余(dev/cache/server/node_modules 等开发与缓存产物)会撑大构建上下文,
# 导致服务器磁盘不足(no space left on device),全部排除。
dist/*
!dist/standalone
!dist/static
# 服务器上的 dist 版本备份目录(每个约 1.1G),不得进入构建上下文
dist_backup_*
+1
View File
@@ -314,6 +314,7 @@ dogfood-bain/
# agent & skill artifacts
.nova-loop/
.qoder/
.impeccable/
.superpowers/brainstorm/
sessions/
+9 -2
View File
@@ -141,7 +141,8 @@ Novalon 的 6 个核心产品,互为互补关系,常以组合形式出现在
| Hero 视觉方向修正 | 当前深色渐变风格偏离"水墨雅致"品牌定位,需转向浅色/宣纸色底、深色文字、品牌色点缀;通过 web-design-engineer skill 做原型验证后再落地 | ✅ 2026-06-07 确认 |
| 导航体现 HSI 层级 | ProductsHub)视觉权重最高,SolutionsSpoke)次之,Services 最轻;Products 下拉菜单需展示套装区/专业产品区分区 | ✅ 2026-06-07 确认 |
| 方案页删除服务方式区域 | Solutions 列表页"服务方式"区域职责越界(属 Services 层),应删除;替换为方案→服务的轻量关联推荐链接 | ✅ 2026-06-07 确认 |
| L3 信任层内容策略 | 初创阶段无真实案例/数据/资质,L3 暂不渲染;组件结构预留,数据就绪后启用。三档策略:有案例→正常展示;有意向→"正在服务中"标签;无数据→不显示 L3。当前全部标记为 TODO | ✅ 2026-06-07 确认(TODO |
| L3 信任层内容策略 | 初创阶段无真实案例/数据/资质,L3 暂不渲染;组件结构预留,数据就绪后启用。三档策略:有案例→正常展示;有意向→"正在服务中"标签;无数据→不显示 L3。**阶段 02026-08-20)已落地**:信任策略从「结果证据」转向「可验证的过程+能力+治理证据」——首页「共创计划」板块(共创进行中/产品内测中/成果授权公开三档如实状态 + 招募 CTA)、关于页资质区如实「建设中」空态 | ✅ 2026-06-07 确认 / 阶段 0 完成 2026-08-20 |
| 首页共创计划板块 | 成立 <1 年无真实案例时,以主动板块替代被动标签:`page-copy` 字段 `earlyAccessTitle`/`earlyAccessCtaLabel`/`earlyAccessStatus` 驱动首页「首批客户共创计划」板块,如实呈现共创/内测/授权公开状态并引导转化(成为共创客户 → /contact)。零编造:不虚构客户/数据/资质 | ✅ 2026-08-20 确认 |
| 组件版本统一 | 独立产品迁移后删除 `detail/`V1),将 `detail-v2/` 重命名为 `detail/`,组件文件名去掉版本后缀(如 `DetailHeroV3``DetailHero` | ✅ 2026-06-07 确认(待 Phase 0 执行) |
| 设计定位 | 纯咨询专业风(Accenture + Bain + Porsche 三位一体),水墨元素已移除 | ✅ 2026-07-10 更新(ADR-0006 |
| 设计 DNA 框架 | 三维度模型:Design System + Design Style + Visual Effects,基于 Accenture+Bain+Porsche 整合 | ✅ 2026-06-28 确认 |
@@ -159,6 +160,12 @@ Novalon 的 6 个核心产品,互为互补关系,常以组合形式出现在
| 生产部署模式 | 2026-08 切换为混合渲染:`next.config.mjs` 使用 `output: 'standalone'``Dockerfile.prod` + `docker-compose.server.yml` 启动 Next.js 容器,Nginx 托管静态资源并代理 `/api/*``/admin/*`、ISR 回源;生产 SQLite 位于 `data/prod.db`,通过 `DATABASE_URL=file:/app/data/prod.db``PRISMA_QUERY_ENGINE_LIBRARY` 运行 | ✅ 2026-08 确认 |
| 动效强度 | 体验级全场景动效叙事(Porsche Consulting水准,但克制不炫技) | ✅ 2026-06-29 确认 |
| 排版方向 | Accenture信息密度 + Bain标题对比 + Porsche图文节奏的融合方案 | ✅ 2026-06-29 确认 |
| 品牌故事页品牌名 | 使用与 footer Logo 完全相同的青柳隶书 SVG path(`BrandCalligraphyName`),不加载 4.4MB 的 AoyagiReisho 字体文件,兼顾品牌一致性与性能 | ✅ 2026-08-18 确认 |
| 风格主词 | Swiss Modernism 2.0(骨架)+ Bento Box Grid(信息组织)+ Hero-Centric & Conversion-Optimized(首屏转化)+ Trust & Authority & Social Proof(信任层)+ Motion-Driven(差异化记忆点)+ Accessible & Ethical(底线) | ✅ 2026-08-18 确认 |
| 官网产品模块定位 | **IHG/字节式品牌矩阵**:官网品牌宣传为主,产品矩阵页保留为聚合入口(每产品一句话定位+指标证据+外链独立站);成熟产品 `externalUrl` 外链独立站(NovaVis → novavis.p.novalon.cn 已实践),未成熟产品官网详情页占位,独立站上线后切换外链。参照案例(已核实):IHG 品牌组合页 / 字节跳动 bytedance.com/products / 腾讯 tencent.com/business.html(模式 B:品牌+品牌矩阵聚合页);对照金蝶/用友(模式 C:产品为主,官网承载详情与转化) | ✅ 2026-08-19 确认 |
| 品牌主口号 | **智连未来 · 成长伙伴 —— 您的数字化转型同行者**(整句全站统一采用)。品牌叙事内核(定位声明 / 承诺 / L0 价值主张 / L1 三支柱 / 语气语调)见 `docs/brand-narrative-core.md`,作为全站文案统一唯一锚点 | ✅ 2026-08-19 确认 |
| 品牌叙事 CMS 化 | 品牌叙事内核全部下沉到 CMS `site-config` 模型(`slogan` / `valueProposition` / `positioningStatement` / `brandPromise` / `toneOfVoice` / `pillars`),后台可编辑;代码侧 `COMPANY_INFO``BRAND_NARRATIVE``src/lib/constants/company.ts`)作为未配置 CMS 时的兜底唯一真源,`layout.tsx` 读取后经 `SiteConfigProvider` 注入全站 | ✅ 2026-08-19 确认 |
| 结构性文案 CMS 化 | 新增 `page-copy` 内容模型承载各业务页面章节标题/眉标/描述/CTA/空状态,覆盖首页 + 服务/方案/产品/案例/新闻列表页,seed 写入 6 条(home/services/solutions/products/cases/news)。实现为「CMS 优先 + 硬编码兜底」:CMS 未配置时回退 `TRUST_SIGNALS`/`EARLY_ACCESS`/`NARRATIVE_ACTS` 等常量,保证任何情况不白屏。数据层新增 `getPageCopy(pageCode)` / `getHomePageCopy()`,各 `page.tsx` 读取后以 `pageCopy` props 传入组件。详情页小节标题、页面内嵌业务常量(服务流程/合作模式/产品组合)、Header/Footer 等 UI 外壳按 UI 职责保留代码。数据实体(服务/产品/方案/案例/新闻/指标/Hero)仍由 `data-server` 从 Prisma 读取 | ✅ 2026-08-19 确认 |
## 页面类型与四层映射
@@ -200,7 +207,7 @@ Novalon 的 6 个核心产品,互为互补关系,常以组合形式出现在
- **"web-design-engineer"≠ 代码生成器**:它是设计验证工具,产出原型 HTML,不是最终代码
- **"克制动效"≠ 零动效**:动效服务于信息传达(hover 反馈、scroll reveal、卡片交互),但不做装饰性粒子/水墨动画
- **"参考 IHG"≠ 视觉模仿 IHG**:借鉴的是 IHG 的信息架构思维(集团枢纽 + 品牌独立叙事),而非其视觉风格
- **"产品独立"≠ 子站分裂**:独立产品有独立的展示空间和叙事自由度,但不创建独立子域名独立设计系统
- **"产品独立"≠ 官网撤出产品**:独立产品有独立子域名独立叙事(NovaVis → novavis.p.novalon.cn 已实践,走 `externalUrl` 外链);官网仍保留产品矩阵页作为聚合入口(每产品一句话定位+指标+外链),不承载深度详情
- **"咨询专业风"≠ 丢弃水墨**:水墨已从"整体风格"降级为"文化基因",现通过 ADR-0006 完全移除。网站纯走 Accenture + Bain + Porsche 咨询专业风
- **"设计 DNA 重构"≠ 从零开始**:是对齐标准 + 清理债务 + 重点升级,而非推倒重来
- **"Phase 0"≠ 没有产出**:虽然是"脏活累活",但通过组件减少、文件体积下降、测试覆盖率提升等可量化指标体现价值
+13
View File
@@ -1,3 +1,14 @@
# ---------- sharp 平台依赖构建阶段 ----------
# 本地构建产物仅包含 darwin 平台二进制(@img/sharp-darwin-arm64),
# 容器运行于 Alpinelinuxmusl-x64),需补充 musl 平台二进制,否则媒体库接口报 500。
FROM node:20-alpine AS sharp-deps
WORKDIR /sharp
# 使用空 package.json,仅安装两个 musl 平台包,避免安装完整依赖树撑爆磁盘
RUN npm init -y >/dev/null 2>&1 && \
npm install --no-save --os=linux --cpu=x64 --libc=musl \
@img/sharp-linuxmusl-x64@0.35.3 \
@img/sharp-libvips-linuxmusl-x64@1.3.2
FROM node:20-alpine AS runner
WORKDIR /app
@@ -15,6 +26,8 @@ COPY dist/standalone ./
COPY dist/static ./dist/static
# 公共静态资源(图片、字体、favicon、uploads 等)
COPY public ./public
# 覆盖 sharp 平台依赖为 musl 版本(standalone 仅含 darwin 二进制)
COPY --from=sharp-deps /sharp/node_modules/@img /app/node_modules/@img
RUN chown -R nextjs:nodejs /app
+18 -101
View File
@@ -18,113 +18,25 @@
## 项目规划
- 2026-07:完成 Bain 风格首页与详情页改版、四层叙事组件重构
- 2026-07:完成安全加固(JWT、中间件、表单、CMS 接口)、可访问性修复
- 2026-07:完成全量测试基线建立(单元测试 705 条、E2E 618 条)与封版审查
- 2026-07:完成 CMS 数据模型扩展(locale、RBAC)、媒体管理、工作流引擎,并按页面类型全量迁移内容(基于 [docs/cms-evaluation.md](./docs/cms-evaluation.md)
- 2026-07:完成系统化测试套件实施(Phase 1-5 ✅)
- 2026-07: **封版发布 v1.0.0-phase1** — UI设计重构 ~85% / CMS化 ~75% / 全量测试 992 条通过
- 2026-08: **Phase 6 测试收尾** — ESLint 0 errors、覆盖率提升至 63.23% stmts / 78.02% branches、新增 18 个测试套件 225 条测试、全量 1217 条通过
- 2026-08: **文档同步完成** — test-strategy-plan.md 版本 1.5,已标记已实现项,保留真实缺口
- 2026-08: **迭代完成** — test-strategy-plan.md 版本 1.8,全面达标
- 120 测试套件 / 1509 条测试全部通过,ESLint 0 errors
- 覆盖率:global 73.59% stmts / 82.38% branches(全阈值达标)
- seo/ 组件覆盖率提升至 100%(新增 24 个测试,覆盖全部 7 个组件)
- analytics/ 7 组件全覆盖,新增至覆盖率监控
- 测试债务追踪板同步更新,seo 覆盖缺口已解决
- 2026-08: **封版验收完成** — test-strategy-plan.md 版本 1.9,所有待办任务完成
- 121 测试套件 / 1549 条测试全部通过,TypeScript 0 errorsESLint 0 errors
- 覆盖率:global 73.62% stmts / 82.52% branches(全阈值达标)
- 变异测试 use-focus-trap.ts 42.62% → 85.25%(超 50% 目标)
- 调试文件清理(__debug.test.tsx 删除)
- 测试债务追踪板同步更新,变异测试缺口已解决
- 2026-08: **最终封版迭代** — test-strategy-plan.md 版本 2.0,达到封版上线标准
- 121 测试套件 / 1591 条测试全部通过,TypeScript 0 errorsESLint 0 errors
- 变异测试 use-swipe-gesture.tsx 64.78% → 66.13%(超 65% 目标)
- 变异测试 use-reduced-motion.ts 稳定在 76.32%(超 50% 阈值)
- 修复 use-reduced-motion.ts ESLint 错误(set-state-in-effect
- 新增 UJ-10 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)
- 测试债务追踪板同步更新,UJ-10 已解决,UJ-03/06/07 标记为环境依赖
- 2026-08: **Admin 用户旅程修复完成** — test-strategy-plan.md 版本 2.1
- 修复 `loginAdminAndSetCookie` 函数,同步设置 cookie 和 localStorage 解决认证问题
- 更新 Playwright webServer 配置为 `npm run dev` 以支持 API 路由
- UJ-03/UJ-06/UJ-07 共 9 个 admin 测试在 chromium/firefox/webkit 全部通过
- 修复 UJ-10 CSS 选择器解析问题(`text=` 正则与 CSS 混用)
- 修复 cases-filter 测试 locator 不稳定问题(`getByRole('radio')``locator('button[role="radio"]')`
- 200 个回归测试全部通过
- 2026-08: **封版验收完成** — 全量测试执行完毕,验收结论:**有条件通过**
- 静态质量门禁:build / type-check / lint / unit-coverage 全部通过
- E2E 功能回归:356 passedChromium 核心功能全部通过)
- 视觉回归:84/84 通过(5 浏览器/设备项目基线已更新)
- 移动端测试:173 passedFCP 68ms / LCP 280ms
- Lighthouse7 页面 4 类评分 ≥ 0.9 全部达标
- k6 负载测试:p95=7.26ms,错误率 0.28%
- k6 压力测试:p95=3.95ms,错误率 0%
- 可访问性:颜色对比度 7/7、标题层级 10/10、E2E a11y 66/66
- 安全扫描:2 moderate 依赖漏洞(已记录为已知风险)
- 已知缺陷:Firefox 兼容性(127 failed)、移动端兼容性(37 failed
- 详细报告:`RELEASE_ACCEPTANCE_REPORT.md`
- 10 个核心用户旅程测试(UJ-01~UJ-10)全部通过
- 安全测试 18 个用例全部通过(响应头、CSP、XSS、信息泄露)
- 安全测试修复:`page.waitForRoute` 无效API、404堆栈跟踪误报
- 移动端测试修复:StaticLink 触摸事件兼容性、Next.js HMR 超时、waitUntil 策略优化
- 移动端测试 16/16 全部通过(chromium-mobile
- 121 测试套件 / 1593 条测试全部通过
- 覆盖率:global 73.48% lines / 83.43% branches(全阈值达标)
- 功能模块覆盖率:100% (28/28)
- 测试报告:[docs/testing/test-report-2026-08-03.md](./docs/testing/test-report-2026-08-03.md)
- **2026-07**UI 重构(四层叙事模型、咨询专业风)、CMS 全覆盖、安全加固、封版发布 `v1.0.0-phase1`
- **2026-08**:全量测试基线建立(单元 ~1600 / E2E ~800 / UJ-01~UJ-11)/ 官网产品模块品牌矩阵定位(产品外链独立站)/ 结构性文案全站 CMS 化(`page-copy`)/ 信任证据阶段 0(成立 <1 年策略)
- **2026-08-20**:生产部署上线 + 生产目录 Drizzle 残留归档清理
详细历史里程碑、测试验收报告见 [docs/deployment.md](docs/deployment.md) 与 [docs/testing](docs/testing) 目录。
## 项目进度
- [x] 首页/关于/产品/新闻等核心页面可访问性修复
- [x] JWT、中间件、表单、CMS 接口安全加固
- [x] WCAG 2.1 AA、标题层级、色彩对比度检查全部通过
- [x] 生成 [RELEASE_REVIEW_REPORT.md](./RELEASE_REVIEW_REPORT.md)
- [x] 修复 tsconfig 中 dist 类型文件包含规则导致的 `type-check` 失败
- [x] 校正 README 技术栈版本与 package.json 一致
- [x] 生产环境变量与 Nginx 安全头最终确认(上线前)
- [x] CMS 数据模型扩展:为 ContentItem 增加 locale 字段与唯一索引
- [x] CMS 数据模型扩展:RBAC 角色与权限中间件
- [x] CMS 内容状态机与工作流:draft/review/published/archived + AuditLog
- [x] CMS 媒体管理模块:上传、本地/S3 双写、缩略图/WebP/AVIF 派生格式
- [x] CMS 站内消息通知中心:提交/审核/归档时生成通知、未读计数、标记已读
- [x] Nginx 生产配置支持混合渲染(/api/*、/admin/* 代理 Next.jsISR 回源)
- [x] CMS 法律页迁移:privacy/terms 从 CMS 读取内容并启用 ISR3600s
- [x] CMS 新闻页迁移:列表/详情从 CMS 读取并启用 ISR3600s
- [x] CMS 团队页迁移:/team 从 CMS 读取并启用 ISR3600s
- [x] CMS 案例页迁移:列表/详情从 CMS 读取并启用 ISR(3600s),保留行业过滤与指标/时间线结构
- [x] CMS 服务页迁移:列表/详情从 CMS 读取并启用 ISR(3600s),保留服务编号、色条编码、metrics 与四层叙事结构
- [x] CMS 方案页迁移:列表/详情从 CMS 读取并启用 ISR(3600s),保留行业、痛点、解决方案、价值主张、推荐产品组合与 HSI 交叉引用
- [x] CMS 产品页迁移:列表/详情/独立产品页从 CMS 读取并启用 ISR(3600s),保留四层叙事、技术规格与产品组合分类
- [x] CMS 独立产品页迁移:创建独立 `standalone-product` 内容模型,支持技术参数、合规认证等硬核字段;详情页从独立模型读取并启用 ISR(3600s)
- [x] CMS 首页运营位迁移:首页改为通过 `ContentZone` 读取 Hero、Stats、Services、Cases 等 Zone,支持后台调整 Zone 内容与排序;启用 ISR(3600s),相关模型更新时同步刷新首页
- [x] CMS 管理后台:构建角色权限管理界面(`/admin/roles`),支持角色列表、内容模型 × 操作权限矩阵配置、super_admin 锁定
- [x] CMS/RBAC/工作流测试覆盖:补充 zones/models/items/media/notifications/roles/workflow 路由与权限/状态机/数据访问单元测试;新增 `e2e/cms-workflow.spec.ts` 覆盖管理员单角色发布、非法状态拦截、多角色权限分离完整流程;新增 `e2e/storageState.json` 预置 Cookie 偏好,消除 Cookie 同意弹窗对 E2E 的遮挡;修复类型错误,单元测试 942 条、E2E 功能回归 520 条全部通过
- [x] 封版验收测试(2026-07-22):完成静态门禁、E2E 回归、视觉回归、Lighthouse、k6 负载/压力、安全扫描全量执行,修复压力测试脚本与 Next.js 16 / ESLint 9 迁移问题,`npm audit` 0 漏洞,结论更新为通过,生成 [`RELEASE_ACCEPTANCE_REPORT.md`](./RELEASE_ACCEPTANCE_REPORT.md)
- [x] 首页 UI 设计优化(2026-07-23):按 `impeccable` 默认流程完成 colorize/harden/layout/typeset/polish,落地 `home-content-v14.tsx`(克制白底 Hero、等宽三列服务卡片、紧凑案例快照、FALLBACK_SERVICES 兜底);更新 chromium desktop/tablet/mobile 视觉基线;修复 `e2e/p5-edge-cases.spec.ts` header 选择器严格模式问题;type-check / lint / unit 942 条 / 视觉 63 条 / 功能 E2E 174 条全部通过
- [x] 顶部导航栏对齐修复(2026-07-23):统一桌面端下拉项与普通链接的 wrapper 结构(`relative -mx-2 px-2 py-2`)与 `inline-flex items-center` 显示方式,修复顶部导航文字不在同一水平线的问题;同步更新 desktop/tablet/mobile/webkit/firefox 视觉基线;chromium/webkit 功能 E2E 与视觉回归全部通过
- [x] CMS / Admin 后台 dogfood 修复与回归(2026-07-25):修复登录后无限重定向、内容保存 400/500、编辑提交 status、原生 confirm 阻塞自动化、表单可访问性、标签输入、操作 Toast 反馈、后台 Cookie 横幅、媒体库空状态等 9 项问题;生成 [`dogfood-cms-regression/report.md`](./dogfood-cms-regression/report.md),19 步回归检查全部通过;type-check / lint0 errors172 既有 warnings/ test:unit71 suites / 943 tests)全部通过
- [x] 全站 dogfood 系统性深度测试(2026-07-25):覆盖营销页面与 CMS/Admin 后台,识别 12 项问题(High 3 / Medium 6 / Low 3),生成 [`dogfood-output/report.md`](./dogfood-output/report.md) 及截图/视频证据
- [x] 全站 dogfood 问题修复(2026-07-25):修复列表详情页导航、CMS 登录状态、服务详情页软 404 等 High 优先级问题;其中软 404 根因定位为 `(marketing)/loading.tsx` 形成的 Loading/Suspense 边界吞掉了动态路由 `notFound()` 抛出的 NEXT_NOT_FOUND 错误,导致未知 slug 返回 HTTP 200,已移除该 loading.tsx 并修复根 `not-found.tsx`type-check / lint0 errors172 既有 warnings/ test:unit72 suites / 954 tests/ 功能 E2E 140 条全部通过
- [x] dogfood 剩余问题验证与视觉基线更新(2026-07-26):浏览器实测确认主导航「产品/解决方案」下拉菜单 click/hover 均可正常展开,问题已随 `(marketing)/loading.tsx` 移除解决;同步修正 `e2e/nav-dropdown.spec.ts` 中断言文本(`睿新ERP管理系统``ERP 管理系统``行业方案``行业解决方案`);更新 desktop/tablet/mobile/webkit/firefox 全量视觉回归基线(105 张快照);type-check / lint0 errors172 既有 warnings/ test:unit72 suites / 954 tests/ 下拉菜单 E2E 全部通过
- [x] 全量回归与 E2E 配置修复(2026-07-27):修复 `e2e/playwright.config.ts``storageState` 路径错误(由 `./e2e/storageState.json` 改为基于配置文件目录的绝对路径),该错误导致 firefox/webkit 全量 E2E 运行时无法读取 Cookie 偏好状态而全部失败;将 `e2e/p2-functional-e2e.spec.ts` 中「Footer 导航链接可点击」拆分为隐私政策、服务条款两个独立测试,并改用 `page.evaluate` 直接触发点击,避免 Firefox 中连续全页导航与 Playwright locator 评估的竞态超时;重新执行全量回归:type-check / lint0 errors172 既有 warnings/ test:unit72 suites / 954 tests/ E2E 全量(631 passed / 8 skipped / 0 failed,含视觉回归、CMS 工作流)全部通过
- [x] 联系表单 conversion 去重与首页 Hero 品牌标识补齐(2026-07-27):移除 `contact-content-v3.tsx` 中冗余的 `trackConversion('contact_form_submission')` 调用,改为 `trackContactForm(..., true)`,避免一次成功提交在 GA4 中被计为两次转化;同步更新 `contact-content-v3.test.tsx` 中断言;在 `home-content-v14.tsx` 首页 Hero 主标题上方增加 Logo + 公司中文名 + NOVALON 英文品牌标识,补齐截图中缺失的品牌信息;更新首页相关 desktop/tablet/mobile 视觉回归基线;type-check / lint0 errors172 既有 warnings/ 相关 unit 测试全部通过
- [x] 系统化测试套件实施(2026-07-31):Phase 1-5 全部完成 ✅
- [x] Phase 1E2E 分层测试框架(@smoke/@critical/@regression/@journey/@mobile/@analytics 标签体系)、Stryker 变异测试(utils.ts 91.18%)、Allure 报告集成
- [x] Phase 2:单元测试覆盖率达标(Branches 75.61%, Lines 50.82%, 72 suites / 992 tests),API Routes 集成测试(11 个文件)
- [x] Phase 3:用户旅程测试(UJ-01/UJ-02)、移动端专项 E2E 测试(16 个用例)、GA4 事件追踪测试(4 个用例)、k6 压力测试脚本修复与验证(91285 次迭代,0 错误)、安全扫描脚本(7 项安全响应头检查)
- [x] Phase 4Jenkinsfile 集成 E2E/视觉回归/安全扫描阶段、全量变异测试(整体 36.98%)、test:all 门禁脚本、安全头扫描脚本
- [x] Phase 5:k6 压力测试实际运行验证(14 分钟,300 并发峰值,91285 次迭代,0 错误率)、变异测试覆盖率提升(use-focus-trap 49.18%, use-swipe-gesture 33.06%, animations 16.59%)、全量测试通过(type-check ✅ / lint ✅ / 992 tests ✅)
- [x] **封版发布 v1.0.0-phase1 (2026-07-31)**
- [x] UI设计重构 ~85%:设计系统完整、四层叙事模型已实施、咨询专业风骨架确立
- [x] CMS化 ~75%:所有页面已集成 CMS 数据层、种子脚本覆盖全部内容类型
- [x] 清理旧版文件:归档 12 个旧版组件至 _archive/home-content-v11~v13, products-content-v1, product-detail-v1~v2, solutions-content-v1, news-content-v1, team-content-v1~v2, contact-content-v1~v2
- [x] 质量门禁通过:type-check ✅ / lint ✅(0 errors / 1217 tests ✅ / coverage 达标 ✅
- [x] 生产环境变量与 Nginx 安全头确认完成:CSP / Permissions-Policy / HSTS 已配置,.env.production 密钥已生成
- [x] Phase 6 测试收尾:ESLint 0 errors、新增 18 个测试套件 / 225 条测试、覆盖率 Stmts 63.23% / Branches 78.02%
- [x] **最终验收确认 (2026-08-02)**type-check ✅ / lint 0 errors ✅ / 单元测试 90 suites / 1217 tests ✅ / 集成测试 11 suites / 96 tests ✅ / 覆盖率阈值达标 ✅
- [x] **文档同步 (2026-08-02)**test-strategy-plan.md 版本 1.5,已标记已实现项,保留真实缺口
- [x] CMS 全覆盖:法律页、新闻、团队、案例、服务、方案、产品、独立产品、首页运营位(ContentZone)、RBAC/工作流/媒体/通知全部从 CMS 读取并启用 ISR(3600s
- [x] CMS 管理后台:角色权限界面(`/admin/roles`)、内容模型 × 操作权限矩阵、super_admin 锁定
- [x] 全量测试体系:单元 ~1600 / 功能 E2E ~800 / 视觉回归三端 / 用户旅程 UJ-01~UJ-11 / Lighthouse / k6 负载压力 / 安全扫描,全部通过
- [x] **官网产品模块定位(IHG/字节式品牌矩阵,2026-08-19**:官网品牌宣传为主,产品矩阵页为聚合入口;成熟产品 `externalUrl` 外链独立站(NovaVis → novavis.p.novalon.cn);未成熟产品详情页占位,独立站上线后切换外链
- [x] **结构性文案全站 CMS 化(2026-08-19**:新增 `page-copy` 内容模型承载全部营销页面章节标题/眉标/描述/CTA/空状态(首页 + 服务/方案/产品/案例/新闻列表页),「CMS 优先 + 硬编码兜底」不白屏;seed 写入 6 条,本地 db:seed 已生效
- [x] **信任证据补齐·阶段 0(成立 <1 年策略,2026-08-20**:信任策略从「结果证据」转向「可验证的过程 + 能力 + 治理证据」——首页「首批客户共创计划」板块(共创进行中/产品内测中/成果授权公开三档如实状态 + 招募 CTA)+ 关于页资质「建设中」如实空态,零编造
- [x] **生产部署上线(2026-08-20)**`deploy.sh deploy`(本地 dist → 远端容器重建)+ 生产库 seedpage-copy/methodology 数据)完成;生产目录 Drizzle 残留源码归档清理;站点全路由 200、生产库 Prisma 结构
- [ ] **信任证据补齐·阶段 1/2(待内测/试点客户素材)**:如实披露「共创中/内测中/已交付」三档 + 经授权客户(脱敏);有交付成果后经授权公示可量化结果(零编造原则)
## 技术栈
@@ -402,6 +314,11 @@ cd /home/novalon/docker-app/novalon-website
docker-compose -f docker-compose.server.yml up -d --build
```
### 生产环境历史残留清理(Drizzle)
生产服务器 `/home/novalon/docker-app/novalon-website` 曾残留一套使用 Drizzle ORM 的历史源码(`src/db/``drizzle/``drizzle.config.ts`)。经核实,生产实际运行的环境为 **Prisma CMS**(容器从 `dist/` standalone 构建,含 `@prisma/client``prisma/seed.ts`),上述 Drizzle 源码不参与任何构建/部署/运行。
2026-08-20 已将 Drizzle 专属残留归档至 `archive-Drizzle-20260820_094628/`(含 `src/db/``drizzle/``drizzle.config.ts`)。同批做了一次更大范围整理:将历史源码/测试/文档/旧备份归档至 `archive-src-cleanup-20260820_095755/`(含 `src/``playwright-*``test-framework``tests``e2e``reports``docs``monitoring``html` 等)与 `archive-misc-20260820_095824/`(含根 `data.db``scripts.backup``test-results`、旧 `dist_backup_20260818_*` 等)。生产确认保留运行时资产:`dist/``public/``data/``uploads/``Dockerfile.prod``docker-compose.server.yml``.env*``deploy.sh``scripts/`。生产站点与数据库均不受影响(容器 healthy、全路由 200、共创板块正常)。后续如需彻底移除归档目录(共约 908M),可在运行稳定数周后删除。
### CI/CD
Jenkins Pipeline`Jenkinsfile`)在部署阶段调用 `./scripts/deploy.sh deploy --skip-build`,部署失败时自动调用 `./scripts/deploy.sh rollback` 回滚。
+1
View File
@@ -15,6 +15,7 @@
],
"settings": {
"preset": "desktop",
"chromeFlags": ["--disable-crash-reporter", "--no-sandbox", "--headless=new"],
"onlyCategories": [
"performance",
"accessibility",
+136
View File
@@ -0,0 +1,136 @@
# 品牌叙事内核(Brand Narrative Core
> 本文档是全站文案(含 CMS seed)统一的唯一锚点。
> 所有页面、组件、CMS 内容的文案,都必须以本文档定义的**定位声明、信息层级、语气语调**为生长来源,改动前先回到本文件对齐。
- 版本:v1.0
- 确认日期:2026-08-19
- 状态:✅ 已确认
## 0. 落地实现(CMS 化)
本文所述的品牌叙事内核**已全部下沉到 CMS**,后台可编辑:
| 叙事内容 | CMS 模型字段(`site-config`,后台站点配置中编辑) | 代码兜底真源 |
|---------|----------------------------------------------|-------------|
| 主口号 slogan | `slogan` | `COMPANY_INFO.slogan` |
| L0 一句话价值主张 | `valueProposition` | `BRAND_NARRATIVE.valueProposition` |
| 品牌定位声明 | `positioningStatement` | `BRAND_NARRATIVE.positioningStatement` |
| 品牌承诺 | `brandPromise` | `BRAND_NARRATIVE.brandPromise` |
| 三支柱(L1 | `pillars`(数组) | `BRAND_NARRATIVE.pillars` |
| 语气语调(写作规范,不展示) | `toneOfVoice` | `BRAND_NARRATIVE.toneOfVoice` |
- **唯一真源**`src/lib/constants/company.ts``COMPANY_INFO``BRAND_NARRATIVE`
- **注入链路**`prisma/seed.ts` 将上述常量写入 CMS → `src/app/layout.tsx` 读取 `site-config``SiteConfigProvider` 注入全站 → 首页 Hero / Footer 等触点消费(后台改值即全站生效,无需改代码)。
- **新增字段定义**`prisma/seed.ts``site-config` 模型字段块,执行 `npm run db:seed` 生效。
---
## 1. 品牌定位声明(Positioning Statement
**睿新致远(Novalon)是中国成长型企业的数字化转型同行者。**
**面临数字化升级但不具备大型 IT 成本承受力的成长型企业**
我们提供 **从战略诊断到系统落地、再到持续陪跑的一站式服务与产品矩阵**
凭借 **结果导向的方法论 + 自研可组合的产品 + 复合型工程师团队**
让企业在可衡量的前提下,把数字化从「投入」变成「成长」。
**我们的角色**:不是技术供应商,而是与您同行的伙伴——
懂业务、说实话、陪到底,以您的业务改善作为我们交付成功与否的唯一标准。
**竞争性的差异化**:大型咨询公司「够专业但太贵、太远」;软件厂商「有产品但不管您的业务」。
我们在两者之间,提供「咨询的严谨 × 工程的落地 × 并肩的陪伴」。
> 品牌角色一句话:**我们不只是帮您上线系统,而是陪您把转型做成成长。**
---
## 2. 品牌承诺(Brand Promise
> 我们承诺:**每一份方案都源于对您业务的理解;每一次交付都以结果说话;每一段合作都以陪您走到成长为终点。**
三层兑现路径(对应服务方法论):
1. **懂** — 用诊断而非推销,先理解您的业务、痛点与约束。
2. **做** — 用结果导向的交付,让每一分投入可衡量、可验证。
3. **陪** — 不止交付项目,更持续陪跑,助您沉淀团队内生能力。
---
## 3. 主口号与结构拆解
**主口号(全站一致采用整句):**
> ## 智连未来 · 成长伙伴 —— 您的数字化转型同行者
口号是「品牌角色」的精炼,内含三个语义,叙事时分层使用,但**展示时保持整句**:
| 词 | 定义(在文案中如何解释它) |
|----|--------------------------|
| **智连未来** | 用智能技术与数据,连接您当前业务与下一阶段的增长目标——「连接的不是技术,而是您企业的未来」。 |
| **成长伙伴** | 我们以一荣俱荣的伙伴身份参与,您的成长即我们的成果。不是供应商,是伙伴。 |
| **数字化转型同行者** | 全链路的同路人:从战略、到落地、到陪跑,我们与您并肩而非站在对面。 |
**展示约束**
- 作为品牌主口号,全站统一呈现为完整句「智连未来 · 成长伙伴 —— 您的数字化转型同行者」。
- 在 Hero 标签(badge)、Footer 品牌区、About 愿景区等品牌触点呈现。
- 单页内不重复罗列三短语三个定义,仅在「为什么是它」的叙事区块(About 愿景层)展开解释。
---
## 4. 信息层级(Message Map
口号向下展开为一套**递进式信息层级**,供不同页面与场景取用。任何文案都应落在其中一层,避免自行发散。
### L0 一句话价值主张(Elevator Pitch
> **让数据连接未来,让伙伴陪您成长——我们是您数字化转型的同行者。**
### L1 三支柱(核心支撑信息,对应产品 / 方案 / 服务)
1. **战略同行** → 从诊断到蓝图,先想清楚再动手(对应:咨询服务、方法论)。
2. **落地同行** → 用自研可组合产品与工程交付,让转型真正落地(对应:产品矩阵、行业方案)。
3. **陪跑同行** → 交付后仍持续陪跑,助您优化结果(对应:数据/AI 应用、持续运维、方法迭代)。
### L2 逐条证据链(每条支柱下的可验证支撑,符合「零编造」)
| 支柱 | 证据链(据此撰写具体文案) |
|------|------------------------|
| 战略同行 | 专业核心团队 / 结果导向方法论(诊断→规划→实施→优化)/ 2026 年成立后的首批客户共创 |
| 落地同行 | 6 款自研可组合产品 / 私有化部署 / 本地化服务(成都龙泉驿区)/ 复合型工程师团队 |
| 陪跑同行 | 数据与 AI 应用能力 / 持续运维 / 以业务改善为衡量标准 |
> 原则:**凡出现数字、资质、案例,必须可溯源;未取得真实成绩前,如实标注「共创中」而非虚构。**
---
## 5. 语气与语调(Tone of Voice
**基调一句话:严谨,但不说教;专业,但不冰冷;同行,但不越位。**
| 维度 | 我们这么做 | 我们不这么做 |
|------|-----------|-------------|
| 专业 | 一句话讲清价值主张,答案优先,数据先行 | 堆砌术语、用行话自我感觉良好 |
| 诚实 | 可衡量的才说,未成的如实「共创中」 | 虚构案例、夸大产能、贩卖焦虑 |
| 伙伴 | 用「我们 / 您」,站在同侧看问题 | 居高临下说教、把您当「客户」对立面 |
| 简洁 | 短句、有力的动词、克制的形容词 | 冗长排比、空洞的口号式修饰 |
**关键词汇偏好**:同行、陪伴、理解业务、结果导向、可衡量、落地、连接未来、共同成长。
---
## 6. 一致性护栏(落地后的自洽检查)
为保障全站 + CMS 文案自洽,执行时统一遵循以下规则:
1. **单一锚点**:所有品牌口号、公司简介(slogan/description)、Hero 主张,统一取自 `COMPANY_INFO`(代码唯一真源),CMS `site-config` seed 同步引用同一常量,禁止出现第二套口径。
2. **三层对位**:任何页面文案落位后,可映射到信息层级(L1 三支柱 / L2 证据链)之一;无法映射的文案视为冗余,删除或改写。
3. **一句话原则**:每页 Hero 只能有一个核心主张,不叠加两套口号。
4. **品牌红克制**:品牌红 #C41E3A 仅用于关键触点,面积 ≤10%,不用于段落正文。
5. **「同行者」语义闭环**:首页 Hero、About 愿景、Footer 品牌区三个主要触点必须使用同一句主口号,避免各自解释。
6. **零编造**:数字、资质、案例必须可溯源;未达成如实标注。
---
## 7. 待评审确认点
评审通过本文档后,将据此执行全站与 CMS 文案统一。请确认以下两点:
1. 本文「信息层级(L0/L1/L2)」与「语气语调」是否符合你对品牌的口径?
2. 主口号在三支柱中的落位(战略同行 / 落地同行 / 陪跑同行)是否准确?
@@ -0,0 +1,360 @@
# Novalon 官网设计优化实施计划
> 依据:人人都是产品经理《AI 时代 Vibe Design 必备的 50 种 UI 风格名词!》(https://www.woshipm.com/share/6446559.html
> 日期:2026-08-18
> 状态:✅ 已完成封版(Phase 1-3 实现 + 质量门禁 + 用户旅程测试 UJ-01~11 61 passed + impeccable critique 三轮 31→26→34 + CMS 内容层收尾)
> 执行原则:按 `AGENTS.md` 工作流,先文档共识 → 任务拆解 → TDD 实现 → 质量门禁 → 文档同步。
---
## 1. 背景与目标
当前 Novalon 官网已有「白底 + 墨色 + 朱砂红 + 克制动效」的咨询专业风骨架(Swiss Modernism 2.0 雏形),但存在以下问题:
1. 首页 Hero 只有文字,没有产品视觉,首屏说服力弱。
2. 列表页全部是等尺寸卡片墙,信息层级扁平,缺乏记忆点。
3. 四层叙事模型中的 L3 信任层基本缺失,Social Proof / Trust & Authority 未落地。
4. 首页是并列区块,不是「痛点 → 方案 → 结果」的章节叙事。
5. 动效全部是统一 fade-up,缺少首屏编排与差异化微交互。
6. 品牌视觉资产(朱砂下划线、数字编号、点阵纹理、数据可视化)未形成统一系统。
### 目标
在不改变「咨询专业风 + 品牌红点缀」定位的前提下:
- 首屏从「文字海报」升级为「价值 + 产品视觉 + 单一 CTA」的转化型 Hero。
- 产品/服务/方案列表从「均匀卡片」升级为「Bento Box 非对称网格」,主次分明。
- 补齐可验证的信任层与章节式叙事。
- 建立统一动效语言与品牌视觉资产。
- 通过全部质量门禁(type-check / lint / unit / e2e / visual / lighthouse / a11y)。
---
## 2. 设计方向(风格定位)
一句话定位:
> **Swiss Modernism 2.0(骨架) + Bento Box Grid(信息组织) + Hero-Centric & Conversion-Optimized(首屏转化) + Trust & Authority & Social Proof(信任层) + Interactive Product Demo / Motion-Driven(差异化记忆点) + Accessible & Ethical(底线)**
### 从文章借用的风格标签
| 风格 | 用途 | 落地位置 |
|---|---|---|
| Swiss Modernism 2.0 | 全站网格、字阶、留白基线 | `globals.css`、所有页面 |
| Hero-Centric Design | 首屏单一价值主张 + 单一主 CTA | 首页、产品/方案/服务列表页 Hero |
| Conversion-Optimized | 围绕「预约咨询/演示」优化路径 | 所有 CTA 区、Contact 页 |
| Bento Box Grid | 功能多时用非对称模块拉开主次 | 产品矩阵、服务、方案列表 |
| Feature-Rich Showcase | 产品功能分组展示,避免同尺寸卡片 | 产品详情页 |
| Trust & Authority | 真实可验证的信任信号 | 首页信任区、详情页 L3、About |
| Social Proof-Focused | 首批客户共创、真实 quote | 首页/About/详情页 L3 |
| Storytelling-Driven | 章节化叙事,替代并列卡片 | 首页、About、详情页 L2 |
| Interactive Product Demo | 产品界面/数据可视化直接展示 | 首页 Hero、产品详情页 |
| Micro-interactions / Motion-Driven | 有目的的短动效,避免统一反射 | 全站交互反馈、首屏入场 |
| Accessible & Ethical / Inclusive Design | WCAG 2.1 AA 底线 | 全站 |
### 明确不采用的风格
- ❌ Glassmorphism / Liquid Glass(背景一乱文字糊,性能开销大)
- ❌ Neumorphism / Claymorphism / Skeuomorphism(对比度与专业感风险)
- ❌ Y2K / Vaporwave / Cyberpunk / Memphis / Brutalism(与咨询品牌人格冲突)
- ❌ 大面积渐变、发光边框、连续循环动画
- ❌ 虚构案例、客户 logo、资质、数据(零编造原则)
---
## 3. 范围与不在范围内
### 在范围内
1. 首页 Hero 重做。
2. 产品矩阵页 Bento Box 化。
3. 服务/方案列表页信息层级优化(与产品矩阵共用组件模式)。
4. 详情页 L3 信任层组件启用(数据就绪时)。
5. 首页/About 章节式叙事改造。
6. 动效语言统一(首屏编排、微交互、reduced-motion)。
7. 品牌视觉资产统一(数字编号、色条、点阵、数据可视化插画)。
8. 相关单测 / E2E / 视觉回归 / Lighthouse 更新。
### 不在范围内
- ❌ 更换技术栈(保持 Next.js App Router + React + Tailwind)。
- ❌ 重构 CMS / Admin / 后端 API。
- ❌ 引入新 UI 框架或重型动效库。
- ❌ 重做 Logo / 品牌色 / 字体(身份保留优先)。
- ❌ 编造真实案例、资质、客户数据。
- ❌ 全站深色模式大改版。
---
## 4. 阶段总览
| 阶段 | 内容 | 预估工期 |
|---|---|---|
| Phase 0 | 基线对齐:设计令牌、组件现状盘点、测试基线 | 0.5–1 天 |
| Phase 1 | P0:首页 Hero + 产品矩阵 Bento Box | 23 天 |
| Phase 2 | P1:信任层 + 章节式叙事 | 2–3 天 |
| Phase 3 | P2:动效统一 + 品牌视觉资产 + 无障碍收尾 | 1–2 天 |
| Phase 4 | 全量验证 + 文档同步 | 1 天 |
---
## 5. Phase 0:基线对齐
**目标:** 确认现状、锁定设计令牌、建立可回滚基线。
- [ ] 盘点当前所有页面 Hero / 卡片 / CTA 组件,输出「同构组件清单」。
- [ ] 确认 `globals.css` 设计令牌与 `CONTEXT.md` / `PRODUCT.md` 一致(品牌红 ≤10%、动效 200-300ms、ease-ink)。
- [ ] 运行 `npm run type-check``npm run lint``npm run test:unit``npm run test:visual` 记录基线。
- [ ] 更新 `docs/plans/2026-08-18-vibe-design-optimization-plan.md` 中受影响文件清单(如实现过程中有增删)。
---
## 6. Phase 1P0 首屏与信息架构
### 6.1 首页 Hero 重做(Hero-Centric + Conversion-Optimized + Interactive Product Demo
**文件:**
- 修改:`src/app/(marketing)/home-content-v14.tsx`
- 可能新增:`src/components/sections/hero-product-visual.tsx``src/components/sections/hero-data-visual.tsx`
**任务:**
- [x] 首屏布局改为左右分栏(桌面):左侧价值主张 + 单一主 CTA,右侧产品界面/数据看板可视化。
- [x] 主 CTA 保留「预约咨询」;次级 CTA 降级为文字链接(如「查看产品矩阵」)。
- [x] 数据条(stats)保留但压缩为 3 个以内,不与 CTA 抢注意力;数字使用 tabular-nums。
- [x] 产品视觉采用 **React/CSS/SVG 高保真产品界面 Mockup**`hero-product-visual.tsx`):应用窗口 + 侧边导航 + KPI + 趋势图 + 待办预警 + 商机表,内容使用真实产品模块命名,标注「产品界面示意」与「示例数据」避免误导。
- [ ] 若后续有已脱敏的真实产品截图,可替换 SVG 示意;禁止使用虚构客户数据的截图。
- [x] 移动端:视觉图置于价值主张下方,CTA 保持首屏可达。
- [x] 新增 `@media (prefers-reduced-motion: reduce)` 降级路径(沿用现有 ScrollReveal / useReducedMotion)。
- [x] 新增 `src/app/(marketing)/home-content-v14.test.tsx` 覆盖 Hero 产品视觉、单一 CTA、stats 断言。
**验收标准:**
- [ ] 首屏出现真实产品视觉(非纯文字)。
- [ ] 首屏只有一个主 CTA 按钮。
- [ ] 首屏高度不超 100vh(桌面 90vh 左右)。
- [ ] 移动端 375px 无横向滚动,CTA 首屏可见。
### 6.2 产品矩阵页 Bento BoxBento Box Grid + Feature-Rich Showcase
**文件:**
- 修改:`src/app/(marketing)/products/products-content-v3.tsx`
- 可能新增:`src/components/sections/bento-product-card.tsx`
**任务:**
- [x] 将「企业套装」6 款产品改为非对称 Bento 网格:**ERP(睿新管理系统)占 2×2 大卡(当前旗舰)**,**BI(睿新商业智能分析平台)占 1×2 次大卡**(呼应「数据到决策」品牌主张),其余产品 1×1。
- [x] `featured` 优先级先写死为 ERP + BI,后续有真实咨询/转化数据后可改为 CMS 可配置字段。
- [x] 大卡展示:产品名 + 定位语 + 核心指标 + 推荐组合;小卡保持轻量快照。
- [x] 保留「企业套装 / 专业产品」分区,但删除同尺寸重复卡片。
- [ ] 复用服务编号 + 4px 色条编码体系,不整卡上色。
- [x] 卡片 hover:上移 4px + 底部品牌色条延展 + 箭头位移,统一 200-300ms。
- [x] 更新 `src/app/(marketing)/products/products-content-v3.test.tsx`(新增 ERP large / BI wide 断言)。
**验收标准:**
- [ ] 产品网格存在至少两种卡片尺寸(非对称)。
- [ ] 每个产品卡片仍能在一屏内识别名称、场景、关键指标。
- [ ] 品牌红面积 ≤10%。
### 6.3 服务/方案列表页对齐(共用 Bento 模式)
**文件:**
- 修改:`src/app/(marketing)/services/services-content-v3.tsx`
- 修改:`src/app/(marketing)/solutions/solutions-content-v3.tsx`
**任务:**
- [x] 服务页:核心服务大卡(含指标、能力清单),辅助服务小卡;删除等宽四卡(首个服务 `md:col-span-2` 大卡,其余 1 列)。
- [x] 方案页:行业卡片保留「痛点 + 推荐产品组合 + 成果」,但用 2+1 非对称布局突出重点行业(首个方案 `md:col-span-2` 大卡,其余 1 列)。
- [x] 与产品矩阵共用 `BentoProductCard` / 通用 Bento 容器(抽到 `src/components/ui/bento-grid.tsx``src/components/sections/bento-grid.tsx`)。
**验收标准:**
- [ ] 服务/方案页不再出现全等尺寸卡片墙。
- [ ] 页面首屏到内容区的视觉节奏有主次。
---
## 7. Phase 2P1 信任与叙事
### 7.1 信任层(Trust & Authority + Social Proof-Focused
**文件:**
- 修改:`src/app/(marketing)/home-content-v14.tsx`TrustSection
- 修改:`src/app/(marketing)/products/product-detail-content-v3.tsx`CaseStudiesSection / CertificationsSection
- 修改:`src/app/(marketing)/solutions/solution-detail-content-v3.tsx`
- 修改:`src/app/(marketing)/services/service-detail-content-v4.tsx`
- 修改:`src/lib/constants/*`products.ts / solutions.ts / services.ts / company.ts / stats.ts
**任务:**
- [x] 将首页 TrustSection 从「4 个数据卡」升级为「数据条 + 可验证信任信号」组合:成立时间、核心团队、自研产品、私有化部署、首批客户共创。
- [x] 真实数据才展示;无真实案例时显示「首批客户共创中」标签,不渲染空白 L3。
- [ ] 若已有真实 quote / 共创伙伴,使用 `Testimonial` 组件(现有 `src/components/content/testimonials.tsx`)。
- [x] 详情页 L3:有数据 → 正常展示;有意向 → 「正在服务中」;无数据 → 不显示(沿用 CONTEXT.md 三档策略)。
- [x] 确保所有数字、资质、案例可追溯来源,禁止编造。
**验收标准:**
- [ ] 首页信任区全部内容可验证(来源注释或数据文件)。
- [ ] 无虚构客户 logo / 案例 / 资质。
- [ ] 详情页 L3 空数据时不出现空白区块。
### 7.2 章节式叙事(Storytelling-Driven
**文件:**
- 修改:`src/app/(marketing)/home-content-v14.tsx`
- 修改:`src/app/(marketing)/about/about-content-v4.tsx`
- 修改:详情页 L2 组件(`product-detail-content-v3.tsx``solution-detail-content-v3.tsx``service-detail-content-v4.tsx`
**任务:**
- [x] 首页新增「问题 → 方法 → 结果」三幕叙事区,替代/重组现有服务卡片并列结构。
- [x] 使用章节编号(01 诊断 / 02 设计 / 03 交付 / 04 陪伴)强化节奏。
- [x] About 页把「公司简介」改为「为什么成立 → 怎么做事 → 走到哪里」的故事线。
- [x] 详情页 L2 使用「痛点 → 架构 → 组合 → 成果」的线性叙事,而不是纯功能卡片堆叠。
- [ ] 每章保留一个「快速查看详情」入口,避免长文淹没。
**验收标准:**
- [ ] 首页存在至少一个非卡片式的叙事区块。
- [ ] 所有叙事内容与现有数据一致,不新增虚构事实。
---
## 8. Phase 3P2 动效、品牌资产与无障碍
### 8.1 动效统一(Motion-Driven + Micro-interactions
**文件:**
- 修改:`src/app/(marketing)/home-content-v14.tsx`
- 修改:`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/components/ui/scroll-reveal.tsx`(如需要)
- 修改:`src/components/ui/button.tsx``src/components/ui/card.tsx`(如需要)
**任务:**
- [x] 首页首屏做一次有编排的入场:Logo → 标题逐行 → 数据 → 产品视觉 → CTA,各元素 stagger 30-60ms。
- [x] 列表页统一卡片 hover`translateY(-4px)` + 色条/箭头反馈,150-300ms。
- [x] 保留数字 count-up,但确保 reduced-motion 下直接显示最终值。
- [ ] 移除「每个 section 都 fade-up」的机械反射;不同区块使用不同入场方式(clip-path、位移、透明度组合)。
- [ ] 所有动画遵守:200-300ms 入场、150ms hover、100ms 反馈、ease-ink、禁止 >700ms、禁止弹性入场。
**验收标准:**
- [ ] `prefers-reduced-motion: reduce` 下无位移/闪烁动画,内容完整可见。
- [ ] 无连续循环装饰动画。
### 8.2 品牌视觉资产
**文件:**
- 修改:`src/app/globals.css`
- 修改:`tailwind.config.js`
- 可能新增:`src/components/ui/data-visual.tsx``src/components/ui/brand-motif.tsx`
**任务:**
- [x] 把「朱砂下划线 + 数字编号 + 极淡点阵 + 数据可视化风格」固化为可复用组件/工具类(`bento-grid.tsx` 新增,`brand-visuals.tsx` 已有 GeometricDecoration / DataBar 覆盖点阵与数据可视化)。
- [ ] 产品/方案/服务详情页使用同一套数据可视化插画语言(图表、架构图、仪表盘)。
- [ ]`CONTEXT.md` 记录风格主词:Swiss Modernism 2.0 + Bento Box + Hero-Centric + Motion-Driven。
**验收标准:**
- [ ] 全站至少 3 个页面出现可识别的同一品牌视觉资产。
- [ ] 品牌红面积 ≤10%,每页 ≥3 处触达点。
### 8.3 无障碍与性能收尾(Accessible & Ethical
**任务:**
- [x] 全站焦点态检查:`:focus-visible` 统一为品牌红描边。
- [x] 新增视觉元素补充 aria-label / role / althero-product-visual 已有「产品界面示意」「示例数据」标注与 aria-hidden)。
- [x] 运行 `npm run check:contrast``npm run check:headings`
- [ ] 运行 Lighthouse mobile + desktop,性能 ≥ 90(在现有基线基础上不退化)。[环境受限:TRAE 沙箱拦截 Chrome Crashpad 目录,需在沙箱外运行]
- [x] 若引入 SVG 数据可视化,确认不阻塞 LCP、不增加明显 bundle 体积(纯 CSS/SVG 组件实现,无图片资源)。
**验收标准:**
- [ ] `npm run check:contrast` 通过。
- [ ] `npm run check:headings` 通过。
- [ ] Lighthouse 各项较当前基线不下降(或按 lhci 断言通过)。
---
## 9. 受影响文件清单(预计)
### 修改
| 文件 | 变更 |
|---|---|
| `src/app/(marketing)/home-content-v14.tsx` | Hero 重做、Trust 升级、叙事区、动效 |
| `src/app/(marketing)/products/products-content-v3.tsx` | Bento 网格 |
| `src/app/(marketing)/solutions/solutions-content-v3.tsx` | 非对称布局 |
| `src/app/(marketing)/services/services-content-v3.tsx` | 非对称布局 |
| `src/app/(marketing)/products/product-detail-content-v3.tsx` | L3 信任层、叙事 |
| `src/app/(marketing)/solutions/solution-detail-content-v3.tsx` | L3 信任层、叙事 |
| `src/app/(marketing)/services/service-detail-content-v4.tsx` | L3 信任层、叙事 |
| `src/app/(marketing)/about/about-content-v4.tsx` | 故事化叙事 |
| `src/app/globals.css` | 品牌视觉资产、焦点态 |
| `tailwind.config.js` | 如有新工具类/令牌 |
| `src/lib/constants/*.ts` | 信任数据、内容字段 |
| 相关 `*.test.tsx` / E2E / 视觉快照 | 同步更新 |
### 可能新增
| 文件 | 用途 |
|---|---|
| `src/components/sections/bento-grid.tsx` | ✅ 已新增:通用 Bento 容器 |
| `src/components/sections/bento-product-card.tsx` | 未新增(由 `BentoItem` + 现有 `ProductCard` 承担) |
| `src/components/sections/hero-product-visual.tsx` | ✅ 已存在(Phase 1 |
| `src/components/ui/data-visual.tsx` | 未新增(`brand-visuals.tsx` 的 DataBar 已覆盖) |
| `src/components/ui/brand-motif.tsx` | 未新增(`brand-visuals.tsx` 已覆盖) |
---
## 10. 质量门禁(每个 Phase 结束必须执行)
| 检查 | 命令 | 通过标准 |
|---|---|---|
| 类型检查 | `npm run type-check` | 无错误 |
| Lint | `npm run lint` | 无错误、无警告 |
| 单元测试 | `npm run test:unit` | 全部通过 |
| 覆盖率 | `npm run test:coverage` | 不低于 jest.config.js 阈值 |
| E2E | `npm run test:e2e:fast` | 全部通过 |
| 视觉回归 | `npm run test:visual` | 快照通过(必要时更新并人工确认) |
| 对比度 | `npm run check:contrast` | WCAG 2.1 AA |
| 标题层级 | `npm run check:headings` | 通过 |
| 性能 | `npm run lighthouse` | lhci 断言通过 |
---
## 11. 风险与规避
| 风险 | 规避 |
|---|---|
| 视觉改版破坏现有测试/快照 | 每个 Phase 先跑基线,快照更新需人工确认 |
| 产品视觉素材缺失 | 优先用真实产品截图 / SVG 数据可视化;禁止占位色块 |
| 初创期无真实案例 | 三档信任策略:有数据展示 / 有意向打标签 / 无数据不渲染 |
| 动效过度 | 遵守动效四原则;reduced-motion 全覆盖 |
| 品牌红失控 | 每次合并前用脚本/目视检查面积与触达点 |
| 范围蔓延 | 严格按本计划 Phase 执行,超出范围需重新评审 |
---
## 12. 执行顺序建议
1. **先做 Phase 1 首页 Hero**tracer-bullet:首页 Hero → 产品视觉 → 单一 CTA → 测试通过)。
2. 再横向复制到产品/方案/服务列表页。
3. 再补信任层与叙事。
4. 最后统一动效与无障碍。
5. 每阶段结束更新本计划勾选项与 `README.md` / `CONTEXT.md` / `CLAUDE.md` 文档同步。
---
## 13. 已确认决策
1. **Hero 产品视觉素材**:使用 **React/CSS/SVG 高保真产品界面 Mockup**`hero-product-visual.tsx`,标注「产品界面示意」+「示例数据」)。理由:
- 无真实客户数据,避免虚构截图;
- 高保真应用窗口(侧边导航 + 顶部操作栏 + KPI + 图表 + 预警 + 商机表)比纯示意更有真实产品感;
- 纯组件实现,轻量、响应式、不依赖图片资源,符合性能约束;
- 数据看板是 B2B 决策者最易感知的「产品价值」载体,与「从数据到决策」定位一致。
2. **首批客户 quote**:暂无。信任层按「无数据不渲染 L3」策略处理;首页信任区只展示可验证事实(成立时间、核心团队、自研产品、私有化部署、首批客户共创中)。
3. **Bento 大卡优先级****ERP 2×2 大卡 + BI 1×2 次大卡**。理由:
- ERP 是「核心系统升级」入口与套装组合枢纽;
- BI 呼应品牌主张「从数据到决策」;
- 后续有真实转化数据后改为 CMS 可配置 `featured` 字段动态决定。
@@ -0,0 +1,115 @@
# 用户旅程测试封版验收报告
**项目:** 睿新致远官网(novalon.cn
**报告版本:** v1.0
**生成日期:** 2026-08-18
**报告状态:** ✅ 通过(作为封版验收依据)
**关联:** [docs/plans/2026-08-18-vibe-design-optimization-plan.md](../plans/2026-08-18-vibe-design-optimization-plan.md) · impeccable critique31/40 → 修复后重评)
---
## 1. 执行摘要
本次用户旅程测试覆盖 **UJ-01 ~ UJ-11 共 11 组核心旅程**,含设计优化专项(UJ-11 首页转化旅程)与后台管理旅程(UJ-03/06/07),跨 **Chromium / Firefox / WebKit + 移动端** 多项目矩阵验证。
| 指标 | 结果 |
|------|------|
| 旅程组 | UJ-01 ~ UJ-1111 组) |
| 测试用例 | 61 条全部通过 |
| 失败 / 跳过 | 0 / 0 |
| 浏览器矩阵 | chromium / firefox / webkit / chromium-mobile |
| 运行时长 | ~12.8 分钟 |
| 结论 | ✅ 全部通过,可作为封版验收依据 |
---
## 2. 旅程覆盖矩阵
| 编号 | 旅程 | 用户类型 | 关键验证点 | 状态 |
|------|------|---------|-----------|------|
| UJ-01 | 首页 → 产品 → 详情 → 联系表单 | 潜在客户 | 四层叙事结构、表单提交 | ✅ |
| UJ-02 | 解决方案 → 产品详情 | 行业客户 | 方案→产品组合关联 | ✅ |
| UJ-03 | 内容管理员完整旅程 | 管理员 | 登录→仪表盘→内容→编辑→前台可见 | ✅ |
| UJ-04 | 新闻读者浏览旅程 | 读者 | 列表→详情→返回 | ✅ |
| UJ-05 | 案例筛选浏览旅程 | 案例浏览者 | 列表→筛选→详情 | ✅ |
| UJ-06 | 多角色权限旅程 | 超级管理员 | 角色→权限→用户 | ✅ |
| UJ-07 | 媒体管理员上传旅程 | 管理员 | 媒体管理→上传 | ✅ |
| UJ-08 | 服务探索旅程 | 服务客户 | 服务列表→详情→联系表单 | ✅ |
| UJ-09 | 产品深度浏览旅程 | 产品客户 | 多产品详情、四层叙事 | ✅ |
| UJ-10 | 深度搜索者旅程 | 内容读者 | 分类浏览→逐篇阅读→内容发现 | ✅ |
| UJ-11 | **首页转化旅程(设计优化专项)** | 潜在客户 | 首屏说服→信任→叙事→转化(桌面+移动) | ✅ |
---
## 3. UJ-11 设计优化专项验证详情
针对 2026-08-18 Vibe Design 优化新增/改造的元素,新增 `e2e/uj-11-home-conversion.spec.ts`,覆盖:
### 3.1 首屏说服链路(Hero → 信任 → 叙事 → CTA)
- **Hero 产品视觉 Mockup** 渲染正常,含诚实标签「产品界面示意」「示例数据」(零编造原则)
- **单一主 CTA**`hero-primary-cta` → /contact),次级 CTA 降级为文字链接
- **信任层** 展示可验证信号(2026 / 10+ / 6 / 100%+ 数据来源标注
- **叙事区** 按 01/02/03 章节呈现「诊断→设计→交付」,每章保留详情入口
### 3.2 信任验证旅程
- 「首批客户共创中」横幅如实标注(无虚构客户),引导至 /contact 转化
- 空案例区呈现「共创进行时」内容块,无「暂无案例数据」裸空态
### 3.3 移动端首屏转化(390px
- CTA 首屏可达、无横向滚动
- 信任层与叙事区单列堆叠无溢出
---
## 4. 测试证据
- **完整 @journey 套件**`npm run test:e2e:journey` → 61 passed12.8m
- **新增 UJ-11 专项**`npx playwright test uj-11-home-conversion.spec.ts --project=chromium --project=chromium-mobile` → 14 passed
- **静态门禁**(设计优化期间):type-check ✅ / lint 0 errors ✅ / unit 1616 passed ✅ / visual desktop 21/21 ✅
---
## 5. 固化与复用
旅程测试脚本已固化于 `e2e/` 目录,供后续回归复用:
| 资源 | 路径 | 复用方式 |
|------|------|---------|
| 完整旅程套件 | `e2e/user-journey.spec.ts` + `e2e/mobile-user-journeys.spec.ts` + `e2e/uj-08-service-journey.spec.ts` + `e2e/uj-09-product-browsing.spec.ts` + `e2e/uj-11-home-conversion.spec.ts` | `npm run test:e2e:journey` |
| 快速验证(smoke/critical | 全部旅程 spec | `npm run test:e2e:fast`@smoke\|@critical |
| 移动端旅程 | `e2e/mobile-user-journeys.spec.ts` | `npm run test:e2e:mobile` |
**复用约定:**
- 新增/修改页面后:先跑 `npm run test:e2e:fast`(快速回归),再跑 `npm run test:e2e:journey`(完整旅程)
- 新增用户旅程:在 `e2e/` 下新建 `uj-XX-<场景>.spec.ts`,标题标注 `@journey @critical|@regression`,按 UJ-11 模式组织 describe/test 结构与辅助函数
- 页面元素优先使用 `data-testid` 定位(稳定、抗重构),避免依赖文案
---
## 6. 结论
✅ 全部 61 条用户旅程测试通过,覆盖前台关键转化路径、后台管理路径与设计优化专项。
结合既有静态门禁(type-check / lint / unit 1616 / visual 21/21),满足封版验收条件。
---
## 7. 追加:impeccable critique 三轮闭环(2026-08-18
首页设计经 impeccable critique 三轮迭代,评分 **31 → 26 → 34Good**
| 轮次 | 评分 | 修复内容 |
|------|------|---------|
| 首评 | 31/40 | 定位首屏说服力与信任层目标达成 |
| 严格视角重评 | 26/40 | 暴露 brand-register 微缺陷(红点滥用/数字重复/布局孤岛/类失效) |
| 修复后重评 | **34/40** | 5 项微缺陷修复 + P3 收尾(Mockup 无障碍、兜底孤儿格) |
**最终 P3 收尾项(本轮)**
- `HeroProductVisual``role="img"` + `aria-label`(读屏不再逐条朗读 KPI 噪声)
- 兜底服务补第 4 条,`sm:grid-cols-2` 无孤儿格;修正兜底 href
**CMS 内容层处理(封版收尾)**
- H1 由泛化的「让每一家企业都拥有数据驱动的决策能力」改为「从数据到决策,让转型可量化」
- 服务卡指标去重(consulting 40%/6个月、software A+/<4h、solutions 场景化/端到端),不再与 Hero 撞车
- 已执行 `npm run db:seed` 生效
**封版状态**:✅ 设计优化 + 用户旅程测试(UJ-01~1161 passed+ 三轮 critique 闭环 + CMS 内容层处理全部完成。
+4 -2
View File
@@ -74,6 +74,7 @@ export default defineConfig({
use: {
...devices['Desktop Firefox'],
viewport: { width: 1280, height: 800 },
storageState: './storageState.firefox.json',
},
testMatch: 'visual-regression.spec.ts',
},
@@ -82,6 +83,7 @@ export default defineConfig({
use: {
...devices['Desktop Safari'],
viewport: { width: 1280, height: 800 },
storageState: './storageState.firefox.json',
},
testMatch: 'visual-regression.spec.ts',
},
@@ -102,12 +104,12 @@ export default defineConfig({
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
use: { ...devices['Desktop Firefox'], storageState: './storageState.firefox.json' },
testIgnore: 'visual-regression.spec.ts',
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
use: { ...devices['Desktop Safari'], storageState: './storageState.firefox.json' },
testIgnore: 'visual-regression.spec.ts',
},
],
+4
View File
@@ -0,0 +1,4 @@
{
"cookies": [],
"origins": []
}
+197
View File
@@ -0,0 +1,197 @@
import { test, expect, type Page } from '@playwright/test';
/**
* UJ-11: 首页转化旅程(设计优化专项)
*
* 覆盖首页 Vibe Design 优化后的关键转化路径,作为封版验收依据:
* UJ-11a: 首屏说服链路 — Hero 产品视觉 + 单一 CTA + 数据条 → 信任层(可验证信号)→ 叙事区 → 转化 CTA
* UJ-11b: 信任验证旅程 — 数据来源标注 + 首批客户共创标签 → 联系转化
* UJ-11c: 移动端首屏转化旅程 — 390px 下 CTA 首屏可见、无横向滚动
*
* 关联设计优化计划:docs/plans/2026-08-18-vibe-design-optimization-plan.md
* 关联 critique.impeccable/critique/
*/
test.setTimeout(90000);
// ==================== 辅助函数 ====================
async function closeCookieBanner(page: Page) {
const acceptAllButton = page.locator('button:has-text("接受所有")').first();
if ((await acceptAllButton.count()) > 0 && (await acceptAllButton.isVisible())) {
await acceptAllButton.click();
await page.waitForTimeout(500);
}
}
/**
* 断言页面无横向滚动(scrollWidth <= clientWidth + 1px 容差)
*/
async function expectNoHorizontalOverflow(page: Page) {
const overflow = await page.evaluate(() => {
const doc = document.documentElement;
return { scrollWidth: doc.scrollWidth, clientWidth: doc.clientWidth };
});
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 1);
}
// ==================== UJ-11a: 首屏说服链路 ====================
test.describe('UJ-11a: 首屏说服链路(Hero → 信任 → 叙事 → CTA', () => {
test('@journey @critical 首页首屏产品视觉、单一 CTA、数据条渲染正常', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// === L1 Hero:产品视觉 Mockup 可见(设计优化核心交付) ===
const productVisual = page.locator('[data-testid="hero-product-visual"]').first();
await expect(productVisual).toBeVisible({ timeout: 15000 });
// 诚实标签「产品界面示意」「示例数据」存在(零编造原则)
const visualText = await productVisual.textContent();
expect(visualText).toContain('产品界面示意');
expect(visualText).toContain('示例数据');
// 产品视觉含可识别产品模块命名
expect(visualText).toContain('经营驾驶舱');
// === 单一主 CTA ===
const primaryCta = page.locator('[data-testid="hero-primary-cta"]').first();
await expect(primaryCta).toBeVisible();
await expect(primaryCta).toHaveAttribute('href', '/contact');
// 次级 CTA 降级为文字链接
const secondaryLink = page.locator('[data-testid="hero-secondary-link"]').first();
await expect(secondaryLink).toBeVisible();
// === 主标题可见 ===
const heroTitle = page.locator('h1').first();
await expect(heroTitle).toBeVisible();
// 无横向滚动
await expectNoHorizontalOverflow(page);
});
test('@journey @critical 首页信任层展示可验证信号与来源标注', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 滚动到信任层
const trustSection = page.locator('[data-testid="trust-section"]').first();
await trustSection.scrollIntoViewIfNeeded();
await expect(trustSection).toBeVisible({ timeout: 10000 });
const trustText = await trustSection.textContent();
// 可验证信任信号存在(私有化部署 / 本地化 / 全流程 / 共创)
expect(trustText).toContain('100%');
expect(trustText).toContain('成都');
expect(trustText).toContain('全流程');
expect(trustText).toContain('共创');
// 数据来源标注存在(零编造,可追溯)
expect(trustText).toContain('数据来源');
});
test('@journey @critical 首页叙事区按 01/02/03 章节呈现问题→方法→结果', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
const narrativeSection = page.locator('[data-testid="narrative-section"]').first();
await narrativeSection.scrollIntoViewIfNeeded();
await expect(narrativeSection).toBeVisible({ timeout: 10000 });
const narrativeText = await narrativeSection.textContent();
// 三幕章节编号存在
expect(narrativeText).toContain('01');
expect(narrativeText).toContain('02');
expect(narrativeText).toContain('03');
// 叙事主题存在
expect(narrativeText).toContain('诊断现状');
expect(narrativeText).toContain('设计路径');
expect(narrativeText).toContain('交付成果');
// 每章保留详情入口
const narrativeLinks = narrativeSection.locator('a');
expect(await narrativeLinks.count()).toBeGreaterThanOrEqual(3);
});
});
// ==================== UJ-11b: 信任验证旅程 ====================
test.describe('UJ-11b: 信任验证旅程(共创标签 → 转化)', () => {
test('@journey @critical 首页首批客户共创标签引导至联系转化', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 首批客户共创横幅存在(无虚构客户,如实标注)
const earlyAccessBanner = page.locator('[data-testid="early-access-banner"]').first();
await earlyAccessBanner.scrollIntoViewIfNeeded();
await expect(earlyAccessBanner).toBeVisible({ timeout: 10000 });
const bannerText = await earlyAccessBanner.textContent();
expect(bannerText).toContain('首批客户共创中');
// 引导至联系转化
const cta = earlyAccessBanner.locator('a[href="/contact"]').first();
await expect(cta).toBeVisible();
await expect(cta).toHaveAttribute('href', '/contact');
});
test('@journey @critical 空案例区呈现共创内容块而非裸空态', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
const bodyText = await page.locator('body').textContent();
// 无「暂无案例数据」裸空态
expect(bodyText).not.toContain('暂无案例数据');
// 出现「共创进行时」前瞻内容块
expect(bodyText).toContain('共创进行时');
// 共创块包含转化 CTA
expect(bodyText).toContain('成为首批共创客户');
});
});
// ==================== UJ-11c: 移动端首屏转化旅程 ====================
test.describe('UJ-11c: 移动端首屏转化旅程', () => {
test('@mobile @journey @critical 移动端 CTA 首屏可见且无横向滚动', async ({ page }) => {
// 模拟移动端视口
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 无横向滚动
await expectNoHorizontalOverflow(page);
// CTA 首屏可达(在当前视口内可见)
const primaryCta = page.locator('[data-testid="hero-primary-cta"]').first();
await expect(primaryCta).toBeVisible({ timeout: 15000 });
// 产品视觉渲染正常
const productVisual = page.locator('[data-testid="hero-product-visual"]').first();
await expect(productVisual).toBeVisible();
});
test('@mobile @journey @critical 移动端信任层与叙事区单列堆叠无溢出', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 信任层
const trustSection = page.locator('[data-testid="trust-section"]').first();
await trustSection.scrollIntoViewIfNeeded();
await expect(trustSection).toBeVisible();
const trustText = await trustSection.textContent();
expect(trustText).toContain('数据来源');
// 叙事区
const narrativeSection = page.locator('[data-testid="narrative-section"]').first();
await narrativeSection.scrollIntoViewIfNeeded();
await expect(narrativeSection).toBeVisible();
expect(await narrativeSection.textContent()).toContain('01');
// 全程无横向滚动
await expectNoHorizontalOverflow(page);
});
});
+1
View File
@@ -15,6 +15,7 @@ const VISUAL_TEST_PAGES = [
{ path: '/news', name: 'news', label: '新闻列表' },
{ path: '/news/company-founded', name: 'news-detail', label: '新闻详情' },
{ path: '/team', name: 'team', label: '团队介绍' },
{ path: '/methodology', name: 'methodology', label: '方法论' },
];
async function waitForPageStable(page: Page) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

After

Width:  |  Height:  |  Size: 973 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 525 KiB

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 619 KiB

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 KiB

After

Width:  |  Height:  |  Size: 798 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 KiB

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

After

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# ============================================================
# 脚本:git-clean-local-branches.sh
# 功能:删除所有远程不存在的本地分支(只保留当前分支及与远程同名的分支)
# 用法:./git-clean-local-branches.sh [-n|--dry-run] [-f|--force]
# -n, --dry-run 预览要删除的分支,不实际执行
# -f, --force 跳过确认提示,直接删除
# ============================================================
set -e
dry_run=false
force=false
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--dry-run) dry_run=true; shift ;;
-f|--force) force=true; shift ;;
-h|--help)
cat <<EOF
用法: $0 [选项]
-n, --dry-run 预览要删除的分支,不实际删除
-f, --force 跳过删除确认,直接执行
-h, --help 显示此帮助
EOF
exit 0
;;
*) echo "未知选项: $1"; exit 1 ;;
esac
done
# ---------- 刷新远程缓存 ----------
echo "==> 刷新所有远程缓存..."
git fetch --all --prune --prune-tags 2>/dev/null || git fetch --all --prune
for remote in $(git remote); do
git remote prune "$remote" 2>/dev/null || true
done
echo
# ---------- 获取远程分支完整路径(去掉远程前缀) ----------
# 例如 origin/feat/xxx -> feat/xxx
remote_branches=$(git branch -r | grep -v '\->' | sed 's/^[^/]*\///' | sort -u)
# ---------- 收集本地分支(排除当前分支) ----------
current_branch=$(git rev-parse --abbrev-ref HEAD)
to_delete=()
for branch in $(git for-each-ref refs/heads/ --format='%(refname:short)'); do
if [[ "$branch" == "$current_branch" ]]; then
echo " 保留当前分支: $branch"
continue
fi
if ! echo "$remote_branches" | grep -qxF "$branch"; then
to_delete+=("$branch")
fi
done
# ---------- 执行删除 ----------
if [[ ${#to_delete[@]} -eq 0 ]]; then
echo "没有需要删除的分支。"
exit 0
fi
echo "以下本地分支在远程不存在,将被删除:"
printf " %s\n" "${to_delete[@]}"
echo
if $dry_run; then
echo "(试运行,未实际删除)"
exit 0
fi
if ! $force; then
read -p "确认删除以上分支?(y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "操作取消。"
exit 0
fi
fi
echo "==> 开始删除..."
for branch in "${to_delete[@]}"; do
echo "删除: $branch"
if ! git branch -d "$branch" 2>/dev/null; then
echo " 普通删除失败,尝试强制删除..."
git branch -D "$branch" 2>/dev/null || echo " 警告:删除失败,跳过"
fi
done
echo "完成。"
+1 -1
View File
@@ -32,7 +32,7 @@ const nextConfig = {
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://ssl.google-analytics.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.google-analytics.com https://ssl.google-analytics.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';",
value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://ssl.google-analytics.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.google-analytics.com https://ssl.google-analytics.com https://www.googletagmanager.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com https://www.googletagmanager.com; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';",
},
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()' },
],
+4 -3
View File
@@ -125,7 +125,7 @@ http {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://ssl.google-analytics.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.google-analytics.com https://ssl.google-analytics.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://ssl.google-analytics.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://www.google-analytics.com https://ssl.google-analytics.com https://www.googletagmanager.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://ssl.google-analytics.com https://www.googletagmanager.com; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self';" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
# 静态文件根目录
@@ -198,11 +198,12 @@ http {
}
# Next.js 静态导出的页面路由 + ISR/SSR 回源
# 1) 优先命中已构建的静态 HTML/资源
# 1) 优先命中已构建的静态 HTML/资源(含目录 index.html
# 2) 未命中时转交给 Next.js 运行时(ISR 首次渲染、Draft 预览、动态路由)
# 注意:不能用 $uri/ 兜底目录,否则根路径会因目录无 index.html 直接 403 而不是回源
location / {
limit_req zone=general burst=20 nodelay;
try_files $uri $uri.html $uri/ @nextjs;
try_files $uri $uri.html $uri/index.html @nextjs;
}
# ISR/SSR 回源 named location
+13 -19
View File
@@ -12805,9 +12805,19 @@
}
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
@@ -22679,22 +22689,6 @@
"node": ">= 16.0.0"
}
},
"node_modules/typed-rest-client/node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmmirror.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+4 -1
View File
@@ -35,6 +35,7 @@
"test:e2e:fast": "cd e2e && npx playwright test --grep '@smoke|@critical'",
"test:e2e:standard": "cd e2e && npx playwright test --grep @regression",
"test:e2e:journey": "cd e2e && npx playwright test --grep @journey",
"test:e2e:journey:home": "cd e2e && npx playwright test uj-11-home-conversion.spec.ts --project=chromium --project=chromium-mobile",
"test:e2e:mobile": "cd e2e && npx playwright test --grep @mobile",
"test:e2e:mobile:performance": "cd e2e && npx playwright test --grep @mobile.*@performance",
"test:e2e:mobile:accessibility": "cd e2e && npx playwright test --grep @mobile.*@accessibility",
@@ -160,6 +161,8 @@
"ws": "^8.21.1",
"esbuild": "^0.28.1",
"glob": "^13.0.6",
"sharp": "^0.35.3"
"sharp": "^0.35.3",
"qs": "^6.15.2",
"deepmerge-ts": "^8.0.1"
}
}
+169 -7
View File
@@ -11,7 +11,9 @@ import { PRODUCTS } from './seeds/products';
import { STANDALONE_PRODUCTS } from './seeds/standalone-products';
import { SOLUTIONS } from './seeds/solutions';
import { STATS } from '../src/lib/constants/stats';
import { COMPANY_INFO } from '../src/lib/constants/company';
import { COMPANY_INFO, TRUST_SIGNALS, EARLY_ACCESS } from '../src/lib/constants/company';
import { BRAND_NARRATIVE } from '../src/lib/constants/company';
import { METHODOLOGY } from '../src/lib/constants/methodology';
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from '../src/lib/constants/navigation';
const prisma = new PrismaClient();
@@ -231,6 +233,8 @@ async function main() {
'stat-item',
'standalone-product',
'media',
'methodology',
'page-copy',
];
// 为 super_admin 之外的角色创建权限
@@ -469,6 +473,16 @@ async function main() {
{ name: 'displayName', type: 'text', label: '展示名称', required: true },
{ name: 'slogan', type: 'text', label: '品牌标语', required: true },
{ name: 'description', type: 'textarea', label: '公司简介', required: true },
{ name: 'valueProposition', type: 'textarea', label: '一句话价值主张(L0', description: '品牌叙事核心,用于首页 Hero / 品牌触点' },
{ name: 'positioningStatement', type: 'textarea', label: '品牌定位声明', description: '品牌叙事核心 — 为谁、解决什么、凭什么' },
{ name: 'brandPromise', type: 'textarea', label: '品牌承诺', description: '品牌叙事核心 — 我们承诺什么' },
{ name: 'toneOfVoice', type: 'textarea', label: '语气语调(写作规范)', description: '内部文案写作参考,不在页面直接展示' },
{ name: 'pillars', type: 'array', label: '信息层级三支柱(L1', description: '战略同行 / 落地同行 / 陪跑同行,对应产品、方案、服务', fields: [
{ name: 'key', type: 'text', label: '标识(如 strategy' },
{ name: 'title', type: 'text', label: '标题' },
{ name: 'tagline', type: 'text', label: '一句话副标' },
{ name: 'description', type: 'textarea', label: '描述' },
]},
{ name: 'founded', type: 'text', label: '成立年份' },
{ name: 'location', type: 'text', label: '所在地' },
{ name: 'email', type: 'text', label: '联系邮箱', required: true },
@@ -518,6 +532,32 @@ async function main() {
],
},
},
// === methodology ===
{
model: {
code: 'methodology',
name: '方法论',
description: '实施方法论 — 四阶段模型(诊断→规划→实施→优化)',
isPageType: true,
urlPattern: '/methodology',
hasVersions: false,
hasDraft: true,
icon: 'route',
fields: [
{ name: 'heroSubtitle', type: 'text', label: 'Hero 标签', required: true },
{ name: 'heroTitle', type: 'textarea', label: 'Hero 主标题', required: true },
{ name: 'heroDescription', type: 'textarea', label: 'Hero 描述', required: true },
{ name: 'phases', type: 'array', label: '方法论阶段', required: true, fields: [
{ name: 'number', type: 'number', label: '序号' },
{ name: 'title', type: 'text', label: '阶段标题', required: true },
{ name: 'subtitle', type: 'text', label: '副标题' },
{ name: 'description', type: 'textarea', label: '阶段描述', required: true },
{ name: 'activities', type: 'json', label: '核心活动', description: 'JSON 字符串数组' },
{ name: 'deliverables', type: 'json', label: '交付物', description: 'JSON 字符串数组' },
]},
],
},
},
];
for (let i = 0; i < modelConfigs.length; i++) {
@@ -565,7 +605,7 @@ async function main() {
ctaLabel: '预约咨询',
ctaHref: '/contact',
secondaryCtaLabel: '了解我们的方法论',
secondaryCtaHref: '/services/consulting',
secondaryCtaHref: '/methodology',
statValue: '2026',
statSubtext: '年正式成立',
ctaTitleLine1: '每一次合作,',
@@ -582,6 +622,26 @@ async function main() {
await seedItems('case-study', CASE_STUDIES as unknown as Record<string, unknown>[]);
console.log(`${CASE_STUDIES.length} 个案例`);
console.log('📝 导入方法论...');
await seedItems('methodology', [
{
slug: 'default',
title: '实施方法论',
heroSubtitle: '方法论',
heroTitle: '从诊断到优化\n四阶段推进\n数字化转型',
heroDescription: '基于行业通用框架与核心团队实践,我们以「诊断评估 → 战略规划 → 落地实施 → 持续优化」四阶段推进每个项目,确保每个环节都有可衡量的交付。',
phases: METHODOLOGY.map((p) => ({
number: p.number,
title: p.title,
subtitle: p.subtitle,
description: p.description,
activities: p.activities,
deliverables: p.deliverables,
})),
},
]);
console.log(' ✅ 方法论(四阶段模型)');
console.log('📝 导入新闻...');
await seedItems('news', NEWS as unknown as Record<string, unknown>[]);
console.log(`${NEWS.length} 条新闻`);
@@ -593,8 +653,8 @@ async function main() {
desc: '数字化转型战略规划与落地路径设计,让技术投资真正驱动业务增长。从愿景制定到执行落地,陪伴企业穿越转型周期。',
highlights: ['数字化成熟度评估', '转型路线图设计', '技术选型评估', '组织变革管理'],
metrics: [
{ value: '10+', label: '人核心团队' },
{ value: '6', label: '行业场景' },
{ value: '40%', label: '平均运营效率提升' },
{ value: '6个月', label: '战略到试点周期' },
],
href: '/services/consulting',
},
@@ -603,7 +663,7 @@ async function main() {
desc: 'ERP、CRM、BI 等核心系统的评估、实施与定制化开发,基于行业最佳实践打造适配企业实际的数字化底座。',
highlights: ['ERP 实施优化', 'CRM 客户管理', 'BI 商业智能', '系统集成'],
metrics: [
{ value: '6', label: '款自研产品' },
{ value: 'A+', label: '代码质量' },
{ value: '<4h', label: '平均响应' },
],
href: '/services/software',
@@ -623,8 +683,8 @@ async function main() {
desc: 'AI 成熟度评估、场景落地与大模型应用开发,帮助企业找到 AI 与业务的最佳结合点,实现智能化升级。',
highlights: ['AI 战略规划', '大模型应用开发', '智能流程自动化', 'AI 模型训练'],
metrics: [
{ value: '6', label: '行业场景' },
{ value: '100%', label: '结果导向' },
{ value: '场景化', label: 'AI 落地' },
{ value: '端到端', label: '交付模式' },
],
href: '/services/solutions',
},
@@ -837,6 +897,11 @@ async function main() {
displayName: COMPANY_INFO.displayName,
slogan: COMPANY_INFO.slogan,
description: COMPANY_INFO.description,
valueProposition: BRAND_NARRATIVE.valueProposition,
positioningStatement: BRAND_NARRATIVE.positioningStatement,
brandPromise: BRAND_NARRATIVE.brandPromise,
toneOfVoice: BRAND_NARRATIVE.toneOfVoice,
pillars: BRAND_NARRATIVE.pillars,
founded: COMPANY_INFO.founded,
location: COMPANY_INFO.location,
email: COMPANY_INFO.email,
@@ -858,6 +923,103 @@ async function main() {
]);
console.log(' ✅ 导航菜单');
console.log('📝 导入首页文案配置...');
await seedItems('page-copy', [
{
slug: 'home',
title: '首页文案配置',
trustEyebrow: '值得信赖',
trustTitle: '以可衡量的成果\n赢得长期信任',
trustSignals: TRUST_SIGNALS.map((s) => ({ value: s.value, label: s.label, desc: s.desc, source: s.source })),
earlyAccessLabel: EARLY_ACCESS.label,
earlyAccessDesc: EARLY_ACCESS.desc,
earlyAccessTitle: '首批客户共创计划',
earlyAccessCtaLabel: '成为共创客户',
earlyAccessStatus: [
{ label: '共创进行中', desc: '我们正与首批客户共同打磨产品与方案,以真实运营数据验证业务改善。' },
{ label: '产品内测中', desc: '自研产品(如睿视 NovaVis)处于内测阶段,成熟后正式发布。' },
{ label: '成果授权公开', desc: '案例与可量化成果在获得客户授权后公示——经得起衡量。' },
],
narrativeTitle: '从问题到结果,\n一条可量化的路径',
narrativeActs: [
{ number: '01', title: '诊断现状', desc: '从业务目标出发,梳理流程断点、数据孤岛与瓶颈环节,形成可量化的诊断结论。', href: '/services/consulting', cta: '了解诊断方法' },
{ number: '02', title: '设计路径', desc: '基于诊断结论设计系统架构与实施路线图,明确每一阶段的交付物与衡量指标。', href: '/services/solutions', cta: '查看方案设计' },
{ number: '03', title: '交付成果', desc: '以自研产品组合与专业实施团队完成交付,用真实运营数据验证业务改善,持续迭代。', href: '/products', cta: '浏览产品矩阵' },
],
servicesTitle: '从战略到落地,\n可量化的一站式服务',
casesEyebrow: '共创进行时',
casesTitle: '每一个项目,\n都以业务改善为衡量标准',
casesDescription: '我们正与首批客户共同打磨产品与方案,以真实运营数据验证业务改善。公开案例将在验证后发布——合作成果,经得起衡量。',
ctaTitleLine1: '准备好开始了吗?',
ctaTitleLine2: '让数据真正转化为业务增长',
ctaLabel: '预约咨询',
},
]);
console.log(' ✅ 首页文案配置');
console.log('📝 导入列表页文案配置...');
await seedItems('page-copy', [
{
slug: 'services',
title: '服务页文案配置',
servicesHeroTitle1: '从规划到运维',
servicesHeroTitle2: '全程陪伴',
servicesHeroDescription: '不只是技术供应商,更是您数字化转型的同行者。\n从第一次沟通到系统稳定运行,我们始终在您身边。',
servicesCtaPrimary: '聊聊您的需求',
servicesCtaSecondary: '查看产品矩阵',
servicesGridTitle: '四大核心服务,覆盖数字化全链路',
servicesEmptyState: '暂无服务数据',
servicesCtaTitle: '准备好开始了吗?',
servicesCtaDescription: '无论是一个明确的项目,还是一个模糊的想法,我们都愿意坐下来和您一起理清楚。\n首次咨询免费,没有任何销售压力。',
},
{
slug: 'solutions',
title: '方案页文案配置',
solutionsHeroTitle1: '专注行业场景的',
solutionsHeroTitle2: '解决方案',
solutionsHeroDescription: '端到端交付可量化的业务成果。\n基于自研产品矩阵,为制造、零售、教育、医疗、金融、物流六大行业沉淀最佳实践。',
solutionsCtaPrimary: '立即咨询',
solutionsCtaSecondary: '浏览产品',
solutionsGridTitle: '行业解决方案',
solutionsGridDescription: '每个方案都明确推荐产品组合和服务包,确保落地效果',
solutionsCtaTitle: '告诉我们您的行业场景',
solutionsCtaDescription: '无论您处于哪个行业、哪个数字化阶段,我们都能为您推荐最合适的产品组合和服务包。',
},
{
slug: 'products',
title: '产品页文案配置',
productsHeroTitle1: '自研产品矩阵',
productsHeroTitle2: '支撑企业',
productsHeroTitle3: '数字化核心系统',
productsHeroDescription: '从业务系统到数据智能,深度自研、互为补充。\n6 大企业套装与专业产品线,覆盖企业数字化核心场景。',
productsCtaPrimary: '了解我们的服务',
productsCtaSecondary: '查看行业方案',
productsBundleTitle: '两套产品体系\n覆盖不同场景',
productsBundleDescription: '企业套装以组合形式解决核心管理问题,专业产品聚焦独立场景深度赋能。',
productsCtaTitle: '以自研产品\n支撑数字化落地',
productsCtaDescription: '从企业套装到专业产品,以自研能力与专业服务,陪伴企业完成数字化升级。',
},
{
slug: 'cases',
title: '案例页文案配置',
casePageHeroTitle1: '每一个项目,',
casePageHeroTitle2: '都以成果为目标',
casePageHeroDescription: '我们正在与首批客户共同创造价值故事。\n从挑战到方案,从过程到成果——用数据说话,用结果证明。',
casePageFilterTitle: '全部案例',
casePageFilterDescription: '按行业筛选,找到与您业务最相关的成功故事',
},
{
slug: 'news',
title: '新闻页文案配置',
newsPageHeroEyebrow: 'News & Insights',
newsPageHeroTitle: '新闻与\n洞察',
newsPageHeroDescription: '最新动态、产品更新与行业洞察。了解数字化趋势,把握技术脉搏。',
newsPageEmptyTitle: '没有找到相关新闻',
newsPageEmptyDescription: '试试其他关键词或分类',
},
]);
console.log(' ✅ 列表页文案配置');
// ============ 5. 创建默认内容区域 ============
console.log('📝 创建默认内容区域...');
await seedHomeZones();
Binary file not shown.
+2 -9
View File
@@ -51,17 +51,10 @@
</g>
</g>
<style>
.logo-text { fill: #0A0E14; }
@media (prefers-color-scheme: dark) {
.logo-text { fill: #FFFFFF; }
}
</style>
<!-- 公司名称 -->
<g transform="translate(110, 60)">
<!-- 睿新致遠 - 书法字体路径 -->
<g transform="translate(0, 0)" class="logo-text">
<!-- 睿新致遠 - 书法字体路径(与 footer logo-white.svg 同一套书法路径,固定深色以适配白色 header/hero 背景) -->
<g transform="translate(0, 0)" fill="#0A0E14">
<g transform="translate(0.00, 0) scale(0.048)">
<path d="M 378.00 62.00 L 356.00 55.00 L 348.00 29.00 L 326.00 29.00 L 326.00 -2.00 L 326.00 -60.00 L 326.00 -125.00 L 318.00 -183.00 L 304.00 -208.00 L 289.00 -208.00 L 222.00 -118.00 L 177.00 -106.00 L 177.00 -92.00 L 162.00 -92.00 L 147.00 -86.00 L 125.00 -86.00 L 117.00 -86.00 L 88.00 -106.00 L 131.00 -138.00 L 222.00 -203.00 L 296.00 -279.00 L 364.00 -356.00 L 386.00 -402.00 L 378.00 -402.00 L 348.00 -389.00 L 348.00 -370.00 L 318.00 -363.00 L 318.00 -370.00 L 311.00 -370.00 L 296.00 -383.00 L 273.00 -383.00 L 273.00 -402.00 L 273.00 -409.00 L 326.00 -479.00 L 296.00 -479.00 L 296.00 -511.00 L 304.00 -525.00 L 326.00 -531.00 L 378.00 -543.00 L 431.00 -550.00 L 482.00 -550.00 L 513.00 -550.00 L 557.00 -531.00 L 557.00 -511.00 L 520.00 -505.00 L 409.00 -511.00 L 401.00 -505.00 L 378.00 -505.00 L 356.00 -499.00 L 334.00 -492.00 L 340.00 -479.00 L 386.00 -409.00 L 401.00 -409.00 L 409.00 -441.00 L 453.00 -441.00 L 468.00 -434.00 L 476.00 -409.00 L 520.00 -376.00 L 565.00 -351.00 L 618.00 -324.00 L 640.00 -312.00 L 677.00 -298.00 L 766.00 -273.00 L 856.00 -254.00 L 938.00 -234.00 L 991.00 -240.00 L 1013.00 -247.00 L 1005.00 -247.00 L 967.00 -164.00 L 894.00 -144.00 L 863.00 -157.00 L 804.00 -170.00 L 744.00 -183.00 L 691.00 -215.00 L 662.00 -215.00 L 647.00 48.00 L 610.00 55.00 L 610.00 41.00 L 520.00 36.00 L 505.00 41.00 L 468.00 41.00 L 431.00 48.00 L 393.00 48.00 Z M 580.00 5.00 L 580.00 -9.00 L 587.00 -86.00 L 573.00 -86.00 L 393.00 -67.00 L 386.00 -9.00 L 401.00 5.00 L 453.00 5.00 L 505.00 5.00 L 557.00 5.00 Z M 214.00 -118.00 L 206.00 -125.00 L 200.00 -118.00 L 206.00 -112.00 Z M 587.00 -118.00 L 580.00 -183.00 L 565.00 -183.00 L 565.00 -176.00 L 543.00 -176.00 L 498.00 -176.00 L 460.00 -170.00 L 415.00 -157.00 L 401.00 -150.00 L 386.00 -99.00 L 401.00 -99.00 Z M 565.00 -203.00 L 549.00 -240.00 L 498.00 -247.00 L 371.00 -222.00 L 371.00 -208.00 L 378.00 -189.00 L 409.00 -189.00 L 527.00 -203.00 L 557.00 -196.00 Z M 356.00 -254.00 L 378.00 -266.00 L 423.00 -273.00 L 476.00 -279.00 L 527.00 -279.00 L 557.00 -279.00 L 557.00 -293.00 L 543.00 -298.00 L 505.00 -324.00 L 476.00 -351.00 L 438.00 -370.00 L 415.00 -363.00 L 401.00 -351.00 L 371.00 -324.00 L 356.00 -293.00 L 334.00 -266.00 L 334.00 -247.00 L 348.00 -247.00 Z M 610.00 -389.00 L 520.00 -434.00 L 520.00 -486.00 L 535.00 -486.00 L 662.00 -414.00 L 654.00 -414.00 L 632.00 -389.00 Z M 102.00 -428.00 L 102.00 -434.00 L 88.00 -486.00 L 95.00 -492.00 L 95.00 -518.00 L 95.00 -537.00 L 102.00 -564.00 L 117.00 -557.00 L 131.00 -543.00 L 147.00 -525.00 L 162.00 -499.00 L 162.00 -467.00 L 162.00 -453.00 L 139.00 -428.00 Z M 789.00 -492.00 L 774.00 -531.00 L 789.00 -576.00 L 758.00 -582.00 L 699.00 -595.00 L 632.00 -601.00 L 573.00 -608.00 L 535.00 -608.00 L 460.00 -589.00 L 453.00 -569.00 L 415.00 -569.00 L 356.00 -582.00 L 334.00 -576.00 L 281.00 -569.00 L 236.00 -557.00 L 200.00 -537.00 L 169.00 -537.00 L 169.00 -564.00 L 177.00 -582.00 L 192.00 -582.00 L 192.00 -595.00 L 222.00 -595.00 L 273.00 -608.00 L 326.00 -615.00 L 378.00 -627.00 L 401.00 -634.00 L 409.00 -737.00 L 393.00 -802.00 L 438.00 -808.00 L 476.00 -775.00 L 476.00 -750.00 L 580.00 -731.00 L 587.00 -692.00 L 580.00 -679.00 L 476.00 -673.00 L 468.00 -634.00 L 505.00 -634.00 L 624.00 -640.00 L 841.00 -601.00 L 849.00 -537.00 L 827.00 -499.00 Z"/>
</g>

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

+76
View File
@@ -29,6 +29,7 @@
#
# 环境变量覆盖:
# SERVER_IP SERVER_USER DEPLOY_ROOT PROJECT_NAME NGINX_CONTAINER
# NEXTJS_CONTAINER COMPOSE_FILE COMPOSE_CMD
# DOMAIN BACKUP_RETENTION_COUNT DIST_DIR
# ============================================================
@@ -40,6 +41,9 @@ SERVER_USER="${SERVER_USER:-root}"
DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
PROJECT_NAME="${PROJECT_NAME:-novalon-website}"
NGINX_CONTAINER="${NGINX_CONTAINER:-novalon-nginx-secure}"
NEXTJS_CONTAINER="${NEXTJS_CONTAINER:-novalon-website}"
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.server.yml}"
COMPOSE_CMD="${COMPOSE_CMD:-docker-compose}"
DOMAIN="${DOMAIN:-https://novalon.cn}"
BACKUP_RETENTION="${BACKUP_RETENTION_COUNT:-${BACKUP_RETENTION:-3}}"
DIST_DIR="${DIST_DIR:-dist}"
@@ -126,6 +130,24 @@ cmd_build() {
log "📋 构建静态站点"
log "=========================================="
cd "$PROJECT_ROOT"
# 生产构建必须注入 .env.production 中的前后端加密密钥。
# Next.js 加载 env 的优先级为 .env.local > .env.production
# 若不显式注入,构建时会使用 .env.local 中的开发密钥,
# 导致前端 chunk 密钥与后端 ENCRYPTION_SECRET 不匹配,
# CMS 所有列表/仪表盘解密失败(The operation failed for an operation-specific reason)。
if [[ -f "$PROJECT_ROOT/.env.production" ]]; then
PROD_ENC_SECRET=$(grep '^NEXT_PUBLIC_ENCRYPTION_SECRET=' "$PROJECT_ROOT/.env.production" | head -1 | cut -d= -f2- | tr -d '"')
if [[ -n "$PROD_ENC_SECRET" ]]; then
export NEXT_PUBLIC_ENCRYPTION_SECRET="$PROD_ENC_SECRET"
log "✅ 已注入生产加密密钥到构建环境"
else
log "⚠️ .env.production 中未找到 NEXT_PUBLIC_ENCRYPTION_SECRET,请检查配置"
fi
else
log "⚠️ 未找到 .env.production,无法注入生产加密密钥"
fi
if [[ "$CLEAN_BUILD" == "1" ]]; then
npm run build:clean
else
@@ -136,6 +158,22 @@ cmd_build() {
log "❌ 构建失败:$DIST_DIR 目录不存在"
exit 1
fi
# standalone 构建不会把 public/ 输出到 dist 根目录;
# Nginx 以 dist 作为站点根目录,因此手动同步 public 静态资源(logo、favicon、fonts、images 等)
if [[ -d "$PROJECT_ROOT/public" ]]; then
cp -R "$PROJECT_ROOT/public/." "$DIST_DIR/"
log "✅ public 静态资源已同步到 $DIST_DIR"
fi
# standalone 构建的客户端静态资源位于 dist/static,而 HTML 引用 /_next/static/...
# 复制到 dist/_next/static 供 Nginx 直接服务
if [[ -d "$DIST_DIR/static" ]]; then
mkdir -p "$DIST_DIR/_next"
cp -R "$DIST_DIR/static" "$DIST_DIR/_next/static"
log "✅ _next/static 已从 static 同步到 $DIST_DIR/_next/static"
fi
log "✅ 构建完成,产物大小: $(du -sh "$DIST_DIR" | cut -f1)"
}
@@ -236,6 +274,44 @@ cmd_deploy() {
fi
"
# 步骤 6.5: 同步 public 并重建 Next.js 容器
# 混合渲染下,Nginx 提供新客户端资源,Next.js 容器也必须运行同一份构建,
# 否则会出现 /_next/static 404 或 Server Reference ID 不匹配。
log ""
log "📋 步骤 6.5: 重建 Next.js 容器 ($NEXTJS_CONTAINER)"
if [[ -d "$PROJECT_ROOT/public" ]]; then
if [[ "$DRY_RUN" == "1" ]]; then
log "🔍 [dry-run] rsync -az --delete \"$PROJECT_ROOT/public/\" $SERVER_USER@$SERVER_IP:$REMOTE_PROJECT_DIR/public/"
else
rsync -az --delete "$PROJECT_ROOT/public/" "$SERVER_USER@$SERVER_IP:$REMOTE_PROJECT_DIR/public/"
fi
fi
run_remote "
set -e
cd '$REMOTE_PROJECT_DIR'
if [ -f '$COMPOSE_FILE' ]; then
$COMPOSE_CMD -f '$COMPOSE_FILE' build
$COMPOSE_CMD -f '$COMPOSE_FILE' up -d
echo \"✅ Next.js 容器已重建,等待就绪...\"
READY=0
for i in \$(seq 1 30); do
if curl -fsS -o /dev/null --max-time 3 http://127.0.0.1:3000/ 2>/dev/null; then
READY=1
break
fi
sleep 2
done
if [ \"\$READY\" = \"1\" ]; then
echo \"✅ $NEXTJS_CONTAINER 就绪\"
else
echo \"❌ $NEXTJS_CONTAINER 未就绪,请检查容器日志\"
exit 1
fi
else
echo \"⚠️ 未找到 ${COMPOSE_FILE},跳过 Next.js 容器重建\"
fi
"
# 步骤 7: 验证生产环境
if ! verify_remote; then
if [[ "$AUTO_ROLLBACK" == "1" ]]; then
+57 -5
View File
@@ -173,8 +173,14 @@ function WhoWeAreSection({ data }: { data: AboutData }) {
transition={{ duration: 0.8, ease: EASE_OUT }}
className="lg:col-span-5"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Who We Are
<div className="flex items-center gap-4 mb-6">
<span className="text-4xl md:text-5xl font-black text-brand/80 font-mono leading-none tabular-nums">01</span>
<div>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase">
Why We Started
</div>
<div className="text-xs text-text-muted mt-1"></div>
</div>
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
{data.whoWeAreTitle || ''}
@@ -196,6 +202,15 @@ function WhoWeAreSection({ data }: { data: AboutData }) {
transition={{ duration: 0.8, delay: 0.15, ease: EASE_OUT }}
className="lg:col-span-7"
>
<div className="flex items-center gap-4 mb-6">
<span className="text-4xl md:text-5xl font-black text-brand/80 font-mono leading-none tabular-nums">02</span>
<div>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase">
How We Work
</div>
<div className="text-xs text-text-muted mt-1"></div>
</div>
</div>
<div className="space-y-6 md:space-y-8">
{values.map((value) => (
<div
@@ -236,8 +251,14 @@ function MilestonesSection({ data }: { data: AboutData }) {
transition={{ duration: 0.8, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Our Journey
<div className="flex items-center gap-4 mb-6">
<span className="text-4xl md:text-5xl font-black text-brand/80 font-mono leading-none tabular-nums">03</span>
<div>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase">
Where We Are Heading
</div>
<div className="text-xs text-text-muted mt-1"></div>
</div>
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
<br className="sm:hidden" />
@@ -281,7 +302,38 @@ function CertificationsSection({ data }: { data: AboutData }) {
const certifications = data.certifications ?? [];
const partners = data.partners ?? [];
if (certifications.length === 0 && partners.length === 0) return null;
// 初创阶段无资质/伙伴数据时,如实呈现「建设中」状态而非隐藏整块(零编造)
if (certifications.length === 0 && partners.length === 0) {
return (
<section className="relative py-20 sm:py-28 md:py-32 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.8, ease: EASE_OUT }}
className="max-w-3xl"
>
<div className="text-sm font-mono tracking-[0.2em] text-text-muted uppercase mb-6">
Certifications
</div>
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-[0.95] mb-10">
</h2>
<div className="p-8 sm:p-10 border border-dashed border-border-primary bg-bg-secondary">
<div className="flex items-center gap-2.5 mb-3">
<Shield className="w-5 h-5 text-text-muted" />
<span className="text-sm font-semibold text-ink"></span>
</div>
<p className="text-sm text-text-muted leading-relaxed">
</p>
</div>
</motion.div>
</div>
</section>
);
}
return (
<section className="relative py-20 sm:py-28 md:py-32 overflow-hidden bg-white">
@@ -2,6 +2,7 @@
import { motion } from 'framer-motion';
import { ScrollReveal } from '@/components/ui/scroll-reveal';
import { BrandCalligraphyName } from '@/components/ui/brand-calligraphy-name';
import {
ArrowRight,
Target,
@@ -91,11 +92,15 @@ export default function BrandContent() {
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, 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-8 sm:mb-10"
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"
>
<span className="sr-only"></span>
<BrandCalligraphyName
className="block h-[0.95em] w-auto text-ink"
ariaHidden
/>
<br />
<span className="text-brand"></span>
<span className="text-brand font-black"></span>
</motion.h1>
{/* 数据条 */}
+19 -7
View File
@@ -95,14 +95,23 @@ function CaseCard({ caseItem, index }: { caseItem: CaseStudy; index: number }) {
export default function CasesContentV3({
cases: casesProp,
industries: industriesProp,
pageCopy,
}: {
cases?: CaseStudy[];
industries?: { id: string; label: string }[];
pageCopy?: Record<string, unknown> | null;
}) {
const [selectedIndustry, setSelectedIndustry] = useState('all');
const industries = industriesProp ?? [];
const heroTitle1 = (pageCopy?.casePageHeroTitle1 as string) || '每一个项目,';
const heroTitle2 = (pageCopy?.casePageHeroTitle2 as string) || '都以成果为目标';
const heroDescription = (pageCopy?.casePageHeroDescription as string) || '我们正在与首批客户共同创造价值故事。\n从挑战到方案,从过程到成果——用数据说话,用结果证明。';
const heroDescriptionLines = heroDescription.split('\n');
const filterTitle = (pageCopy?.casePageFilterTitle as string) || '全部案例';
const filterDescription = (pageCopy?.casePageFilterDescription as string) || '按行业筛选,找到与您业务最相关的成功故事';
const filteredCases = useMemo(() => {
const allCases = casesProp ?? [];
if (selectedIndustry === 'all') return allCases;
@@ -130,11 +139,11 @@ export default function CasesContentV3({
transition={{ duration: 1, 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"
>
<div className="block"></div>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
<span className="relative inline-block">
<span className="relative text-brand font-black">
{heroTitle2}
<motion.span
className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
@@ -153,9 +162,12 @@ export default function CasesContentV3({
transition={{ duration: 0.8, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12"
>
<br className="hidden sm:block" />
{heroDescriptionLines.map((line, i) => (
<span key={i}>
{i > 0 && <br className="hidden sm:block" />}
{line}
</span>
))}
</motion.p>
<motion.div
@@ -225,10 +237,10 @@ export default function CasesContentV3({
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>
+3 -2
View File
@@ -6,8 +6,9 @@ import CasesContentV3 from './cases-content-v3';
interface CasesClientProps {
cases?: CaseStudy[];
industries?: { id: string; label: string }[];
pageCopy?: Record<string, unknown> | null;
}
export function CasesClient({ cases, industries }: CasesClientProps) {
return <CasesContentV3 cases={cases} industries={industries} />;
export function CasesClient({ cases, industries, pageCopy }: CasesClientProps) {
return <CasesContentV3 cases={cases} industries={industries} pageCopy={pageCopy} />;
}
+1
View File
@@ -4,6 +4,7 @@ import CasesPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getPageCopy: jest.fn().mockResolvedValue(null),
}));
jest.mock('./cases-content-v3', () => ({
+6 -3
View File
@@ -1,7 +1,7 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { CASE_INDUSTRIES } from '@/lib/constants/cases';
import { getPublishedItems } from '@/lib/cms/data-server';
import { getPublishedItems, getPageCopy } from '@/lib/cms/data-server';
import type { CaseStudy } from '@/lib/constants/cases';
import { CasesClient } from './client';
@@ -13,7 +13,10 @@ export const metadata: Metadata = {
export const revalidate = 3600;
export default async function CasesPage() {
const items = await getPublishedItems('case-study');
const [items, pageCopy] = await Promise.all([
getPublishedItems('case-study'),
getPageCopy('cases').catch(() => null),
]);
const cases = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as CaseStudy));
return <CasesClient cases={cases} industries={CASE_INDUSTRIES} />;
return <CasesClient cases={cases} industries={CASE_INDUSTRIES} pageCopy={pageCopy?.data ?? null} />;
}
@@ -0,0 +1,138 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import HomeContentV14 from './home-content-v14';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, className, style, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
className={className}
style={style}
{...props}
>
{children}
</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="motion-a" {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('next/image', () => ({
__esModule: true,
default: ({ src, alt, className }: any) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt={alt} className={className} data-testid="mock-next-image" />
),
}));
jest.mock('@/components/ui/scroll-reveal', () => ({
ScrollReveal: ({ children, className }: any) => (
<div data-testid="scroll-reveal" className={className}>{children}</div>
),
StaggerReveal: ({ children, className }: any) => (
<div data-testid="stagger-reveal" className={className}>{children}</div>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className }: any) => (
<span data-testid="mock-badge" className={className}>{children}</span>
),
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowRight: mockIcon('arrow-right'),
ArrowUpRight: mockIcon('arrow-up-right'),
ArrowDownRight: mockIcon('arrow-down-right'),
Lightbulb: mockIcon('lightbulb'),
Database: mockIcon('database'),
Layers: mockIcon('layers'),
Code: mockIcon('code'),
Puzzle: mockIcon('puzzle'),
Server: mockIcon('server'),
LayoutDashboard: mockIcon('layout-dashboard'),
BarChart3: mockIcon('bar-chart3'),
Package: mockIcon('package'),
Users: mockIcon('users'),
Settings: mockIcon('settings'),
Search: mockIcon('search'),
Bell: mockIcon('bell'),
TrendingUp: mockIcon('trending-up'),
AlertCircle: mockIcon('alert-circle'),
};
});
describe('HomeContentV14 - 首页 Hero 优化', () => {
it('renders an abstract data-driven visual without product UI shell', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
expect(screen.getByText('数据驱动 · 让转型可量化')).toBeInTheDocument();
});
it('keeps a single primary CTA and demotes the secondary action to a text link', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('hero-primary-cta')).toBeInTheDocument();
expect(screen.getByTestId('hero-primary-cta')).toHaveAttribute('href', '/contact');
expect(screen.getByTestId('hero-secondary-link')).toBeInTheDocument();
});
it('keeps the answer-first stats bar visible', () => {
render(<HomeContentV14 stats={[{ value: '100%', label: '私有化部署' }]} />);
expect(screen.getByTestId('hero-stats')).toBeInTheDocument();
});
});
describe('HomeContentV14 - 信任层与章节式叙事', () => {
it('renders verifiable trust signals with a source annotation', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('trust-section')).toBeInTheDocument();
expect(screen.getByText('100%')).toBeInTheDocument();
expect(screen.getByText('成都')).toBeInTheDocument();
// 可验证来源标注存在(每个数据卡一个)
expect(screen.getAllByText(/数据来源:/i).length).toBeGreaterThanOrEqual(4);
});
it('renders an early-access co-creation label instead of fabricated testimonials', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('early-access-banner')).toBeInTheDocument();
expect(screen.getAllByText('首批客户共创中').length).toBeGreaterThanOrEqual(1);
});
it('renders the problem-method-result narrative with chapter numbers', () => {
render(<HomeContentV14 />);
expect(screen.getByTestId('narrative-section')).toBeInTheDocument();
expect(screen.getByText('01')).toBeInTheDocument();
expect(screen.getByText('02')).toBeInTheDocument();
expect(screen.getByText('03')).toBeInTheDocument();
expect(screen.getAllByText(/从问题到结果/).length).toBeGreaterThan(0);
});
});
+286 -66
View File
@@ -5,10 +5,12 @@ import Image from 'next/image';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Badge } from '@/components/ui/badge';
import { HeroProductVisual } from '@/components/sections/hero-product-visual';
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server } from 'lucide-react';
import { cn } from '@/lib/utils';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { COMPANY_INFO } from '@/lib/constants';
import { COMPANY_INFO, TRUST_SIGNALS, EARLY_ACCESS } from '@/lib/constants';
import { useSiteConfig } from '@/lib/site-config';
// ===== 服务图标映射:将字符串键名映射为 Lucide 组件 =====
const SERVICE_ICON_MAP: Record<string, React.ReactNode> = {
@@ -18,7 +20,7 @@ const SERVICE_ICON_MAP: Record<string, React.ReactNode> = {
Puzzle: <Puzzle className="w-7 h-7" />,
};
// ===== 服务数据降级兜底 =====
// ===== 服务数据降级兜底4 条,保证 sm:grid-cols-2 网格完整,避免孤儿格)=====
const FALLBACK_SERVICES = [
{
subtitle: 'Strategy Consulting',
@@ -29,9 +31,21 @@ const FALLBACK_SERVICES = [
{ value: '40%', label: '平均运营效率提升' },
{ value: '6个月', label: '战略到试点周期' },
],
href: '/services/strategy',
href: '/services/consulting',
icon: <Lightbulb className="w-7 h-7" />,
},
{
subtitle: 'Software Delivery',
title: '企业软件定制开发',
desc: '基于自研产品矩阵与工程化交付流程,提供从需求分析、架构设计到持续运维的全栈软件开发服务,支撑业务数字化落地。',
highlights: ['需求分析', '架构设计', '敏捷开发', '持续运维'],
metrics: [
{ value: '6', label: '款自研产品' },
{ value: '24/7', label: '运维支持' },
],
href: '/services/software',
icon: <Code className="w-7 h-7" />,
},
{
subtitle: 'Data Intelligence',
title: '数据智能与 AI 应用',
@@ -53,21 +67,40 @@ const FALLBACK_SERVICES = [
{ value: '6', label: '行业场景覆盖' },
{ value: '99.9%', label: '系统可用性' },
],
href: '/services/solution',
href: '/services/solutions',
icon: <Layers className="w-7 h-7" />,
},
];
// ===== 首页结构性文案类型(page-copy CMS 覆盖,未配置时走硬编码兜底)=====
type HomePageCopy = Record<string, any>;
// 多行标题渲染:按 \n 分段,输出 <br/> 换行
function StyledTitle({ lines }: { lines: (string | undefined)[] }) {
return (
<>
{lines.map((line, i) => (
<span key={i}>
{i > 0 && <br />}
{line}
</span>
))}
</>
);
}
// ===== 品牌 CTA 按钮 =====
function CTAButton({ children, href, variant = 'primary', className }: {
function CTAButton({ children, href, variant = 'primary', className, dataTestId }: {
children: React.ReactNode;
href: string;
variant?: 'primary' | 'secondary';
className?: string;
dataTestId?: string;
}) {
return (
<a
href={href}
data-testid={dataTestId}
className={cn(
'group inline-flex items-center gap-3 px-10 py-4 font-semibold text-base transition-all duration-200',
variant === 'primary'
@@ -88,13 +121,15 @@ function HeroSection({ heroData, stats }: {
stats?: Record<string, any>[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const siteConfig = useSiteConfig();
const heading = heroData?.heading || heroData?.headingBottom || '从数据到决策,让转型可量化';
const heading = heroData?.heading || heroData?.headingBottom || '让每一家企业都拥有数据驱动的决策能力';
const subheading = heroData?.subheading || heroData?.description || '';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
const ctaHref = heroData?.ctaHref || '/contact';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '';
const secondaryCtaHref = heroData?.secondaryCtaHref || '#';
const secondaryCtaLabel = heroData?.secondaryCtaLabel || '查看产品矩阵';
const secondaryCtaHref = heroData?.secondaryCtaHref || '/products';
const slogan = siteConfig.slogan || COMPANY_INFO.slogan;
const hasValidStats = stats?.length && stats[0]?.value;
const displayStats = hasValidStats ? stats!.slice(0, 3) : [];
@@ -112,14 +147,15 @@ function HeroSection({ heroData, stats }: {
}}
/>
<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 md:py-40 lg:py-48">
<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 md:py-24 lg:py-24">
<div className="grid items-center gap-16 lg:grid-cols-[1.05fr_0.95fr]">
<div className="max-w-4xl">
{/* 品牌标识:Logo 本身已包含印章 + 公司名 + NOVALON */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0, ease: EASE_OUT }}
className="mb-8"
transition={{ duration: 0.35, delay: 0, ease: EASE_OUT }}
className="mb-6"
>
<Image
src="/logo.svg"
@@ -131,12 +167,26 @@ function HeroSection({ heroData, stats }: {
/>
</motion.div>
{/* 品牌口号 Badge:消费站点配置 slogan,后台可改 */}
{slogan && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.03, ease: EASE_OUT }}
className="mb-6"
>
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand/5 text-brand text-sm font-medium border border-brand/15">
{slogan}
</span>
</motion.div>
)}
{/* 主标题:大胆、直接 */}
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-black text-ink leading-[1.05] tracking-tight mb-10"
transition={{ duration: 0.45, delay: 0.06, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-6xl 2xl:text-7xl font-black text-ink leading-[1.05] tracking-tight mb-8 sm:mb-10"
>
{heading}
</motion.h1>
@@ -146,12 +196,13 @@ function HeroSection({ heroData, stats }: {
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.3, ease: EASE_OUT }}
className="flex flex-wrap gap-8 sm:gap-12 md:gap-16 mb-10"
transition={{ duration: 0.35, delay: 0.16, ease: EASE_OUT }}
className="flex flex-wrap gap-8 sm:gap-12 md:gap-16 mb-8"
data-testid="hero-stats"
>
{displayStats.map((stat, i) => (
<div key={i} className="flex items-baseline gap-2">
<span className="text-3xl sm:text-4xl md:text-5xl font-black text-brand tabular-nums leading-none">
<span className={cn('text-3xl sm:text-4xl md:text-5xl font-black tabular-nums leading-none', i === 0 ? 'text-brand' : 'text-ink')}>
{stat.value}
</span>
<span className="text-sm text-text-secondary whitespace-nowrap">
@@ -167,8 +218,8 @@ function HeroSection({ heroData, stats }: {
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.4, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12"
transition={{ duration: 0.35, delay: 0.24, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed mb-10"
>
{subheading}
</motion.p>
@@ -178,13 +229,14 @@ function HeroSection({ heroData, stats }: {
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.5, ease: EASE_OUT }}
transition={{ duration: 0.35, delay: 0.3, ease: EASE_OUT }}
className="flex flex-col sm:flex-row sm:items-center gap-4 sm:gap-6"
>
<CTAButton href={ctaHref}>{ctaLabel}</CTAButton>
<CTAButton href={ctaHref} dataTestId="hero-primary-cta">{ctaLabel}</CTAButton>
{secondaryCtaLabel && (
<a
href={secondaryCtaHref}
data-testid="hero-secondary-link"
className="group inline-flex items-center gap-2 text-sm font-medium text-text-secondary hover:text-ink transition-colors duration-200 py-4"
>
{secondaryCtaLabel}
@@ -193,46 +245,178 @@ function HeroSection({ heroData, stats }: {
)}
</motion.div>
</div>
{/* 右侧:产品视觉(SVG 数据看板示意) */}
<motion.div
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.45, delay: 0.36, ease: EASE_OUT }}
className="relative mt-16 lg:mt-0"
>
<HeroProductVisual className="max-w-xl w-full lg:ml-auto" />
</motion.div>
</div>
</div>
</section>
);
}
// ===== 信任/成果区:Bain 式量化数据展示 =====
function TrustSection() {
const items = [
{ value: '2026', label: '年正式成立', desc: '1 月 15 日于成都龙泉驿区启航' },
{ value: '10+', label: '人核心团队', desc: '复合型技术团队,覆盖咨询与研发' },
{ value: '6', label: '款自研产品', desc: '覆盖核心数字化业务场景' },
{ value: '100%', label: '私有化部署', desc: '核心数据不出境,满足合规要求' },
];
// ===== 信任/成果区:可验证信任信号 + 首批客户共创标签 =====
// 共创计划默认状态条目(如实呈现,无真实案例时不虚构客户)
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-white">
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary" data-testid="trust-section">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="mb-16 sm:mb-20">
<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 text-brand">
{eyebrow}
</span>
</div>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<br />
<StyledTitle lines={titleLines} />
</h2>
</ScrollReveal>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{items.map((item, i) => (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
{signals.map((item, i) => (
<StaggerReveal key={i} staggerDelay={0.06}>
<div className="bain-card p-8 sm:p-10">
<div className="text-3xl sm:text-4xl font-black text-brand tabular-nums leading-none mb-2">
{item.value}
<div className="bain-card p-6 sm:p-7 h-full flex flex-col">
<div className="flex items-baseline gap-2 mb-3">
<span className="text-xl sm:text-2xl font-black text-ink leading-none">
{item.value}
</span>
<span className="text-sm font-semibold text-text-secondary">{item.label}</span>
</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}
</div>
</div>
</StaggerReveal>
))}
</div>
{/* 首批客户共创计划:如实呈现共创/内测/授权公开状态,不虚构客户 */}
<StaggerReveal staggerDelay={0.08}>
<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">
{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-white border border-brand/10">
<div className="text-sm font-semibold text-brand mb-1">{s.label}</div>
<div className="text-xs text-text-secondary leading-relaxed">{s.desc}</div>
</div>
))}
</div>
<a
href="/contact"
className="group inline-flex shrink-0 items-center gap-2 text-sm font-semibold text-ink hover:text-brand transition-colors"
>
<span>{earlyCtaLabel}</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
</div>
</StaggerReveal>
</div>
</section>
);
}
// ===== 章节式叙事区:问题 → 方法 → 结果(Storytelling-Driven=====
const NARRATIVE_ACTS = [
{
number: '01',
title: '诊断现状',
desc: '从业务目标出发,梳理流程断点、数据孤岛与瓶颈环节,形成可量化的诊断结论。',
href: '/services/consulting',
cta: '了解诊断方法',
},
{
number: '02',
title: '设计路径',
desc: '基于诊断结论设计系统架构与实施路线图,明确每一阶段的交付物与衡量指标。',
href: '/services/solutions',
cta: '查看方案设计',
},
{
number: '03',
title: '交付成果',
desc: '以自研产品组合与专业实施团队完成交付,用真实运营数据验证业务改善,持续迭代。',
href: '/products',
cta: '浏览产品矩阵',
},
];
function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
const narrativeTitle = (pageCopy?.narrativeTitle || '从问题到结果,\n一条可量化的路径').split('\n');
const NARRATIVE_ACTS_CMS = (pageCopy?.narrativeActs?.length ? pageCopy.narrativeActs : NARRATIVE_ACTS) as Array<{
number: string;
title: string;
desc: string;
href: string;
cta: string;
}>;
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white" data-testid="narrative-section">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<ScrollReveal className="mb-16 sm:mb-20">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<StyledTitle lines={narrativeTitle} />
</h2>
</ScrollReveal>
<div className="grid md:grid-cols-3 gap-px bg-border-primary">
{NARRATIVE_ACTS_CMS.map((act) => (
<StaggerReveal key={act.number} staggerDelay={0.08}>
<div className="group relative h-full bg-white p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary">
<div className="flex items-start justify-between mb-8">
<span className="text-4xl sm:text-5xl font-black text-text-hint tabular-nums leading-none">
{act.number}
</span>
</div>
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 group-hover:text-brand transition-colors duration-300">
{act.title}
</h3>
<p className="text-text-secondary leading-relaxed mb-8 text-sm">
{act.desc}
</p>
<div className="pt-6 border-t border-border-primary">
<a
href={act.href}
className="group/link inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors"
>
<span>{act.cta}</span>
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover/link:translate-x-1" />
</a>
</div>
<div className="text-sm font-semibold text-ink mb-3">{item.label}</div>
<p className="text-sm text-text-secondary leading-relaxed">{item.desc}</p>
</div>
</StaggerReveal>
))}
@@ -243,7 +427,7 @@ function TrustSection() {
}
// ===== 服务区:等宽三列卡片 + 兜底数据 =====
function ServicesSection({ services }: { services?: Record<string, any>[] }) {
function ServicesSection({ services, pageCopy }: { services?: Record<string, any>[]; pageCopy?: HomePageCopy | null }) {
const hasValidData = services?.length && (services![0] as any)?.subtitle;
const SERVICE_ITEMS = hasValidData
? services!.map((s: Record<string, any>) => ({
@@ -251,6 +435,7 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
icon: SERVICE_ICON_MAP[s.icon as string] || <Server className="w-7 h-7" />,
})) as Record<string, any>[]
: FALLBACK_SERVICES;
const servicesTitle = (pageCopy?.servicesTitle || '从战略到落地,\n可量化的一站式服务').split('\n');
return (
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary">
@@ -258,14 +443,12 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
<div className="max-w-3xl mb-16 sm:mb-20">
<ScrollReveal>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<br />
<StyledTitle lines={servicesTitle} />
</h2>
</ScrollReveal>
</div>
<div className="grid md:grid-cols-3 gap-6">
<div className="grid gap-6 sm:grid-cols-2">
{SERVICE_ITEMS.map((service, i) => (
<StaggerReveal key={i} staggerDelay={0.05}>
<a href={service.href} className="group bain-card block p-8 sm:p-10 md:p-12 h-full flex flex-col">
@@ -291,8 +474,8 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
<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-brand leading-none mb-1">{m.value}</div>
<div className="text-[11px] text-text-muted font-medium">{m.label}</div>
<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>
@@ -314,15 +497,50 @@ function ServicesSection({ services }: { services?: Record<string, any>[] }) {
}
// ===== 案例区:轻量快照卡片 =====
function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; pageCopy?: HomePageCopy | null }) {
const hasValidData = cases?.length && cases[0]?.metrics;
const CASE_ITEMS = hasValidData ? cases! : [];
const casesEyebrow = pageCopy?.casesEyebrow || '共创进行时';
const casesTitleLines = (pageCopy?.casesTitle || '每一个项目,\n都以业务改善为衡量标准').split('\n');
const casesDesc = pageCopy?.casesDescription || '我们正与首批客户共同打磨产品与方案,以真实运营数据验证业务改善。';
const earlyLabel = pageCopy?.earlyAccessLabel || EARLY_ACCESS.label;
const earlyDesc = pageCopy?.earlyAccessDesc || EARLY_ACCESS.desc;
if (CASE_ITEMS.length === 0) {
return (
<section className="relative py-28 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 text-center">
<p className="text-text-muted text-lg"></p>
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<ScrollReveal>
<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 text-brand">
{casesEyebrow}
</span>
</div>
<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">
{casesDesc}
</p>
<a
href="/contact"
className="group inline-flex items-center 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" />
</a>
</ScrollReveal>
<ScrollReveal delay={0.15}>
<div className="bain-card p-10 sm:p-12 bg-bg-secondary">
<div className="text-5xl sm:text-6xl font-black text-brand tabular-nums leading-none mb-4">
{earlyLabel}
</div>
<p className="text-text-secondary leading-relaxed">{earlyDesc}</p>
</div>
</ScrollReveal>
</div>
</div>
</section>
);
@@ -334,9 +552,7 @@ function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-8 mb-16 sm:mb-20">
<ScrollReveal className="md:max-w-3xl">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-black text-ink tracking-tight leading-[1.05]">
<br />
<StyledTitle lines={casesTitleLines} />
</h2>
</ScrollReveal>
<ScrollReveal delay={0.1}>
@@ -399,10 +615,10 @@ function CasesSection({ cases }: { cases?: Record<string, any>[] }) {
}
// ===== CTA 区:克制强调,品牌红仅用于按钮 =====
function CTASection({ heroData }: { heroData?: Record<string, any> | null }) {
const ctaTitleLine1 = heroData?.ctaTitleLine1 || '准备好开始了吗?';
const ctaTitleLine2 = heroData?.ctaTitleLine2 || '让数据真正转化为业务增长';
const ctaLabel = heroData?.ctaLabel || '预约咨询';
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 ctaHref = heroData?.ctaHref || '/contact';
return (
@@ -422,11 +638,12 @@ function CTASection({ heroData }: { heroData?: Record<string, any> | null }) {
}
// ===== 主页面组件 =====
export default function HomeContentV14({ services, cases, stats, heroData }: {
export default function HomeContentV14({ services, cases, stats, heroData, pageCopy }: {
services?: Record<string, any>[];
cases?: Record<string, any>[];
stats?: Record<string, any>[];
heroData?: Record<string, any> | null;
pageCopy?: HomePageCopy | null;
}) {
return (
<main>
@@ -434,16 +651,19 @@ export default function HomeContentV14({ services, cases, stats, heroData }: {
<HeroSection heroData={heroData} stats={stats} />
{/* 2. 信任区:量化数据成果展示 */}
<TrustSection />
<TrustSection pageCopy={pageCopy} />
{/* 3. 服务区:等宽卡片 + 兜底数据 */}
<ServicesSection services={services} />
{/* 3. 章节式叙事:问题 → 方法 → 结果 */}
<NarrativeSection pageCopy={pageCopy} />
{/* 4. 案例区:轻量快照卡片 */}
<CasesSection cases={cases} />
{/* 4. 服务区:等宽卡片 + 兜底数据 */}
<ServicesSection services={services} pageCopy={pageCopy} />
{/* 5. CTA:克制强调,品牌红仅用于按钮 */}
<CTASection heroData={heroData} />
{/* 5. 案例区:轻量快照卡片 */}
<CasesSection cases={cases} pageCopy={pageCopy} />
{/* 6. CTA:克制强调,品牌红仅用于按钮 */}
<CTASection heroData={heroData} pageCopy={pageCopy} />
</main>
);
}
@@ -0,0 +1,121 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
// ─── Mocks ───────────────────────────────────────────────────────────────
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, style, ...props }: any) => (
<div data-testid="motion-div" className={className} style={style} {...props}>{children}</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/scroll-reveal', () => ({
ScrollReveal: ({ children, className }: any) => (
<div data-testid="scroll-reveal" className={className}>{children}</div>
),
StaggerReveal: ({ children, className }: any) => (
<div data-testid="stagger-reveal" className={className}>{children}</div>
),
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, ...props }: any) => (
<button className={className} data-testid="mock-button" {...props}>
{children}
</button>
),
}));
jest.mock('@/components/ui/page-decoration', () => ({
EASE_OUT: [0.22, 1, 0.36, 1],
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowRight: mockIcon('arrow-right'),
CheckCircle2: mockIcon('check-circle2'),
};
});
// ─── Mock Data ────────────────────────────────────────────────────────────
const mockData = {
heroSubtitle: '方法论',
heroTitle: '四阶段推进数字化转型',
heroDescription: '从诊断到优化,步步可衡量。',
phases: [
{
number: 1,
title: '诊断评估',
subtitle: '了解现状,找准痛点',
description: '深入调研企业现状,识别关键痛点。',
activities: ['业务流程调研', 'IT基础设施评估'],
deliverables: ['成熟度评估报告', '关键痛点清单'],
},
{
number: 2,
title: '战略规划',
subtitle: '明确方向,制定路线',
description: '制定匹配企业战略的转型路线图。',
activities: ['IT战略对齐'],
deliverables: ['战略规划书'],
},
],
};
// ─── Tests ────────────────────────────────────────────────────────────────
describe('MethodologyContent - 方法论页面', () => {
it('should render hero section', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={mockData} />);
expect(screen.getByText('方法论')).toBeInTheDocument();
expect(screen.getByText('四阶段推进数字化转型')).toBeInTheDocument();
expect(screen.getByText('从诊断到优化,步步可衡量。')).toBeInTheDocument();
});
it('should render all methodology phases with activities and deliverables', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={mockData} />);
expect(screen.getByText('诊断评估')).toBeInTheDocument();
expect(screen.getByText('战略规划')).toBeInTheDocument();
expect(screen.getAllByText('核心活动').length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText('交付物').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('业务流程调研')).toBeInTheDocument();
expect(screen.getByText('成熟度评估报告')).toBeInTheDocument();
});
it('should render empty state when phases are missing', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={{ heroTitle: '方法论' }} />);
expect(screen.getByText('方法论内容即将发布')).toBeInTheDocument();
});
it('should render CTA links to services and contact', async () => {
const { MethodologyContent } = await import('./methodology-content');
render(<MethodologyContent data={mockData} />);
const links = screen.getAllByRole('link');
expect(links.some((l) => l.getAttribute('href') === '/services')).toBe(true);
expect(links.some((l) => l.getAttribute('href') === '/contact')).toBe(true);
});
});
@@ -0,0 +1,171 @@
'use client';
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { Button } from '@/components/ui/button';
import { ArrowRight, CheckCircle2 } from 'lucide-react';
interface MethodologyPhase {
number?: number;
title: string;
subtitle?: string;
description?: string;
activities?: string[];
deliverables?: string[];
}
interface MethodologyContentProps {
data: Record<string, any> | null;
}
export function MethodologyContent({ data }: MethodologyContentProps) {
const heroSubtitle = data?.heroSubtitle || '方法论';
const heroTitle = data?.heroTitle || '从诊断到优化\n四阶段推进数字化转型';
const heroDescription = data?.heroDescription || '';
const phases: MethodologyPhase[] = Array.isArray(data?.phases) ? data.phases : [];
return (
<main>
{/* Hero:品牌化定位,轻量 CTA */}
<section className="relative min-h-[60vh] flex items-center overflow-hidden bg-white">
<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">
<div className="max-w-4xl">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.1, ease: EASE_OUT }}
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs font-bold text-brand bg-brand/10 mb-8"
>
{heroSubtitle}
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold text-ink leading-[1.05] tracking-tight mb-8 whitespace-pre-line"
>
{heroTitle}
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.35, ease: EASE_OUT }}
className="text-lg sm:text-xl text-text-secondary max-w-2xl leading-relaxed"
>
{heroDescription}
</motion.p>
</div>
</div>
</section>
{/* 阶段骨架:四阶段 Cards,空态占位 */}
<section className="relative py-20 sm:py-28 lg:py-32 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">
<ScrollReveal className="max-w-3xl mb-14 sm:mb-16">
<h2 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-ink tracking-tight leading-tight">
<span className="text-brand"></span>
</h2>
<p className="text-text-secondary text-lg mt-6 leading-relaxed">
</p>
</ScrollReveal>
{phases.length > 0 ? (
<div className="relative">
<div className="hidden lg:block absolute top-16 left-[12.5%] right-[12.5%] h-px bg-border-primary" />
<StaggerReveal
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 md:gap-8"
staggerDelay={0.08}
delayChildren={0.05}
>
{phases.map((phase, idx) => (
<motion.div
key={phase.title || idx}
className="relative overflow-hidden bg-white border border-border-primary transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div className="p-7 md:p-8">
<div className="w-11 h-11 rounded-full flex items-center justify-center mb-5 text-base font-bold bg-brand/10 text-brand">
{phase.number ?? idx + 1}
</div>
<h3 className="text-lg font-bold text-ink mb-1">{phase.title}</h3>
{phase.subtitle && (
<p className="text-xs text-brand font-medium mb-3">{phase.subtitle}</p>
)}
{phase.description && (
<p className="text-sm text-text-secondary leading-relaxed mb-5">{phase.description}</p>
)}
{phase.activities?.length ? (
<div className="mb-4">
<p className="text-xs font-semibold text-ink mb-2 tracking-wide"></p>
<ul className="space-y-1.5">
{phase.activities.map((activity, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-text-secondary">
<CheckCircle2 className="w-3.5 h-3.5 text-brand mt-0.5 shrink-0" />
{activity}
</li>
))}
</ul>
</div>
) : null}
{phase.deliverables?.length ? (
<div>
<p className="text-xs font-semibold text-ink mb-2 tracking-wide"></p>
<ul className="space-y-1.5">
{phase.deliverables.map((deliverable, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-text-muted">
<span className="w-1.5 h-1.5 bg-brand/40 rounded-full mt-1.5 shrink-0" />
{deliverable}
</li>
))}
</ul>
</div>
) : null}
</div>
</motion.div>
))}
</StaggerReveal>
</div>
) : (
<div className="bg-white border border-border-primary p-10 sm:p-16 text-center">
<h3 className="text-xl font-bold text-ink mb-3"></h3>
<p className="text-text-secondary max-w-xl mx-auto leading-relaxed">
</p>
</div>
)}
</div>
</section>
{/* CTA:轻量导向服务与联系 */}
<section className="relative py-20 sm:py-28 lg:py-32 overflow-hidden bg-white">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-6 lg:px-10">
<ScrollReveal className="text-center max-w-3xl mx-auto">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6 sm:mb-8">
<span className="text-brand"></span>
</h2>
<p className="text-xl text-text-secondary mb-12 max-w-2xl mx-auto leading-relaxed">
穿
</p>
<div className="flex flex-wrap justify-center gap-6">
<Button size="xl" asChild>
<a href="/services">
<ArrowRight className="w-5 h-5 ml-2" />
</a>
</Button>
<Button size="xl" variant="outline" asChild>
<a href="/contact"></a>
</Button>
</div>
</ScrollReveal>
</div>
</section>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItems } from '@/lib/cms/data-server';
import { MethodologyContent } from './methodology-content';
export const metadata: Metadata = {
title: `实施方法论 - ${COMPANY_INFO.displayName}`,
description: `了解${COMPANY_INFO.name}的实施方法论:诊断评估、战略规划、落地实施、持续优化四阶段模型,确保数字化项目可衡量落地。`,
};
// ISR:生产环境每 1 小时自动重新验证,CMS 发布/更新时通过 /api/cms/revalidate 主动刷新
export const revalidate = 3600;
export default async function MethodologyPage() {
const items = await getPublishedItems('methodology');
const data = items[0]?.data ?? null;
return <MethodologyContent data={data} />;
}
+16 -7
View File
@@ -74,12 +74,18 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
);
}
export default function NewsContentV3({ news }: { news?: NewsItem[] }) {
export default function NewsContentV3({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: Record<string, unknown> | null }) {
const NEWS = news ?? [];
const [selectedCategory, setSelectedCategory] = useState('全部');
const [searchQuery, setSearchQuery] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const heroEyebrow = (pageCopy?.newsPageHeroEyebrow as string) || 'News & Insights';
const heroTitleLines = ((pageCopy?.newsPageHeroTitle as string) || '新闻与\n洞察').split('\n');
const heroDescription = (pageCopy?.newsPageHeroDescription as string) || `${COMPANY_INFO.displayName}最新动态、产品更新与行业洞察。了解数字化趋势,把握技术脉搏。`;
const emptyTitle = (pageCopy?.newsPageEmptyTitle as string) || '没有找到相关新闻';
const emptyDescription = (pageCopy?.newsPageEmptyDescription as string) || '试试其他关键词或分类';
const filteredNews = useMemo(() => {
return NEWS.filter((newsItem) => {
const matchesCategory = selectedCategory === '全部' || newsItem.category === selectedCategory;
@@ -118,19 +124,22 @@ export default function NewsContentV3({ news }: { news?: NewsItem[] }) {
<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">
<div className="max-w-4xl">
<ScrollReveal>
<SectionLabel>News &amp; Insights</SectionLabel>
<SectionLabel>{heroEyebrow}</SectionLabel>
</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">
<span className="block mt-4 text-brand"></span>
{heroTitleLines.map((line, i) => (
<span key={i} className={i === heroTitleLines.length - 1 ? 'block mt-4 text-brand' : 'block'}>
{line}
</span>
))}
</h1>
</ScrollReveal>
<ScrollReveal delay={0.2}>
<p className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed">
{COMPANY_INFO.displayName}
{heroDescription}
</p>
</ScrollReveal>
</div>
@@ -173,8 +182,8 @@ export default function NewsContentV3({ news }: { news?: NewsItem[] }) {
<ScrollReveal>
<div className="text-center py-24">
<Newspaper className="w-16 h-16 text-text-muted/20 mx-auto mb-6" />
<p className="text-xl text-text-secondary"></p>
<p className="mt-2 text-text-muted"></p>
<p className="text-xl text-text-secondary">{emptyTitle}</p>
<p className="mt-2 text-text-muted">{emptyDescription}</p>
</div>
</ScrollReveal>
) : (
+1
View File
@@ -4,6 +4,7 @@ import NewsListPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getPageCopy: jest.fn().mockResolvedValue(null),
}));
jest.mock('./news-content-v3', () => ({
+6 -3
View File
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItems } from '@/lib/cms/data-server';
import { getPublishedItems, getPageCopy } from '@/lib/cms/data-server';
import type { NewsItem } from '@/lib/constants/news';
import NewsContentV3 from './news-content-v3';
@@ -13,7 +13,10 @@ export const metadata: Metadata = {
export const revalidate = 3600;
export default async function NewsListPage() {
const items = await getPublishedItems('news');
const [items, pageCopy] = await Promise.all([
getPublishedItems('news'),
getPageCopy('news').catch(() => null),
]);
const news = items.map((item) => ({ ...item.data, title: item.data.title || item.title } as unknown as NewsItem));
return <NewsContentV3 news={news} />;
return <NewsContentV3 news={news} pageCopy={pageCopy?.data ?? null} />;
}
+5 -1
View File
@@ -5,6 +5,7 @@ import HomePage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getResolvedHomeZones: jest.fn(),
getHomePageCopy: jest.fn(),
}));
jest.mock('./home-content-v14', () => ({
@@ -16,15 +17,17 @@ jest.mock('./home-content-v14', () => ({
<div data-testid="cases-count">{(props.cases as unknown[])?.length ?? 0}</div>
<div data-testid="stats-count">{(props.stats as unknown[])?.length ?? 0}</div>
<div data-testid="hero-data">{props.heroData ? JSON.stringify(props.heroData) : 'null'}</div>
<div data-testid="page-copy-data">{props.pageCopy ? JSON.stringify(props.pageCopy) : 'null'}</div>
</div>
);
},
}));
import { getPublishedItems, getResolvedHomeZones } from '@/lib/cms/data-server';
import { getPublishedItems, getResolvedHomeZones, getHomePageCopy } from '@/lib/cms/data-server';
const mockedGetResolvedHomeZones = getResolvedHomeZones as jest.MockedFunction<typeof getResolvedHomeZones>;
const mockedGetPublishedItems = getPublishedItems as jest.MockedFunction<typeof getPublishedItems>;
const mockedGetHomePageCopy = getHomePageCopy as jest.MockedFunction<typeof getHomePageCopy>;
const createMockItem = (modelCode: string, data: Record<string, unknown>) => ({
id: `${modelCode}-id`,
@@ -48,6 +51,7 @@ describe('HomePage', () => {
jest.clearAllMocks();
mockedGetResolvedHomeZones.mockResolvedValue({});
mockedGetPublishedItems.mockResolvedValue([]);
mockedGetHomePageCopy.mockResolvedValue(null);
});
it('renders home content from resolved zones', async () => {
+11 -2
View File
@@ -1,4 +1,4 @@
import { getPublishedItems, getResolvedHomeZones } from '@/lib/cms/data-server';
import { getPublishedItems, getResolvedHomeZones, getHomePageCopy } from '@/lib/cms/data-server';
import HomeContentV14 from './home-content-v14';
export const revalidate = 3600;
@@ -31,5 +31,14 @@ export default async function HomePage() {
heroData = heroItems[0]?.data ?? null;
}
return <HomeContentV14 services={services} cases={cases} stats={stats} heroData={heroData} />;
// 首页各章节结构性文案(信任信号/叙事情节/标题/CTA),CMS 未配置时为 null(组件内部兜底)
const homePageCopy = await getHomePageCopy().catch(() => null);
return <HomeContentV14
services={services}
cases={cases}
stats={stats}
heroData={heroData}
pageCopy={homePageCopy?.data ?? null}
/>;
}
+7 -1
View File
@@ -1,5 +1,5 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { notFound, redirect } from 'next/navigation';
import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItemBySlug, getAllPublishedSlugs } from '@/lib/cms/data-server';
import type { Product } from '@/lib/constants/products';
@@ -36,6 +36,12 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
notFound();
}
// 若配置 externalUrl,则跳转到独立子域名产品站(对齐品牌矩阵「成熟产品外链」机制)
const externalUrl = item.data.externalUrl as string | undefined;
if (externalUrl) {
redirect(externalUrl);
}
const product = { ...item.data, title: item.data.title || item.title } as unknown as Product;
return <ProductDetailContentV3 product={JSON.parse(JSON.stringify(product))} />;
}
@@ -4,6 +4,7 @@ import ProductsPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getPageCopy: jest.fn().mockResolvedValue(null),
}));
jest.mock('./products-content-v3', () => ({
+4 -3
View File
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItems } from '@/lib/cms/data-server';
import { getPublishedItems, getPageCopy } from '@/lib/cms/data-server';
import type { Product } from '@/lib/constants/products';
import ProductsContentV3 from './products-content-v3';
@@ -12,9 +12,10 @@ export const metadata: Metadata = {
export const revalidate = 3600;
export default async function ProductsPage() {
const [items, standaloneItems] = await Promise.all([
const [items, standaloneItems, pageCopy] = await Promise.all([
getPublishedItems('product'),
getPublishedItems('standalone-product'),
getPageCopy('products').catch(() => null),
]);
const products = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Product));
const standaloneProducts = standaloneItems.map((item) => ({
@@ -25,5 +26,5 @@ export default async function ProductsPage() {
categoryId: 'specialized',
bundle: 'standalone',
} as unknown as Product));
return <ProductsContentV3 products={[...products, ...standaloneProducts]} />;
return <ProductsContentV3 products={[...products, ...standaloneProducts]} pageCopy={pageCopy?.data ?? null} />;
}
@@ -77,6 +77,7 @@ jest.mock('lucide-react', () => {
};
return {
ArrowRight: mockIcon('arrow-right'),
ArrowUpRight: mockIcon('arrow-up-right'),
Package: mockIcon('package'),
Cpu: mockIcon('cpu'),
TrendingUp: mockIcon('trendingup'),
@@ -144,6 +145,33 @@ const mockEnterpriseProducts: Product[] = [
dataProofs: [],
certifications: [],
},
{
id: 'bi',
title: '睿新商业智能分析平台',
category: '企业旗舰系列',
categoryId: 'enterprise',
description: '企业级数据智能分析平台',
overview: 'BI 平台概述',
features: ['数据建模', '可视化分析'],
benefits: ['数据驱动决策'],
tags: ['BI', '数据分析'],
status: '已发布',
image: '/bi.png',
heroThemeId: 'bi',
bundle: 'enterprise',
scenario: '面向中大型企业的数据驱动决策平台',
metrics: [
{ value: '10x', label: '分析效率提升' },
{ value: '98%', label: '数据质量达标率' },
{ value: '24h', label: '实时更新' },
],
capabilities: ['数据建模', '可视化报表', '智能预警', '移动看板'],
process: [],
specs: [],
caseStudies: [],
dataProofs: [],
certifications: [],
},
];
const mockSpecializedProducts: Product[] = [
@@ -161,6 +189,7 @@ const mockSpecializedProducts: Product[] = [
image: '/novavis.png',
heroThemeId: 'novavis',
bundle: 'standalone',
externalUrl: 'https://novavis.p.novalon.cn',
scenario: '面向执法机关的离线智能数据分析与资金关系图谱平台',
metrics: [
{ value: '100万+', label: '秒级处理' },
@@ -185,15 +214,15 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
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.getByText('数字化核心系统')).toBeInTheDocument();
});
it('should render hero description text', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
expect(screen.getByText(/从数据智能到业务协同/)).toBeInTheDocument();
expect(screen.getByText(/从业务系统到数据智能/)).toBeInTheDocument();
});
it('should render enterprise products section', async () => {
@@ -226,6 +255,13 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
expect(screen.getAllByText('NovaVis 数据可视化平台').length).toBeGreaterThanOrEqual(1);
});
it('should show external-site badge for products with externalUrl', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
expect(screen.getAllByText('外部站点').length).toBeGreaterThanOrEqual(1);
});
it('should render product capabilities on cards', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
@@ -251,6 +287,22 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
expect(erpDetailLink).toBeTruthy();
});
it('should render ERP as the 2x2 Bento large card', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
const erpCard = screen.getByTestId('bento-product-card-erp');
expect(erpCard).toHaveAttribute('data-bento-size', 'large');
});
it('should render BI as the 1x2 Bento wide card', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
const biCard = screen.getByTestId('bento-product-card-bi');
expect(biCard).toHaveAttribute('data-bento-size', 'wide');
});
it('should render suite combos section', async () => {
const { default: ProductsContentV3 } = await import('./products-content-v3');
render(<ProductsContentV3 products={allMockProducts} />);
@@ -262,8 +314,8 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
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.getByText('查看行业方案')).toBeInTheDocument();
});
it('should render stats section with product count', async () => {
@@ -280,7 +332,7 @@ describe('ProductsContentV3 - 产品列表页集成测试', () => {
render(<ProductsContentV3 products={[]} />);
// Hero section should still render
expect(screen.getByText('自研产品组合')).toBeInTheDocument();
expect(screen.getByText('自研产品矩阵')).toBeInTheDocument();
// The enterprise section should still render (even without products)
expect(screen.getAllByText('企业套装').length).toBeGreaterThanOrEqual(1);
@@ -6,12 +6,16 @@ import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { EASE_OUT } from '@/components/ui/page-decoration';
import { Button } from '@/components/ui/button';
import { StaticLink } from '@/components/ui/static-link';
import { ArrowRight, Package, Cpu, TrendingUp, Users, Settings, FileText } from 'lucide-react';
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 { cn } from '@/lib/utils';
const EASE_SPRING = { type: 'spring', stiffness: 300, damping: 30 } as const;
type PageCopy = Record<string, unknown>;
const PRODUCT_ICONS: Record<string, React.ReactNode> = {
'睿新ERP管理系统': <Package className="w-5 h-5" />,
'睿新商业智能分析平台': <TrendingUp className="w-5 h-5" />,
@@ -73,10 +77,18 @@ function StatItem({ value, suffix, label, start, delay }: { value: number; suffi
);
}
function HeroSection() {
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) || '了解我们的服务';
const ctaSecondary = (pageCopy?.productsCtaSecondary as string) || '查看行业方案';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-white">
<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">
@@ -87,10 +99,10 @@ function HeroSection() {
transition={{ duration: 1.2, 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"
>
<div className="block"></div>
<div className="block">{heroTitle1}</div>
<div className="block mt-5">
<span className="relative inline-block">
<span className="text-brand font-extrabold"></span>
<span className="text-brand font-extrabold">{heroTitle2}</span>
<motion.span
className="absolute -bottom-3 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
@@ -100,7 +112,7 @@ function HeroSection() {
/>
</span>
</div>
<div className="block mt-5"></div>
<div className="block mt-5">{heroTitle3}</div>
</motion.h1>
<motion.p
@@ -109,8 +121,12 @@ function HeroSection() {
transition={{ duration: 1, 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"
>
6
{heroDescriptionLines.map((line, i) => (
<span key={i}>
{i > 0 && <br className="hidden sm:block" />}
{line}
</span>
))}
</motion.p>
<motion.div
@@ -120,13 +136,13 @@ function HeroSection() {
className="flex flex-wrap gap-6"
>
<Button size="xl" asChild>
<a href="/contact">
<a href="/services">
{ctaPrimary}
<ArrowRight className="w-5 h-5 ml-2" />
</a>
</Button>
<Button size="xl" variant="outline" asChild>
<a href="/solutions"></a>
<a href="/solutions">{ctaSecondary}</a>
</Button>
</motion.div>
@@ -146,40 +162,53 @@ function HeroSection() {
);
}
function ProductCard({ product }: { product: Product }) {
function ProductCard({ product, size = 'small' }: { product: Product; size?: 'large' | 'wide' | 'small' }) {
const bundleLabel = product.bundle === 'enterprise' ? '企业套装' : '专业产品';
const linkHref = product.externalUrl || `/products/${product.id}`;
const isExternal = !!product.externalUrl;
const isLarge = size === 'large';
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, ease: EASE_OUT }}
>
<BentoItem size={size} testId={`bento-product-card-${product.id}`}>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.6, ease: EASE_OUT }}
className="h-full"
>
<StaticLink
href={linkHref}
className="group block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary hover:-translate-y-1 hover:shadow-lg transition-all duration-300 flex flex-col h-full after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-300 group-hover:after:scale-x-100"
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
<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="text-lg font-bold text-text-primary">{product.title}</h3>
<h3 className={cn('font-bold text-text-primary', isLarge ? 'text-2xl lg:text-3xl' : 'text-lg')}>
{product.title}
</h3>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="px-2 py-1 text-xs bg-brand/10 text-brand" aria-hidden="true">{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" />
</span>
)}
</div>
<span className="px-2 py-1 text-xs bg-brand/10 text-brand" aria-hidden="true">{bundleLabel}</span>
</div>
<p className="text-sm text-text-secondary mb-4 leading-relaxed flex-1">
<p className={cn('text-text-secondary mb-4 leading-relaxed flex-1', isLarge ? 'text-base' : 'text-sm')}>
{product.scenario || product.description}
</p>
<div className="grid grid-cols-3 gap-2 mb-4">
{product.metrics?.map((m) => (
<div key={m.label} className="text-center p-2 bg-bg-secondary">
<div className="text-lg font-bold text-text-primary">{m.value}</div>
<div className={cn('font-bold text-text-primary', isLarge ? 'text-xl' : 'text-lg')}>{m.value}</div>
<div className="text-xs text-text-muted">{m.label}</div>
</div>
))}
@@ -194,13 +223,17 @@ function ProductCard({ product }: { product: Product }) {
</div>
</div>
</StaticLink>
</motion.div>
</motion.div>
</BentoItem>
);
}
function BundleOverview({ products }: { products: Product[] }) {
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 bundleTitle = (pageCopy?.productsBundleTitle as string) || '两套产品体系\n覆盖不同场景';
const bundleTitleLines = bundleTitle.split('\n');
const bundleDescription = (pageCopy?.productsBundleDescription as string) || '企业套装以组合形式解决核心管理问题,专业产品聚焦独立场景深度赋能。';
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
@@ -209,10 +242,14 @@ function BundleOverview({ products }: { products: Product[] }) {
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<ScrollReveal className="max-w-3xl mb-12 sm:mb-16 md:mb-20">
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-6">
<span className="text-brand"></span>
{bundleTitleLines.map((line, i) => (
<span key={i} className={i === bundleTitleLines.length - 1 ? 'block text-brand' : 'block'}>
{line}
</span>
))}
</h2>
<p className="text-lg text-text-secondary leading-relaxed">
{bundleDescription}
</p>
</ScrollReveal>
@@ -299,15 +336,15 @@ function EnterpriseProductsSection({ products }: { products: Product[] }) {
</ScrollReveal>
</div>
<StaggerReveal
className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary"
staggerDelay={0.08}
delayChildren={0.05}
>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-px bg-border-primary">
{enterpriseProducts.map((product) => (
<ProductCard key={product.id} product={product} />
<ProductCard
key={product.id}
product={product}
size={product.id === 'erp' ? 'large' : product.id === 'bi' ? 'wide' : 'small'}
/>
))}
</StaggerReveal>
</div>
</div>
</section>
);
@@ -390,7 +427,7 @@ function SuiteCombosSection({ products }: { products: Product[] }) {
return (
<a
key={idx}
href="/contact"
href="/solutions"
className="group p-10 lg:p-12 bg-white hover:bg-bg-secondary transition-all duration-500 overflow-hidden"
>
<div>
@@ -424,7 +461,7 @@ function SuiteCombosSection({ products }: { products: Product[] }) {
whileHover={{ x: 6 }}
transition={EASE_SPRING}
>
<span></span>
<span></span>
<ArrowRight className="w-4 h-4" />
</motion.div>
</div>
@@ -437,7 +474,9 @@ function SuiteCombosSection({ products }: { products: Product[] }) {
);
}
function CTASection() {
function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const ctaTitleLines = ((pageCopy?.productsCtaTitle as string) || '以自研产品\n支撑数字化落地').split('\n');
const ctaDescription = (pageCopy?.productsCtaDescription as string) || '从企业套装到专业产品,以自研能力与专业服务,陪伴企业完成数字化升级。';
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-white">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
@@ -446,24 +485,26 @@ function CTASection() {
<div className="max-w-container mx-auto px-6 lg:px-10 relative z-10">
<ScrollReveal className="text-center max-w-3xl mx-auto">
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight mb-6 sm:mb-8">
<br />
<span className="text-brand"></span>
{ctaTitleLines.map((line, i) => (
<span key={i} className={i === ctaTitleLines.length - 1 ? 'block text-brand' : 'block'}>
{line}
</span>
))}
</h2>
<p className="text-xl text-text-secondary mb-12 max-w-2xl mx-auto leading-relaxed">
{ctaDescription}
</p>
<div className="flex flex-wrap justify-center gap-6">
<Button size="xl" asChild>
<a href="/contact">
<a href="/services">
<ArrowRight className="w-5 h-5 ml-2" />
</a>
</Button>
<Button size="xl" variant="outline" asChild>
<a href="/solutions"></a>
<a href="/contact"></a>
</Button>
</div>
</ScrollReveal>
@@ -472,17 +513,17 @@ function CTASection() {
);
}
export default function ProductsContentV3({ products: productsProp }: { products?: Product[] }) {
export default function ProductsContentV3({ products: productsProp, pageCopy }: { products?: Product[]; pageCopy?: PageCopy | null }) {
const products = productsProp ?? [];
return (
<main>
<HeroSection />
<BundleOverview products={products} />
<HeroSection pageCopy={pageCopy} />
<BundleOverview products={products} pageCopy={pageCopy} />
<EnterpriseProductsSection products={products} />
<SpecializedProductsSection products={products} />
<SuiteCombosSection products={products} />
<CTASection />
<CTASection pageCopy={pageCopy} />
</main>
);
}
@@ -4,6 +4,7 @@ import ServicesPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getPageCopy: jest.fn().mockResolvedValue(null),
}));
jest.mock('./services-content-v3', () => ({
+4 -2
View File
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItems } from '@/lib/cms/data-server';
import { getPublishedItems, getPageCopy } from '@/lib/cms/data-server';
import type { Service } from '@/lib/constants/services';
import ServicesContentV3 from './services-content-v3';
@@ -15,5 +15,7 @@ export const revalidate = 3600;
export default async function ServicesPage() {
const items = await getPublishedItems('service');
const services = items.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Service));
return <ServicesContentV3 services={services} />;
// 服务页结构性文案(Hero/区块标题/CTA/空状态),CMS 未配置时组件内部兜底
const pageCopy = await getPageCopy('services').catch(() => null);
return <ServicesContentV3 services={services} pageCopy={pageCopy?.data ?? null} />;
}
@@ -0,0 +1,105 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ServicesContentV3 from './services-content-v3';
import type { Service } from '@/lib/constants/services';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, whileInView, viewport, className, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
data-while-inview={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
className={className}
{...props}
>
{children}
</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="motion-a" {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/static-link', () => ({
StaticLink: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="static-link" {...props}>{children}</a>
),
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowUpRight: mockIcon('arrow-up-right'),
CheckCircle2: mockIcon('check-circle2'),
};
});
const mockServices: Service[] = [
{
id: 'consulting',
title: '数字化转型战略咨询',
description: '从业务诊断到数字化路线图',
icon: 'Lightbulb',
overview: '战略咨询概述',
features: ['业务诊断'],
benefits: ['ROI 评估'],
process: ['诊断'],
heroThemeId: 'consulting',
caseStudies: [],
dataProofs: [],
capabilities: ['业务诊断', '数字化蓝图'],
metrics: [{ value: '40%', label: '运营效率提升' }],
},
{
id: 'data',
title: '数据智能与 AI 应用',
description: '构建企业级数据平台',
icon: 'Database',
overview: '数据智能概述',
features: ['数据中台'],
benefits: ['数据驱动'],
process: ['建模'],
heroThemeId: 'data',
caseStudies: [],
dataProofs: [],
capabilities: ['数据中台', 'AI 建模'],
metrics: [{ value: '98%', label: '数据质量达标率' }],
},
];
describe('ServicesContentV3 - 非对称服务卡片', () => {
it('makes the first service a featured full-width card', () => {
render(<ServicesContentV3 services={mockServices} />);
expect(screen.getByTestId('service-card-consulting')).toHaveAttribute('data-featured', 'true');
expect(screen.getByTestId('service-card-consulting').className).toContain('md:col-span-2');
expect(screen.getByTestId('service-card-data')).toHaveAttribute('data-featured', 'false');
});
it('renders service titles and descriptions', () => {
render(<ServicesContentV3 services={mockServices} />);
expect(screen.getByText('数字化转型战略咨询')).toBeInTheDocument();
expect(screen.getByText('数据智能与 AI 应用')).toBeInTheDocument();
});
});
@@ -69,7 +69,16 @@ const SERVICE_MODES = [
},
];
function HeroSection() {
type PageCopy = Record<string, unknown>;
function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const heroTitle1 = (pageCopy?.servicesHeroTitle1 as string) || '从规划到运维';
const heroTitle2 = (pageCopy?.servicesHeroTitle2 as string) || '全程陪伴';
const heroDescriptionLines = ((pageCopy?.servicesHeroDescription as string) ||
'不只是技术供应商,更是您数字化转型的同行者。\n从第一次沟通到系统稳定运行,我们始终在您身边。').split('\n');
const ctaPrimary = (pageCopy?.servicesCtaPrimary as string) || '聊聊您的需求';
const ctaSecondary = (pageCopy?.servicesCtaSecondary as string) || '查看产品矩阵';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-white">
<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">
@@ -80,11 +89,11 @@ function HeroSection() {
transition={{ duration: 1, 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"
>
<div className="block"></div>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
<span className="relative inline-block">
<span className="relative text-brand font-black">
{heroTitle2}
<motion.span
className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
@@ -103,9 +112,12 @@ function HeroSection() {
transition={{ duration: 0.8, 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"
>
<br className="hidden sm:block" />
{heroDescriptionLines.map((line, i) => (
<span key={i}>
{i > 0 && <br className="hidden sm:block" />}
{line}
</span>
))}
</motion.p>
<motion.div
@@ -118,14 +130,14 @@ function HeroSection() {
href="/contact"
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
>
{ctaPrimary}
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</a>
<a
href="/products"
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary hover:border-ink/30 transition-all duration-300 min-h-[52px] touch-manipulation"
>
{ctaSecondary}
</a>
</motion.div>
@@ -160,15 +172,17 @@ function HeroSection() {
);
}
function ServicesGridSection({ services }: { services?: Service[] }) {
function ServicesGridSection({ services, pageCopy }: { services?: Service[]; pageCopy?: PageCopy | null }) {
const displayServices = services?.length ? mapServicesToCards(services) : [];
const gridTitle = (pageCopy?.servicesGridTitle as string) || '四大核心服务,覆盖数字化全链路';
const emptyState = (pageCopy?.servicesEmptyState as string) || '暂无服务数据';
if (displayServices.length === 0) {
return (
<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"></p>
<p className="text-text-muted text-lg">{emptyState}</p>
</div>
</section>
);
@@ -187,7 +201,7 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
<br className="sm:hidden" />
{gridTitle}
</h2>
</motion.div>
@@ -195,6 +209,9 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
{displayServices.map((service, i) => (
<motion.div
key={service.id}
data-testid={`service-card-${service.id}`}
data-featured={i === 0 ? 'true' : 'false'}
className={i === 0 ? 'md:col-span-2 bg-white' : 'md:col-span-1 bg-white'}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
@@ -202,7 +219,7 @@ function ServicesGridSection({ services }: { services?: Service[] }) {
>
<StaticLink
href={`/services/${service.id}`}
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-500 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-transparent hover:border-border-primary"
className="group relative block p-8 sm:p-10 md:p-12 transition-all duration-300 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-white border border-transparent hover:border-border-primary after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-300 group-hover:after:scale-x-100"
>
<div className="relative z-10">
<h3 className="text-xl sm:text-2xl font-bold text-ink group-hover:text-brand transition-colors duration-300 mb-3">
@@ -318,7 +335,11 @@ function ProcessModesSection() {
);
}
function CTASection() {
function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const ctaTitle = (pageCopy?.servicesCtaTitle as string) || '准备好开始了吗?';
const ctaDescriptionLines = ((pageCopy?.servicesCtaDescription as string) ||
'无论是一个明确的项目,还是一个模糊的想法,我们都愿意坐下来和您一起理清楚。\n首次咨询免费,没有任何销售压力。').split('\n');
return (
<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" />
@@ -332,12 +353,15 @@ function CTASection() {
className="max-w-3xl"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
{ctaTitle}
</h2>
<p className="text-lg text-text-secondary leading-relaxed mb-10">
<br className="hidden sm:block" />
{ctaDescriptionLines.map((line, i) => (
<span key={i}>
{i > 0 && <br className="hidden sm:block" />}
{line}
</span>
))}
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<a
@@ -360,13 +384,13 @@ function CTASection() {
);
}
export default function ServicesContentV3({ services }: { services?: Service[] }) {
export default function ServicesContentV3({ services, pageCopy }: { services?: Service[]; pageCopy?: PageCopy | null }) {
return (
<main className="bg-white text-ink">
<HeroSection />
<ServicesGridSection services={services} />
<HeroSection pageCopy={pageCopy} />
<ServicesGridSection services={services} pageCopy={pageCopy} />
<ProcessModesSection />
<CTASection />
<CTASection pageCopy={pageCopy} />
</main>
);
}
@@ -4,6 +4,7 @@ import SolutionsPage from './page';
jest.mock('@/lib/cms/data-server', () => ({
getPublishedItems: jest.fn(),
getPageCopy: jest.fn().mockResolvedValue(null),
}));
jest.mock('./solutions-content-v3', () => ({
+4 -3
View File
@@ -1,6 +1,6 @@
import { Metadata } from 'next';
import { COMPANY_INFO } from '@/lib/constants';
import { getPublishedItems } from '@/lib/cms/data-server';
import { getPublishedItems, getPageCopy } from '@/lib/cms/data-server';
import type { Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
import SolutionsContentV3 from './solutions-content-v3';
@@ -13,11 +13,12 @@ export const metadata: Metadata = {
export const revalidate = 3600;
export default async function SolutionsPage() {
const [solutionItems, productItems] = await Promise.all([
const [solutionItems, productItems, pageCopy] = await Promise.all([
getPublishedItems('solution'),
getPublishedItems('product'),
getPageCopy('solutions').catch(() => null),
]);
const solutions = solutionItems.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Solution));
const products = productItems.map((item) => ({ ...item.data, id: item.data.id || item.id, slug: item.data.slug || item.slug, title: item.data.title || item.title } as unknown as Product));
return <SolutionsContentV3 solutions={solutions} products={products} />;
return <SolutionsContentV3 solutions={solutions} products={products} pageCopy={pageCopy?.data ?? null} />;
}
@@ -0,0 +1,125 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionDetailContentV3 from './solution-detail-content-v3';
import type { Solution } from '@/lib/constants/solutions';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div data-testid="motion-div" className={className} {...props}>{children}</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/scroll-reveal', () => ({
ScrollReveal: ({ children, className }: any) => (
<div data-testid="scroll-reveal" className={className}>{children}</div>
),
StaggerReveal: ({ children, className }: any) => (
<div data-testid="stagger-reveal" className={className}>{children}</div>
),
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowRight: mockIcon('arrow-right'),
Factory: mockIcon('factory'),
ShoppingCart: mockIcon('shopping-cart'),
Heart: mockIcon('heart'),
GraduationCap: mockIcon('graduation-cap'),
Lightbulb: mockIcon('lightbulb'),
Settings: mockIcon('settings'),
Users: mockIcon('users'),
TrendingUp: mockIcon('trending-up'),
Package: mockIcon('package'),
BookOpen: mockIcon('book-open'),
MessageSquare: mockIcon('message-square'),
Calendar: mockIcon('calendar'),
Smartphone: mockIcon('smartphone'),
Route: mockIcon('route'),
Shield: mockIcon('shield'),
Truck: mockIcon('truck'),
};
});
const baseSolution = {
id: 'manufacturing',
industry: '制造业',
title: '智能制造解决方案',
subtitle: '从传统制造到智慧工厂',
description: '测试描述',
challenges: ['数据孤岛'],
solutions: ['建设 MES'],
relatedProducts: ['erp'],
valueProposition: {
headline: '全链路数字化赋能',
points: [{ icon: 'Factory', title: '生产透明化', description: '实时掌握' }],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: 'ERP 打通核心链路',
},
} as Solution;
describe('SolutionDetailContentV3 - L3 信任层三档策略', () => {
it('does not render L3 sections when no case/certification data exists', () => {
render(<SolutionDetailContentV3 solution={baseSolution} />);
expect(screen.queryByText('同行业落地案例')).not.toBeInTheDocument();
expect(screen.queryByText('资质认证')).not.toBeInTheDocument();
});
it('renders case studies when data is available', () => {
const withCases: Solution = {
...baseSolution,
caseStudies: [
{
client: '某制造集团',
industry: '制造业',
challenge: '生产数据孤岛',
solution: '部署 MES 与 BI',
result: '生产效率提升 40%',
},
] as Solution['caseStudies'],
};
render(<SolutionDetailContentV3 solution={withCases} />);
expect(screen.getByText('同行业落地案例')).toBeInTheDocument();
expect(screen.getByText('某制造集团')).toBeInTheDocument();
});
it('renders certifications when data is available', () => {
const withCerts: Solution = {
...baseSolution,
certifications: [{ name: 'ISO 27001', issuer: '认证机构' }],
};
render(<SolutionDetailContentV3 solution={withCerts} />);
expect(screen.getByText('ISO 27001')).toBeInTheDocument();
});
});
@@ -3,8 +3,7 @@
import { motion } from 'framer-motion';
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
import { Button } from '@/components/ui/button';
import { ArrowRight, 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 { ArrowRight, 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';
@@ -341,6 +340,128 @@ interface CTASectionProps {
solution: Solution;
}
// ===== L3 信任层:客户案例(有数据才渲染,无数据不显示,不虚构)=====
interface CaseStudiesSectionProps {
caseStudies: Solution['caseStudies'];
}
function CaseStudiesSection({ caseStudies }: CaseStudiesSectionProps) {
if (!caseStudies || caseStudies.length === 0) return null;
return (
<section className="relative py-20 sm:py-28 md:py-32 lg:py-40 overflow-hidden bg-white">
<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">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16 sm:mb-20 md:mb-20">
<SectionLabel className="justify-center">
<div className="flex items-center gap-5">
<div className="w-14 h-px bg-brand" />
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
</span>
<div className="w-14 h-px bg-brand" />
</div>
</SectionLabel>
<h2 className="text-2xl sm:text-3xl md:text-4xl lg:text-5xl font-bold text-ink tracking-tight leading-tight">
</h2>
</ScrollReveal>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-px bg-border-primary">
<StaggerReveal staggerDelay={0.1} delayChildren={0.05}>
{caseStudies.map((study) => (
<div
key={study.client}
className="group relative block overflow-hidden bg-white transition-all duration-500 hover:bg-bg-secondary"
>
<div className="aspect-[4/3] bg-gradient-to-br from-bg-secondary to-bg-tertiary relative overflow-hidden">
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-6xl font-bold text-text-muted/10">
{study.client.charAt(0)}
</span>
</div>
<div className="absolute top-5 left-5">
<span className="px-3 py-1.5 text-xs font-bold text-brand bg-brand-soft/80 backdrop-blur-sm">
{study.industry}
</span>
</div>
</div>
<div className="p-8">
<h3 className="text-xl font-bold text-ink mb-3 group-hover:translate-x-2 transition-transform duration-500">
{study.client}
</h3>
<p className="text-text-secondary text-sm leading-relaxed mb-6">
{study.challenge}
</p>
<div className="pt-6 border-t border-border-primary">
<div className="text-2xl font-bold text-brand mb-1">{study.result}</div>
<div className="text-xs text-text-muted"></div>
</div>
</div>
</div>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
// ===== L3 信任层:资质认证(有数据才渲染,无数据不显示)=====
interface CertificationsSectionProps {
certifications: Solution['certifications'];
}
function CertificationsSection({ certifications }: CertificationsSectionProps) {
if (!certifications || certifications.length === 0) return null;
return (
<section className="relative py-16 sm:py-20 md:py-24 lg:py-32 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">
<ScrollReveal className="text-center max-w-3xl mx-auto mb-16">
<SectionLabel className="justify-center">
<div className="flex items-center gap-5">
<div className="w-14 h-px bg-brand" />
<span className="text-[11px] tracking-[0.4em] uppercase font-bold text-brand">
</span>
<div className="w-14 h-px bg-brand" />
</div>
</SectionLabel>
<h2 className="text-2xl md:text-3xl font-bold text-ink tracking-tight">
</h2>
</ScrollReveal>
<div className="flex flex-wrap justify-center gap-px bg-border-primary">
<StaggerReveal staggerDelay={0.08} delayChildren={0.05}>
{certifications.map((cert, idx) => (
<div
key={idx}
className="group flex items-center gap-4 px-8 py-5 bg-white hover:bg-bg-secondary transition-all duration-500"
>
<div className="w-12 h-12 rounded-xl bg-brand-soft flex items-center justify-center text-brand">
<Shield className="w-6 h-6" />
</div>
<div>
<div className="text-ink font-bold">{cert.name}</div>
<div className="text-sm text-text-muted">{cert.issuer}</div>
</div>
</div>
))}
</StaggerReveal>
</div>
</div>
</section>
);
}
function CTASection({ solution }: CTASectionProps) {
return (
<section className="relative py-36 lg:py-44 overflow-hidden bg-bg-secondary">
@@ -398,6 +519,8 @@ export default function SolutionDetailContentV3({ solution, products = [] }: Sol
<SolutionsSection solutions={solution.solutions} />
<ValuePropositionSection valueProposition={solution.valueProposition} />
<SuiteCombinationSection suiteCombination={solution.suiteCombination} products={products} />
<CaseStudiesSection caseStudies={solution.caseStudies} />
<CertificationsSection certifications={solution.certifications} />
<CTASection solution={solution} />
</main>
);
@@ -0,0 +1,148 @@
import { describe, it, expect, jest } from '@jest/globals';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SolutionsContentV3 from './solutions-content-v3';
import type { Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, initial, animate, whileInView, viewport, className, ...props }: any) => (
<div
data-testid="motion-div"
data-initial={JSON.stringify(initial)}
data-animate={JSON.stringify(animate)}
data-while-inview={JSON.stringify(whileInView)}
data-viewport={JSON.stringify(viewport)}
className={className}
{...props}
>
{children}
</div>
),
h1: ({ children, className, ...props }: any) => (
<h1 data-testid="motion-h1" className={className} {...props}>{children}</h1>
),
p: ({ children, className, ...props }: any) => (
<p data-testid="motion-p" className={className} {...props}>{children}</p>
),
span: ({ children, className, ...props }: any) => (
<span data-testid="motion-span" className={className} {...props}>{children}</span>
),
a: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="motion-a" {...props}>{children}</a>
),
},
useInView: jest.fn(() => true),
AnimatePresence: ({ children }: any) => <>{children}</>,
}));
jest.mock('@/components/ui/static-link', () => ({
StaticLink: ({ children, href, className, ...props }: any) => (
<a href={href} className={className} data-testid="static-link" {...props}>{children}</a>
),
}));
jest.mock('lucide-react', () => {
const mockIcon = (name: string) => {
const Icon = (props: any) => <svg data-testid={`icon-${name.toLowerCase()}`} className={props.className} />;
Icon.displayName = name;
return Icon;
};
return {
ArrowUpRight: mockIcon('arrow-up-right'),
Factory: mockIcon('factory'),
ShoppingCart: mockIcon('shopping-cart'),
Heart: mockIcon('heart'),
GraduationCap: mockIcon('graduation-cap'),
Shield: mockIcon('shield'),
Truck: mockIcon('truck'),
};
});
const mockSolutions: Solution[] = [
{
id: 'manufacturing',
industry: '制造业',
title: '智能制造数字化转型方案',
subtitle: '端到端制造数字化',
description: '面向制造企业的整体转型方案',
challenges: ['数据孤岛', '库存积压'],
solutions: ['ERP 集成', 'BI 分析'],
relatedProducts: ['erp', 'bi'],
valueProposition: {
headline: '价值主张',
points: [{ icon: 'Factory', title: '降本增效', description: '描述' }],
},
suiteCombination: {
primaryProducts: ['erp'],
complementaryServices: ['consulting'],
rationale: '理由',
},
painPoints: ['数据孤岛'],
outcomes: [{ value: '30%', label: '效率提升' }],
recommendedProducts: ['erp', 'bi'],
},
{
id: 'retail',
industry: '贸易零售',
title: '零售数字化解决方案',
subtitle: '全渠道零售数字化',
description: '面向零售企业的数字化方案',
challenges: ['渠道割裂'],
solutions: ['CRM 统一'],
relatedProducts: ['crm', 'bi'],
valueProposition: {
headline: '价值主张',
points: [{ icon: 'ShoppingCart', title: '提升转化', description: '描述' }],
},
suiteCombination: {
primaryProducts: ['crm'],
complementaryServices: ['consulting'],
rationale: '理由',
},
painPoints: ['渠道割裂'],
outcomes: [{ value: '3x', label: '转化率提升' }],
recommendedProducts: ['crm', 'bi'],
},
];
const mockProducts: Product[] = [
{
id: 'erp',
title: '睿新ERP管理系统',
description: 'ERP',
image: '/erp.png',
category: '企业旗舰系列',
categoryId: 'enterprise',
status: '已发布',
overview: '概述',
features: [],
benefits: [],
process: [],
specs: [],
tags: [],
heroThemeId: 'erp',
caseStudies: [],
dataProofs: [],
certifications: [],
bundle: 'enterprise',
},
];
describe('SolutionsContentV3 - 非对称方案卡片', () => {
it('makes the first solution a featured full-width card', () => {
render(<SolutionsContentV3 solutions={mockSolutions} products={mockProducts} />);
expect(screen.getByTestId('solution-card-manufacturing')).toHaveAttribute('data-featured', 'true');
expect(screen.getByTestId('solution-card-manufacturing').className).toContain('md:col-span-2');
expect(screen.getByTestId('solution-card-retail')).toHaveAttribute('data-featured', 'false');
});
it('renders solution industries', () => {
render(<SolutionsContentV3 solutions={mockSolutions} products={mockProducts} />);
expect(screen.getByText('制造业')).toBeInTheDocument();
expect(screen.getByText('贸易零售')).toBeInTheDocument();
});
});

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