feat(home): 首页对标埃森哲重构 V15 + 全站 CTA Pill 化 #23
@@ -89,12 +89,16 @@ e2e-tests/reports/
|
||||
*.crt
|
||||
.auth/
|
||||
|
||||
# WorkBuddy 工作记忆目录(工具生成,不入库)
|
||||
.workbuddy/
|
||||
|
||||
# ============================================================
|
||||
# Database
|
||||
# ============================================================
|
||||
*.db
|
||||
*.db-journal
|
||||
data.db
|
||||
data/
|
||||
|
||||
# ============================================================
|
||||
# Uploads & Storage
|
||||
|
||||
@@ -166,6 +166,7 @@ Novalon 的 6 个核心产品,互为互补关系,常以组合形式出现在
|
||||
| 品牌主口号 | **智连未来 · 成长伙伴 —— 您的数字化转型同行者**(整句全站统一采用)。品牌叙事内核(定位声明 / 承诺 / L0 价值主张 / L1 三支柱 / 语气语调)见 `docs/brand-narrative-core.md`,作为全站文案统一唯一锚点 | ✅ 2026-08-19 确认 |
|
||||
| 品牌叙事 CMS 化 | 品牌叙事内核全部下沉到 CMS `site-config` 模型(`slogan` / `valueProposition` / `positioningStatement` / `brandPromise` / `toneOfVoice` / `pillars`),后台可编辑;代码侧 `COMPANY_INFO` 与 `BRAND_NARRATIVE`(`src/lib/constants/company.ts`)作为未配置 CMS 时的兜底唯一真源,`layout.tsx` 读取后经 `SiteConfigProvider` 注入全站 | ✅ 2026-08-19 确认 |
|
||||
| 结构性文案 CMS 化 | 新增 `page-copy` 内容模型承载各业务页面章节标题/眉标/描述/CTA/空状态,覆盖首页 + 服务/方案/产品/案例/新闻列表页,seed 写入 6 条(home/services/solutions/products/cases/news)。实现为「CMS 优先 + 硬编码兜底」:CMS 未配置时回退 `TRUST_SIGNALS`/`EARLY_ACCESS`/`NARRATIVE_ACTS` 等常量,保证任何情况不白屏。数据层新增 `getPageCopy(pageCode)` / `getHomePageCopy()`,各 `page.tsx` 读取后以 `pageCopy` props 传入组件。详情页小节标题、页面内嵌业务常量(服务流程/合作模式/产品组合)、Header/Footer 等 UI 外壳按 UI 职责保留代码。数据实体(服务/产品/方案/案例/新闻/指标/Hero)仍由 `data-server` 从 Prisma 读取 | ✅ 2026-08-19 确认 |
|
||||
| 首页对标埃森哲重构(scene#16) | **视觉基调:局部深色 Hero(推荐项)**——保持浅色咨询风为底,仅 Hero 升级为全出血深色画布(近黑底 `bg-ink` + 白色 Logo `/logo-white.svg` + 品牌红 #C41E3A 单电压 ≤10%),制造埃森哲式戏剧感;**实施范围:Phase A+B 全量**——①内容支柱补齐:新增 Insights 行业洞察(2×2 gap-px 卡片网格 + 方法论/观点/共创类型标签)、FounderQuote 创始人观点(深红区块 + 白色点阵 + 大引言)、News 新闻动态(最近 3 条 + 查看全部)三个对标埃森哲思想领导力支柱的区块;②Hero 深色化;③CTA 签名符号:全站 Button 组件 `rounded-md` → `rounded-full`(Pill 胶囊化)+ ArrowRight 签名箭头。零编造内容原则:公司 2026-01-15 成立,Insights 兜底 4 条均为可验证方法论内容(不虚构研究报告/人名),FounderQuote 署名兜底「Novalon 创始团队」不虚构具体人名。实现:`home-content-v15.tsx` 替换 v14(9 区块顺序 Hero→Trust→Narrative→Insights→Services→FounderQuote→Cases→News→CTA),CMS 优先 + FALLBACK_INSIGHTS/NEWS 常量兜底;seed page-copy 新增 insights*/founderQuote*/news* 字段(upsert 幂等)。验证:tsc 0 errors / eslint 0 errors / Jest 128 套件 1621 通过 / Playwright 视觉回归 22 passed(macOS 11 限制下用 Chromium 117 兼容配置生成快照) | ✅ 2026-08-31 确认 |
|
||||
|
||||
## 页面类型与四层映射
|
||||
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
# Impeccable 复审报告(代码更新后)
|
||||
|
||||
复审日期:2026-08-29
|
||||
第一轮:`/audit` 6/20(Poor)
|
||||
第二轮(代码更新后):**14/20(Good)**
|
||||
第三轮(执行修复后):**17/20(Good)**
|
||||
|
||||
> 代码库已发生重大重构:字体体系重建、设计令牌重写、`effects/` 目录整体删除、
|
||||
> 深色模式移除、页面改由 CMS 驱动(新增 `components/content`、`components/detail`、`lib/cms`)。
|
||||
> 第二轮为**全量重新审计**;第三轮为**按建议执行修复**后的复核。
|
||||
|
||||
---
|
||||
|
||||
## 第三轮 · 修复记录
|
||||
|
||||
见文末「附录:修复记录与审计更正」。
|
||||
|
||||
---
|
||||
|
||||
## 评分对比
|
||||
|
||||
| 维度 | 上轮 | 本轮 | 变化 | 关键 |
|
||||
|---|:---:|:---:|:---:|---|
|
||||
| Accessibility | 2 | **3** | +1 | 3 处对比度违规修好 2 处,剩 `text-hint` |
|
||||
| Performance | 1 | **3** | **+2** | 4.4MB 字体删除、强制重排归零、profiling 移除 |
|
||||
| Responsive | 2 | **2** | — | 两项建议未采纳 |
|
||||
| Theming | 1 | **3** | **+2** | 令牌体系落地,硬编码从普遍降到 29 处 |
|
||||
| Anti-Patterns | 0 | **3** | **+3** | 渐变文字/spring/bounce/特效库全部清除 |
|
||||
| **合计** | **6** | **14** | **+8** | **Poor → Good** |
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns 判定:由「未通过」转为「通过」
|
||||
|
||||
上一轮点名的 7 项生效中的套路,本轮逐一复查:
|
||||
|
||||
| 特征 | 上轮 | 本轮 | 验证 |
|
||||
|---|---|---|---|
|
||||
| 渐变文字 | 生效中 | **0 处** | `grep bg-clip-text` = 0 |
|
||||
| Spring 回弹缓动 | 43 处 | **0 处** | `grep type:'spring'` = 0 |
|
||||
| Bounce 缓动 | 生效中 | **0 处使用** | `CounterWithEffect` 组件仍在 `lib/animations.tsx:852`,但引用数为 0(死代码) |
|
||||
| 特效组件过载 | 20+ 个 | **目录已删除** | `components/effects/` = 0 文件 |
|
||||
| Hero 指标网格 | 4 格统计 | **已拆解** | Hero 改为「克制白底 + 品牌红点缀」;仅 `why-us-section` 保留 1 项指标 |
|
||||
| 动 width 布局动画 | 2 处关键帧 | **0** | `expandWidth`/`typewriter` 已移除 |
|
||||
| 毛玻璃 | header 全宽 | **收敛为 14 处局部** | 主要是对话框遮罩 `bg-ink/60 backdrop-blur-sm`,属标准模态用法 |
|
||||
|
||||
Hero 区注释直接写明意图:`// ===== Hero 区:克制白底 + 品牌红点缀 =====`
|
||||
——这是有意识的设计决策,不是碰巧。
|
||||
|
||||
**结论**:上一轮判定的"AI 套路层"已被系统性剥离,底下真实的品牌观点(水墨 + 朱砂红)现在看得见了。
|
||||
|
||||
---
|
||||
|
||||
## 已修复(按重要性)
|
||||
|
||||
### ✅ 4.4MB 字体阻塞 → 删除(上轮 P0-1)
|
||||
`public/fonts/AoyagiReisho.ttf`(4,412,684 B)已移除。
|
||||
新增 `src/app/fonts/` 仅 140KB(geist-sans + geist-mono woff2)。
|
||||
书法字体改为系统楷体栈,不再下载:
|
||||
|
||||
```css
|
||||
--font-brand: "STKaiti", "KaiTi", "楷体", "SimKai", serif;
|
||||
```
|
||||
|
||||
**这是本轮最大的一笔收益。**
|
||||
|
||||
### ✅ 中文字体栈补齐(上轮 P1-5,我上轮的判断被推翻并修好)
|
||||
```css
|
||||
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||
"Noto Sans SC", -apple-system, BlinkMacSystemFont, ...;
|
||||
```
|
||||
CJK 字体栈完整,跨平台字形可控。
|
||||
|
||||
### ✅ 滚动强制重排 → 归零(上轮 P1-1)
|
||||
`header.tsx` 中的 `offsetTop`/`offsetHeight` 循环读取已完全移除,
|
||||
不再有每滚动事件 12 次强制同步布局。
|
||||
|
||||
### ✅ 深色模式 → 彻底移除(上轮 P0-3)
|
||||
采纳了我给的选项二。验证结果:
|
||||
- `layout.tsx` 无 `.dark` 注入脚本
|
||||
- 全站 `dark:` 变体 **0 处**
|
||||
- `ThemeProvider` 已删除
|
||||
- `themeColor` 仅保留 `#FFFFFF`
|
||||
|
||||
**干净利落,没有留下中间态。**
|
||||
|
||||
### ✅ 生产 profiling 移除(上轮 P1-2)
|
||||
### ✅ 6 个悬空 CSS 变量 → 0(上轮 P1-8,通过删除死代码)
|
||||
### ✅ 对比度违规修好 2/3
|
||||
| 上轮 FAIL | 本轮 |
|
||||
|---|---|
|
||||
| Hero 描述 `#718096` 4.02:1 | → slate 令牌 **4.76+ PASS** |
|
||||
| 页脚 `#718096` 3.68:1 | → **PASS** |
|
||||
| 统计占位 `#CBD5E0` 1.49:1 | → **仍 FAIL**(见下) |
|
||||
|
||||
### ✅ 图片缺失 20 张 → 8 张
|
||||
|
||||
---
|
||||
|
||||
## 仍然存在的问题
|
||||
|
||||
### ⚠️ [P1] `text-hint` 1.48:1,且用在有实际含义的内容上
|
||||
- **位置**:`globals.css:66` `--color-text-hint: #CBD5E1`
|
||||
- **使用处**:
|
||||
- `home-content-v14.tsx:401` — `text-4xl sm:text-5xl font-black` 章节序号(01/02/03)
|
||||
- `select.tsx:25,28` / `textarea.tsx:17` — `placeholder:text-text-hint`
|
||||
- **实测**:**1.48:1**(AA 要求 4.5:1,大字也要求 3:1)→ **双重不达标**
|
||||
- **影响**:章节序号几乎不可见;表单 placeholder 同样。
|
||||
- **注意**:这个 token 比上轮的 `#CBD5E0` 只差最后一位,本质上同一个问题换了个名字。
|
||||
- **标准**:WCAG 2.1 AA 1.4.3
|
||||
- **建议**:若序号是装饰性的 → 加 `aria-hidden` 并淡化得更彻底;若承载信息 → 改 `--color-text-muted`(#64748B,4.76:1)。placeholder 直接用 `text-muted`。
|
||||
|
||||
### ⚠️ [P1] 图片优化仍关闭,且改为无条件
|
||||
- **位置**:`next.config.mjs:8` `images.unoptimized: true`
|
||||
- **变化**:上轮是 `!isDev`(开发环境还优化),现在**生产环境也完全不优化**。
|
||||
同文件 `formats: ['image/avif','image/webp']` 依然是死配置。
|
||||
- **说明**:如果是有意走 CDN 优化(`assetPrefix` + `CDN_DOMAIN` 的存在支持这个猜测),
|
||||
那这是合理选择——但 `formats` 配置项应当一并删除,否则会误导后来者。
|
||||
- **建议**:确认意图。若走 CDN → 删掉 `formats` 并加注释说明;否则改 `false`。
|
||||
|
||||
### ⚠️ [P1] 8 张图片仍缺失(裂图)
|
||||
```
|
||||
/images/cases/manufacturing.jpg /images/erp.jpg
|
||||
/images/insights/trends.jpg /images/news/cms.webp
|
||||
/images/news/fallback.webp /images/news/test.webp
|
||||
/images/product.png /images/products/erp.jpg
|
||||
```
|
||||
`news/fallback.webp` 缺失尤其危险——兜底图本身不存在,意味着兜底机制是失效的。
|
||||
|
||||
### ⚠️ [P2] 移动端全局 44px 规则未改
|
||||
- **位置**:`globals.css:1122-1126`
|
||||
- ```css
|
||||
a:not(nav[aria-label="breadcrumb"] a),
|
||||
button { min-height: 44px; min-width: 44px; }
|
||||
```
|
||||
- 仅排除了面包屑,正文行内链接仍会被撑成 44px 方块。
|
||||
|
||||
### ⚠️ [P2] 根字号 16/17/18px 缩放未改
|
||||
- **位置**:`globals.css:343-359`
|
||||
- 所有 rem(即整个 Tailwind 尺寸体系)在断点处放大 12.5%。
|
||||
|
||||
### ⚠️ [P2] 新增越界色,色板又开始漂移
|
||||
| 颜色 | 位置 | 对比度 |
|
||||
|---|---|---|
|
||||
| `#d97706` amber | `detail/brand-elements.tsx:28,30` | **3.19 FAIL** |
|
||||
| `#2563eb` blue | `detail/brand-elements.tsx:34,36` | 5.17 PASS |
|
||||
| `#3b82f6` / `#eff6ff` | `detail-cross-recommend.tsx:16,17` | — |
|
||||
| `#1e3a5f` navy | `erp-upgrade-v3/page.tsx:26`、`lib/constants/hero-themes.ts:29` | — |
|
||||
|
||||
上一轮清理掉冷蓝灰后,**蓝色和琥珀色又从 detail 组件里长了出来**。
|
||||
`#d97706` 同时还是个对比度 FAIL。
|
||||
|
||||
---
|
||||
|
||||
## 新发现(上轮未覆盖)
|
||||
|
||||
### 🆕 [P1] 生产代码 70 个 TypeScript 错误
|
||||
```
|
||||
src/app/(marketing)/cases/[slug]/page.tsx(16,21) TS7006 隐式 any
|
||||
src/app/(marketing)/news/[slug]/page.tsx(13,21) TS7006
|
||||
src/app/(marketing)/products/[id]/page.tsx(13,21) TS7006
|
||||
src/app/(marketing)/services/[id]/page.tsx(13,21) TS7006
|
||||
src/app/(marketing)/solutions/[id]/page.tsx(14,21) TS7006
|
||||
src/app/api/admin/models/route.ts(17,32) TS7006
|
||||
...共 70 处
|
||||
```
|
||||
另有测试文件大量 `TS2305: '@testing-library/react' has no exported member 'screen'`——
|
||||
说明 `@testing-library/react` 依赖缺失或版本不匹配,**测试套件可能跑不起来**。
|
||||
|
||||
### 🆕 [P2] 140KB 字体文件无引用
|
||||
`src/app/fonts/geist-sans.woff2`(69KB)+ `geist-mono.woff2`(71KB)**全站 0 引用**——
|
||||
`@font-face` 未声明,也没有 `next/font/local` 导入。是孤立资源。
|
||||
|
||||
### 🆕 [P2] 安全与缓存头回退
|
||||
上轮配置里的 `Strict-Transport-Security`(HSTS,max-age=63072000)与
|
||||
静态资源 `Cache-Control: immutable` 规则**在新配置中消失**。
|
||||
新配置新增了 CSP(含 `unsafe-inline`/`unsafe-eval`,Next.js 水合所需,可接受)。
|
||||
|
||||
---
|
||||
|
||||
## 正面发现(本轮新增,值得肯定)
|
||||
|
||||
1. **敢删东西**——`effects/` 整个目录、4.4MB 字体、深色模式半实现,全部删除而非修修补补。这比加功能更需要判断力。
|
||||
2. **深色模式选择了"彻底移除"而非"凑合补全"**,不留中间态,符合工程洁癖。
|
||||
3. **令牌体系真正落地**——71 个语义化令牌 + Tailwind config 映射(`font-sans`/`text-ink`/`bg-brand`),硬编码从"普遍"降到 29 处 / 13 文件。
|
||||
4. **`optimizePackageImports` 新增 `framer-motion`**。
|
||||
5. **收紧了图像配置**——`dangerouslyAllowSVG`、`remotePatterns: '**'` 均已移除。
|
||||
6. **Hero 有明确的克制意图**(代码注释直接写明),不是被动简化。
|
||||
7. **叙事化章节序号**(01/02/03 + hairline grid)替代了 4 格指标堆砌,信息架构更成熟——只是序号的对比度需要修。
|
||||
|
||||
---
|
||||
|
||||
## 建议下一步(按性价比排序)
|
||||
|
||||
| 优先级 | 动作 | 理由 |
|
||||
|---|---|---|
|
||||
| **1** | 修 `text-hint`(改用 `text-muted` 或加 `aria-hidden`) | 1 行改动,消除唯一 AA 违规 |
|
||||
| **2** | 补齐 8 张图,优先 `news/fallback.webp` | 兜底图缺失 = 兜底失效 |
|
||||
| **3** | 确认 `unoptimized: true` 是否刻意为 CDN 让路 | 若刻意,删掉 `formats` 死配置以免误导 |
|
||||
| **4** | 清掉 3 处越界色(`#d97706` 尤其,它 FAIL) | 防止色板二次漂移 |
|
||||
| **5** | 修 70 个 TS 错误 + 测试库依赖 | 类型安全是后续重构的地基 |
|
||||
| **6** | 删除 140KB 无用字体,或补 `next/font/local` 接入 | 要么用,要么删 |
|
||||
| **7** | 恢复 HSTS 与静态资源缓存头 | 安全与性能的低成本回归 |
|
||||
| **8** | 移动端 44px 改为精确选择器、根字号固定 16px | 上轮建议,仍未采纳 |
|
||||
|
||||
> 前 4 项预计 2 小时内可完成,能直接把分数推到 16-17 区间。
|
||||
> 修复后重跑 `/audit` 复核。
|
||||
|
||||
---
|
||||
|
||||
## 一句话总结
|
||||
|
||||
从 6 分到 14 分,主要靠**做减法**——删掉 4.4MB 字体、20+ 特效组件、整层 AI 套路和半吊子深色模式。
|
||||
剩下的失分集中在三类:一个换名未换实的对比度 token、尚未补完的图片资源、以及新长出来的色板漂移。
|
||||
这些都是小修,地基已经稳了。
|
||||
|
||||
---
|
||||
|
||||
# 附录:修复记录与审计更正
|
||||
|
||||
## 一、第三轮评分:14 → 17
|
||||
|
||||
| 维度 | 二轮 | 三轮 | 变化 |
|
||||
|---|:---:|:---:|---|
|
||||
| Accessibility | 3 | **4** | 全部对比度违规清零(含新发现的状态色问题) |
|
||||
| Performance | 3 | **3** | 图片项为误判;补 `sizes` 后维持 |
|
||||
| Responsive | 2 | **2** | 未改动(两项建议待定) |
|
||||
| Theming | 3 | **4** | 越界色全部令牌化,新增状态色文字变体 |
|
||||
| Anti-Patterns | 3 | **3** | 维持 |
|
||||
| **合计** | **14** | **17** | |
|
||||
|
||||
## 二、已执行的修复
|
||||
|
||||
### 1. `text-hint` 对比度(1.48:1 → 合规)
|
||||
根因不是配色,是**用错了令牌**——系统里早有 `--color-text-placeholder`(4.76:1),
|
||||
组件却拿 `text-hint` 当占位符用。修正如下:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `globals.css:66-68` | `--color-text-hint` 由 `#CBD5E1`(1.48:1) 改为 `#7C8CA5`(3.41:1),并加注释限定「仅 ≥24px 大号文本」 |
|
||||
| `ui/input.tsx:18` | `placeholder:text-text-hint` → `text-text-placeholder` |
|
||||
| `ui/select.tsx:25,28` | 同上(含 `data-[placeholder]:`) |
|
||||
| `ui/textarea.tsx:17` | 同上 |
|
||||
| `sections/why-us-section.tsx:177` | 10px 小字 → `text-muted` |
|
||||
| `layout/mobile-menu.tsx:104` | 12px 小字 → `text-muted` |
|
||||
| `home-content-v14.tsx:401` | 装饰性章节序号加 `aria-hidden="true"`,保留 `text-hint` |
|
||||
|
||||
> 后三处是我二轮漏检的:`text-hint` 还被用在 10px/12px 的小字上,
|
||||
> 即便按新值 3.41:1 也对正文不达标。
|
||||
|
||||
### 2. 图片项:确认是误判,改为修健壮性
|
||||
**更正**:所谓「8 张缺失图片」全部只出现在 `.test.tsx` 测试夹具里。
|
||||
严格排除测试文件后,生产代码仅引用 3 张图(`beian-icon.png`、`qrcode.webp`、`wechat-business-qr.webp`),
|
||||
**且这 3 张都存在**。测试断言的是 `src` 字符串,不需要真实文件——不构成生产问题。
|
||||
|
||||
但底层担忧(CMS 图片失效会裂图)成立,因此改为修健壮性:
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `sections/insight-card.tsx` | `<img>` → CSS `background-image`,404 时静默降级,加 `aria-hidden` |
|
||||
| `sections/case-card.tsx` | 同上 |
|
||||
| `detail/product-card.tsx` | 补 `sizes`(上轮标记的 P2-8) |
|
||||
|
||||
### 3. 越界色清理
|
||||
**更正**:二轮把 `#3b82f6` 判为越界是错的——它正是设计系统的 `--color-accent-blue`。
|
||||
真正越界的只有 `BrandSeal` 里的 gold/blue 变体,而该组件**零引用**。
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `detail/brand-elements.tsx` | 移除 gold(`#d97706`, 3.19:1 FAIL) 与 blue(`#2563eb`) 变体,仅保留品牌红;硬编码改令牌 |
|
||||
| `detail/brand-elements.tsx:63` | 修 `${color.border}10` 拼接——改令牌后会产生 `var(...)10` 无效值,改用 `rgba(var(--color-brand-rgb), 0.06)` |
|
||||
| `detail/detail-cross-recommend.tsx` | 三变体全部改令牌:`accent-blue` / `brand` / `accent-purple` |
|
||||
|
||||
`hero-themes.ts` 的 navy(`#1e3a5f`/`#1e40af`)**保留**——经查该文件被
|
||||
`detail-hero.tsx`、`detail-cta-section.tsx`、`standalone/[id]/client.tsx` 使用,
|
||||
属有意的产品主题变体,不是漂移。
|
||||
|
||||
### 4. `next.config.mjs`:确认非 bug
|
||||
**更正**:`CLAUDE.md:189` 明确记载
|
||||
「Images are unoptimized (static export limitation)」,且存在 `docs/CDN_CONFIGURATION.md`、
|
||||
`docs/CDN_QUICK_START.md`。这是 Nginx 静态托管 + CDN 分发的架构决定,**不是缺陷**。
|
||||
|
||||
仅补注释说明意图,并标注 `formats` 在 `unoptimized: true` 下不生效,避免后人误改。
|
||||
|
||||
## 三、修复中新发现的问题(已一并修复)
|
||||
|
||||
### 状态色整体不满足文字对比度
|
||||
审计时发现一个系统性盲区:**所有状态色都是按图形用途调的,却被当作文字色使用**。
|
||||
|
||||
| 令牌 | 色值 | 白底对比度 | 判定 |
|
||||
|---|---|---|---|
|
||||
| `--color-success` | `#16A34A` | 3.30:1 | FAIL |
|
||||
| `--color-warning` | `#D97706` | 3.19:1 | FAIL |
|
||||
| `--color-info` | `#3B82F6` | 3.68:1 | FAIL |
|
||||
| `--color-error` | `#C41E3A` | 5.84:1 | PASS |
|
||||
|
||||
影响面:`ui/alert.tsx`、`ui/badge.tsx`(小到 10px)、`ui/product-card.tsx` 状态徽章、`ui/sonner.tsx`。
|
||||
|
||||
**修法**:新增三个「文字专用」令牌,图形用途保持原色不变。
|
||||
|
||||
| 新令牌 | 色值 | 白底 | 于对应浅底 |
|
||||
|---|---|---|---|
|
||||
| `--color-success-text` | `#15803D` | 5.02:1 | 4.79:1 |
|
||||
| `--color-warning-text` | `#B45309` | 5.02:1 | 4.84:1 |
|
||||
| `--color-info-text` | `#1D4ED8` | 6.70:1 | 6.16:1 |
|
||||
|
||||
已在 `tailwind.config.js` 注册为 `success.text` / `warning.text` / `info.text`,
|
||||
并应用于 alert、badge、product-card、sonner。
|
||||
|
||||
### 11 个悬空 CSS 变量引用(导致边框/网格/悬停态不渲染)
|
||||
全站 47 个 `var(--color-*)` 引用中有 **11 个从未定义**。这类变量不会报错,
|
||||
但对应样式静默失效——是"看起来没坏、实际没生效"的典型。
|
||||
|
||||
| 悬空变量 | 影响位置 | 实际后果 | 修法 |
|
||||
|---|---|---|---|
|
||||
| `--color-border` | `privacy/page.tsx:139,142-144,147` | **Cookie 表格边框全部不渲染** | → `--color-border-primary` |
|
||||
| `--color-border-primary-rgb` | `detail/list-page-hero.tsx:66` | 网格背景线不渲染 | 新增令牌 `226, 232, 240` |
|
||||
| `--color-brand-lighter` | `mobile-menu.tsx:49`、`CookieConsent.tsx:163` | 悬停背景失效 | → `--color-brand-bg` |
|
||||
| `--color-challenge-isolation-hover` | `not-found-content.tsx:50,60,70,80` | 悬停背景失效 | → `--color-brand-soft` |
|
||||
| `--color-hero-dark-end` | `privacy:251`、`terms:196` | CTA 渐变末端失效 | → `--color-brand-section` |
|
||||
| `--color-flip-card-bg` | `ui/flip-clock.tsx`(5 处) | 翻页钟卡片背景失效 | → `--color-bg-secondary` |
|
||||
| `--color-flip-card-border` | 同上 | 边框失效 | → `--color-border-primary` |
|
||||
| `--color-flip-card-divider` | 同上 | 分隔线失效 | → `--color-border-primary` |
|
||||
| `--color-flip-card-divider-subtle` | 同上 | 次级分隔线失效 | → `--color-border-light` |
|
||||
| `--color-accent-cyan` / `-rgb` | `ui/product-card.tsx:21`、`brand-visuals.tsx:58`、`hero-section-v2.tsx:177` | 第 4 个强调色不渲染 | 新增令牌 `#06B6D4` + rgb |
|
||||
|
||||
修复后复检:**悬空引用 0 / 39 个唯一引用**。
|
||||
|
||||
> 检测方法(可复用):
|
||||
> ```bash
|
||||
> grep -rhoE "var\(--color-[a-z0-9-]+\)" src/ --include='*.tsx' --include='*.css' \
|
||||
> | sed 's/var(--//;s/)//' | sort -u > /tmp/refs.txt
|
||||
> while read v; do grep -q -- "--$v:" src/app/globals.css || echo "悬空: --$v"; done < /tmp/refs.txt
|
||||
> ```
|
||||
|
||||
## 四、最终对比度全景
|
||||
|
||||
| 令牌 | 色值 | 对比度 | 要求 | 判定 |
|
||||
|---|---|---|:---:|---|
|
||||
| text-primary | `#0A0E14` | 19.34:1 | 4.5 | PASS |
|
||||
| text-secondary | `#334155` | 10.35:1 | 4.5 | PASS |
|
||||
| text-tertiary | `#475569` | 7.58:1 | 4.5 | PASS |
|
||||
| text-muted / subtle / placeholder | `#64748B` | 4.76:1 | 4.5 | PASS |
|
||||
| text-hint(大字专用) | `#7C8CA5` | 3.41:1 | 3.0 | PASS |
|
||||
| brand | `#C41E3A` | 5.84:1 | 4.5 | PASS |
|
||||
| success-text | `#15803D` | 5.02:1 | 4.5 | PASS |
|
||||
| warning-text | `#B45309` | 5.02:1 | 4.5 | PASS |
|
||||
| info-text | `#1D4ED8` | 6.70:1 | 4.5 | PASS |
|
||||
| error | `#C41E3A` | 5.84:1 | 4.5 | PASS |
|
||||
|
||||
**全站 WCAG AA 对比度违规:0。**
|
||||
|
||||
## 五、收尾修复记录(三轮评审后的最终批次)
|
||||
|
||||
原"遗留事项"清单已全部处置完毕,逐项结论如下:
|
||||
|
||||
| # | 原遗留项 | 处置结果 |
|
||||
|---|---|---|
|
||||
| 1 | [P1] 70 个 TS 错误 | **已解决(误判根因)**。错误源于 npm 依赖缺失破坏类型推断,而非代码缺陷。补齐依赖后 `tsc` 0 错误,构建 TS 检查 32.9s 通过 |
|
||||
| 2 | [P2] 测试库缺导出 | **已解决**。同一依赖问题;`screen`/`waitFor`/`fireEvent` 已验证存在 |
|
||||
| 3 | [P2] Geist 字体 0 引用 | **已解决**。`layout.tsx` 经 `next/font/local` 挂载 `--font-geist-sans/mono`,globals.css 字体栈已接 `var(--font-geist-sans, ...)` |
|
||||
| 4 | [P2] 移动端全局 44px + 根字号缩放 | **已修复**。44px 规则收窄至 `@media (max-width:768px)` 下的 nav/header/footer/menu 交互元素,不再撑大正文行内链接;根字号删除 640px→17px、1024px→18px 两档缩放,固定 16px,恢复 rem 缩放对用户浏览器字号设置的响应 |
|
||||
| 5 | [P3] Tailwind v3 透明度修饰符失效 | **已修复**。`tailwind.config.js` 全部颜色令牌改写为 `rgb(var(--color-*-rgb) / <alpha-value>)` 形式(含 `border.*` 六项——代码中 `border-brand/30` 等 54 处实际用法的兜底),globals.css 补齐对应 `-rgb` 通道令牌;`prefers-contrast: more` 无障碍覆写同步补写 `--color-border-primary-rgb: 0,0,0`。已用 Tailwind CLI 探针验证:`bg-brand/10 → rgb(var(--color-brand-rgb) / 0.1)`、`border-brand/30` 等均正确编译 |
|
||||
| 6 | [P3] 死代码 | **已删除**。`detail/index.ts` barrel 中 `CrossRecommendGrid`、`BrandSeal/CalligraphyText/SectionHeader` 三组 0 消费者(生产引用为 0)的重新导出已移除;组件文件 `brand-elements.tsx`、`detail-cross-recommend.tsx` 于 2026-08-31 删除;`detail.test.tsx` 中仅针对该组件的 5 个测试用例与专用 mock 一并删除(tsc 全量 0 错误复验)。barrel 与测试文件均附留档注释。`erp-upgrade-v3` 经核实是真实路由(`/products/erp-upgrade-v3`,构建产物 ○ Static 可直达 URL),属信息架构/SEO 问题而非死代码:**保留(产品决策项,可选:补充内链入口)** |
|
||||
|
||||
### 最终构建验证
|
||||
|
||||
- **✅ 完整生产构建通过(`/tmp/nb4`,`npm run build` exit=0)**:`Compiled successfully` + TypeScript 通过 + 全部页面数据收集成功
|
||||
- 此前 page-data 收集失败的根因链(已定案并修复):
|
||||
1. `.env.local` 的 `DATABASE_URL=file:./data/novalon.db` 为相对路径,构建期解析落空 → `Unable to open the database file`
|
||||
2. 改绝对路径后数据库可打开,但本地 `data/novalon.db`(2026-04 旧库)是旧 schema:只有 `content`/`site_config`/`users`/`audit_logs`/`content_versions` 表,无 Prisma schema 的 `ContentItem` 等 10 张表 → `P2021: table main.ContentItem does not exist`
|
||||
3. **修复**:`prisma migrate reset --force` 重建 schema(旧库备份为 `data/novalon.db.bak-20260830`)→ seed 填充(16 模型 / 38 条目 / 6 区域 / admin)→ 构建全绿
|
||||
- **注意**:本机 `tsx`/`ts-node` 均不可用(esbuild 0.28.1 需 macOS 12+,本机 11.7)。seed 改用已装的 esbuild 0.18.20 打包 `prisma/seed.ts` 为 ESM(`--packages=external`,import.meta 兼容)后以 node 运行;如再遇 `db:seed` 失败可复用此方案(脚本 `.tmp-seed/seed.mjs` 已清理)
|
||||
- middleware 弃用警告:Next 16.3 已将其识别为 "ƒ Proxy (Middleware)",构建无警告,无需迁移
|
||||
- Tailwind 透明度转换已产物级验证(Tailwind CLI 编译探针,非仅构建通过)
|
||||
- 测试套件:**1616 通过 / 0 失败 / 2 跳过**(128 套件全绿;死代码清理移除了 5 个 CrossRecommendGrid 用例)。
|
||||
insight-card、case-card 两处断言原查找 `<img src>`,已随组件改为 CSS 背景图同步更新断言
|
||||
(`[style*="background-image"]` + `toHaveStyle`);删除后 `tsc --noEmit` 全量复验 0 错误
|
||||
- 注意事项:`NEXT_DIST_DIR` 传绝对路径会被 Next.js 规范化为**项目内相对路径**(如传 `/tmp/nb4` 实际写入 `./tmp/nb4`),
|
||||
且会向 `tsconfig.json` 的 `include` 追加(绝对化后的)临时目录条目。构建后需:`git checkout tsconfig.json` 还原 + 删除项目内 `tmp/` 产物
|
||||
(本次已多次触发并清理,最多时 449MB)。不传 `NEXT_DIST_DIR` 时产物落在默认 `dist/`,同样注意 tsconfig 污染
|
||||
|
||||
### 全站路由 × sitemap × 站内入口对照(泛化检查)
|
||||
|
||||
| 路由 | sitemap | 站内入口 | 结论 |
|
||||
|---|---|---|:---:|---|
|
||||
| `/`、`/about`、`/services`、`/products`、`/solutions`、`/cases`、`/news`、`/team`、`/methodology`、`/contact` | ✅ | ✅ | OK |
|
||||
| `/[id]` 系列(services/products/solutions/cases/news) | ✅ CMS slug | ✅ 列表页 | OK |
|
||||
| `/about/brand`(品牌故事) | ❌→**✅ 已加** | ❌ | **修复**:正规内容页此前站内零入口且不进 sitemap;入口可经 CMS 配置(about 页 `hero*CtaHref` 字段) |
|
||||
| `/products/erp-upgrade`(ERP升级专题) | ❌→**✅ 已加** | ❌ | **修复**:孤儿营销页(详见上);站内入口待产品决策 |
|
||||
| `/products/erp-upgrade-v3` | ❌ 有意 | ❌ | 内部迭代/dogfood 页,保持现状 |
|
||||
| `/products/standalone/[id]` | ❌ 有意 | ❌ | externalUrl 外链机制(成熟产品跳独立子域名站),设计使然 |
|
||||
| `/privacy`、`/terms` | ❌ 惯例 | ✅ footer(StaticLink) | 法律页惯例不进 sitemap,footer 有入口,OK |
|
||||
| `/test-error-tracking` | ❌ | ❌ | **修复**:QA 测试页此前可被搜索引擎收录(robots 未屏蔽),已重构为 server wrapper + `metadata.robots: noindex` |
|
||||
| `/admin/*` | ❌ | ❌ | robots.txt disallow,OK |
|
||||
| `/api/*` | ❌ | ❌ | robots.txt disallow,OK |
|
||||
|
||||
**泛化检查结论**:全站非 admin 路由逐一对照完毕,孤儿/SEO 问题共 3 处(erp-upgrade、about/brand、test-error-tracking),已全部修复;其余有意排除项均经核实有据。
|
||||
|
||||
### 剩余留档项(非缺陷,需产品决策)
|
||||
|
||||
1. **`erp-upgrade-v3`(内部迭代页,保持现状)**——经核实为"ERP 产品页 V3 深度打磨版"(meta 为内部打磨描述),与项目 dogfood-* 目录同属内部验证产物。不进 sitemap、站内无入口是**有意设计**,无需修改。
|
||||
2. **`/products/erp-upgrade`(孤儿营销页,已修复)**——"ERP升级专题"是正规营销页,但此前站内零入口且不进 sitemap(搜索引擎无法发现)。**已加入 `sitemap.ts`**(priority 0.7 / monthly);站内导航入口仍建议由产品确认(products 页无现成位置,改动涉及页面设计)。
|
||||
3. **`/about/brand` 站内入口**——sitemap 已补(priority 0.6 / monthly);站内入口建议通过 about 页 CMS 字段(`hero*CtaHref`)配置,无需代码改动。
|
||||
4. **根目录 `data.db`(遗留旧库,已被 .gitignore 正确处理)**——2026-04 旧库(57KB),源码零引用,
|
||||
且**从未被 git 跟踪**(`.gitignore:97` 已忽略,初判"已跟踪"系误读 check-ignore 输出所致)。无需任何 git 操作;
|
||||
本地遗留文件可自行删除或归档。
|
||||
5. **本地 `data/novalon.db` 已重置重建**——旧数据备份于 `data/novalon.db.bak-20260830`;如需找回旧内容可从备份迁移。
|
||||
|
||||
### 修复总览(三轮累计)
|
||||
|
||||
- WCAG AA 对比度违规:**0**
|
||||
- 悬空 CSS 变量引用:**0 / 39**
|
||||
- 透明度修饰符失效(静默样式失败):**~140 处恢复生效**
|
||||
- 综合评分:6/20(首轮)→ 14/20(二轮)→ 17/20(三轮收尾)
|
||||
|
||||
## 六、三处我判断错了的地方(留档)
|
||||
|
||||
审计要可复核,所以把自己判错的部分一并记下:
|
||||
|
||||
| # | 我的判断 | 实际情况 |
|
||||
|---|---|---|
|
||||
| 1 | 「8 张图片缺失,兜底机制失效」 | 全是测试夹具;生产代码 3 张图全部存在 |
|
||||
| 2 | 「`unoptimized: true` 是缺陷」 | 架构决定,`CLAUDE.md` 有明确记载 + 三份 CDN 文档 |
|
||||
| 3 | 「`#3b82f6` 等属色板二次漂移」 | `#3b82f6` 正是设计系统的 `accent-blue`;navy 是有意的产品主题变体 |
|
||||
|
||||
教训:**看到硬编码色值不等于色板漂移**——要先比对令牌定义再下结论;
|
||||
**看到"缺失资源"也要先区分测试夹具与生产引用**。
|
||||
|
||||
---
|
||||
|
||||
# 七、Lint 专项修复(第四轮,2026-08-31)
|
||||
|
||||
补跑 eslint(前三轮未覆盖该维度):基线 **0 errors / 149 warnings**。
|
||||
本轮针对"可修且真实收益"的三类规则清零,其余全部留档并说明理由。
|
||||
|
||||
## 已清零
|
||||
|
||||
| 规则 | 前 | 后 | 修法 |
|
||||
|---|---|---:|---:|---|
|
||||
| `@next/next/no-html-link-for-pages` | 21 | **0** | 组件内硬编码站内 `<a href="/xxx">` 统一改为项目自研 `<StaticLink>`(17 文件 / 39 处 + 11 个文件补 import)。锚点 `href="#..."` 与动态/外链(mailto、https、模板变量)保留 |
|
||||
| `jsx-a11y/alt-text` | 1 | **0** | `admin/media/page.tsx` 空状态 lucide `Image` 图标(装饰性 SVG,非 `<img>`,`LucideProps` 不接受 alt)→ `aria-hidden` + 豁免注释 |
|
||||
| `react-hooks/exhaustive-deps` | 2 | **0** | ① `news-content-v3.tsx`:`const NEWS = news ?? []` 包裹为 `useMemo(() => news ?? [], [news])`(消除 `?? []` 的每次渲染不稳定引用);② `admin-layout.tsx`:`getExpandedMenusForPath` 为组件内函数(引用随渲染变化),effect 已有 `prevPathname` 变更保护 → 行内豁免 + 注释说明,避免加依赖破坏逻辑 |
|
||||
|
||||
> **方案说明**:未引入 `next/link`。项目为 Nginx 静态托管 + CDN 架构,`StaticLink`(`e.preventDefault()` 整页导航,阻止 RSC 拦截)是既有设计决策(footer / product-card 已采用),`<a>` → `<StaticLink>` 与架构一致且满足 lint。顺带修正 home-content-v14 中 3 处此前 lint 漏报的静态站内链接。
|
||||
|
||||
## 留档(有意或低收益,不改)
|
||||
|
||||
| 规则 | 数量 | 理由 |
|
||||
|---|---|---:|---|
|
||||
| `no-console` | 56 | analytics.ts、示例组件、Sentry 配置、seed 脚本——诊断/配置用途合理 |
|
||||
| `@typescript-eslint/no-explicit-any` | 45 | 类型收紧改动面大、收益低、风险高(e2e 测试占比高) |
|
||||
| `@next/next/no-img-element` | 20 | `images.unoptimized: true` 静态托管架构下 `next/image` 不可用(CLAUDE.md:189 记录);含测试夹具 |
|
||||
| `react-hooks/set-state-in-effect` | 13 | React 19 新规则偏激进;多数为 hydration mounted / admin 认证后数据获取 / 动画的标准模式,改动风险高 |
|
||||
|
||||
## 验证
|
||||
|
||||
- eslint:**0 errors / 125 warnings**(剩余全部为上述留档项)
|
||||
- `tsc --noEmit`:**0 errors**
|
||||
- 修改文件累计 **47**(前三轮 31 + 本轮 16)
|
||||
|
||||
---
|
||||
|
||||
# 八、完整测试验证(第五轮,2026-08-31)
|
||||
|
||||
本轮改动 17 文件 JSX(`<a>` → `<StaticLink>`、`useMemo` 包裹、`aria-hidden` 修),tsc/eslint 仅保证类型与静态规则,**渲染级回归(SSR/水合、`<Button asChild>` 包裹下的 StaticLink)需全量测试 + 生产构建确认**。据此执行完整验证,四条链路全部闭环:
|
||||
|
||||
## 链路 1:生产构建(standalone)
|
||||
|
||||
```
|
||||
npm run build(注入 DATABASE_URL 绝对路径 + JWT 占位值 + CODEBUDDY_SAFE_DELETE_ENABLED=0)
|
||||
```
|
||||
|
||||
| 阶段 | 结果 |
|
||||
|---|---|
|
||||
| 编译 | ✓ 3.7min |
|
||||
| TypeScript | ✓ 6.3min,**0 errors** |
|
||||
| 静态页生成 | ✓ **63/63** in 5.3s |
|
||||
| 页面优化 | ✓ 58s |
|
||||
| 路由表 | ✓ `/about/brand`、`/products/erp-upgrade`、`/test-error-tracking`、`/sitemap.xml` 均在 |
|
||||
|
||||
> ~~已知非阻塞警告:`middleware` 文件约定已弃用~~(已在本轮第六轮迁移为 `proxy`,见第九轮)
|
||||
|
||||
## 链路 2:构建产物抽查(SEO 关键点)
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|---|---|
|
||||
| `/test-error-tracking` 预渲染 HTML 含 `noindex` | ✓ |
|
||||
| `sitemap.xml` 含 `/about/brand` + `/products/erp-upgrade` | ✓ |
|
||||
| `robots.txt` 产物存在 | ✓ |
|
||||
|
||||
## 链路 3:全量 Jest(限内存防 OOM)
|
||||
|
||||
```
|
||||
npx jest --maxWorkers=2 --workerIdleMemoryLimit=512MB
|
||||
```
|
||||
|
||||
- **Test Suites: 128 passed, 128 total**
|
||||
- **Tests: 2 skipped, 1616 passed, 1618 total**(与基线完全一致,0 失败 0 回归)
|
||||
- Time: 805s(比预估 26min 快,限内存参数生效、无 OOM)
|
||||
|
||||
## 链路 4:静态质量门禁(前轮已验证,本轮改动后复跑无新增)
|
||||
|
||||
- eslint:**0 errors / 125 warnings**
|
||||
- `tsc --noEmit`:**0 errors**
|
||||
|
||||
## 结论
|
||||
|
||||
**四条链路全绿,本轮 17 文件 JSX 改动无任何渲染级回归。** 项目处于可发布状态;剩余项均为已留档技术债(middleware 弃用约定、4 类 lint 留档)与产品决策项(站内入口配置),非阻塞。
|
||||
|
||||
---
|
||||
|
||||
# 九、剩余项闭环(第六轮,2026-08-31)
|
||||
|
||||
第五轮结论中挂账的 2 类剩余项(middleware 弃用技术债 + 站内入口产品决策项)本轮全部处理完毕:
|
||||
|
||||
## 1. middleware → proxy 迁移(技术债清除)
|
||||
|
||||
Next 16 弃用 `middleware` 文件约定。迁移方式为纯机械改动:
|
||||
|
||||
| 改动 | 内容 |
|
||||
|---|---|
|
||||
| `src/middleware.ts` → `src/proxy.ts` | `git mv` 保留历史 |
|
||||
| 导出函数 | `export async function middleware` → `export async function proxy` |
|
||||
| `config.matcher` | 保留 `['/admin/:path*']` 不变 |
|
||||
| 引用面 | 全仓仅自身文件,无测试依赖,零连带改动 |
|
||||
|
||||
**验证**:
|
||||
- ✅ 构建日志中弃用警告**消失**(此前必现 `⚠ The "middleware" file convention is deprecated`)
|
||||
- ✅ 编译产物(`dist/server/chunks/[root-of-the-server]*.js`)含 `proxy` 函数与 `admin/login`、`novalon_token` JWT 验证逻辑,`/admin` 保护逻辑完整编译
|
||||
- ✅ 全量 tsc 0 errors;eslint 改动文件 0/0
|
||||
- ✅ 静态页 63/63
|
||||
- ℹ️ Next 16 内部产物文件仍命名 `middleware.js`/`middleware-manifest.json`(框架内部实现细节,不影响功能与约定)
|
||||
|
||||
## 2. /products/erp-upgrade 站内入口(产品决策,已拍板)
|
||||
|
||||
审计第三轮标记"站内入口待产品确认"。经产品拍板采用**详情页 CTA 方案**:
|
||||
|
||||
- **改动**:`product-detail-content-v3.tsx` `CTASection` 按钮组下方新增条件渲染入口(仅 `product.id === 'erp'` 显示):
|
||||
```
|
||||
正在使用旧版 ERP? 了解睿新 ERP 升级专题 →
|
||||
```
|
||||
链接 `/products/erp-upgrade`,次级入口样式(`text-text-secondary` + `text-brand` 链接),不喧宾夺主。
|
||||
- **闭环**:升级专题页本已有反向链接(`erp-upgrade-content-v2.tsx:414` → `/products/erp`「查看产品详情」),至此**双向互链闭环**。
|
||||
- **验证**:eslint 0/0;products 相关测试 2 套件 / 21 测试全过(含 CTA 链接测试);构建 63/63。
|
||||
|
||||
## 3. /about/brand 站内入口(CMS 配置,零代码)
|
||||
|
||||
已核实 `about-content-v4.tsx` 支持 CMS 字段 `heroPrimaryCtaHref` / `heroSecondaryCtaHref`(默认 `#`)。入口可通过 about 页 CMS 配置 `heroSecondaryCtaHref: '/about/brand'` 实现,**无需代码改动**。
|
||||
|
||||
## 剩余挂账(均为有意项,不再处理)
|
||||
|
||||
- 4 类 lint 留档(no-console / no-explicit-any / no-img-element / set-state-in-effect),理由见第七轮。
|
||||
- `/products/erp-upgrade-v3` 内部迭代页(有意排除,不进 sitemap)。
|
||||
|
||||
## 结论
|
||||
|
||||
**六轮审计(设计 17/20 → SEO 泛化 → lint 专项 → 完整测试 → 剩余项闭环)全部完成。** 挂账项清零:技术债 0、产品决策项已闭环(1 个拍板实施 + 1 个 CMS 配置指引)、留档项均为有意设计。项目处于可发布状态。
|
||||
|
||||
---
|
||||
|
||||
# 十、自检补充:文档一致性(第七轮,2026-08-31)
|
||||
|
||||
应"还有什么遗漏吗"追问做全站自检,发现并处理 **文档-代码漂移** 类问题:
|
||||
|
||||
## 已修复(随本次改动直接相关)
|
||||
|
||||
| 文件 | 问题 | 修复 |
|
||||
|---|---|---|
|
||||
| `docs/test-strategy-plan.md:65` | 项目结构树仍列 `middleware.ts # Next.js 中间件`(第六轮已迁移 proxy) | 改为 `proxy.ts # Next.js Proxy(原 middleware,Next 16 弃用约定;/admin 路由 JWT 校验)` |
|
||||
| `docs/guides/SECURITY.md` | 声称"项目实现了" 6 项安全机制(验证码/限流/清理/日志/安全中间件/监控),但 `src/lib/security/` **零实现** | 文档头部加**失实警示**:标注为设计蓝图/参考,非实际防护能力;指向实际生效的 JWT 认证(proxy.ts / auth.ts) |
|
||||
| `docs/testing/TESTING_REPORT.md:32-38` | 声称 6 个 `src/lib/security/*.test.ts` 测试存在,实际全不存在 | 加**失实标注**:历史规划记录,勿作实际覆盖依据 |
|
||||
|
||||
## 系统性发现(建议独立批次治理,未在本轮处理)
|
||||
|
||||
docs 引用的代码路径经全量扫描,约 20+ 处指向不存在的文件,分两类:
|
||||
|
||||
- **重构后旧路径**(文档未随重构更新):`api/admin/content/route.ts`(现为 `api/admin/items`)、`src/components/cms/*`(现仅 RichTextEditor)、`src/components/detail-v2/*`、`src/components/detail/*` 等
|
||||
- **彻底失实**(功能不存在):`api/admin/security/route.ts`、`src/components/Hero.tsx`、`src/wasm/ink-filter/` 等
|
||||
|
||||
涉及多份 docs 逐条核实与更新,且部分需产品确认(如 wasm ink-filter 是否历史删除),建议作为独立"文档一致性"批次处理。
|
||||
|
||||
## 已核实无遗漏
|
||||
|
||||
- ✅ `.env.example` 已含 `JWT_SECRET` / `JWT_REFRESH_SECRET` 占位值(与构建注入一致)
|
||||
- ✅ `cross-references.test.ts` 14/14 通过(CTA 新链接不破坏数据驱动一致性)
|
||||
- ✅ `next.config.mjs` 改动为历史合理项(NEXT_DIST_DIR 覆盖 + images 注释)
|
||||
- ✅ ESLint 两改动文件 0/0;tsc 0 errors;构建 63/63
|
||||
- ⚠️ `product-detail-content-v3.tsx` 无专属测试文件(CTA 改动由构建 + products 21 测试覆盖,可接受)
|
||||
- ⚠️ **git 有 48 个文件改动未提交**(六轮累计,含本轮),建议提交或审查
|
||||
|
||||
---
|
||||
|
||||
# 十一、文档一致性治理(第八轮,2026-08-31)
|
||||
|
||||
第七轮发现 docs 约 132/245(54%)路径引用指向不存在的文件。本轮全量扫描 + 分类治理:
|
||||
|
||||
## 扫描结论:缺失分三类
|
||||
|
||||
| 类别 | 数量 | 性质 | 处理 |
|
||||
|---|---:|---|---|
|
||||
| **归档类**(`docs/superpowers/plans|specs/*`、`docs/adr/*`、`docs/plans/*`、`OPTIMIZATION_REPORT.md`、`test-coverage-improvement-plan.md`) | ~112 | 历史设计规划 / ADR 决策记录 / 历史报告,引用当时组件属**正常属性** | **不改** |
|
||||
| **时效性文档**(描述当前架构) | 20 | 文档与重构后代码不一致 | **已全部处理**(见下) |
|
||||
|
||||
## 时效性文档处理(5 份)
|
||||
|
||||
| 文档 | 问题 | 处理 |
|
||||
|---|---|---|
|
||||
| `docs/test-strategy-plan.md:65` | 结构树 `middleware.ts`(已迁移 proxy) | ✅ **路径直接更新** → `proxy.ts`(唯一 0 缺失) |
|
||||
| `docs/testing.md` | 描述 `e2e/src/` 分层 + Page Object 模式,实际为**扁平 spec 结构**(`e2e/*.spec.ts` + `playwright.config.ts`) | ✅ 头部加**结构时效警示**(保留作 Playwright 使用参考) |
|
||||
| `docs/cms/api-contract.md` | 前端对接 SDK `src/lib/cms/client.ts` / `mock-data.ts` 不存在(现为 `data-server.ts`) | ✅ 加**路径时效注**(API 契约主体仍有效) |
|
||||
| `docs/guides/SECURITY.md` | 声称实现 6 项安全机制,实际 `src/lib/security/` 零实现 | ✅(第七轮)加失实警示,降级为设计蓝图 |
|
||||
| `docs/testing/TESTING_REPORT.md` | 声称 6 个 security 测试存在,实际全不存在 | ✅(第七轮)加失实标注 |
|
||||
|
||||
## 治理策略说明
|
||||
|
||||
- **归档类不改的理由**:superpowers/plans、specs、adr 是**带时间戳的历史记录**(如 `2026-04-28-phase3-webgpu-ppr-wasm.md`),引用已重构/移除的组件(gsap/lenis/webgpu/ink 等)是记录的固有属性,改写会破坏归档真实性。
|
||||
- **时效性文档采用"标注优于改写"**:除可确定的新路径(middleware→proxy)外,重写级差异(testing.md 605 行、api-contract.md 950 行)不擅自重构,加警示标注说明现状,具体重写留待专项。
|
||||
|
||||
## 验证
|
||||
|
||||
- 5 份时效性文档复扫:test-strategy-plan 0 缺失,其余 4 份引用保留但已全部标注(符合预期)。
|
||||
- 剩余 ~112 条缺失全部归属归档类文档,确认无需处理。
|
||||
- 本轮仅改 md 文档,无代码/构建影响。
|
||||
|
||||
---
|
||||
|
||||
# 十二、本地运行故障修复(第九轮,2026-08-31)
|
||||
|
||||
用户报告本地 `npm run dev` 报错(营销页 500/502),且此前测试未覆盖。排查修复闭环:
|
||||
|
||||
## 根因(双重环境配置问题)
|
||||
|
||||
| # | 根因 | 影响 | 修复 |
|
||||
|---|---|---|---|
|
||||
| 1 | `.env.local` 的 `DATABASE_URL=file:./data/novalon.db` 为**相对路径**,Prisma SQLite 按 **schema 目录**(`prisma/`)解析 → 指向不存在的 `prisma/data/novalon.db` | `PrismaClientInitializationError: Error code 14: Unable to open the database file` → 所有依赖 DB 的营销页 500 | 改为**绝对路径** `file:/Users/zhangxiang/Codes/Novalon/novalon-website/data/novalon.db`(与构建期注入一致) |
|
||||
| 2 | `.env.local` 缺 `JWT_SECRET` / `JWT_REFRESH_SECRET` | `src/lib/auth.ts:11` 模块级 throw,任何导入 auth 链的路由 500(第二隐患) | 补占位值(与 `.env.example` 一致) |
|
||||
|
||||
## 排查路径(可复用)
|
||||
|
||||
1. `lsof -i :3000` 确认 dev server 在跑(用户实例)→ 非端口冲突
|
||||
2. curl 路由探测:营销页 500 / admin 200 → 定位到 DB 依赖层
|
||||
3. 读 `dist/dev/logs/next-development.log`(Next 16 dev 日志落盘位置)→ 直接命中 `PrismaClientInitializationError: Error code 14`
|
||||
4. `prisma.config.ts` 揭示 Prisma 6 用 dotenv 只读 `.env`(非 `.env.local`),但 Next dev 读 `.env.local` → 确认路径解析是 Prisma 引擎层行为
|
||||
|
||||
## 测试盲区确认(用户核心关切)
|
||||
|
||||
- `src/lib/db.test.ts` 注释:"PrismaClient is already mocked in jest.setup.js"——**Jest 全量 mock PrismaClient**,单元测试永不触及真实 DB 连接与路径解析 → 此类环境配置问题只能靠 dev/prod 运行冒烟验证。
|
||||
- **补救建议**(可选后续):新增真实连接冒烟脚本(`scripts/verify-db.mjs`,绝对路径连库 + count 查询),纳入 CI 前置检查。
|
||||
|
||||
## 验证
|
||||
|
||||
- dev server 重启后全路由探测:`/` 200、`/products/erp-upgrade` 200、`/about` 200、`/products/erp` 200、`/admin/login` 200
|
||||
- 首页标题/描述渲染正常(「四川睿新致远科技有限公司 - 企业数字化转型服务商」)
|
||||
- 附带发现:Tailwind 4 条 ambiguous warnings(`duration-[var(--transition-*)]` / `ease-[var(--ease-ink)]`),非报错,记录留档
|
||||
@@ -4,6 +4,8 @@
|
||||
> **日期**:2026-07-02
|
||||
> **技术栈**:Java 21 + Spring Boot WebFlux + PostgreSQL + Flyway
|
||||
> **前端对接**:Next.js 14 + CMS SDK(`src/lib/cms/client.ts`)
|
||||
>
|
||||
> ⚠ **路径时效注(2026-08-31 一致性审计)**:`src/lib/cms/client.ts` / `mock-data.ts` 在当前代码库中不存在(`src/lib/cms/` 现含 `data-server.ts` / `workflow.ts` / `notifications.ts` 等)。本文档 API 契约本身仍有效,前端对接实现以 `src/lib/cms/` 实际代码为准。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 安全配置文档
|
||||
|
||||
> **⚠ 文档失实警示(2026-08-31 全站一致性审计)**:本文档描述的六项安全机制(验证码、频率限制、输入清理、安全日志、安全中间件 `SecurityMiddleware`、安全监控仪表板)在代码库中**均未实现**——`src/lib/security/` 目录不存在,源码中无对应实现或调用。本文档现仅作**安全设计蓝图/参考**保留,不代表当前系统实际防护能力。实际生效的安全措施见「联系方式 API 校验」「JWT 认证(`src/proxy.ts` / `src/lib/auth.ts`)」等已实现模块。
|
||||
|
||||
本文档详细说明了项目中新增的安全功能及其配置方法。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -62,7 +62,7 @@ src/
|
||||
│ ├── media/ # 媒体处理(image-processor/storage/media-service)
|
||||
│ └── 其他工具 # auth/analytics/utils/colors/animations 等
|
||||
├── generated/prisma/ # Prisma 生成客户端
|
||||
└── middleware.ts # Next.js 中间件
|
||||
└── proxy.ts # Next.js Proxy(原 middleware,Next 16 起弃用 middleware 约定;/admin 路由 JWT 校验)
|
||||
```
|
||||
|
||||
### 1.3 当前测试覆盖现状
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 测试文档
|
||||
|
||||
> **⚠ 结构时效警示(2026-08-31 全站一致性审计)**:本文档描述的 `e2e/src/` 分层结构(config/data/fixtures/pages/tests 子目录、Page Object 模式)与当前代码**不符**——现行 `e2e/` 为**扁平 spec 结构**(如 `cases-filter.spec.ts`、`user-journey.spec.ts` 等直接位于 `e2e/` 根,配合 `playwright.config.ts`)。Page Object 类、fixtures 等示例代码在代码库中不存在,npm scripts 以实际 `package.json` 为准。本文档保留作 Playwright 使用参考,具体结构以代码为准。
|
||||
|
||||
## 测试概述
|
||||
|
||||
项目使用 Playwright 进行端到端(E2E)测试,测试框架位于 `e2e/` 目录,采用 Page Object 模式组织测试代码。
|
||||
项目使用 Playwright 进行端到端(E2E)测试,测试框架位于 `e2e/` 目录(扁平 spec 结构)。
|
||||
|
||||
## 测试框架结构
|
||||
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
5. `src/lib/security/logger.test.ts` - 安全日志系统
|
||||
6. `src/lib/security/middleware.test.ts` - 安全中间件
|
||||
|
||||
> ⚠ **失实标注(2026-08-31 一致性审计)**:上述 6 个 security 测试文件在代码库中**均不存在**(`src/lib/security/` 目录缺失),对应安全模块未实现。本段为历史规划记录,勿作为实际测试覆盖依据。
|
||||
|
||||
#### 联系表单API测试
|
||||
- **测试套件**: 1 个
|
||||
- **测试用例**: 11 个
|
||||
|
||||
|
Before Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 966 B |
|
Before Width: | Height: | Size: 970 B |
|
Before Width: | Height: | Size: 959 B |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 931 KiB |
|
Before Width: | Height: | Size: 494 KiB |
|
Before Width: | Height: | Size: 710 KiB |
|
Before Width: | Height: | Size: 752 KiB |
|
Before Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 592 KiB |
|
Before Width: | Height: | Size: 582 KiB |
|
Before Width: | Height: | Size: 462 KiB |
|
Before Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 533 KiB After Width: | Height: | Size: 613 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 798 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 481 KiB After Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 973 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 617 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 739 KiB |
|
Before Width: | Height: | Size: 828 KiB After Width: | Height: | Size: 947 KiB |
|
Before Width: | Height: | Size: 495 KiB After Width: | Height: | Size: 571 KiB |
|
Before Width: | Height: | Size: 652 KiB After Width: | Height: | Size: 750 KiB |
|
Before Width: | Height: | Size: 630 KiB After Width: | Height: | Size: 721 KiB |
|
Before Width: | Height: | Size: 518 KiB After Width: | Height: | Size: 598 KiB |
|
Before Width: | Height: | Size: 619 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 798 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 798 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -2,10 +2,15 @@ const cdnDomain = process.env.CDN_DOMAIN || '';
|
||||
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
distDir: 'dist',
|
||||
// 默认 dist/;可用 NEXT_DIST_DIR 覆盖(CI 分环境输出,或本地需要避开既有构建产物)
|
||||
distDir: process.env.NEXT_DIST_DIR || 'dist',
|
||||
assetPrefix: cdnDomain || undefined,
|
||||
images: {
|
||||
// 有意关闭 Next 图片优化:产物经 Nginx 静态托管 + CDN 分发(见 docs/CDN_CONFIGURATION.md、
|
||||
// CLAUDE.md「Images are unoptimized (static export limitation)」)。
|
||||
// 不要仅因「性能」理由改为 false —— 在无 Node 运行时接管 /_next/image 的部署模式下会导致图片 404。
|
||||
unoptimized: true,
|
||||
// 注意:unoptimized 为 true 时下面这行不生效,仅在改回 false 后才会参与构建产物生成。
|
||||
formats: ['image/avif', 'image/webp'],
|
||||
},
|
||||
compress: true,
|
||||
|
||||
@@ -947,6 +947,22 @@ async function main() {
|
||||
{ number: '03', title: '交付成果', desc: '以自研产品组合与专业实施团队完成交付,用真实运营数据验证业务改善,持续迭代。', href: '/products', cta: '浏览产品矩阵' },
|
||||
],
|
||||
servicesTitle: '从战略到落地,\n可量化的一站式服务',
|
||||
insightsEyebrow: 'Insights',
|
||||
insightsTitle: '我们的思考,\n以实践为锚',
|
||||
insightsDescription: '方法论、观点与共创进展——可验证的思考,不空谈趋势。',
|
||||
insightsItems: [
|
||||
{ tag: '方法论', title: '数字化转型诊断:从哪里开始,如何衡量', desc: '从业务目标出发的四步诊断法——梳理断点、量化差距、排定优先级、定义验收指标。', date: '2026-08', href: '/methodology' },
|
||||
{ tag: '观点', title: '私有化部署:为什么核心数据需要留在企业内', desc: '合规要求之外,数据主权正在成为企业数字化决策的第一优先级。', date: '2026-08', href: '/services/data' },
|
||||
{ tag: '方法论', title: '从需求到交付:全流程陪伴式实施如何降低转型风险', desc: '诊断 → 设计 → 交付 → 运维,每一阶段都有明确的交付物与衡量指标。', date: '2026-07', href: '/services/software' },
|
||||
{ tag: '共创', title: '首批客户共创计划:与早期客户共同打磨产品', desc: '以真实运营数据验证业务改善,成果在获得客户授权后公开。', date: '2026-06', href: '/contact' },
|
||||
],
|
||||
founderQuoteEyebrow: '创始团队观点',
|
||||
founderQuoteText: '我们相信,数字化不是一次性项目,而是持续迭代的能力建设。每一份投入,都应当经得起衡量。',
|
||||
founderQuoteName: 'Novalon 创始团队',
|
||||
founderQuoteRole: '技术咨询 · 数字化转型同行者',
|
||||
newsEyebrow: '新闻动态',
|
||||
newsTitle: '公司要闻\n与研发动态',
|
||||
newsCtaLabel: '查看全部新闻',
|
||||
casesEyebrow: '共创进行时',
|
||||
casesTitle: '每一个项目,\n都以业务改善为衡量标准',
|
||||
casesDescription: '我们正与首批客户共同打磨产品与方案,以真实运营数据验证业务改善。公开案例将在验证后发布——合作成果,经得起衡量。',
|
||||
|
||||
@@ -106,7 +106,7 @@ function HeroSection({ data }: { data: AboutData }) {
|
||||
<a
|
||||
href={data.heroPrimaryCtaHref || '#'}
|
||||
aria-label={data.heroPrimaryCtaLabel || '预约咨询'}
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
{data.heroPrimaryCtaLabel || '预约咨询'}
|
||||
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
@@ -114,7 +114,7 @@ function HeroSection({ data }: { data: AboutData }) {
|
||||
<a
|
||||
href={data.heroSecondaryCtaHref || '#'}
|
||||
aria-label={data.heroSecondaryCtaLabel || '了解更多'}
|
||||
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-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
{data.heroSecondaryCtaLabel || '了解更多'}
|
||||
</a>
|
||||
@@ -432,7 +432,7 @@ function CTASection({ data }: { data: AboutData }) {
|
||||
<a
|
||||
href={data.ctaPrimaryHref || '#'}
|
||||
aria-label={data.ctaPrimaryLabel || '预约咨询'}
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
{data.ctaPrimaryLabel || '预约咨询'}
|
||||
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
@@ -440,7 +440,7 @@ function CTASection({ data }: { data: AboutData }) {
|
||||
<a
|
||||
href={data.ctaSecondaryHref || '#'}
|
||||
aria-label={data.ctaSecondaryLabel || '了解更多'}
|
||||
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-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
{data.ctaSecondaryLabel || '了解更多'}
|
||||
</a>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrollReveal } from '@/components/ui/scroll-reveal';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { BrandCalligraphyName } from '@/components/ui/brand-calligraphy-name';
|
||||
import {
|
||||
ArrowRight,
|
||||
@@ -355,20 +356,20 @@ export default function BrandContent() {
|
||||
从一次深入的业务诊断开始,开启您的数据驱动之旅。
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
<a
|
||||
<StaticLink
|
||||
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]"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px]"
|
||||
>
|
||||
预约咨询
|
||||
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
<a
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
href="/cases"
|
||||
className="group inline-flex items-center justify-center gap-2 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
|
||||
className="group inline-flex items-center justify-center gap-2 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border rounded-full hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
|
||||
>
|
||||
查看客户案例
|
||||
<ChevronRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Building2, ArrowUpRight, Target, Lightbulb, Filter } from 'lucide-react';
|
||||
import {
|
||||
type CaseStudy,
|
||||
@@ -308,19 +309,19 @@ export default function CasesContentV3({
|
||||
让我们聊聊您的挑战,看看能创造什么样的成果。
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
<a
|
||||
<StaticLink
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
预约咨询
|
||||
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
<a
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
href="/services"
|
||||
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-border-secondary hover:bg-white transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-white transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
了解服务
|
||||
</a>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -531,14 +531,14 @@ function ContactFormContent({ data }: { data: ContactData }) {
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 justify-center">
|
||||
<a
|
||||
href={data.ctaPrimaryHref || '#'}
|
||||
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-bold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90"
|
||||
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-bold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90"
|
||||
>
|
||||
{data.ctaPrimaryLabel || ''}
|
||||
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
<a
|
||||
href={data.ctaSecondaryHref || '#'}
|
||||
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-bold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
|
||||
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-bold text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
|
||||
>
|
||||
{data.ctaSecondaryLabel || ''}
|
||||
</a>
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it, expect, jest } from '@jest/globals';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import HomeContentV15 from './home-content-v15';
|
||||
|
||||
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'),
|
||||
Quote: mockIcon('quote'),
|
||||
Newspaper: mockIcon('newspaper'),
|
||||
Calendar: mockIcon('calendar'),
|
||||
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('HomeContentV15 - 深色 Hero(对标 Accenture 纯黑 Hero)', () => {
|
||||
it('renders the dark canvas hero with a white logo variant', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('hero-section')).toBeInTheDocument();
|
||||
const heroLogo = screen.getByTestId('mock-next-image');
|
||||
expect(heroLogo).toHaveAttribute('src', '/logo-white.svg');
|
||||
});
|
||||
|
||||
it('keeps a single primary CTA and demotes the secondary action to a text link', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
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(<HomeContentV15 stats={[{ value: '100%', label: '私有化部署' }]} />);
|
||||
|
||||
expect(screen.getByTestId('hero-stats')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the abstract data-driven visual without product UI shell', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('hero-product-visual')).toBeInTheDocument();
|
||||
expect(screen.getByText('数据驱动 · 让转型可量化')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 信任层与章节式叙事', () => {
|
||||
it('renders verifiable trust signals with a source annotation', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
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(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('early-access-banner')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('首批客户共创中').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('renders the problem-method-result narrative with chapter numbers', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('narrative-section')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('01').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('02').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('03').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText(/从问题到结果/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 行业洞察区(对标 Accenture Insights)', () => {
|
||||
it('renders the insights pillar with typed tags and verifiable methodology content', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('insights-section')).toBeInTheDocument();
|
||||
// 类型标签(对标 Research Report / Perspective / Case Study)
|
||||
expect(screen.getAllByText('方法论').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('观点')).toBeInTheDocument();
|
||||
// 全部链接真实页面,不虚构研究报告
|
||||
expect(screen.getByText('数字化转型诊断:从哪里开始,如何衡量')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 创始人观点区(对标 Accenture CEO Quote)', () => {
|
||||
it('renders the founder quote with attribution (no fabricated persona)', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('founder-quote-section')).toBeInTheDocument();
|
||||
expect(screen.getByText(/数字化不是一次性项目/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Novalon 创始团队')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 新闻动态区(对标 Accenture News)', () => {
|
||||
it('renders the news section with latest items and a view-all entry', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
expect(screen.getByTestId('news-section')).toBeInTheDocument();
|
||||
expect(screen.getByText('四川睿新致远科技有限公司正式成立')).toBeInTheDocument();
|
||||
expect(screen.getByText('查看全部新闻')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders CMS-provided news when available', () => {
|
||||
render(<HomeContentV15 news={[{
|
||||
id: 'cms-news-1',
|
||||
title: 'CMS 新闻条目',
|
||||
excerpt: 'CMS 提供的新闻摘要',
|
||||
date: '2026-08-30',
|
||||
category: '研发动态',
|
||||
image: '',
|
||||
content: '',
|
||||
}]} />);
|
||||
|
||||
expect(screen.getByText('CMS 新闻条目')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('HomeContentV15 - 案例区 Client Spotlight(对标 Accenture Client Spotlight)', () => {
|
||||
it('renders a co-creation spotlight card (verifiable, no fabricated clients) when no cases exist', () => {
|
||||
render(<HomeContentV15 />);
|
||||
|
||||
// 空态 spotlight:共创流程 3 步 + 深色卡 CTA
|
||||
expect(screen.getAllByText('首批客户共创中').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('业务诊断').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('方案共创')).toBeInTheDocument();
|
||||
expect(screen.getByText('验证发布')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('01').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByTestId('cases-co-creation-cta')).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('renders a dark spotlight card with metrics for the featured case when data exists', () => {
|
||||
const mockCases = [
|
||||
{
|
||||
slug: 'spotlight-demo',
|
||||
industry: '制造业',
|
||||
title: 'Spotlight 案例标题',
|
||||
href: '/cases/spotlight-demo',
|
||||
verified: true,
|
||||
testimonial: {
|
||||
quote: '这是一段客户引言,用于验证 spotlight 渲染。',
|
||||
author: '客户 CIO',
|
||||
role: '某制造企业',
|
||||
},
|
||||
metrics: [
|
||||
{ value: '38%', label: '交付效率提升' },
|
||||
{ value: '2x', label: '数据查询提速' },
|
||||
{ value: '3周', label: '上线周期' },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'secondary-case',
|
||||
industry: '贸易零售',
|
||||
title: '次要案例标题',
|
||||
href: '/cases/secondary-case',
|
||||
metrics: [
|
||||
{ value: '15%', label: '库存周转改善' },
|
||||
{ value: '4h', label: '对账耗时' },
|
||||
{ value: '9成', label: '预测准确率' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<HomeContentV15 cases={mockCases as any} />);
|
||||
|
||||
// spotlight 大卡:深色画布 + 引言 + 授权 badge + 量化指标
|
||||
expect(screen.getByText('Spotlight 案例标题')).toBeInTheDocument();
|
||||
expect(screen.getByText('这是一段客户引言,用于验证 spotlight 渲染。')).toBeInTheDocument();
|
||||
expect(screen.getByText('已获客户授权')).toBeInTheDocument();
|
||||
expect(screen.getByText('38%')).toBeInTheDocument();
|
||||
expect(screen.getByText('查看完整案例')).toBeInTheDocument();
|
||||
|
||||
// 次要案例保持轻量卡片
|
||||
expect(screen.getByText('次要案例标题')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('了解详情').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,15 @@ 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 { StaticLink } from '@/components/ui/static-link';
|
||||
import { HeroProductVisual } from '@/components/sections/hero-product-visual';
|
||||
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server } from 'lucide-react';
|
||||
import { ArrowRight, Lightbulb, Database, Layers, Code, Puzzle, Server, Quote, Calendar } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { EASE_OUT } from '@/components/ui/page-decoration';
|
||||
import { COMPANY_INFO, TRUST_SIGNALS, EARLY_ACCESS } from '@/lib/constants';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { type NewsItem } from '@/lib/constants/news';
|
||||
import { NEWS } from '@/lib/constants/news';
|
||||
|
||||
// ===== 服务图标映射:将字符串键名映射为 Lucide 组件 =====
|
||||
const SERVICE_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
@@ -72,6 +75,47 @@ const FALLBACK_SERVICES = [
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 行业洞察兜底数据(对标 Accenture Insights:类型标签 + 卡片)=====
|
||||
// 零编造原则:全部为可验证的方法论/观点类内容,链接真实页面;不做虚构研究报告
|
||||
const FALLBACK_INSIGHTS = [
|
||||
{
|
||||
tag: '方法论',
|
||||
title: '数字化转型诊断:从哪里开始,如何衡量',
|
||||
desc: '从业务目标出发的四步诊断法——梳理断点、量化差距、排定优先级、定义验收指标。',
|
||||
date: '2026-08',
|
||||
href: '/methodology',
|
||||
},
|
||||
{
|
||||
tag: '观点',
|
||||
title: '私有化部署:为什么核心数据需要留在企业内',
|
||||
desc: '合规要求之外,数据主权正在成为企业数字化决策的第一优先级。',
|
||||
date: '2026-08',
|
||||
href: '/services/data',
|
||||
},
|
||||
{
|
||||
tag: '方法论',
|
||||
title: '从需求到交付:全流程陪伴式实施如何降低转型风险',
|
||||
desc: '诊断 → 设计 → 交付 → 运维,每一阶段都有明确的交付物与衡量指标。',
|
||||
date: '2026-07',
|
||||
href: '/services/software',
|
||||
},
|
||||
{
|
||||
tag: '共创',
|
||||
title: '首批客户共创计划:与早期客户共同打磨产品',
|
||||
desc: '以真实运营数据验证业务改善,成果在获得客户授权后公开。',
|
||||
date: '2026-06',
|
||||
href: '/contact',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 创始人观点兜底(对标 Accenture CEO Quote,人格化背书)=====
|
||||
// 零编造原则:不虚构具体人名/原话;姓名职务为空时仅展示观点立场,真实引言可在 CMS page-copy 配置
|
||||
const FALLBACK_FOUNDER_QUOTE = {
|
||||
text: '我们相信,数字化不是一次性项目,而是持续迭代的能力建设。每一份投入,都应当经得起衡量。',
|
||||
name: 'Novalon 创始团队',
|
||||
role: '技术咨询 · 数字化转型同行者',
|
||||
};
|
||||
|
||||
// ===== 首页结构性文案类型(page-copy CMS 覆盖,未配置时走硬编码兜底)=====
|
||||
type HomePageCopy = Record<string, any>;
|
||||
|
||||
@@ -89,7 +133,7 @@ function StyledTitle({ lines }: { lines: (string | undefined)[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 品牌 CTA 按钮 =====
|
||||
// ===== 品牌 CTA 按钮(Pill 胶囊化,对标 Accenture > CTA)=====
|
||||
function CTAButton({ children, href, variant = 'primary', className, dataTestId }: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
@@ -102,7 +146,7 @@ function CTAButton({ children, href, variant = 'primary', className, dataTestId
|
||||
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',
|
||||
'group inline-flex items-center gap-3 px-10 py-4 font-semibold text-base transition-all duration-200 rounded-full',
|
||||
variant === 'primary'
|
||||
? 'bg-brand text-white hover:bg-brand-hover'
|
||||
: 'text-ink border border-ink/20 hover:border-ink/40 hover:bg-ink/5',
|
||||
@@ -115,7 +159,7 @@ function CTAButton({ children, href, variant = 'primary', className, dataTestId
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Hero 区:克制白底 + 品牌红点缀 =====
|
||||
// ===== Hero 区:全出血深色画布(对标 Accenture 纯黑 Hero)+ 品牌红单电压 =====
|
||||
function HeroSection({ heroData, stats }: {
|
||||
heroData?: Record<string, any> | null;
|
||||
stats?: Record<string, any>[];
|
||||
@@ -137,12 +181,13 @@ function HeroSection({ heroData, stats }: {
|
||||
return (
|
||||
<section
|
||||
ref={containerRef}
|
||||
className="relative min-h-[90vh] flex items-center overflow-hidden bg-white"
|
||||
className="relative min-h-[90vh] flex items-center overflow-hidden bg-ink"
|
||||
data-testid="hero-section"
|
||||
>
|
||||
{/* 背景纹理:极淡的品牌色点阵 */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.03]"
|
||||
{/* 背景纹理:极淡的品牌色点阵(深色画布上可见) */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.06]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(196,30,58,0.4) 1px, transparent 1px)`,
|
||||
backgroundImage: `radial-gradient(circle at 25% 25%, rgba(196,30,58,0.6) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
@@ -150,7 +195,7 @@ 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-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 */}
|
||||
{/* 品牌标识:深色画布使用白色版 Logo(浅色版见 header/footer) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -158,7 +203,7 @@ function HeroSection({ heroData, stats }: {
|
||||
className="mb-6"
|
||||
>
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
src="/logo-white.svg"
|
||||
alt={`${COMPANY_INFO.name} Logo`}
|
||||
width={224}
|
||||
height={56}
|
||||
@@ -167,7 +212,7 @@ function HeroSection({ heroData, stats }: {
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* 品牌口号 Badge:消费站点配置 slogan,后台可改 */}
|
||||
{/* 品牌口号 Badge:深色画布上的品牌红电压 */}
|
||||
{slogan && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -175,23 +220,23 @@ function HeroSection({ heroData, stats }: {
|
||||
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">
|
||||
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand/15 text-brand-light text-sm font-medium border border-brand/30">
|
||||
{slogan}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 主标题:大胆、直接 */}
|
||||
{/* 主标题:大胆、直接(深色画布上的白色宣言) */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
className="text-4xl sm:text-5xl md:text-6xl lg:text-6xl 2xl:text-7xl font-black text-white leading-[1.05] tracking-tight mb-8 sm:mb-10"
|
||||
>
|
||||
{heading}
|
||||
</motion.h1>
|
||||
|
||||
{/* 数据指标条:答案优先,数据先行 */}
|
||||
{/* 数据指标条:答案优先,数据先行(品牌红仅用于第一个数字) */}
|
||||
{displayStats.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
@@ -202,10 +247,10 @@ function HeroSection({ heroData, stats }: {
|
||||
>
|
||||
{displayStats.map((stat, i) => (
|
||||
<div key={i} className="flex items-baseline gap-2">
|
||||
<span className={cn('text-3xl sm:text-4xl md:text-5xl font-black tabular-nums leading-none', i === 0 ? 'text-brand' : 'text-ink')}>
|
||||
<span className={cn('text-3xl sm:text-4xl md:text-5xl font-black tabular-nums leading-none', i === 0 ? 'text-brand-light' : 'text-white')}>
|
||||
{stat.value}
|
||||
</span>
|
||||
<span className="text-sm text-text-secondary whitespace-nowrap">
|
||||
<span className="text-sm text-white/60 whitespace-nowrap">
|
||||
{stat.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -219,7 +264,7 @@ function HeroSection({ heroData, stats }: {
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
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"
|
||||
className="text-lg sm:text-xl text-white/70 max-w-2xl leading-relaxed mb-10"
|
||||
>
|
||||
{subheading}
|
||||
</motion.p>
|
||||
@@ -237,7 +282,7 @@ function HeroSection({ heroData, stats }: {
|
||||
<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"
|
||||
className="group inline-flex items-center gap-2 text-sm font-medium text-white/70 hover:text-white transition-colors duration-200 py-4"
|
||||
>
|
||||
{secondaryCtaLabel}
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
@@ -246,7 +291,7 @@ function HeroSection({ heroData, stats }: {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 右侧:产品视觉(SVG 数据看板示意) */}
|
||||
{/* 右侧:产品视觉(SVG 数据看板示意,浅色卡片浮于深色画布) */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -335,13 +380,13 @@ function TrustSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<a
|
||||
<StaticLink
|
||||
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>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</StaggerReveal>
|
||||
</div>
|
||||
@@ -398,7 +443,10 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
<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">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="text-4xl sm:text-5xl font-black text-text-hint tabular-nums leading-none"
|
||||
>
|
||||
{act.number}
|
||||
</span>
|
||||
</div>
|
||||
@@ -426,6 +474,84 @@ function NarrativeSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 行业洞察区(对标 Accenture Insights:类型标签 + 内容卡片,思想领导力支柱)=====
|
||||
function InsightsSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
const eyebrow = (pageCopy?.insightsEyebrow as string) || 'Insights';
|
||||
const titleLines = ((pageCopy?.insightsTitle as string) || '我们的思考,\n以实践为锚').split('\n');
|
||||
const desc = (pageCopy?.insightsDescription as string) || '方法论、观点与共创进展——可验证的思考,不空谈趋势。';
|
||||
const items = (pageCopy?.insightsItems?.length ? pageCopy.insightsItems : FALLBACK_INSIGHTS) as Array<{
|
||||
tag: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
date?: string;
|
||||
href: string;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-white" data-testid="insights-section">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
|
||||
<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">
|
||||
<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]">
|
||||
<StyledTitle lines={titleLines} />
|
||||
</h2>
|
||||
<p className="mt-6 text-base sm:text-lg text-text-secondary leading-relaxed max-w-2xl">
|
||||
{desc}
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<StaticLink
|
||||
href="/news"
|
||||
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" />
|
||||
</StaticLink>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-px bg-border-primary border border-border-primary">
|
||||
{items.slice(0, 4).map((insight, i) => (
|
||||
<StaggerReveal key={i} staggerDelay={0.07}>
|
||||
<a
|
||||
href={insight.href}
|
||||
className="group flex flex-col h-full bg-white p-8 sm:p-10 md:p-12 transition-colors duration-300 hover:bg-bg-secondary"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<span className="text-[13px] tracking-[0.12em] font-semibold text-brand">
|
||||
{insight.tag}
|
||||
</span>
|
||||
{insight.date && (
|
||||
<span className="text-xs text-text-muted tabular-nums">{insight.date}</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-4 leading-[1.25] group-hover:text-brand transition-colors duration-300">
|
||||
{insight.title}
|
||||
</h3>
|
||||
<p className="text-text-secondary leading-relaxed mb-8 text-sm flex-1">
|
||||
{insight.desc}
|
||||
</p>
|
||||
<div className="pt-6 border-t border-border-primary">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
|
||||
阅读全文
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
</StaggerReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 服务区:等宽三列卡片 + 兜底数据 =====
|
||||
function ServicesSection({ services, pageCopy }: { services?: Record<string, any>[]; pageCopy?: HomePageCopy | null }) {
|
||||
const hasValidData = services?.length && (services![0] as any)?.subtitle;
|
||||
@@ -496,7 +622,59 @@ function ServicesSection({ services, pageCopy }: { services?: Record<string, any
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 案例区:轻量快照卡片 =====
|
||||
// ===== 创始人观点区(对标 Accenture CEO Quote:大引言 + 署名,人格化背书)=====
|
||||
function FounderQuoteSection({ pageCopy }: { pageCopy?: HomePageCopy | null }) {
|
||||
const eyebrow = (pageCopy?.founderQuoteEyebrow as string) || '创始团队观点';
|
||||
const quoteText = (pageCopy?.founderQuoteText as string) || FALLBACK_FOUNDER_QUOTE.text;
|
||||
const quoteName = (pageCopy?.founderQuoteName as string) || FALLBACK_FOUNDER_QUOTE.name;
|
||||
const quoteRole = (pageCopy?.founderQuoteRole as string) || FALLBACK_FOUNDER_QUOTE.role;
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-brand-section" data-testid="founder-quote-section">
|
||||
{/* 背景纹理:极淡白色点阵(Bain 式深红区块 + 克制质感) */}
|
||||
<div className="absolute inset-0 pointer-events-none opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 75% 25%, rgba(255,255,255,0.5) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
|
||||
<ScrollReveal className="max-w-4xl mx-auto text-center">
|
||||
<div className="flex items-center justify-center gap-3 mb-8">
|
||||
<div className="w-10 h-px bg-white/40" />
|
||||
<span className="text-[13px] tracking-[0.12em] font-semibold text-white/80">
|
||||
{eyebrow}
|
||||
</span>
|
||||
<div className="w-10 h-px bg-white/40" />
|
||||
</div>
|
||||
<Quote className="w-10 h-10 mx-auto mb-8 text-white/40" aria-hidden="true" />
|
||||
<blockquote className="text-2xl sm:text-3xl md:text-4xl lg:text-[2.75rem] font-bold text-white leading-[1.2] tracking-tight mb-10">
|
||||
{quoteText}
|
||||
</blockquote>
|
||||
{(quoteName || quoteRole) && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{quoteName && (
|
||||
<div className="text-base font-semibold text-white">{quoteName}</div>
|
||||
)}
|
||||
{quoteRole && (
|
||||
<div className="text-sm text-white/70">{quoteRole}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 共创流程(可验证方法论,不虚构客户)=====
|
||||
const CO_CREATION_STEPS = [
|
||||
{ step: '01', title: '业务诊断', desc: '深入理解业务场景,识别高价值切入点' },
|
||||
{ step: '02', title: '方案共创', desc: '与核心团队共同设计解决方案与实施路径' },
|
||||
{ step: '03', title: '验证发布', desc: '以真实运营数据验证成效,经授权后公开案例' },
|
||||
];
|
||||
|
||||
// ===== 案例区:Client Spotlight(对标 Accenture:深色大卡 spotlight + 其余小卡)=====
|
||||
function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; pageCopy?: HomePageCopy | null }) {
|
||||
const hasValidData = cases?.length && cases[0]?.metrics;
|
||||
const CASE_ITEMS = hasValidData ? cases! : [];
|
||||
@@ -524,20 +702,55 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
|
||||
<p className="text-text-secondary leading-relaxed text-lg mb-8">
|
||||
{casesDesc}
|
||||
</p>
|
||||
<a
|
||||
<StaticLink
|
||||
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>
|
||||
</StaticLink>
|
||||
</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 className="relative overflow-hidden rounded-[2rem] bg-ink p-10 sm:p-12 h-full flex flex-col shadow-card">
|
||||
{/* 背景纹理:极淡白色点阵(对标埃森哲深色 spotlight 画布) */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 75% 25%, rgba(255,255,255,0.5) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col h-full">
|
||||
<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-light">
|
||||
{casesEyebrow}
|
||||
</span>
|
||||
</div>
|
||||
<Quote className="w-8 h-8 mb-6 text-white/30" aria-hidden="true" />
|
||||
<div className="text-4xl sm:text-5xl font-black text-white tabular-nums leading-none mb-4">
|
||||
{earlyLabel}
|
||||
</div>
|
||||
<p className="text-white/70 leading-relaxed mb-10">{earlyDesc}</p>
|
||||
<ol className="space-y-5 mb-10">
|
||||
{CO_CREATION_STEPS.map((s) => (
|
||||
<li key={s.step} className="flex items-start gap-4">
|
||||
<span className="text-sm font-black text-brand-light tabular-nums leading-6 shrink-0">
|
||||
{s.step}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white mb-0.5">{s.title}</div>
|
||||
<div className="text-sm text-white/60 leading-relaxed">{s.desc}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="mt-auto">
|
||||
<CTAButton href="/contact" dataTestId="cases-co-creation-cta">
|
||||
成为首批共创客户
|
||||
</CTAButton>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-text-secondary leading-relaxed">{earlyDesc}</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
@@ -546,6 +759,9 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
|
||||
);
|
||||
}
|
||||
|
||||
const [spotlightCase, ...restCases] = CASE_ITEMS;
|
||||
const spotlightTestimonial = spotlightCase?.testimonial as Record<string, any> | undefined;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -556,56 +772,201 @@ function CasesSection({ cases, pageCopy }: { cases?: Record<string, any>[]; page
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<a
|
||||
<StaticLink
|
||||
href="/cases"
|
||||
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>
|
||||
</StaticLink>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{CASE_ITEMS.map((caseItem, i) => (
|
||||
<ScrollReveal key={i} delay={i * 0.1}>
|
||||
<a
|
||||
href={caseItem.href || `/cases/${caseItem.slug || ''}`}
|
||||
className="group bain-card block p-8 sm:p-10 h-full flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Badge className="bg-bg-secondary text-ink border-transparent">
|
||||
{caseItem.industry}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-8 leading-[1.2]">
|
||||
{caseItem.title}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
{caseItem.metrics.slice(0, 3).map((metric: Record<string, any>, j: number) => (
|
||||
<div key={j}>
|
||||
<div className={cn(
|
||||
'text-xl sm:text-2xl font-black leading-none mb-2',
|
||||
j === 0 ? 'text-brand' : 'text-ink'
|
||||
)}>
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary font-medium leading-tight">
|
||||
{metric.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4 border-t border-border-primary">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
|
||||
<span>了解详情</span>
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
{/* Client Spotlight 大卡:深色画布 + 客户引言 + 量化成果(对标 Accenture) */}
|
||||
{spotlightCase && (
|
||||
<ScrollReveal className="mb-6">
|
||||
<a
|
||||
href={spotlightCase.href || `/cases/${spotlightCase.slug || ''}`}
|
||||
className="group relative block overflow-hidden rounded-[2rem] bg-ink p-10 sm:p-14 h-full flex flex-col shadow-card"
|
||||
>
|
||||
{/* 背景纹理:极淡白色点阵 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.05]"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle at 75% 25%, rgba(255,255,255,0.5) 1px, transparent 1px)`,
|
||||
backgroundSize: '48px 48px',
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col lg:flex-row lg:items-stretch gap-10 lg:gap-16">
|
||||
<div className="flex flex-col flex-1">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Badge className="bg-white/10 text-white border-transparent">
|
||||
{spotlightCase.industry}
|
||||
</Badge>
|
||||
{spotlightCase.verified && (
|
||||
<Badge className="bg-brand/15 text-brand-light border-brand/30">
|
||||
已获客户授权
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-2xl sm:text-3xl lg:text-4xl font-black text-white leading-[1.15] mb-8">
|
||||
{spotlightCase.title}
|
||||
</h3>
|
||||
{spotlightTestimonial?.quote && (
|
||||
<blockquote className="max-w-2xl">
|
||||
<Quote className="w-6 h-6 text-brand-light mb-4" aria-hidden="true" />
|
||||
<p className="text-white/80 text-lg leading-relaxed">
|
||||
{spotlightTestimonial.quote}
|
||||
</p>
|
||||
{(spotlightTestimonial.author || spotlightTestimonial.name) && (
|
||||
<footer className="mt-4 text-sm text-white/60">
|
||||
{spotlightTestimonial.author || spotlightTestimonial.name}
|
||||
{spotlightTestimonial.role && ` · ${spotlightTestimonial.role}`}
|
||||
</footer>
|
||||
)}
|
||||
</blockquote>
|
||||
)}
|
||||
<div className="mt-auto pt-6 border-t border-white/10 flex items-center justify-between gap-6 flex-wrap">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-white group-hover:text-brand-light transition-colors">
|
||||
查看完整案例
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div className="lg:w-[38%] shrink-0">
|
||||
<div className="grid grid-cols-3 lg:grid-cols-1 gap-6 lg:gap-8 h-full">
|
||||
{spotlightCase.metrics?.slice(0, 3).map((metric: Record<string, any>, j: number) => (
|
||||
<div key={j} className={cn('lg:py-4', j < 2 && 'lg:border-b lg:border-white/10')}>
|
||||
<div className={cn(
|
||||
'text-3xl sm:text-4xl font-black leading-none mb-2 tabular-nums',
|
||||
j === 0 ? 'text-brand-light' : 'text-white'
|
||||
)}>
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="text-sm text-white/60 font-medium leading-tight">
|
||||
{metric.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
|
||||
{restCases.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{restCases.map((caseItem, i) => (
|
||||
<ScrollReveal key={i} delay={i * 0.1}>
|
||||
<a
|
||||
href={caseItem.href || `/cases/${caseItem.slug || ''}`}
|
||||
className="group bain-card block p-8 sm:p-10 h-full flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Badge className="bg-bg-secondary text-ink border-transparent">
|
||||
{caseItem.industry}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl lg:text-2xl font-bold text-ink mb-8 leading-[1.2]">
|
||||
{caseItem.title}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||
{caseItem.metrics.slice(0, 3).map((metric: Record<string, any>, j: number) => (
|
||||
<div key={j}>
|
||||
<div className={cn(
|
||||
'text-xl sm:text-2xl font-black leading-none mb-2',
|
||||
j === 0 ? 'text-brand' : 'text-ink'
|
||||
)}>
|
||||
{metric.value}
|
||||
</div>
|
||||
<div className="text-xs text-text-secondary font-medium leading-tight">
|
||||
{metric.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-4 border-t border-border-primary">
|
||||
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
|
||||
<span>了解详情</span>
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 新闻动态区(对标 Accenture News:最近 3 条 + 查看全部入口)=====
|
||||
function NewsSection({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: HomePageCopy | null }) {
|
||||
const eyebrow = (pageCopy?.newsEyebrow as string) || '新闻动态';
|
||||
const titleLines = ((pageCopy?.newsTitle as string) || '公司要闻\n与研发动态').split('\n');
|
||||
const ctaLabel = (pageCopy?.newsCtaLabel as string) || '查看全部新闻';
|
||||
const NEWS_ITEMS = (news?.length ? news.slice(0, 3) : NEWS.slice(0, 3)) as NewsItem[];
|
||||
|
||||
return (
|
||||
<section className="relative py-20 sm:py-28 md:py-36 overflow-hidden bg-bg-secondary" data-testid="news-section">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
|
||||
<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">
|
||||
<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]">
|
||||
<StyledTitle lines={titleLines} />
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
<ScrollReveal delay={0.1}>
|
||||
<StaticLink
|
||||
href="/news"
|
||||
className="group inline-flex items-center gap-3 text-ink font-semibold text-sm"
|
||||
>
|
||||
{ctaLabel}
|
||||
<ArrowRight className="w-4 h-5 transition-transform duration-300 group-hover:translate-x-2 text-text-secondary group-hover:text-brand" />
|
||||
</StaticLink>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{NEWS_ITEMS.map((item, i) => (
|
||||
<ScrollReveal key={item.id} delay={i * 0.1}>
|
||||
<StaticLink
|
||||
href={`/news/${item.id}`}
|
||||
className="group bain-card block p-8 sm:p-9 h-full flex flex-col"
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Badge variant="outline" className="text-xs border-brand/30 text-brand">
|
||||
{item.category}
|
||||
</Badge>
|
||||
<span className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||
<Calendar className="w-3.5 h-3.5" aria-hidden="true" />
|
||||
{item.date}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg lg:text-xl font-bold text-ink mb-4 leading-[1.3] line-clamp-2 group-hover:text-brand transition-colors duration-300">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed mb-6 line-clamp-2 flex-1">
|
||||
{item.excerpt}
|
||||
</p>
|
||||
<div className="pt-4 border-t border-border-primary">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors">
|
||||
阅读全文
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
</div>
|
||||
</StaticLink>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
@@ -638,16 +999,17 @@ function CTASection({ heroData, pageCopy }: { heroData?: Record<string, any> | n
|
||||
}
|
||||
|
||||
// ===== 主页面组件 =====
|
||||
export default function HomeContentV14({ services, cases, stats, heroData, pageCopy }: {
|
||||
export default function HomeContentV15({ services, cases, stats, heroData, news, pageCopy }: {
|
||||
services?: Record<string, any>[];
|
||||
cases?: Record<string, any>[];
|
||||
stats?: Record<string, any>[];
|
||||
heroData?: Record<string, any> | null;
|
||||
news?: NewsItem[];
|
||||
pageCopy?: HomePageCopy | null;
|
||||
}) {
|
||||
return (
|
||||
<main>
|
||||
{/* 1. Hero:克制白底 + 品牌红点缀 */}
|
||||
{/* 1. Hero:全出血深色画布 + 品牌红单电压(对标 Accenture 纯黑 Hero) */}
|
||||
<HeroSection heroData={heroData} stats={stats} />
|
||||
|
||||
{/* 2. 信任区:量化数据成果展示 */}
|
||||
@@ -656,14 +1018,23 @@ export default function HomeContentV14({ services, cases, stats, heroData, pageC
|
||||
{/* 3. 章节式叙事:问题 → 方法 → 结果 */}
|
||||
<NarrativeSection pageCopy={pageCopy} />
|
||||
|
||||
{/* 4. 服务区:等宽卡片 + 兜底数据 */}
|
||||
{/* 4. 行业洞察:思想领导力支柱(对标 Accenture Insights) */}
|
||||
<InsightsSection pageCopy={pageCopy} />
|
||||
|
||||
{/* 5. 服务区:等宽卡片 + 兜底数据 */}
|
||||
<ServicesSection services={services} pageCopy={pageCopy} />
|
||||
|
||||
{/* 5. 案例区:轻量快照卡片 */}
|
||||
{/* 6. 创始人观点:人格化背书(对标 Accenture CEO Quote) */}
|
||||
<FounderQuoteSection pageCopy={pageCopy} />
|
||||
|
||||
{/* 7. 案例区:轻量快照卡片 */}
|
||||
<CasesSection cases={cases} pageCopy={pageCopy} />
|
||||
|
||||
{/* 6. CTA:克制强调,品牌红仅用于按钮 */}
|
||||
{/* 8. 新闻动态:持续动态(对标 Accenture News) */}
|
||||
<NewsSection news={news} pageCopy={pageCopy} />
|
||||
|
||||
{/* 9. CTA:克制强调,品牌红仅用于按钮 */}
|
||||
<CTASection heroData={heroData} pageCopy={pageCopy} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { EASE_OUT } from '@/components/ui/page-decoration';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, CheckCircle2 } from 'lucide-react';
|
||||
@@ -154,13 +155,13 @@ export function MethodologyContent({ data }: MethodologyContentProps) {
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<Button size="xl" asChild>
|
||||
<a href="/services">
|
||||
<StaticLink href="/services">
|
||||
了解我们的服务
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/contact">联系我们</a>
|
||||
<StaticLink href="/contact">联系我们</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -75,7 +75,7 @@ function NewsCard({ newsItem, index }: { newsItem: NewsItem; index: number }) {
|
||||
}
|
||||
|
||||
export default function NewsContentV3({ news, pageCopy }: { news?: NewsItem[]; pageCopy?: Record<string, unknown> | null }) {
|
||||
const NEWS = news ?? [];
|
||||
const NEWS = useMemo(() => news ?? [], [news]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -94,7 +94,7 @@ export default function NewsContentV3({ news, pageCopy }: { news?: NewsItem[]; p
|
||||
newsItem.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [selectedCategory, searchQuery]);
|
||||
}, [selectedCategory, searchQuery, NEWS]);
|
||||
|
||||
const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE);
|
||||
const paginatedNews = useMemo(() => {
|
||||
@@ -251,14 +251,14 @@ export default function NewsContentV3({ news, pageCopy }: { news?: NewsItem[]; p
|
||||
<div className="mt-12 flex flex-col sm:flex-row justify-center gap-4 sm:gap-6">
|
||||
<a
|
||||
href="/contact"
|
||||
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90"
|
||||
className="group inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90"
|
||||
>
|
||||
联系我们
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
<a
|
||||
href="/about"
|
||||
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
|
||||
className="inline-flex items-center justify-center gap-3 px-12 py-6 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
|
||||
>
|
||||
了解公司
|
||||
</a>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ScrollReveal } from '@/components/ui/scroll-reveal';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar, ArrowLeft, Newspaper, ArrowRight } from 'lucide-react';
|
||||
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
||||
@@ -63,9 +64,9 @@ export default function NewsDetailContentV3({ news, relatedNews = [] }: NewsDeta
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
|
||||
<ScrollReveal>
|
||||
<nav aria-label="breadcrumb" className="flex items-center gap-2 text-sm text-text-muted mb-8 sm:mb-10">
|
||||
<a href="/news" className="hover:text-brand transition-colors duration-300">
|
||||
<StaticLink href="/news" className="hover:text-brand transition-colors duration-300">
|
||||
新闻动态
|
||||
</a>
|
||||
</StaticLink>
|
||||
<span>/</span>
|
||||
<span className="text-text-secondary line-clamp-1">{news.title}</span>
|
||||
</nav>
|
||||
@@ -145,20 +146,20 @@ export default function NewsDetailContentV3({ news, relatedNews = [] }: NewsDeta
|
||||
{/* Navigation */}
|
||||
<ScrollReveal>
|
||||
<div className="mt-16 sm:mt-20 flex flex-col sm:flex-row justify-center gap-4">
|
||||
<a
|
||||
<StaticLink
|
||||
href="/news"
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-4 font-medium text-base text-ink border border-border-primary hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
|
||||
className="inline-flex items-center justify-center gap-2 px-8 py-4 font-medium text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回新闻列表
|
||||
</a>
|
||||
<a
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
href="/contact"
|
||||
className="group inline-flex items-center justify-center gap-2 px-8 py-4 font-medium text-base bg-brand text-white transition-all duration-300 hover:bg-brand/90"
|
||||
className="group inline-flex items-center justify-center gap-2 px-8 py-4 font-medium text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90"
|
||||
>
|
||||
联系我们
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
@@ -8,14 +8,15 @@ jest.mock('@/lib/cms/data-server', () => ({
|
||||
getHomePageCopy: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./home-content-v14', () => ({
|
||||
jest.mock('./home-content-v15', () => ({
|
||||
__esModule: true,
|
||||
default: function MockHomeContentV14(props: Record<string, unknown>) {
|
||||
default: function MockHomeContentV15(props: Record<string, unknown>) {
|
||||
return (
|
||||
<div data-testid="home-content">
|
||||
<div data-testid="services-count">{(props.services as unknown[])?.length ?? 0}</div>
|
||||
<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="news-count">{(props.news 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>
|
||||
@@ -90,6 +91,9 @@ describe('HomePage', () => {
|
||||
if (modelCode === 'hero-banner') {
|
||||
return Promise.resolve([createMockItem('hero-banner', { heading: 'Direct Hero' })]);
|
||||
}
|
||||
if (modelCode === 'news') {
|
||||
return Promise.resolve([createMockItem('news', { title: '公司新闻' })]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
@@ -98,6 +102,7 @@ describe('HomePage', () => {
|
||||
|
||||
expect(screen.getByTestId('services-count')).toHaveTextContent('1');
|
||||
expect(screen.getByTestId('hero-data')).toHaveTextContent('Direct Hero');
|
||||
expect(screen.getByTestId('news-count')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('handles empty zones gracefully', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getPublishedItems, getResolvedHomeZones, getHomePageCopy } from '@/lib/cms/data-server';
|
||||
import HomeContentV14 from './home-content-v14';
|
||||
import type { NewsItem } from '@/lib/constants/news';
|
||||
import HomeContentV15 from './home-content-v15';
|
||||
|
||||
export const revalidate = 3600;
|
||||
|
||||
@@ -11,6 +12,7 @@ export default async function HomePage() {
|
||||
let cases: Record<string, unknown>[] = [];
|
||||
let stats: Record<string, unknown>[] = [];
|
||||
let heroData: Record<string, unknown> | null = null;
|
||||
let news: NewsItem[] = [];
|
||||
|
||||
if (hasZones) {
|
||||
services = zones.services?.map((item) => item.data) ?? [];
|
||||
@@ -18,27 +20,30 @@ export default async function HomePage() {
|
||||
stats = zones.stats?.map((item) => item.data) ?? [];
|
||||
heroData = zones.hero?.[0]?.data ?? null;
|
||||
} else {
|
||||
const [serviceItems, caseItems, statItems, heroItems] = await Promise.all([
|
||||
const [serviceItems, caseItems, statItems, heroItems, newsItems] = await Promise.all([
|
||||
getPublishedItems('service'),
|
||||
getPublishedItems('case-study'),
|
||||
getPublishedItems('stat-item'),
|
||||
getPublishedItems('hero-banner'),
|
||||
getPublishedItems('news'),
|
||||
]);
|
||||
|
||||
services = serviceItems.map((item) => item.data);
|
||||
cases = caseItems.map((item) => item.data);
|
||||
stats = statItems.map((item) => item.data);
|
||||
heroData = heroItems[0]?.data ?? null;
|
||||
news = newsItems.map((item) => item.data as unknown as NewsItem);
|
||||
}
|
||||
|
||||
// 首页各章节结构性文案(信任信号/叙事情节/标题/CTA),CMS 未配置时为 null(组件内部兜底)
|
||||
const homePageCopy = await getHomePageCopy().catch(() => null);
|
||||
|
||||
return <HomeContentV14
|
||||
return <HomeContentV15
|
||||
services={services}
|
||||
cases={cases}
|
||||
stats={stats}
|
||||
heroData={heroData}
|
||||
news={news}
|
||||
pageCopy={homePageCopy?.data ?? null}
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -153,10 +154,10 @@ export default function ErpUpgradeContentV2({ item }: ErpUpgradeContentV2Props)
|
||||
<ScrollReveal delay={0.3}>
|
||||
<div className="mt-12 flex flex-wrap gap-4">
|
||||
<Button size="xl" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
免费诊断咨询
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="#features">了解功能模块</a>
|
||||
@@ -404,13 +405,13 @@ export default function ErpUpgradeContentV2({ item }: ErpUpgradeContentV2Props)
|
||||
|
||||
<div className="flex flex-wrap gap-6 justify-center">
|
||||
<Button size="xl" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
预约免费诊断
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/products/erp">查看产品详情</a>
|
||||
<StaticLink href="/products/erp">查看产品详情</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowRight, Package, Check, Settings, Users, FileText, Cpu, Shield, TrendingUp } from 'lucide-react';
|
||||
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
||||
@@ -89,13 +90,13 @@ function DetailHero({ product }: DetailHeroProps) {
|
||||
className="flex flex-wrap gap-6"
|
||||
>
|
||||
<Button size="xl" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
预约体验
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/products">查看更多产品</a>
|
||||
<StaticLink href="/products">查看更多产品</StaticLink>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -403,15 +404,29 @@ function CTASection({ product }: CTASectionProps) {
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<Button size="xl" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
预约演示
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/products">查看更多产品</a>
|
||||
<StaticLink href="/products">查看更多产品</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 站内入口:ERP 升级专题(与 erp-upgrade-content-v2 的反向链接形成互链闭环) */}
|
||||
{product.id === 'erp' && (
|
||||
<div className="mt-10 inline-flex flex-wrap items-center justify-center gap-x-2 gap-y-1 text-sm text-text-secondary">
|
||||
正在使用旧版 ERP?
|
||||
<StaticLink
|
||||
href="/products/erp-upgrade"
|
||||
className="inline-flex items-center gap-1 font-medium text-brand hover:underline"
|
||||
>
|
||||
了解睿新 ERP 升级专题
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</StaticLink>
|
||||
</div>
|
||||
)}
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -136,13 +136,13 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
|
||||
className="flex flex-wrap gap-6"
|
||||
>
|
||||
<Button size="xl" asChild>
|
||||
<a href="/services">
|
||||
<StaticLink href="/services">
|
||||
{ctaPrimary}
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/solutions">{ctaSecondary}</a>
|
||||
<StaticLink href="/solutions">{ctaSecondary}</StaticLink>
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
||||
@@ -425,7 +425,7 @@ function SuiteCombosSection({ products }: { products: Product[] }) {
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<a
|
||||
<StaticLink
|
||||
key={idx}
|
||||
href="/solutions"
|
||||
className="group p-10 lg:p-12 bg-white hover:bg-bg-secondary transition-all duration-500 overflow-hidden"
|
||||
@@ -465,7 +465,7 @@ function SuiteCombosSection({ products }: { products: Product[] }) {
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</motion.div>
|
||||
</div>
|
||||
</a>
|
||||
</StaticLink>
|
||||
);
|
||||
})}
|
||||
</StaggerReveal>
|
||||
@@ -498,13 +498,13 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<Button size="xl" asChild>
|
||||
<a href="/services">
|
||||
<StaticLink href="/services">
|
||||
了解我们的服务
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/contact">联系我们</a>
|
||||
<StaticLink href="/contact">联系我们</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getHeroTheme } from '@/lib/constants/hero-themes';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { DetailHero } from '@/components/detail/detail-hero';
|
||||
import { DetailTrustSection } from '@/components/detail/detail-trust-section';
|
||||
import { DetailCTASection } from '@/components/detail/detail-cta-section';
|
||||
@@ -112,19 +113,19 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<a
|
||||
<StaticLink
|
||||
href="/contact"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand px-8 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-brand/90 hover:shadow-lg"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-brand px-8 py-3 text-sm font-semibold text-white shadow-md transition-all hover:bg-brand/90 hover:shadow-lg"
|
||||
>
|
||||
获取上线通知
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
href="/products"
|
||||
className="inline-flex items-center rounded-lg border border-border-primary px-6 py-3 text-sm font-semibold text-text-secondary transition-all hover:border-border-secondary hover:bg-bg-secondary"
|
||||
className="inline-flex items-center rounded-full border border-border-primary px-6 py-3 text-sm font-semibold text-text-secondary transition-all hover:border-border-secondary hover:bg-bg-secondary"
|
||||
>
|
||||
查看其他产品
|
||||
</a>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { ArrowUpRight, CheckCircle2, Zap, Clock, Users, Award, TrendingUp, Shield } from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { Service, CaseStudy } from '@/lib/constants/services';
|
||||
@@ -80,16 +81,16 @@ function DetailHero({ service }: { service: Service }) {
|
||||
transition={{ duration: 0.8, delay: 0.9, ease: EASE_OUT }}
|
||||
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
|
||||
>
|
||||
<a
|
||||
<StaticLink
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
免费咨询
|
||||
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
<a
|
||||
href="#cases"
|
||||
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"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-ink/30 transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
查看案例
|
||||
</a>
|
||||
@@ -336,19 +337,19 @@ function CTASection() {
|
||||
首次咨询免费,没有任何销售压力。
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
<a
|
||||
<StaticLink
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
预约免费咨询
|
||||
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
<a
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
href="/services"
|
||||
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"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-ink/30 transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
返回服务列表
|
||||
</a>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -126,19 +126,19 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
|
||||
transition={{ duration: 0.8, delay: 0.7, ease: EASE_OUT }}
|
||||
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
|
||||
>
|
||||
<a
|
||||
<StaticLink
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full 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
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
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"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-ink/30 transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
{ctaSecondary}
|
||||
</a>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -364,19 +364,19 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
|
||||
))}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
<a
|
||||
<StaticLink
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
预约免费咨询
|
||||
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
<a
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
href="/team"
|
||||
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"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-ink/30 transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
了解我们的团队
|
||||
</a>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
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 type { Solution } from '@/lib/constants/solutions';
|
||||
@@ -83,13 +84,13 @@ function DetailHero({ solution }: DetailHeroProps) {
|
||||
className="flex flex-wrap gap-6"
|
||||
>
|
||||
<Button size="xl" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
获取方案详情
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/products">查看产品</a>
|
||||
<StaticLink href="/products">查看产品</StaticLink>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -491,13 +492,13 @@ function CTASection({ solution }: CTASectionProps) {
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-6">
|
||||
<Button size="xl" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
预约咨询
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="xl" variant="outline" asChild>
|
||||
<a href="/solutions">查看更多方案</a>
|
||||
<StaticLink href="/solutions">查看更多方案</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -74,19 +74,19 @@ function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
|
||||
transition={{ duration: 0.8, delay: 0.7, ease: EASE_OUT }}
|
||||
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
|
||||
>
|
||||
<a
|
||||
<StaticLink
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full 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
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
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"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-ink/30 transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
{ctaSecondary}
|
||||
</a>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@@ -240,19 +240,19 @@ function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
|
||||
{ctaDescription}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
<a
|
||||
<StaticLink
|
||||
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"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
获取定制方案
|
||||
<ArrowUpRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||
</a>
|
||||
<a
|
||||
</StaticLink>
|
||||
<StaticLink
|
||||
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"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-ink/30 transition-all duration-300 min-h-[52px] touch-manipulation"
|
||||
>
|
||||
浏览产品
|
||||
</a>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
|
||||
>
|
||||
<a
|
||||
href={teamData.heroPrimaryCtaHref || '/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]"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px]"
|
||||
>
|
||||
{teamData.heroPrimaryCtaLabel || '加入我们'}
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
@@ -225,7 +225,7 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
|
||||
{teamData.heroSecondaryCtaLabel && (
|
||||
<a
|
||||
href={teamData.heroSecondaryCtaHref || '#'}
|
||||
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-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px]"
|
||||
className="inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-ink border border-border-primary rounded-full hover:border-border-secondary hover:bg-bg-secondary transition-all duration-300 min-h-[52px]"
|
||||
>
|
||||
{teamData.heroSecondaryCtaLabel}
|
||||
</a>
|
||||
@@ -349,14 +349,14 @@ export default function TeamContentV3({ data }: { data: Record<string, unknown>
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
|
||||
<a
|
||||
href={teamData.ctaPrimaryHref || '/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]"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base bg-brand text-white rounded-full transition-all duration-300 hover:bg-brand/90 min-h-[52px]"
|
||||
>
|
||||
{teamData.ctaPrimaryLabel || '预约咨询'}
|
||||
<ArrowRight className="w-5 h-5 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</a>
|
||||
<a
|
||||
href={teamData.ctaSecondaryHref || '/cases'}
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
|
||||
className="group inline-flex items-center justify-center gap-3 px-10 py-5 font-semibold text-base text-dark-text-primary border border-dark-border rounded-full hover:border-dark-text-muted transition-all duration-300 min-h-[52px]"
|
||||
>
|
||||
{teamData.ctaSecondaryLabel || '查看客户案例'}
|
||||
</a>
|
||||
|
||||
@@ -193,7 +193,8 @@ export default function MediaPage() {
|
||||
|
||||
{items.length === 0 && !loading && (
|
||||
<div className="text-center py-16 bg-white rounded-lg border border-dashed border-gray-300">
|
||||
<Image className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text -- lucide Image 图标(装饰性 SVG,非 <img>),aria-hidden 表明装饰意图 */}
|
||||
<Image aria-hidden="true" className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-sm font-medium text-gray-900 mb-1">暂无媒体文件</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">上传图片、文档等资源,即可在内容编辑中使用</p>
|
||||
<label className="inline-flex items-center gap-2 bg-gray-900 text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-gray-800 cursor-pointer transition-colors">
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function Error({
|
||||
href="/contact"
|
||||
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors">
|
||||
<AlertTriangle className="w-5 h-5 text-[var(--color-brand)]" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
@@ -94,7 +94,7 @@ export default function Error({
|
||||
href="/services"
|
||||
className="flex items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||
<div className="w-10 h-10 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mr-4 group-hover:bg-[var(--color-brand-soft)] transition-colors">
|
||||
<RefreshCw className="w-5 h-5 text-[var(--color-brand)]" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
|
||||
@@ -47,32 +47,49 @@ body {
|
||||
--color-accent-purple: #8B5CF6;
|
||||
--color-accent-purple-soft: rgba(139, 92, 246, 0.12);
|
||||
--color-accent-purple-rgb: 139, 92, 246;
|
||||
--color-accent-cyan: #06B6D4;
|
||||
--color-accent-cyan-soft: rgba(6, 182, 212, 0.12);
|
||||
--color-accent-cyan-rgb: 6, 182, 212;
|
||||
|
||||
/* 背景色层级 */
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-secondary: #F8FAFC;
|
||||
--color-bg-secondary-rgb: 248, 250, 252;
|
||||
--color-bg-tertiary: #F1F5F9;
|
||||
--color-bg-section: #F8FAFC;
|
||||
--color-bg-hover: #F1F5F9;
|
||||
--color-bg-elevated: #FFFFFF;
|
||||
--color-bg-primary-rgb: 255, 255, 255;
|
||||
|
||||
/* 文字色层级 */
|
||||
--color-text-primary: #0A0E14;
|
||||
--color-text-secondary: #334155;
|
||||
--color-text-secondary-rgb: 51, 65, 85;
|
||||
--color-text-tertiary: #475569;
|
||||
--color-text-muted: #64748B;
|
||||
--color-text-muted-rgb: 100, 116, 139;
|
||||
--color-text-subtle: #64748B;
|
||||
--color-text-placeholder: #64748B;
|
||||
--color-text-hint: #CBD5E1;
|
||||
/* 仅用于 ≥24px 大号装饰性文本(如章节序号),对白底 3.41:1,满足 AA 大字 3:1。
|
||||
正文、占位符、提示文案请改用 --color-text-placeholder(4.76:1)或更深的令牌。 */
|
||||
--color-text-hint: #7C8CA5;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
|
||||
/* 边框色层级 */
|
||||
--color-border-primary: #E2E8F0;
|
||||
/* 边框色层级 —— 中度软化:--color-border-primary 从 #E2E8F0 调整为 #EEF2F7(更淡),
|
||||
让 20 处 gap-px bg-border-primary 网格线与所有卡片边框都自动弱化(约 50%),
|
||||
与增强的卡片阴影呼应,视觉从"靠硬边分界"转为"靠阴影分层"。 */
|
||||
--color-border-primary: #EEF2F7;
|
||||
--color-border-primary-rgb: 238, 242, 247;
|
||||
--color-border-secondary: #CBD5E1;
|
||||
--color-border-secondary-rgb: 203, 213, 225;
|
||||
--color-border-accent: #0A0E14;
|
||||
--color-border-accent-rgb: 10, 14, 20;
|
||||
--color-border-light: #F1F5F9;
|
||||
--color-border-light-rgb: 241, 245, 249;
|
||||
--color-border-dark: #334155;
|
||||
--color-border-dark-rgb: 51, 65, 85;
|
||||
--color-border-brand: #C41E3A;
|
||||
--color-border-brand-rgb: 196, 30, 58;
|
||||
|
||||
/* 链接色 */
|
||||
--color-link: #0A0E14;
|
||||
@@ -83,11 +100,15 @@ body {
|
||||
--color-success-hover: #15803D;
|
||||
--color-success-bg: #F0FDF4;
|
||||
--color-success-rgb: 22, 163, 74;
|
||||
/* 文字专用:--color-success 仅 3.30:1,作正文色不达 AA;本值 5.02:1(于 success-bg 上 4.79:1) */
|
||||
--color-success-text: #15803D;
|
||||
|
||||
--color-warning: #D97706;
|
||||
--color-warning-hover: #B45309;
|
||||
--color-warning-bg: #FFFBEB;
|
||||
--color-warning-rgb: 217, 119, 6;
|
||||
/* 文字专用:--color-warning 仅 3.19:1,作正文色不达 AA;本值 5.02:1(于 warning-bg 上 4.84:1) */
|
||||
--color-warning-text: #B45309;
|
||||
|
||||
--color-error: #C41E3A;
|
||||
--color-error-hover: #A01830;
|
||||
@@ -95,7 +116,10 @@ body {
|
||||
--color-error-rgb: 196, 30, 58;
|
||||
|
||||
--color-info: #3B82F6;
|
||||
--color-info-rgb: 59, 130, 246;
|
||||
--color-info-bg: #EFF6FF;
|
||||
/* 文字专用:--color-info 仅 3.68:1,作正文色不达 AA;本值 6.70:1(于 info-bg 上 6.16:1) */
|
||||
--color-info-text: #1D4ED8;
|
||||
|
||||
/* 深色调性区域 */
|
||||
--color-dark-bg: #0A0E14;
|
||||
@@ -125,10 +149,13 @@ body {
|
||||
/* 光晕阴影:发光效果(慎用) */
|
||||
--shadow-glow: 0 0 40px rgba(196, 30, 58, 0.15);
|
||||
|
||||
/* 字体系统 */
|
||||
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
/* 字体系统
|
||||
字族按「逐字符」回退:拉丁字母命中 Geist(无 CJK 字形),
|
||||
中文自动落到后面的 PingFang SC / 微软雅黑。
|
||||
var() 必须给回退值,否则 next/font 变量缺失时整条声明会失效。 */
|
||||
--font-sans: var(--font-geist-sans, ui-sans-serif), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: var(--font-geist-sans, ui-sans-serif), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: var(--font-geist-mono, ui-monospace), SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
--font-brand: "STKaiti", "KaiTi", "楷体", "SimKai", serif;
|
||||
--font-calligraphy-system: "STKaiti", "KaiTi", "楷体", "SimKai", serif;
|
||||
--font-serif: "Songti SC", "STSongti-SC", "Noto Serif CJK SC", "SimSun", "STSong", serif;
|
||||
@@ -180,14 +207,16 @@ body {
|
||||
--spacing-5xl: 8rem;
|
||||
--spacing-6xl: 12rem;
|
||||
|
||||
/* 圆角系统(按用途分类) */
|
||||
--radius-xs: 2px; /* 极小:标签、徽章 */
|
||||
--radius-sm: 4px; /* 小:按钮、输入框 */
|
||||
--radius-md: 6px; /* 中:卡片、弹窗 */
|
||||
--radius-lg: 10px; /* 大:大型卡片、容器 */
|
||||
--radius-xl: 14px; /* 特大:英雄卡片、特色模块 */
|
||||
--radius-2xl: 20px; /* 超大:全屏弹窗、大型容器 */
|
||||
--radius-3xl: 28px; /* 巨大:展示卡片 */
|
||||
/* 圆角系统(按用途分类)—— 中度软化:4→6/6→8/10→12/14→18,
|
||||
让"硬边"卡片语言变为"柔边",与增强的卡片阴影呼应。
|
||||
注意:SM/MD/LG 同步联动大量 Tailwind 圆角类,全局生效。 */
|
||||
--radius-xs: 3px; /* 极小:标签、徽章 */
|
||||
--radius-sm: 6px; /* 小:按钮、输入框 */
|
||||
--radius-md: 8px; /* 中:卡片、弹窗 */
|
||||
--radius-lg: 12px; /* 大:大型卡片、容器 */
|
||||
--radius-xl: 18px; /* 特大:英雄卡片、特色模块 */
|
||||
--radius-2xl: 24px; /* 超大:全屏弹窗、大型容器 */
|
||||
--radius-3xl: 32px; /* 巨大:展示卡片 */
|
||||
--radius-full: 9999px; /* 圆形:头像、圆形按钮 */
|
||||
|
||||
/* 过渡系统(按时长分级) */
|
||||
@@ -234,25 +263,28 @@ body {
|
||||
--btn-padding-x: 1.5rem;
|
||||
--btn-font-size: 0.875rem;
|
||||
|
||||
/* 卡片 */
|
||||
/* 卡片 —— 中度软化:阴影加深(透明度 0.06→0.08),新增 y 偏移的 hover 抬升过渡。
|
||||
让卡片从"靠 1px 硬边分界"变为"靠柔和阴影分层",与增大圆角呼应。 */
|
||||
--card-padding: 1.5rem;
|
||||
--card-padding-lg: 2.5rem;
|
||||
--card-gap: 1.5rem;
|
||||
--card-border-radius: var(--radius-lg);
|
||||
/* Bain 式干净卡片:边框 + 阴影,无复杂光晕 */
|
||||
--card-shadow: 0 1px 3px rgba(10, 14, 20, 0.06);
|
||||
--card-shadow-hover: 0 8px 24px rgba(10, 14, 20, 0.1);
|
||||
--card-border: 1px solid var(--color-border-primary);
|
||||
--card-shadow: 0 1px 2px rgba(10, 14, 20, 0.04), 0 2px 8px rgba(10, 14, 20, 0.08);
|
||||
--card-shadow-hover: 0 4px 12px rgba(10, 14, 20, 0.06), 0 12px 32px rgba(10, 14, 20, 0.12);
|
||||
--card-border: 1px solid var(--color-border-light);
|
||||
--card-transition: transform var(--transition-normal) var(--ease-out),
|
||||
box-shadow var(--transition-normal) var(--ease-out),
|
||||
border-color var(--transition-normal) var(--ease-out);
|
||||
|
||||
/* 输入框 */
|
||||
--input-height: 2.75rem;
|
||||
--input-padding-x: 1rem;
|
||||
--input-border-radius: var(--radius-sm);
|
||||
|
||||
/* Section 间距 —— Mobile First,默认移动端 */
|
||||
--section-gap: var(--spacing-3xl);
|
||||
--section-gap-sm: var(--spacing-2xl);
|
||||
--section-padding-y: var(--spacing-3xl);
|
||||
/* Section 间距 —— Mobile First,默认移动端 —— 中度软化:节奏更舒展 */
|
||||
--section-gap: var(--spacing-4xl);
|
||||
--section-gap-sm: var(--spacing-3xl);
|
||||
--section-padding-y: var(--spacing-4xl);
|
||||
|
||||
/* === 组件令牌层(Component Tokens)=== */
|
||||
|
||||
@@ -340,6 +372,10 @@ body {
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
/* 桌面端字号分级(mobile-first 16 → tablet 17 → desktop 18):
|
||||
桌面端 18px 根字号使所有 rem 单位放大 12.5%,标题与留白更舒展,
|
||||
与增强的圆角+阴影形成"软化"语言。
|
||||
权衡:会覆盖用户浏览器字号缩放设置 —— 经 design review 决定保留视觉一致性。 */
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
@@ -612,6 +648,8 @@ body {
|
||||
--color-text-primary: #000000;
|
||||
--color-text-secondary: #1a1a1a;
|
||||
--color-border-primary: #000000;
|
||||
/* Tailwind border-* 工具类消费 rgb 通道,需同步覆写,否则高对比度模式下边框静默失效 */
|
||||
--color-border-primary-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,15 +1157,22 @@ p, li, span {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
a:not(nav[aria-label="breadcrumb"] a),
|
||||
button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
nav[aria-label="breadcrumb"] a {
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
/* WCAG 2.5.5 (AAA): 44×44px 触控目标仅作用于移动端导航/页眉/页脚交互元素。
|
||||
原全局选择器会撑大正文行内链接,破坏排版节奏与组件自有的尺寸控制。 */
|
||||
@media (max-width: 768px) {
|
||||
nav a,
|
||||
nav button,
|
||||
header a,
|
||||
header button,
|
||||
footer a,
|
||||
footer button,
|
||||
[role="menu"] a,
|
||||
[role="menu"] button,
|
||||
[data-mobile-menu] a,
|
||||
[data-mobile-menu] button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Suspense } from "react";
|
||||
import { GoogleAnalyticsWrapper } from "@/components/analytics/GoogleAnalyticsWrapper";
|
||||
@@ -18,6 +19,26 @@ import { SiteConfigProvider, type SiteConfig, type NavigationItem, type MegaDrop
|
||||
import { COMPANY_INFO, BRAND_NARRATIVE, type BrandPillar } from "@/lib/constants/company";
|
||||
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from "@/lib/constants/navigation";
|
||||
|
||||
/**
|
||||
* 拉丁字形使用 Geist(本地 woff2,无网络请求);
|
||||
* 中文由 globals.css 的 --font-sans 回退到 PingFang SC / 微软雅黑。
|
||||
* 这样英文数字有设计字体,中文又不需下载数 MB 的 CJK 字重。
|
||||
* 两者总重 140KB,display: swap 不阻塞首屏。
|
||||
*/
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/geist-sans.woff2",
|
||||
variable: "--font-geist-sans",
|
||||
display: "swap",
|
||||
preload: true,
|
||||
});
|
||||
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/geist-mono.woff2",
|
||||
variable: "--font-geist-mono",
|
||||
display: "swap",
|
||||
preload: false,
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://www.novalon.cn"),
|
||||
title: {
|
||||
@@ -143,7 +164,11 @@ export default async function RootLayout({
|
||||
};
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" className="scroll-smooth" suppressHydrationWarning>
|
||||
<html
|
||||
lang="zh-CN"
|
||||
className={`scroll-smooth ${geistSans.variable} ${geistMono.variable}`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
|
||||
@@ -47,7 +47,7 @@ export function NotFoundContent() {
|
||||
href="/products"
|
||||
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
|
||||
<Search className="w-4 h-4 text-[var(--color-brand)]" />
|
||||
</div>
|
||||
<span className="font-medium text-sm text-[var(--color-text-primary)]">产品</span>
|
||||
@@ -57,7 +57,7 @@ export function NotFoundContent() {
|
||||
href="/solutions"
|
||||
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
|
||||
<Search className="w-4 h-4 text-[var(--color-brand)]" />
|
||||
</div>
|
||||
<span className="font-medium text-sm text-[var(--color-text-primary)]">解决方案</span>
|
||||
@@ -67,7 +67,7 @@ export function NotFoundContent() {
|
||||
href="/about"
|
||||
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
|
||||
<Search className="w-4 h-4 text-[var(--color-brand)]" />
|
||||
</div>
|
||||
<span className="font-medium text-sm text-[var(--color-text-primary)]">关于我们</span>
|
||||
@@ -77,7 +77,7 @@ export function NotFoundContent() {
|
||||
href="/contact"
|
||||
className="flex flex-col items-center p-4 bg-[var(--color-bg-primary)] rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-challenge-isolation-hover)] transition-colors">
|
||||
<div className="w-9 h-9 bg-[var(--color-brand-bg)] rounded-lg flex items-center justify-center mb-2 group-hover:bg-[var(--color-brand-soft)] transition-colors">
|
||||
<Search className="w-4 h-4 text-[var(--color-brand)]" />
|
||||
</div>
|
||||
<span className="font-medium text-sm text-[var(--color-text-primary)]">联系我们</span>
|
||||
|
||||
@@ -136,16 +136,16 @@ function PrivacyContent() {
|
||||
我们使用 Cookie 和类似技术来提供、保护和改进我们的服务。Cookie 是存储在您设备上的小型文本文件,帮助我们识别您的设备、记住您的偏好设置。
|
||||
</p>
|
||||
<div className="overflow-x-auto mb-6">
|
||||
<table className="min-w-full border border-[var(--color-border)] rounded-lg">
|
||||
<table className="min-w-full border border-[var(--color-border-primary)] rounded-lg">
|
||||
<thead className="bg-[var(--color-bg-tertiary)]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">Cookie 类型</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">用途</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">持续时间</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border)]">是否必需</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]">Cookie 类型</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]">用途</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]">持续时间</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[var(--color-text-primary)] border-b border-[var(--color-border-primary)]">是否必需</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--color-border)]">
|
||||
<tbody className="divide-y divide-[var(--color-border-primary)]">
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">必要 Cookie</td>
|
||||
<td className="px-4 py-3 text-sm text-[var(--color-text-muted)]">网站基本功能运行</td>
|
||||
@@ -248,7 +248,7 @@ export default async function PrivacyPolicyPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-hero-dark-end)] py-20">
|
||||
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-brand-section)] py-20">
|
||||
<div className="container-wide">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
{heroTitle}
|
||||
|
||||
@@ -7,10 +7,15 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{ url: `${BASE_URL}/`, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
|
||||
{ url: `${BASE_URL}/about`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.8 },
|
||||
// 品牌故事(正规内容页,此前站内零入口且不在 sitemap)
|
||||
{ url: `${BASE_URL}/about/brand`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.6 },
|
||||
{ url: `${BASE_URL}/services`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.9 },
|
||||
{ url: `${BASE_URL}/products`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.9 },
|
||||
// ERP升级专题(正规营销页,此前为孤儿页面:站内无入口且不进 sitemap)
|
||||
{ url: `${BASE_URL}/products/erp-upgrade`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.7 },
|
||||
{ url: `${BASE_URL}/solutions`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.9 },
|
||||
{ url: `${BASE_URL}/cases`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
|
||||
// 案例列表页:数据库 case-study 暂无已发布内容,空列表页不收录(页面保留,待产品补数据后恢复)。
|
||||
// { url: `${BASE_URL}/cases`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 },
|
||||
{ url: `${BASE_URL}/news`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.7 },
|
||||
{ url: `${BASE_URL}/team`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.6 },
|
||||
{ url: `${BASE_URL}/methodology`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.6 },
|
||||
|
||||
@@ -193,7 +193,7 @@ export default async function TermsOfServicePage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-bg-primary)]">
|
||||
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-hero-dark-end)] py-20">
|
||||
<div className="bg-gradient-to-br from-[var(--color-brand)] via-[var(--color-brand)]/80 to-[var(--color-brand-section)] py-20">
|
||||
<div className="container-wide">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
{heroTitle}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { trackError } from '@/lib/analytics';
|
||||
|
||||
export default function TestErrorTrackingClient() {
|
||||
const [testResults, setTestResults] = useState<string[]>([]);
|
||||
|
||||
const addResult = (message: string) => {
|
||||
setTestResults((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
|
||||
};
|
||||
|
||||
const testJavaScriptError = () => {
|
||||
try {
|
||||
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
|
||||
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
trackError('javascript_error', errorMessage, false, {
|
||||
test_id: 'test_js_error',
|
||||
filename: 'test-error-page.tsx',
|
||||
lineno: 20,
|
||||
});
|
||||
addResult('✅ JavaScript 错误已发送到 GA4');
|
||||
}
|
||||
};
|
||||
|
||||
const testPromiseRejection = () => {
|
||||
addResult('🧪 测试 2: 触发 Promise 未捕获异常...');
|
||||
Promise.reject(new Error('测试错误:Promise 故意拒绝'));
|
||||
addResult('✅ Promise 异常已触发(由 GlobalErrorTracker 自动捕获)');
|
||||
};
|
||||
|
||||
const testReactError = () => {
|
||||
addResult('🧪 测试 3: 触发 React 渲染错误...');
|
||||
trackError('react_error', '组件渲染失败:测试故意错误', true, {
|
||||
component: 'TestComponent',
|
||||
stack_trace: 'Error: 测试错误\n at TestComponent...',
|
||||
});
|
||||
addResult('✅ React 错误已发送到 GA4(标记为 fatal)');
|
||||
};
|
||||
|
||||
const testNetworkError = () => {
|
||||
addResult('🧪 测试 4: 模拟网络请求错误...');
|
||||
trackError('network_error', 'Failed to fetch: https://api.example.com/data', false, {
|
||||
url: 'https://api.example.com/data',
|
||||
status_code: 0,
|
||||
request_method: 'GET',
|
||||
});
|
||||
addResult('✅ 网络错误已发送到 GA4');
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
setTestResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
||||
🧪 GA4 错误监控测试面板
|
||||
</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
测试用例
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={testJavaScriptError}
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
📛 测试 JavaScript 错误
|
||||
</button>
|
||||
<button
|
||||
onClick={testPromiseRejection}
|
||||
className="px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
⚠️ 测试 Promise 异常
|
||||
</button>
|
||||
<button
|
||||
onClick={testReactError}
|
||||
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
💥 测试 React 致命错误
|
||||
</button>
|
||||
<button
|
||||
onClick={testNetworkError}
|
||||
className="px-6 py-3 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors"
|
||||
>
|
||||
🌐 测试网络错误
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearResults}
|
||||
className="mt-4 px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🗑️ 清除日志
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-green-400 mb-4">
|
||||
📋 测试日志
|
||||
</h2>
|
||||
<div className="space-y-2 font-mono text-sm">
|
||||
{testResults.map((result, index) => (
|
||||
<div key={index} className="text-green-300">
|
||||
{result}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
💡 如何验证错误是否成功发送到 GA4?
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-yellow-700">
|
||||
<li>点击上面的测试按钮</li>
|
||||
<li>
|
||||
打开{' '}
|
||||
<a
|
||||
href="https://analytics.google.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-semibold"
|
||||
>
|
||||
Google Analytics 后台
|
||||
</a>
|
||||
</li>
|
||||
<li>导航到:报告 → 实时</li>
|
||||
<li>在事件搜索框输入 <code className="bg-yellow-100 px-1">exception</code></li>
|
||||
<li>你应该能在 30 秒内看到错误事件出现!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
🔧 开发者工具调试
|
||||
</h3>
|
||||
<p className="text-blue-700 mb-2">
|
||||
在开发模式下,打开浏览器控制台(F12),你会看到类似这样的日志:
|
||||
</p>
|
||||
<pre className="bg-blue-900 text-green-300 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{`[GA4] Error tracked: {
|
||||
description: "[javascript_error] 测试错误:这是一个故意的 JavaScript 错误",
|
||||
fatal: "false",
|
||||
url: "http://localhost:3000/test-error-tracking",
|
||||
timestamp: "2025-01-15T10:30:00.000Z",
|
||||
test_id: "test_js_error",
|
||||
filename: "test-error-page.tsx",
|
||||
lineno: 20
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +1,12 @@
|
||||
'use client';
|
||||
import { Metadata } from 'next';
|
||||
import TestErrorTrackingClient from './page-client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { trackError } from '@/lib/analytics';
|
||||
// QA 内部测试页:禁止搜索引擎收录(robots.txt 未屏蔽,需 metadata 层 noindex)
|
||||
export const metadata: Metadata = {
|
||||
title: '错误监控测试面板(内部)',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function TestErrorTrackingPage() {
|
||||
const [testResults, setTestResults] = useState<string[]>([]);
|
||||
|
||||
const addResult = (message: string) => {
|
||||
setTestResults((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${message}`]);
|
||||
};
|
||||
|
||||
const testJavaScriptError = () => {
|
||||
try {
|
||||
addResult('🧪 测试 1: 触发 JavaScript 运行时错误...');
|
||||
throw new Error('测试错误:这是一个故意的 JavaScript 错误');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
trackError('javascript_error', errorMessage, false, {
|
||||
test_id: 'test_js_error',
|
||||
filename: 'test-error-page.tsx',
|
||||
lineno: 20,
|
||||
});
|
||||
addResult('✅ JavaScript 错误已发送到 GA4');
|
||||
}
|
||||
};
|
||||
|
||||
const testPromiseRejection = () => {
|
||||
addResult('🧪 测试 2: 触发 Promise 未捕获异常...');
|
||||
Promise.reject(new Error('测试错误:Promise 故意拒绝'));
|
||||
addResult('✅ Promise 异常已触发(由 GlobalErrorTracker 自动捕获)');
|
||||
};
|
||||
|
||||
const testReactError = () => {
|
||||
addResult('🧪 测试 3: 触发 React 渲染错误...');
|
||||
trackError('react_error', '组件渲染失败:测试故意错误', true, {
|
||||
component: 'TestComponent',
|
||||
stack_trace: 'Error: 测试错误\n at TestComponent...',
|
||||
});
|
||||
addResult('✅ React 错误已发送到 GA4(标记为 fatal)');
|
||||
};
|
||||
|
||||
const testNetworkError = () => {
|
||||
addResult('🧪 测试 4: 模拟网络请求错误...');
|
||||
trackError('network_error', 'Failed to fetch: https://api.example.com/data', false, {
|
||||
url: 'https://api.example.com/data',
|
||||
status_code: 0,
|
||||
request_method: 'GET',
|
||||
});
|
||||
addResult('✅ 网络错误已发送到 GA4');
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
setTestResults([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-8">
|
||||
🧪 GA4 错误监控测试面板
|
||||
</h1>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">
|
||||
测试用例
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={testJavaScriptError}
|
||||
className="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
📛 测试 JavaScript 错误
|
||||
</button>
|
||||
<button
|
||||
onClick={testPromiseRejection}
|
||||
className="px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
⚠️ 测试 Promise 异常
|
||||
</button>
|
||||
<button
|
||||
onClick={testReactError}
|
||||
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
💥 测试 React 致命错误
|
||||
</button>
|
||||
<button
|
||||
onClick={testNetworkError}
|
||||
className="px-6 py-3 bg-purple-500 text-white rounded-lg hover:bg-purple-600 transition-colors"
|
||||
>
|
||||
🌐 测试网络错误
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={clearResults}
|
||||
className="mt-4 px-6 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
🗑️ 清除日志
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResults.length > 0 && (
|
||||
<div className="bg-gray-900 rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-green-400 mb-4">
|
||||
📋 测试日志
|
||||
</h2>
|
||||
<div className="space-y-2 font-mono text-sm">
|
||||
{testResults.map((result, index) => (
|
||||
<div key={index} className="text-green-300">
|
||||
{result}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-yellow-800 mb-2">
|
||||
💡 如何验证错误是否成功发送到 GA4?
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-yellow-700">
|
||||
<li>点击上面的测试按钮</li>
|
||||
<li>
|
||||
打开{' '}
|
||||
<a
|
||||
href="https://analytics.google.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-semibold"
|
||||
>
|
||||
Google Analytics 后台
|
||||
</a>
|
||||
</li>
|
||||
<li>导航到:报告 → 实时</li>
|
||||
<li>在事件搜索框输入 <code className="bg-yellow-100 px-1">exception</code></li>
|
||||
<li>你应该能在 30 秒内看到错误事件出现!</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-2">
|
||||
🔧 开发者工具调试
|
||||
</h3>
|
||||
<p className="text-blue-700 mb-2">
|
||||
在开发模式下,打开浏览器控制台(F12),你会看到类似这样的日志:
|
||||
</p>
|
||||
<pre className="bg-blue-900 text-green-300 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{`[GA4] Error tracked: {
|
||||
description: "[javascript_error] 测试错误:这是一个故意的 JavaScript 错误",
|
||||
fatal: "false",
|
||||
url: "http://localhost:3000/test-error-tracking",
|
||||
timestamp: "2025-01-15T10:30:00.000Z",
|
||||
test_id: "test_js_error",
|
||||
filename: "test-error-page.tsx",
|
||||
lineno: 20
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <TestErrorTrackingClient />;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [pathname]);
|
||||
}, [pathname]); // eslint-disable-line react-hooks/exhaustive-deps -- getExpandedMenusForPath 为组件内函数(引用随渲染变化),effect 已用 prevPathname 变更保护,刻意不列入依赖
|
||||
|
||||
const toggleMenu = (label: string) => {
|
||||
setExpandedMenus((prev) => {
|
||||
|
||||
@@ -160,14 +160,14 @@ export function CookieConsent() {
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-bg)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
管理偏好
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRejectAll}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-bg)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
仅必要
|
||||
</button>
|
||||
@@ -196,7 +196,7 @@ export function CookieConsent() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-bg)] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
@@ -215,7 +215,7 @@ export function CookieConsent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-bg)] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.analytics}
|
||||
@@ -231,7 +231,7 @@ export function CookieConsent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg opacity-50">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-bg)] rounded-lg opacity-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.marketing}
|
||||
@@ -252,7 +252,7 @@ export function CookieConsent() {
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-bg)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
@@ -288,7 +288,7 @@ export function CookieSettingsButton() {
|
||||
const event = new CustomEvent('open-cookie-settings');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-brand-lighter)] transition-colors"
|
||||
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-brand-bg)] transition-colors"
|
||||
aria-label="Cookie 设置"
|
||||
>
|
||||
Cookie 设置
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface BrandSealProps {
|
||||
text?: string;
|
||||
variant?: 'red' | 'gold' | 'blue';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function BrandSeal({ text = '旗舰', variant = 'red', size = 'md' }: BrandSealProps) {
|
||||
const sizes = {
|
||||
sm: 'w-14 h-14 text-xs',
|
||||
md: 'w-20 h-20 text-sm',
|
||||
lg: 'w-28 h-28 text-base',
|
||||
};
|
||||
|
||||
const colors = {
|
||||
red: {
|
||||
border: '#C41E3A',
|
||||
bg: 'rgba(196, 30, 58, 0.06)',
|
||||
text: '#C41E3A',
|
||||
shadow: 'rgba(196, 30, 58, 0.2)',
|
||||
},
|
||||
gold: {
|
||||
border: '#d97706',
|
||||
bg: 'rgba(217, 119, 6, 0.06)',
|
||||
text: '#d97706',
|
||||
shadow: 'rgba(217, 119, 6, 0.2)',
|
||||
},
|
||||
blue: {
|
||||
border: '#2563eb',
|
||||
bg: 'rgba(37, 99, 235, 0.06)',
|
||||
text: '#2563eb',
|
||||
shadow: 'rgba(37, 99, 235, 0.2)',
|
||||
},
|
||||
};
|
||||
|
||||
const color = colors[variant];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.5, rotate: -15 }}
|
||||
whileInView={{ opacity: 1, scale: 1, rotate: -12 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.5,
|
||||
ease: [0.34, 1.56, 0.64, 1] as const,
|
||||
}}
|
||||
className={`relative ${sizes[size]} flex items-center justify-center rounded-lg border-2 font-bold select-none`}
|
||||
style={{
|
||||
borderColor: color.border,
|
||||
backgroundColor: color.bg,
|
||||
color: color.text,
|
||||
boxShadow: `0 4px 16px ${color.shadow}`,
|
||||
fontFamily: "'Noto Serif SC', serif",
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-[4px] border border-current opacity-20 rounded-md" />
|
||||
<span
|
||||
className="relative z-10 tracking-widest"
|
||||
style={{ writingMode: 'vertical-rl' }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
<div
|
||||
className="absolute inset-0 rounded-lg opacity-0 hover:opacity-100 transition-opacity duration-500"
|
||||
style={{
|
||||
background: `conic-gradient(from 0deg, transparent 0%, ${color.border}10 50%, transparent 100%)`,
|
||||
animation: 'rotate 8s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CalligraphyTextProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
as?: 'h1' | 'h2' | 'h3' | 'span';
|
||||
}
|
||||
|
||||
export function CalligraphyText({
|
||||
children,
|
||||
className = '',
|
||||
as: Tag = 'span',
|
||||
}: CalligraphyTextProps) {
|
||||
return (
|
||||
<Tag
|
||||
className={`font-calligraphy ${className}`}
|
||||
style={{
|
||||
fontFamily: "'Noto Serif SC', 'STKaiti', 'KaiTi', serif",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
badge?: string;
|
||||
title: ReactNode;
|
||||
subtitle?: string;
|
||||
align?: 'left' | 'center';
|
||||
icon?: LucideIcon;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
badge,
|
||||
title,
|
||||
subtitle,
|
||||
align = 'center',
|
||||
icon: Icon,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 28 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-80px' }}
|
||||
transition={{ duration: 0.75, ease: [0.22, 1, 0.36, 1] as const }}
|
||||
className={`mb-16 ${align === 'center' ? 'text-center max-w-3xl mx-auto' : ''}`}
|
||||
>
|
||||
{(badge || Icon) && (
|
||||
<div className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-red-50 text-brand text-sm font-bold tracking-wider uppercase mb-5 ${
|
||||
align === 'center' ? '' : ''
|
||||
}`}>
|
||||
{Icon && <Icon className="w-4 h-4" />}
|
||||
{badge}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-tight text-gray-900 mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-600 leading-relaxed">{subtitle}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { ArrowRight, Package, Lightbulb, Wrench } from 'lucide-react';
|
||||
import type { CrossReference } from '@/lib/constants/cross-references';
|
||||
|
||||
interface CrossRecommendGridProps {
|
||||
items: CrossReference[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const typeConfig = {
|
||||
product: {
|
||||
icon: Package,
|
||||
label: '产品',
|
||||
color: '#3b82f6',
|
||||
bg: '#eff6ff',
|
||||
},
|
||||
solution: {
|
||||
icon: Lightbulb,
|
||||
label: '方案',
|
||||
color: '#C41E3A',
|
||||
bg: '#FEF2F4',
|
||||
},
|
||||
service: {
|
||||
icon: Wrench,
|
||||
label: '服务',
|
||||
color: '#7c3aed',
|
||||
bg: '#f5f3ff',
|
||||
},
|
||||
};
|
||||
|
||||
export function CrossRecommendGrid({ items, title = '相关推荐' }: CrossRecommendGridProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="bg-bg-secondary py-16 sm:py-20 md:py-28">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="mb-12"
|
||||
>
|
||||
<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">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-black text-ink tracking-tight leading-[0.95]">
|
||||
探索更多可能
|
||||
</h2>
|
||||
<p className="text-text-secondary mt-3">
|
||||
发现与您需求相关的产品、方案和服务
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item, index) => {
|
||||
const config = typeConfig[item.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<motion.a
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.4, delay: index * 0.08 }}
|
||||
className="bain-card flex items-start gap-4 p-5 group"
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center"
|
||||
style={{ backgroundColor: config.bg, color: config.color }}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{
|
||||
color: config.color,
|
||||
backgroundColor: `${config.color}15`,
|
||||
}}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
<ArrowRight className="h-3 w-3 text-text-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-ink">{item.title}</h3>
|
||||
<p className="mt-1 text-xs leading-relaxed text-text-muted">
|
||||
{item.reason}
|
||||
</p>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -261,12 +261,6 @@ const mockProduct = {
|
||||
],
|
||||
};
|
||||
|
||||
const mockCrossRefItems = [
|
||||
{ id: 's1', title: '智能制造方案', type: 'solution' as const, href: '/solutions/s1', reason: 'ERP 是核心组件' },
|
||||
{ id: 'p2', title: 'CRM 系统', type: 'product' as const, href: '/products/crm', reason: '常与 ERP 一起使用' },
|
||||
{ id: 'sv1', title: '软件定制开发', type: 'service' as const, href: '/services/sv1', reason: '配套服务' },
|
||||
];
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DetailHero', () => {
|
||||
@@ -573,58 +567,4 @@ describe('DetailCTASection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CrossRecommendGrid', () => {
|
||||
it('should render nothing when items is empty', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
const { container } = render(<CrossRecommendGrid items={[]} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('should render default title', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
expect(screen.getByText('相关推荐')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render custom title', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} title="更多推荐" />);
|
||||
expect(screen.getByText('更多推荐')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all items', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
expect(screen.getByText('智能制造方案')).toBeInTheDocument();
|
||||
expect(screen.getByText('CRM 系统')).toBeInTheDocument();
|
||||
expect(screen.getByText('软件定制开发')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render reason for each item', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
expect(screen.getByText('ERP 是核心组件')).toBeInTheDocument();
|
||||
expect(screen.getByText('常与 ERP 一起使用')).toBeInTheDocument();
|
||||
expect(screen.getByText('配套服务')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correct type labels', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
// Type labels: product=产品, solution=方案, service=服务
|
||||
expect(screen.getByText('产品')).toBeInTheDocument();
|
||||
// There might be multiple "方案" texts in the page, so use getAllByText
|
||||
const solutionLabels = screen.getAllByText('方案');
|
||||
expect(solutionLabels.length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('服务')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render links for items', async () => {
|
||||
const { CrossRecommendGrid } = await import('./detail-cross-recommend');
|
||||
render(<CrossRecommendGrid items={mockCrossRefItems} />);
|
||||
const solutionLink = screen.getByText('智能制造方案').closest('a');
|
||||
expect(solutionLink).toHaveAttribute('href', '/solutions/s1');
|
||||
const productLink = screen.getByText('CRM 系统').closest('a');
|
||||
expect(productLink).toHaveAttribute('href', '/products/crm');
|
||||
});
|
||||
});
|
||||
// 注:CrossRecommendGrid 相关测试已随组件删除(2026-08-31,组件生产 0 引用)
|
||||
@@ -8,7 +8,6 @@ export { DetailCTASection } from './detail-cta-section';
|
||||
|
||||
export { CaseStudyCard } from './detail-case-study';
|
||||
export { CertificationList } from './detail-certification';
|
||||
export { CrossRecommendGrid } from './detail-cross-recommend';
|
||||
|
||||
export { SolutionValueSection } from './solution-value';
|
||||
export { ServiceValueSection } from './service-value';
|
||||
@@ -19,4 +18,5 @@ export { SolutionCard } from './solution-service-card';
|
||||
|
||||
export { TiltCard, PressableButton, HoverLink } from './micro-interactions';
|
||||
|
||||
export { BrandSeal, CalligraphyText, SectionHeader } from './brand-elements';
|
||||
// 注:brand-elements.tsx(BrandSeal/CalligraphyText/SectionHeader)与
|
||||
// detail-cross-recommend.tsx(CrossRecommendGrid)此前 0 消费者,已于 2026-08-31 删除。
|
||||
|
||||
@@ -36,6 +36,7 @@ export function ProductCard({ product }: ProductCardProps) {
|
||||
src={product.image}
|
||||
alt={product.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="p-3 rounded-md hover:bg-[var(--color-brand-lighter)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
className="p-3 rounded-md hover:bg-[var(--color-brand-bg)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="mobile-menu-panel"
|
||||
@@ -101,7 +101,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand)] hover:bg-[var(--color-brand-bg)] rounded-md transition-colors"
|
||||
>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{sub.title}</span>
|
||||
<span className="block text-xs text-[var(--color-text-hint)] mt-0.5">{sub.description}</span>
|
||||
<span className="block text-xs text-[var(--color-text-muted)] mt-0.5">{sub.description}</span>
|
||||
</StaticLink>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -83,16 +83,18 @@ describe('CaseCard', () => {
|
||||
it('should render background image when provided', async () => {
|
||||
const { CaseCard } = await import('./case-card');
|
||||
const { container } = render(<CaseCard {...mockCase} />);
|
||||
const img = container.querySelector('img[alt=""]');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/images/cases/manufacturing.jpg');
|
||||
// 组件用 CSS 背景图而非 <img>,使 404 图片静默降级为纯色底
|
||||
const bg = container.querySelector('[style*="background-image"]');
|
||||
expect(bg).toBeInTheDocument();
|
||||
expect(bg).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(bg).toHaveStyle({ backgroundImage: 'url(/images/cases/manufacturing.jpg)' });
|
||||
});
|
||||
|
||||
it('should not render background image when not provided', async () => {
|
||||
const { CaseCard } = await import('./case-card');
|
||||
const { container } = render(<CaseCard {...mockCase} image={undefined} />);
|
||||
const img = container.querySelector('img[alt=""]');
|
||||
expect(img).not.toBeInTheDocument();
|
||||
const bg = container.querySelector('[style*="background-image"]');
|
||||
expect(bg).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use context as fallback when challenge is empty', async () => {
|
||||
|
||||
@@ -48,14 +48,11 @@ export function CaseCard({
|
||||
>
|
||||
{/* Background image */}
|
||||
{image && (
|
||||
<div className="absolute inset-0 transition-opacity duration-500 opacity-[0.02] group-hover:opacity-[0.05]">
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ backgroundImage: `url(${image})` }}
|
||||
className="absolute inset-0 bg-cover bg-center transition-opacity duration-500 opacity-[0.02] group-hover:opacity-[0.05]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-col flex-1">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { SectionHeader } from '@/components/sections/section-header';
|
||||
import { ServiceCard } from '@/components/sections/service-card';
|
||||
import { ScrollProgress } from '@/components/ui/scroll-progress';
|
||||
@@ -175,10 +176,10 @@ function CaseDetailHero({ data }: CaseDetailHeroProps) {
|
||||
transition={{ duration: 0.8, delay: 0.8, ease: EASE }}
|
||||
>
|
||||
<Button size="lg" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
咨询类似方案
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<a href="#solution">查看方案</a>
|
||||
@@ -512,13 +513,13 @@ function CTASection() {
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||
<Button size="lg" asChild>
|
||||
<a href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
免费咨询
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</a>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" asChild>
|
||||
<a href="/cases">查看更多案例</a>
|
||||
<StaticLink href="/cases">查看更多案例</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
@@ -75,9 +75,11 @@ describe('InsightCard', () => {
|
||||
const { container } = render(
|
||||
<InsightCard {...mockInsight} featured image="/images/insights/trends.jpg" />
|
||||
);
|
||||
const img = container.querySelector('img');
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute('src', '/images/insights/trends.jpg');
|
||||
// 组件用 CSS 背景图而非 <img>,使 404 图片静默降级为纯色底
|
||||
const bg = container.querySelector('[style*="background-image"]');
|
||||
expect(bg).toBeInTheDocument();
|
||||
expect(bg).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(bg).toHaveStyle({ backgroundImage: 'url(/images/insights/trends.jpg)' });
|
||||
});
|
||||
|
||||
it('should render link element when provided', async () => {
|
||||
|
||||
@@ -38,11 +38,10 @@ export function InsightCard({
|
||||
>
|
||||
{/* Background image */}
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="absolute inset-0 w-full h-full object-cover transition-transform duration-700 group-hover:scale-105 opacity-10"
|
||||
loading="lazy"
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ backgroundImage: `url(${image})` }}
|
||||
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105 opacity-10"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ServiceCard({
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`group relative bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] rounded-[var(--radius-lg)] overflow-hidden transition-all duration-[var(--transition-normal)] ease-[var(--ease-ink)] hover:-translate-y-1 hover:shadow-[var(--shadow-lg)] hover:border-[var(--color-brand)]/20 ${className}`}
|
||||
className={`group relative bg-[var(--color-bg-primary)] border border-[var(--color-border-primary)] rounded-[var(--radius-lg)] overflow-hidden transition-all duration-normal ease-ink hover:-translate-y-1 hover:shadow-[var(--shadow-lg)] hover:border-[var(--color-brand)]/20 ${className}`}
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 24 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-40px' }}
|
||||
@@ -50,11 +50,11 @@ export function ServiceCard({
|
||||
{/* Link */}
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex items-center text-sm font-medium transition-all duration-[var(--transition-fast)] group-hover:gap-2.5 text-[var(--color-brand)]"
|
||||
className="inline-flex items-center text-sm font-medium transition-all duration-fast group-hover:gap-2.5 text-[var(--color-brand)]"
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<span>了解详情</span>
|
||||
<ArrowRight className="w-4 h-4 transform transition-transform duration-[var(--transition-fast)] group-hover:translate-x-1" />
|
||||
<ArrowRight className="w-4 h-4 transform transition-transform duration-fast group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -73,11 +73,11 @@ export function StatsBar({ items, dark = false, className = '' }: StatsBarProps)
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="py-10 sm:py-12 md:py-14 px-4 sm:px-6 md:px-8 text-center transition-all duration-[var(--transition-normal)] ease-[var(--ease-ink)] hover:bg-[var(--color-bg-hover)] group"
|
||||
className="py-10 sm:py-12 md:py-14 px-4 sm:px-6 md:px-8 text-center transition-all duration-normal ease-ink hover:bg-[var(--color-bg-hover)] group"
|
||||
style={{ background: dark ? 'var(--color-brand)' : 'var(--color-bg-primary)' }}
|
||||
>
|
||||
<div
|
||||
className="font-sans tabular-nums text-[clamp(36px,8vw,64px)] font-bold leading-none mb-1 md:mb-2 tracking-[-0.02em] transition-all duration-[var(--transition-slow)] ease-[var(--ease-ink)] group-hover:scale-105"
|
||||
className="font-sans tabular-nums text-[clamp(36px,8vw,64px)] font-bold leading-none mb-1 md:mb-2 tracking-[-0.02em] transition-all duration-slow ease-ink group-hover:scale-105"
|
||||
style={{
|
||||
color: dark ? '#FFFFFF' : 'var(--color-text-primary)',
|
||||
transform: inView ? 'scale(1)' : 'scale(0.9)',
|
||||
@@ -101,7 +101,7 @@ export function StatsBar({ items, dark = false, className = '' }: StatsBarProps)
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-[12px] sm:text-[13px] font-medium mb-3 md:mb-4 tracking-[0.08em] uppercase transition-all duration-[var(--transition-normal)]"
|
||||
className="text-[12px] sm:text-[13px] font-medium mb-3 md:mb-4 tracking-[0.08em] uppercase transition-all duration-normal"
|
||||
style={{
|
||||
color: dark ? 'rgba(255,255,255,0.5)' : 'var(--color-text-muted)',
|
||||
opacity: inView ? 1 : 0,
|
||||
@@ -113,7 +113,7 @@ export function StatsBar({ items, dark = false, className = '' }: StatsBarProps)
|
||||
</div>
|
||||
{item.desc && (
|
||||
<div
|
||||
className="text-[12px] sm:text-[13px] leading-relaxed transition-all duration-[var(--transition-normal)]"
|
||||
className="text-[12px] sm:text-[13px] leading-relaxed transition-all duration-normal"
|
||||
style={{
|
||||
color: dark ? 'rgba(255,255,255,0.35)' : 'var(--color-text-subtle)',
|
||||
opacity: inView ? 1 : 0,
|
||||
|
||||
@@ -174,7 +174,7 @@ export function WhyUsSection() {
|
||||
{tech.abbr}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-[var(--color-text-hint)]">{tech.name}</span>
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">{tech.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -14,11 +14,12 @@ const alertVariants = cva(
|
||||
default: 'bg-bg-primary text-text-primary border-border-primary',
|
||||
destructive:
|
||||
'text-error bg-error-bg border-error [&>svg]:text-current',
|
||||
/* 状态色 DEFAULT 仅 3.2-3.7:1,作文字不达 AA,故统一用 -text 变体 */
|
||||
success:
|
||||
'text-success bg-success-bg border-success [&>svg]:text-current',
|
||||
'text-success-text bg-success-bg border-success [&>svg]:text-current',
|
||||
warning:
|
||||
'text-warning bg-warning-bg border-warning [&>svg]:text-current',
|
||||
info: 'text-info bg-info-bg border-info [&>svg]:text-current',
|
||||
'text-warning-text bg-warning-bg border-warning [&>svg]:text-current',
|
||||
info: 'text-info-text bg-info-bg border-info [&>svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -16,10 +16,11 @@ const badgeVariants = cva(
|
||||
outline:
|
||||
'border-border-accent text-text-primary bg-transparent',
|
||||
ghost: 'text-text-muted hover:text-text-primary hover:bg-bg-secondary',
|
||||
success: 'bg-success/10 text-success border-transparent',
|
||||
warning: 'bg-warning/10 text-warning border-transparent',
|
||||
/* 状态色 DEFAULT 仅 3.2-3.7:1,作文字不达 AA,故统一用 -text 变体 */
|
||||
success: 'bg-success/10 text-success-text border-transparent',
|
||||
warning: 'bg-warning/10 text-warning-text border-transparent',
|
||||
error: 'bg-error/10 text-error border-transparent',
|
||||
info: 'bg-info/10 text-info border-transparent',
|
||||
info: 'bg-info/10 text-info-text border-transparent',
|
||||
},
|
||||
size: {
|
||||
sm: 'px-2 py-0.5 text-[10px]',
|
||||
|
||||
@@ -24,11 +24,11 @@ const buttonVariants = cva(
|
||||
'bg-error text-white shadow-sm hover:bg-error-hover hover:-translate-y-px active:translate-y-0 active:scale-[0.97] transition-all duration-fast ease-standard',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-9 rounded-md px-3 text-xs font-medium',
|
||||
default: 'h-11 rounded-md px-5 py-2 font-medium',
|
||||
lg: 'h-12 rounded-md px-6 text-base font-semibold',
|
||||
xl: 'h-14 rounded-md px-8 text-base font-semibold',
|
||||
icon: 'h-11 w-11 rounded-md',
|
||||
sm: 'h-9 rounded-full px-3 text-xs font-medium',
|
||||
default: 'h-11 rounded-full px-5 py-2 font-medium',
|
||||
lg: 'h-12 rounded-full px-6 text-base font-semibold',
|
||||
xl: 'h-14 rounded-full px-8 text-base font-semibold',
|
||||
icon: 'h-11 w-11 rounded-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -18,13 +18,13 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
return (
|
||||
<div className="relative w-14 h-20 sm:w-16 sm:h-24 md:w-20 md:h-28 perspective-1000">
|
||||
{/* 背景卡片 - 静态显示当前数字 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] via-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-lg shadow-xl overflow-hidden border border-[var(--color-flip-card-border)]">
|
||||
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] border-b border-[var(--color-flip-card-border)] overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] via-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] rounded-lg shadow-xl overflow-hidden border border-[var(--color-border-primary)]">
|
||||
<div className="absolute top-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] border-b border-[var(--color-border-primary)] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
|
||||
{digit}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] overflow-hidden">
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center text-4xl sm:text-5xl md:text-6xl font-bold text-[var(--color-brand)]">
|
||||
{digit}
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
animate={{ rotateX: -180 }}
|
||||
exit={{ rotateX: -180 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-t-lg overflow-hidden border-t border-l border-r border-[var(--color-flip-card-border)]"
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] rounded-t-lg overflow-hidden border-t border-l border-r border-[var(--color-border-primary)]"
|
||||
style={{
|
||||
transformOrigin: 'bottom',
|
||||
backfaceVisibility: 'hidden',
|
||||
@@ -58,7 +58,7 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
animate={{ rotateX: 0 }}
|
||||
exit={{ rotateX: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-flip-card-bg)] rounded-b-lg overflow-hidden border-b border-l border-r border-[var(--color-flip-card-border)]"
|
||||
className="absolute inset-0 bg-gradient-to-b from-[var(--color-bg-primary)] to-[var(--color-bg-secondary)] rounded-b-lg overflow-hidden border-b border-l border-r border-[var(--color-border-primary)]"
|
||||
style={{
|
||||
transformOrigin: 'top',
|
||||
backfaceVisibility: 'hidden',
|
||||
@@ -70,9 +70,9 @@ function FlipDigit({ digit, prevDigit }: FlipDigitProps) {
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-[var(--color-flip-card-divider)] transform -translate-y-1/2" />
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-[var(--color-flip-card-divider-subtle)]/50" />
|
||||
<div className="absolute top-1/2 left-0 right-0 h-px bg-[var(--color-border-primary)] transform -translate-y-1/2" />
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-[var(--color-border-light)]/50" />
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-[var(--color-border-light)]/50" />
|
||||
|
||||
{/* 侧面阴影增强立体感 */}
|
||||
<div className="absolute inset-0 rounded-lg shadow-[inset_0_2px_4px_rgba(0,0,0,0.1)] pointer-events-none" />
|
||||
|
||||
@@ -15,7 +15,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
data-slot="input"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-text-hint selection:bg-brand selection:text-white',
|
||||
'file:text-foreground placeholder:text-text-placeholder selection:bg-brand selection:text-white',
|
||||
'flex h-11 w-full min-w-0 rounded-md border border-border-primary bg-bg-primary px-3 py-1 text-base',
|
||||
'transition-all duration-fast outline-none',
|
||||
'file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
|
||||
@@ -35,8 +35,8 @@ const defaultConfig = {
|
||||
|
||||
const statusConfig = {
|
||||
'研发中': { bg: 'rgba(var(--color-brand-rgb), 0.06)', text: 'var(--color-brand)', border: 'rgba(var(--color-brand-rgb), 0.12)' },
|
||||
'内测中': { bg: 'rgba(var(--color-warning-rgb), 0.06)', text: 'var(--color-warning)', border: 'rgba(var(--color-warning-rgb), 0.12)' },
|
||||
'已发布': { bg: 'rgba(var(--color-success-rgb), 0.06)', text: 'var(--color-success)', border: 'rgba(var(--color-success-rgb), 0.12)' },
|
||||
'内测中': { bg: 'rgba(var(--color-warning-rgb), 0.06)', text: 'var(--color-warning-text)', border: 'rgba(var(--color-warning-rgb), 0.12)' },
|
||||
'已发布': { bg: 'rgba(var(--color-success-rgb), 0.06)', text: 'var(--color-success-text)', border: 'rgba(var(--color-success-rgb), 0.12)' },
|
||||
} as const;
|
||||
|
||||
export function ProductCard({ title, description, href, index, status }: ProductCardProps) {
|
||||
|
||||
@@ -22,10 +22,10 @@ const SelectTrigger = React.forwardRef<
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
'flex h-11 w-full items-center justify-between rounded-md border border-border-primary bg-bg-primary px-3 py-2 text-sm shadow-sm',
|
||||
'placeholder:text-text-hint',
|
||||
'placeholder:text-text-placeholder',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[placeholder]:text-text-hint',
|
||||
'data-[placeholder]:text-text-placeholder',
|
||||
'transition-colors duration-fast hover:border-border-secondary',
|
||||
'[&>span]:line-clamp-1',
|
||||
className
|
||||
|
||||