feat(ui): CTAButton 可复用组件 + P5 全站 CTA 签名切换(17 页面 27 处)

feat(ui): 抽离 CTAButton 为 src/components/ui/cta-button.tsx
- 4 variant 覆盖全站 CTA 场景:
  * primary:品牌红填充 pill(hero 主行动 / 底部大 CTA)
  * secondary:描边墨色 pill(浅色 section 次行动)
  * dark:描边浅色 pill(深色区块 bg-dark-bg 内次行动,如 brand/team 底部 CTA)
  * inline:透明 + 小箭头(卡片内次行动)
- 品牌箭头签名化:内置 ArrowRight + hover translate-x 微交互
- 200-300ms transition,对齐项目动效四原则
- data-testid 透传避免破坏既有测试

style(cta): P5 全站 CTA 签名切换(17 页面 27 处)
- home V15 / news×3 / products×4(含 erp)/ cases / services×2 / solutions×2
- brand / methodology / team(hero 2 + L4 2 = 4 处,含 dark variant)
- error / not-found(保留 onClick 功能按钮与卡片式导航 StaticLink)
- 清理:StaticLink / Button / ArrowRight / ChevronRight unused import

test(marketing): services/solutions lucide-react mock 补 ArrowRight
- CTAButton 内部用 ArrowRight,原 mock 漏导导致渲染崩(4 测试失败已修)
- 对齐 home-content-v15.test.tsx 既有 mock 范式
This commit is contained in:
2026-08-31 17:25:08 +08:00
parent 011189a38c
commit 5c1b883abf
21 changed files with 215 additions and 268 deletions
+84
View File
@@ -0,0 +1,84 @@
'use client';
import * as React from 'react';
import { ArrowRight } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* CTAButton — 全站统一的「行动召唤」组件
*
* 设计意图(见 .impeccable.md / Phase B P5):
* - Pill 化(rounded-full),与项目 Button 组件 size 体系一致
* - 品牌箭头签名化:内置 ArrowRight,hover 时 translate-x 形成微交互
* - 四变体覆盖全站 CTA 场景,避免"全是大红 pill"的 UI 噪音:
* - primary:品牌红填充 pill + 大箭头(hero 主行动 / 底部大 CTA)
* - secondary:描边墨色 pill + 大箭头(浅色 section 次行动 / 跳转按钮)
* - dark:描边浅色 pill + 大箭头(深色区块(bg-dark-bg)内的次行动,如 brand/team 底部 CTA)
* - inline:透明 + 小箭头 + 文字色(卡片内"了解更多"轻量次行动)
*
* dark 变体于 2026-08-31 P5 批次 4 新增:brand/team 底部深色 CTA 区块的次行动此前用
* text-dark-text-primary + border-dark-border 手写描边,现统一进组件,避免深色背景上误用
* secondary(text-ink)导致不可见。
* - 200-300ms transition(动效四原则区间内)
* - 主行动(primary)触达品牌红 #C41E3A 单电压,遵循"每页 ≥ 3 处品牌红触达点,面积 ≤ 10%"约束
*
* 抽离自 src/app/(marketing)/home-content-v15.tsx (2026-08-31),原 primary/secondary 行为完全保留;
* 新增 inline 变体支持次行动场景;data-testid 透传避免破坏既有测试快照。
*/
export type CTAButtonVariant = 'primary' | 'secondary' | 'dark' | 'inline';
export interface CTAButtonProps {
children: React.ReactNode;
href: string;
variant?: CTAButtonVariant;
className?: string;
dataTestId?: string;
/** 内部 span 的额外类名(用于覆盖默认间距/字号等极端定制场景) */
innerClassName?: string;
}
const VARIANT_STYLES: Record<CTAButtonVariant, string> = {
primary:
'bg-brand text-white hover:bg-brand-hover px-10 py-4 font-semibold text-base',
secondary:
'text-ink border border-ink/20 hover:border-ink/40 hover:bg-ink/5 px-10 py-4 font-semibold text-base',
dark:
'text-dark-text-primary border border-dark-border hover:border-dark-text-muted hover:bg-white/5 px-10 py-4 font-semibold text-base',
inline:
'text-text-primary hover:text-brand px-0 py-0 font-medium text-sm bg-transparent border-0',
};
const ARROW_STYLES: Record<CTAButtonVariant, string> = {
primary: 'w-5 h-5 transition-transform duration-300 group-hover:translate-x-2',
secondary: 'w-5 h-5 transition-transform duration-300 group-hover:translate-x-2',
dark: 'w-5 h-5 transition-transform duration-300 group-hover:translate-x-2',
inline: 'w-4 h-4 transition-transform duration-300 group-hover:translate-x-1',
};
export function CTAButton({
children,
href,
variant = 'primary',
className,
dataTestId,
innerClassName,
}: CTAButtonProps) {
const isPill = variant !== 'inline';
return (
<a
href={href}
data-testid={dataTestId}
className={cn(
'group inline-flex items-center gap-3 transition-all duration-200',
isPill ? 'rounded-full' : '',
VARIANT_STYLES[variant],
className
)}
>
<span className={cn(innerClassName)}>{children}</span>
<ArrowRight className={ARROW_STYLES[variant]} />
</a>
);
}
export default CTAButton;