Files
novalon-website/src/app/(marketing)/solutions/solutions-content-v3.tsx
T
zhangxiang 4dcc15ebd0 fix(metrics): 数字口径 basis 结构强制,删除详情页虚构佐证
复评 P0 收尾:把「每个数字带口径」从个别页自觉升级为结构性强制。
- metrics-basis.ts:METRIC_BASES / METRIC_BASIS_NOTES + resolveMetricBasis
  (缺失·非法·大小写错一律回落最弱 target)+ weakestBasis(一组数字取最弱者聚合)
  + FORBIDDEN_PROOF_PHRASES;MetricsBasisNote 收 basis prop
- 类型层 Product.metrics / DataProof / ServiceMetric / Solution.outcomes /
  DataMetric / about keyMetrics 全部加 basis?;9 处调用点改为 basis 驱动
- content-types.ts 新增共享 metricBasisField(select)展开进 9 处字段定义;
  trustSignals 有意跳过(已有自己的 source)。seed 显式声明 55 条 target
- 删除字面虚构佐证:详情页「每一项指标都源自真实客户案例」、「经过实战验证」×3、
  「来自真实客户的使用数据」、「经实际场景验证」;给此前完全裸奔的 3 个共享
  数字渲染器(DataProofSection / detail-trust / detail-product-value)补上角注
- metrics-basis.test 3→16 例:basis 解析正控制、weakestBasis 次序、seed 全量
  显式声明 + 覆盖证明(防空跑假绿)+ 禁任何条目自称 verified + 禁词全源码扫描
- 文档同源:DESIGN.md 立 The Declared-Basis Rule;PRODUCT.md 常驻禁令;
  CONTEXT.md 记决策并警示 admin 字段需重跑 seed 才激活

边界:CMS 字段定义存 ContentModel.fields(DB 内 JSON),basis 下拉须重跑 seed
才会出现在 admin 表单;本次 DB 与 seed 数据值零改动,渲染端对缺 basis 兜底 target。

Gates: type-check 0 · jest 1594/132 · contrast 7/7 · headings 10/10
lint 0e/128w(与 stash 基线逐行比对,零新增告警)
2026-09-20 11:50:58 +08:00

241 lines
11 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 { MetricsBasisNote } from '@/components/ui/metrics-basis-note';
import { CTA_LABELS, canonicalCtaLabel } from '@/lib/constants/cta-labels';
import { weakestBasis } from '@/lib/constants/metrics-basis';
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 = canonicalCtaLabel(pageCopy?.solutionsCtaPrimary as string) || '预约咨询';
const ctaSecondary = (pageCopy?.solutionsCtaSecondary as string) || '浏览产品';
return (
<section className="relative overflow-hidden bg-bg-primary">
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10 relative z-10 w-full py-16 sm:py-20 lg:py-28">
<div className="max-w-4xl">
<motion.h1
initial={{ opacity: 0, y: 40 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2, ease: EASE_OUT }}
className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-black text-ink leading-[0.95] tracking-tightest mb-8 sm:mb-10 md:mb-12"
>
<div className="block">{heroTitle1}</div>
<div className="block mt-4 sm:mt-6">
<span className="relative inline-block">
<span className="relative text-brand-ink 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-10 sm:mb-12"
>
{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>
</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-ink">{o.value}</div>
<div className="text-xs text-text-muted">{o.label}</div>
</div>
))}
</div>
{solution.outcomes && solution.outcomes.length > 0 && (
<MetricsBasisNote basis={weakestBasis(solution.outcomes.map((o) => o.basis))} className="-mt-2 mb-4" />
)}
<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-ink 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]">
{CTA_LABELS.consult}
</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 (
<div className="bg-bg-primary text-ink">
<HeroSection pageCopy={pageCopy} />
<SolutionsGridSection solutions={solutions} products={products} pageCopy={pageCopy} />
<CTASection pageCopy={pageCopy} />
</div>
);
}