Files
novalon-website/src/app/(marketing)/solutions/solutions-content-v3.tsx
T
zhangxiang d959fa9a81 refactor(theme): 营销页 bg-white token 化(暗黑模式 Phase 2 · marketing 批次)
问题:Tailwind 原生 bg-white 是硬编码值,深色主题下不翻转,
导致整页/整 section 恒为纯白,与转深色的页头页脚割裂。
(globals.css:--color-bg-primary-rgb 浅色 255 255 255 → 深色 10 14 20)

改动:10 文件 67 处 bg-white → 项目 token,按语义二分类
- bg-bg-primary  (33 处):页面/大块骨架(min-h-screen 容器、<section>、<main>)
- bg-bg-elevated (34 处):浮起层(带 border 的卡片、表单输入、图标盒、grid 单元格)
  --color-bg-elevated 浅色 #FFFFFF → 深色 #151B23,同样翻转。

浅色主题下 bg-white / bg-bg-primary / bg-bg-elevated 三者同值(纯白),
故本次改动在浅色主题下零像素差异,只修复深色主题。

新增审计工具:
- scripts/audit/hardcoded-color-audit.mjs:硬编码颜色分级盘点
  (含 alpha 误报修正:bg-white/NN 半透明叠加两主题均成立,判 INFO 非 P0)
- scripts/audit/darkmode-bg-verify.mjs:直接验证背景随主题翻转
  (浅色应纯白、深色应 rgb(10,14,20) 且无残留纯白块)

审计结果:marketing P0 67 → 0;全站 P0 97 → 30
(剩余 components 16 / other 8 / layout 3 / ui-kit 3 为第二批;admin 本就为 0)

门禁:
- tsc 0 error
- eslint 0 error(50 warnings 全为既有,改动文件未新增)
- jest 129/130 套件通过,1548 通过 / 2 跳过 / **0 断言失败**
  唯一失败 src/lib/admin-api.test.ts 为**沙箱环境限制非代码问题**:
  CODEBUDDY_BROKER_DENY 拦截 readFileSync,单独复验同样失败,
  与本次改动零交集(改动仅限 src/app/(marketing)/ 下 10 个页面组件)
- 暗黑模式背景验证 18/18 PASS(9 路由 × 浅深双版)
2026-09-20 11:50:58 +08:00

261 lines
12 KiB
TypeScript

'use client';
import { motion } from 'framer-motion';
import { ArrowUpRight, Factory, ShoppingCart, Heart, GraduationCap, Shield, Truck, type LucideIcon } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { CTAButton } from '@/components/ui/cta-button';
import { type Solution } from '@/lib/constants/solutions';
import type { Product } from '@/lib/constants/products';
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
type PageCopy = Record<string, unknown>;
const INDUSTRY_ICONS: Record<string, LucideIcon> = {
制造业: Factory,
贸易零售: ShoppingCart,
医疗健康: Heart,
教育培训: GraduationCap,
金融服务: Shield,
物流运输: Truck,
};
function HeroSection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const heroTitle1 = (pageCopy?.solutionsHeroTitle1 as string) || '专注行业场景的';
const heroTitle2 = (pageCopy?.solutionsHeroTitle2 as string) || '解决方案';
const heroDescriptionLines = ((pageCopy?.solutionsHeroDescription as string) ||
'端到端交付可量化的业务成果。\n基于自研产品矩阵,为制造、零售、教育、医疗、金融、物流六大行业沉淀最佳实践。').split('\n');
const ctaPrimary = (pageCopy?.solutionsCtaPrimary as string) || '立即咨询';
const ctaSecondary = (pageCopy?.solutionsCtaSecondary as string) || '浏览产品';
return (
<section className="relative min-h-[85vh] flex items-center overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-28 sm:py-36 md:py-44 lg:py-56">
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl font-black text-ink leading-[0.92] tracking-tightest mb-10 sm:mb-12 md:mb-16"
>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
<span className="relative inline-block">
<span className="relative text-brand font-black">
{heroTitle2}
<motion.span
className="absolute -bottom-2 left-0 w-full h-[3px] bg-brand"
style={{ transformOrigin: 'left' }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: 0.3, delay: 1.2, ease: EASE_OUT }}
/>
</span>
</span>
</div>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.5, ease: EASE_OUT }}
className="text-lg md:text-xl text-text-secondary max-w-2xl leading-relaxed mb-12 sm:mb-16"
>
{heroDescriptionLines.map((line, i) => (
<span key={i}>
{i > 0 && <br className="hidden sm:block" />}
{line}
</span>
))}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7, ease: EASE_OUT }}
className="flex flex-col sm:flex-row gap-4 sm:gap-6"
>
<CTAButton href="/contact" dataTestId="solutions-hero-primary" className="min-h-[52px]">
{ctaPrimary}
</CTAButton>
<CTAButton href="/products" variant="secondary" dataTestId="solutions-hero-secondary" className="min-h-[52px]">
{ctaSecondary}
</CTAButton>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.9, ease: EASE_OUT }}
className="grid grid-cols-3 gap-8 mt-20 pt-12 border-t border-border-primary max-w-xl"
>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
6<span className="text-brand"></span>
</div>
<div className="text-sm text-text-muted">行业覆盖</div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
端到<span className="text-brand"></span>
</div>
<div className="text-sm text-text-muted">交付模式</div>
</div>
<div>
<div className="text-3xl sm:text-4xl font-black text-ink tracking-tight leading-none mb-2">
定制<span className="text-brand"></span>
</div>
<div className="text-sm text-text-muted">方案特点</div>
</div>
</motion.div>
</div>
</div>
</section>
);
}
function SolutionCard({ solution, index, products, featured = false }: { solution: Solution; index: number; products?: Product[]; featured?: boolean }) {
const productLookup = products ?? [];
const Icon = INDUSTRY_ICONS[solution.industry] || Factory;
const recommendedProductNames = (solution.recommendedProducts ?? [])
.map((pid) => productLookup.find((p) => p.id === pid)?.title)
.filter(Boolean) as string[];
return (
<motion.div
data-testid={`solution-card-${solution.id}`}
data-featured={featured ? 'true' : 'false'}
className={featured ? 'md:col-span-2 bg-bg-elevated' : 'md:col-span-1 bg-bg-elevated'}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-60px' }}
transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }}
>
<StaticLink
href={`/solutions/${solution.id}`}
className="group relative block transition-all duration-300 hover:bg-bg-secondary hover:shadow-lg hover:-translate-y-1 bg-bg-elevated border border-border-primary hover:border-brand/20 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-full after:origin-left after:scale-x-0 after:bg-brand after:transition-transform after:duration-300 group-hover:after:scale-x-100"
>
<div className="relative z-10 p-8 sm:p-10 md:p-12">
<div className="flex items-center gap-3 mb-6">
<Icon className="w-5 h-5 text-text-muted" strokeWidth={1.5} />
<h3 className="text-lg font-bold text-text-primary">
{solution.industry}
</h3>
</div>
<div className="mb-4">
<div className="text-xs text-text-muted mb-2">核心痛点</div>
<p className="text-sm text-text-secondary leading-relaxed">
{solution.painPoints?.join('、')}
</p>
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
{solution.outcomes?.map((o) => (
<div key={o.label} className="p-2 bg-bg-secondary">
<div className="text-base font-bold text-brand">{o.value}</div>
<div className="text-xs text-text-muted">{o.label}</div>
</div>
))}
</div>
<div className="pt-4 border-t border-border-primary">
<div className="text-xs text-text-muted mb-2">推荐产品组合</div>
<div className="flex flex-wrap gap-2">
{recommendedProductNames.map((name) => (
<span key={name} className="px-2 py-1 text-xs bg-bg-secondary text-text-secondary">
{name.replace('睿新', '')}
</span>
))}
</div>
</div>
<div className="inline-flex items-center gap-2 text-sm font-semibold text-text-secondary group-hover:text-brand transition-colors duration-300 mt-6">
<span>了解详情</span>
<ArrowUpRight className="w-4 h-4 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 transition-transform duration-300" />
</div>
</div>
</StaticLink>
</motion.div>
);
}
function SolutionsGridSection({ solutions, products, pageCopy }: { solutions: Solution[]; products?: Product[]; pageCopy?: PageCopy | null }) {
const gridTitle = (pageCopy?.solutionsGridTitle as string) || '行业解决方案';
const gridDescription = (pageCopy?.solutionsGridDescription as string) || '每个方案都明确推荐产品组合和服务包,确保落地效果';
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-secondary">
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="max-w-3xl mb-16 sm:mb-20 md:mb-24"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95]">
{gridTitle}
</h2>
<p className="mt-6 text-lg text-text-secondary leading-relaxed">
{gridDescription}
</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-px bg-border-primary">
{solutions.map((solution, index) => (
<SolutionCard key={solution.id} solution={solution} index={index} products={products} featured={index === 0} />
))}
</div>
</div>
</section>
);
}
function CTASection({ pageCopy }: { pageCopy?: PageCopy | null }) {
const ctaTitle = (pageCopy?.solutionsCtaTitle as string) || '告诉我们您的行业场景';
const ctaDescription = (pageCopy?.solutionsCtaDescription as string) || '无论您处于哪个行业、哪个数字化阶段,我们都能为您推荐最合适的产品组合和服务包。';
return (
<section className="relative py-20 sm:py-28 md:py-36 lg:py-44 overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10">
<motion.div
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-80px' }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className="max-w-3xl"
>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-extrabold text-ink tracking-tight leading-[0.95] mb-8">
{ctaTitle}
</h2>
<p className="text-lg text-text-secondary leading-relaxed mb-10">
{ctaDescription}
</p>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6">
<CTAButton href="/contact" dataTestId="solutions-bottom-primary" className="min-h-[52px]">
获取定制方案
</CTAButton>
<CTAButton href="/products" variant="secondary" dataTestId="solutions-bottom-secondary" className="min-h-[52px]">
浏览产品
</CTAButton>
</div>
</motion.div>
</div>
</section>
);
}
export default function SolutionsContentV3({ solutions: solutionsProp, products, pageCopy }: { solutions?: Solution[]; products?: Product[]; pageCopy?: PageCopy | null }) {
const solutions = solutionsProp ?? [];
return (
<main className="bg-bg-primary text-ink">
<HeroSection pageCopy={pageCopy} />
<SolutionsGridSection solutions={solutions} products={products} pageCopy={pageCopy} />
<CTASection pageCopy={pageCopy} />
</main>
);
}