feat(detail): Layer 3 信任层空数据兜底骨架(L3SignalsSlot + L3EmptyFallback + EarlyAccessNotice)
- 新增 L3SignalsSlot:4 类可验证替代信号卡(方法论/团队/历程/服务承诺), 服务承诺卡 disabled 占位待业务确认;零编造约束下替代真实案例/认证 - 新增 EarlyAccessNotice:首批客户共创状态条(单一真源 company.ts EARLY_ACCESS) - 新增 L3EmptyFallback:caseStudies/certifications/dataProofs 三者全空时渲染兜底, 任一非空 return null 保留本地真实 section 视觉 - 接入 5 处详情页:solution v3 / service v4 / product v3 / erp-upgrade-v3 / standalone/[id] - 修复 StaggerReveal 网格布局:grid 类须挂在 StaggerReveal 自身(否则卡片堆单列) - 更新 solution 测试至新行为(空数据→兜底渲染)+ 补 lucide 图标 mock - 修复 header.test.tsx 双 logo 断言(2593599 遗留) - 新增 scripts/audit/l3-fallback-verify.mjs 运行时验证(浅/深双版 PASS) 注:必须用 localhost 而非 127.0.0.1 —— Next dev allowedDevOrigins 默认白名单 不含 127.0.0.1,chunk 请求带该 Origin 被 403 → hydration 不发生 → 动画全冻结 验证:type-check 0 错 · lint 0 错(127 既有 warnings) · 单测 129 套件/1628 通过 · L3 兜底浅深双版渲染截图 PASS
This commit is contained in:
@@ -0,0 +1,123 @@
|
|||||||
|
// 验证 Layer 3 空数据兜底(L3EmptyFallback):信号卡 + 共创占位渲染
|
||||||
|
// 用法:node scripts/audit/l3-fallback-verify.mjs
|
||||||
|
//
|
||||||
|
// 断言(solution 详情页,当前无 caseStudies/certifications → 兜底应渲染):
|
||||||
|
// 1. section[aria-labelledby=l3-signals-heading] 存在且可见(opacity→1)
|
||||||
|
// 2. 4 张信号卡齐全,其中 3 张为链接(/methodology /team /about/brand),1 张 disabled
|
||||||
|
// 3. EarlyAccessNotice(首批客户共创中)存在
|
||||||
|
// 4. 深色模式下同样成立(截图目视 + token 反色由 globals.css 变量层保证)
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
// 注意:必须用 localhost(非 127.0.0.1)。Next dev 的 allowedDevOrigins 默认白名单
|
||||||
|
// 不含 127.0.0.1,chunk 子资源请求带 Origin: http://127.0.0.1:3000 会被 403 拒掉,
|
||||||
|
// 导致 JS 加载失败 → hydration 不发生 → 全站 whileInView 动画冻结(页面空白)。
|
||||||
|
// 代理问题由 chromium --no-proxy-server 解决(见 launch 参数),与 URL 无关。
|
||||||
|
const BASE = 'http://localhost:3000';
|
||||||
|
const OUT = 'dogfood-output/l3-fallback-verify';
|
||||||
|
const PAGE_URL = `${BASE}/solutions/manufacturing`;
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
async function probe(page, theme) {
|
||||||
|
await page.goto(PAGE_URL, { waitUntil: 'networkidle' });
|
||||||
|
// dev 模式 hydration 较慢(Turbopack 按需编译):等待 framer-motion 生效的标志——
|
||||||
|
// 页面上任一 motion div 的 opacity 变为 '1'(说明 IO + rAF 循环已运行)
|
||||||
|
let hydrated = true;
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => [...document.querySelectorAll('div[style]')].some((d) => d.style.opacity === '1'),
|
||||||
|
{ timeout: 20000 }
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
hydrated = false;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const sec = page.locator('section[aria-labelledby="l3-signals-heading"]');
|
||||||
|
const secCount = await sec.count();
|
||||||
|
|
||||||
|
if (secCount === 0) {
|
||||||
|
results.push({ theme, pass: false, reason: 'L3 signals section not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动触发 whileInView,等动画完成(轮询 opacity,上限 10s)
|
||||||
|
await sec.scrollIntoViewIfNeeded();
|
||||||
|
let headingOpacity = '0';
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const h = document.getElementById('l3-signals-heading');
|
||||||
|
if (!h) return false;
|
||||||
|
const o = Number(getComputedStyle(h.closest('div')).opacity);
|
||||||
|
return o > 0.9;
|
||||||
|
},
|
||||||
|
{ timeout: 10000 }
|
||||||
|
);
|
||||||
|
headingOpacity = '1';
|
||||||
|
} catch {
|
||||||
|
headingOpacity = await sec
|
||||||
|
.locator('#l3-signals-heading')
|
||||||
|
.evaluate((el) => getComputedStyle(el.closest('div')).opacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = await sec.locator('a, [aria-disabled="true"]').count();
|
||||||
|
const linkHrefs = await sec.locator('a').evaluateAll((as) => as.map((a) => a.getAttribute('href')));
|
||||||
|
const disabledCard = await sec.locator('[aria-disabled="true"]').count();
|
||||||
|
|
||||||
|
const notice = page.locator('section[aria-labelledby="early-access-notice-heading"]');
|
||||||
|
const noticeCount = await notice.count();
|
||||||
|
const noticeText = noticeCount > 0 ? await notice.locator('p').first().textContent() : null;
|
||||||
|
|
||||||
|
await page.screenshot({ path: `${OUT}/l3-${theme}-full.png` });
|
||||||
|
await sec.screenshot({ path: `${OUT}/l3-${theme}-section.png` });
|
||||||
|
if (noticeCount > 0) {
|
||||||
|
await notice.screenshot({ path: `${OUT}/notice-${theme}.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass =
|
||||||
|
Number(headingOpacity) > 0.9 &&
|
||||||
|
cards === 4 &&
|
||||||
|
disabledCard === 1 &&
|
||||||
|
linkHrefs.includes('/methodology') &&
|
||||||
|
linkHrefs.includes('/team') &&
|
||||||
|
linkHrefs.includes('/about/brand') &&
|
||||||
|
noticeCount === 1 &&
|
||||||
|
(noticeText || '').includes('首批客户共创中');
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
theme,
|
||||||
|
pass,
|
||||||
|
hydrated,
|
||||||
|
headingOpacity,
|
||||||
|
signalCards: cards,
|
||||||
|
disabledCard,
|
||||||
|
linkHrefs,
|
||||||
|
noticeCount,
|
||||||
|
noticeText: (noticeText || '').slice(0, 20),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
// --no-proxy-server:本机系统代理会 403 掉 _next/static chunk(JS 加载失败 → hydration 不发生)
|
||||||
|
const browser = await chromium.launch({ args: ['--no-proxy-server'] });
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
|
||||||
|
await probe(page, 'light');
|
||||||
|
|
||||||
|
await page.evaluate(() => localStorage.setItem('novalon-theme', 'dark'));
|
||||||
|
await probe(page, 'dark');
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
console.log(JSON.stringify(results, null, 2));
|
||||||
|
const allPass = results.every((r) => r.pass);
|
||||||
|
console.log(`\n[result] ${allPass ? 'PASS' : 'FAIL'} (light: ${results[0].pass}, dark: ${results[1]?.pass})`);
|
||||||
|
if (!allPass) process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { DetailHero } from '@/components/detail/detail-hero';
|
|||||||
import { ProductValueSection } from '@/components/detail/detail-product-value';
|
import { ProductValueSection } from '@/components/detail/detail-product-value';
|
||||||
import { DetailTrustSection } from '@/components/detail/detail-trust-section';
|
import { DetailTrustSection } from '@/components/detail/detail-trust-section';
|
||||||
import { DetailCTASection } from '@/components/detail/detail-cta-section';
|
import { DetailCTASection } from '@/components/detail/detail-cta-section';
|
||||||
|
import { L3EmptyFallback } from '@/components/detail';
|
||||||
import type { HeroTheme } from '@/lib/constants/hero-themes';
|
import type { HeroTheme } from '@/lib/constants/hero-themes';
|
||||||
import type { Product, CaseStudy, DataProof, Certification } from '@/lib/constants/products';
|
import type { Product, CaseStudy, DataProof, Certification } from '@/lib/constants/products';
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ export default async function ERPUpgradeV3Page() {
|
|||||||
|
|
||||||
<ProductValueSection product={data as unknown as Product} />
|
<ProductValueSection product={data as unknown as Product} />
|
||||||
|
|
||||||
|
<L3EmptyFallback caseStudies={caseStudies} dataProofs={dataProofs} certifications={certifications} />
|
||||||
<DetailTrustSection
|
<DetailTrustSection
|
||||||
caseStudies={caseStudies}
|
caseStudies={caseStudies}
|
||||||
dataProofs={dataProofs}
|
dataProofs={dataProofs}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ArrowRight, Package, Check, Settings, Users, FileText, Cpu, Shield, Tre
|
|||||||
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
||||||
import type { Product } from '@/lib/constants/products';
|
import type { Product } from '@/lib/constants/products';
|
||||||
import { MethodologyFramework, TechStackShowcase, FAQSection, DataProofSection } from '@/components/content/sections';
|
import { MethodologyFramework, TechStackShowcase, FAQSection, DataProofSection } from '@/components/content/sections';
|
||||||
|
import { L3EmptyFallback } from '@/components/detail';
|
||||||
|
|
||||||
const PRODUCT_ICONS: Record<string, React.ReactNode> = {
|
const PRODUCT_ICONS: Record<string, React.ReactNode> = {
|
||||||
'睿新ERP管理系统': <Package className="w-8 h-8" />,
|
'睿新ERP管理系统': <Package className="w-8 h-8" />,
|
||||||
@@ -453,6 +454,7 @@ export default function ProductDetailContentV3({ product }: ProductDetailContent
|
|||||||
categories={product.techStack}
|
categories={product.techStack}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<L3EmptyFallback caseStudies={product.caseStudies} certifications={product.certifications} dataProofs={product.dataProofs} />
|
||||||
{product.dataProofs && product.dataProofs.length > 0 && (
|
{product.dataProofs && product.dataProofs.length > 0 && (
|
||||||
<DataProofSection
|
<DataProofSection
|
||||||
title="可衡量的成果"
|
title="可衡量的成果"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CTAButton } from '@/components/ui/cta-button';
|
|||||||
import { DetailHero } from '@/components/detail/detail-hero';
|
import { DetailHero } from '@/components/detail/detail-hero';
|
||||||
import { DetailTrustSection } from '@/components/detail/detail-trust-section';
|
import { DetailTrustSection } from '@/components/detail/detail-trust-section';
|
||||||
import { DetailCTASection } from '@/components/detail/detail-cta-section';
|
import { DetailCTASection } from '@/components/detail/detail-cta-section';
|
||||||
|
import { L3EmptyFallback } from '@/components/detail';
|
||||||
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
|
import { DetailSwipeNav } from '@/components/ui/detail-swipe-nav';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { CheckCircle2, Shield, Lock, Cpu, HardDrive, FileCheck, Gauge } from 'lucide-react';
|
import { CheckCircle2, Shield, Lock, Cpu, HardDrive, FileCheck, Gauge } from 'lucide-react';
|
||||||
@@ -257,6 +258,7 @@ export function StandaloneProductClient({ item }: StandaloneProductClientProps)
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<L3EmptyFallback caseStudies={caseStudies} dataProofs={dataProofs} certifications={certifications} />
|
||||||
<DetailTrustSection
|
<DetailTrustSection
|
||||||
caseStudies={caseStudies}
|
caseStudies={caseStudies}
|
||||||
dataProofs={dataProofs}
|
dataProofs={dataProofs}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CTAButton } from '@/components/ui/cta-button';
|
|||||||
import { ArrowUpRight, CheckCircle2, Zap, Clock, Users, Award, TrendingUp, Shield } from 'lucide-react';
|
import { ArrowUpRight, CheckCircle2, Zap, Clock, Users, Award, TrendingUp, Shield } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import type { Service, CaseStudy } from '@/lib/constants/services';
|
import type { Service, CaseStudy } from '@/lib/constants/services';
|
||||||
|
import { L3EmptyFallback } from '@/components/detail';
|
||||||
|
|
||||||
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
const EASE_OUT = [0.22, 1, 0.36, 1] as const;
|
||||||
|
|
||||||
@@ -350,6 +351,7 @@ export default function ServiceDetailContentV4({ service }: { service: Service }
|
|||||||
<OverviewSection service={service} />
|
<OverviewSection service={service} />
|
||||||
<FeaturesSection service={service} />
|
<FeaturesSection service={service} />
|
||||||
<ProcessSection service={service} />
|
<ProcessSection service={service} />
|
||||||
|
<L3EmptyFallback caseStudies={service.caseStudies} />
|
||||||
<CaseStudiesSection caseStudies={service.caseStudies} />
|
<CaseStudiesSection caseStudies={service.caseStudies} />
|
||||||
<CTASection />
|
<CTASection />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -63,6 +63,11 @@ jest.mock('lucide-react', () => {
|
|||||||
Route: mockIcon('route'),
|
Route: mockIcon('route'),
|
||||||
Shield: mockIcon('shield'),
|
Shield: mockIcon('shield'),
|
||||||
Truck: mockIcon('truck'),
|
Truck: mockIcon('truck'),
|
||||||
|
// L3SignalsSlot(L3 空数据兜底,commit: L3 骨架)用到的图标
|
||||||
|
ArrowUpRight: mockIcon('arrow-up-right'),
|
||||||
|
Compass: mockIcon('compass'),
|
||||||
|
Milestone: mockIcon('milestone'),
|
||||||
|
ShieldCheck: mockIcon('shield-check'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,11 +92,24 @@ const baseSolution = {
|
|||||||
} as Solution;
|
} as Solution;
|
||||||
|
|
||||||
describe('SolutionDetailContentV3 - L3 信任层三档策略', () => {
|
describe('SolutionDetailContentV3 - L3 信任层三档策略', () => {
|
||||||
it('does not render L3 sections when no case/certification data exists', () => {
|
it('renders L3 fallback (signals + early-access notice) when no case/certification data exists', () => {
|
||||||
|
// 修订方案 B'(2026-09-02):空数据不再是「整段消失 = 信任真空」,
|
||||||
|
// 而是由 L3EmptyFallback 渲染 4 类替代信号卡 + 首批客户共创占位。
|
||||||
render(<SolutionDetailContentV3 solution={baseSolution} />);
|
render(<SolutionDetailContentV3 solution={baseSolution} />);
|
||||||
|
|
||||||
|
// 真实数据 section 仍不渲染
|
||||||
expect(screen.queryByText('同行业落地案例')).not.toBeInTheDocument();
|
expect(screen.queryByText('同行业落地案例')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('资质认证')).not.toBeInTheDocument();
|
expect(screen.queryByText('资质认证')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// 兜底:信任锚点信号区 + 共创占位
|
||||||
|
expect(screen.getByText('在数据之外,我们用透明度建立信任')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /交付方法论/ })).toHaveAttribute('href', '/methodology');
|
||||||
|
expect(screen.getByRole('link', { name: /团队资历/ })).toHaveAttribute('href', '/team');
|
||||||
|
expect(screen.getByRole('link', { name: /发展历程/ })).toHaveAttribute('href', '/about/brand');
|
||||||
|
// 服务承诺卡:disabled 占位,不可点击
|
||||||
|
expect(screen.getByText(/服务承诺/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('待业务确认')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('首批客户共创中')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders case studies when data is available', () => {
|
it('renders case studies when data is available', () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CTAButton } from '@/components/ui/cta-button';
|
|||||||
import { 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 { Factory, ShoppingCart, Heart, GraduationCap, Lightbulb, Settings, Users, TrendingUp, Package, BookOpen, MessageSquare, Calendar, Smartphone, Route, Shield, Truck } from 'lucide-react';import { SectionLabel, EASE_OUT } from '@/components/ui/page-decoration';
|
||||||
import type { Solution } from '@/lib/constants/solutions';
|
import type { Solution } from '@/lib/constants/solutions';
|
||||||
import type { Product } from '@/lib/constants/products';
|
import type { Product } from '@/lib/constants/products';
|
||||||
|
import { L3EmptyFallback } from '@/components/detail';
|
||||||
|
|
||||||
const INDUSTRY_ICONS: Record<string, React.ReactNode> = {
|
const INDUSTRY_ICONS: Record<string, React.ReactNode> = {
|
||||||
制造业: <Factory className="w-8 h-8" />,
|
制造业: <Factory className="w-8 h-8" />,
|
||||||
@@ -514,6 +515,7 @@ export default function SolutionDetailContentV3({ solution, products = [] }: Sol
|
|||||||
<SolutionsSection solutions={solution.solutions} />
|
<SolutionsSection solutions={solution.solutions} />
|
||||||
<ValuePropositionSection valueProposition={solution.valueProposition} />
|
<ValuePropositionSection valueProposition={solution.valueProposition} />
|
||||||
<SuiteCombinationSection suiteCombination={solution.suiteCombination} products={products} />
|
<SuiteCombinationSection suiteCombination={solution.suiteCombination} products={products} />
|
||||||
|
<L3EmptyFallback caseStudies={solution.caseStudies} certifications={solution.certifications} />
|
||||||
<CaseStudiesSection caseStudies={solution.caseStudies} />
|
<CaseStudiesSection caseStudies={solution.caseStudies} />
|
||||||
<CertificationsSection certifications={solution.certifications} />
|
<CertificationsSection certifications={solution.certifications} />
|
||||||
<CTASection solution={solution} />
|
<CTASection solution={solution} />
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EarlyAccessNotice — L3 信任层「首批客户共创」透明状态条
|
||||||
|
*
|
||||||
|
* 设计意图:
|
||||||
|
* - 数据真实性优先:客户案例 / 资质认证需获得授权后才能公开;
|
||||||
|
* 与其让 L3 整段 `return null` 制造「信任真空」,不如显式标注公司当前阶段。
|
||||||
|
* - 内容源于 `EARLY_ACCESS` 常量(company.ts L119),单一真源。
|
||||||
|
*
|
||||||
|
* 与 L3SignalsSlot 关系:EarlyAccessNotice 是 L3EmptyFallback 的次要兜底块,
|
||||||
|
* 在 signals(4 类信号卡)下方呈现——回答「为什么没有真实客户案例」这个问题。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ScrollReveal } from '@/components/ui/scroll-reveal';
|
||||||
|
import { EARLY_ACCESS } from '@/lib/constants/company';
|
||||||
|
|
||||||
|
export function EarlyAccessNotice() {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="early-access-notice-heading"
|
||||||
|
className="py-12 sm:py-16 bg-bg-secondary"
|
||||||
|
>
|
||||||
|
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||||
|
<ScrollReveal>
|
||||||
|
<aside
|
||||||
|
role="note"
|
||||||
|
aria-label="首批客户共创状态"
|
||||||
|
className="p-5 sm:p-6 rounded-lg border border-dashed border-brand/40 bg-brand-soft/30"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
id="early-access-notice-heading"
|
||||||
|
className="text-sm font-semibold text-brand mb-1"
|
||||||
|
>
|
||||||
|
{EARLY_ACCESS.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-text-secondary leading-relaxed mb-3">
|
||||||
|
{EARLY_ACCESS.desc}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-brand underline-offset-4 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-secondary rounded"
|
||||||
|
>
|
||||||
|
了解共创合作
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
</Link>
|
||||||
|
</aside>
|
||||||
|
</ScrollReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ export { ProductValueSection } from './detail-product-value';
|
|||||||
|
|
||||||
export { DetailTrustSection } from './detail-trust-section';
|
export { DetailTrustSection } from './detail-trust-section';
|
||||||
|
|
||||||
|
export { L3SignalsSlot } from './l3-signals-slot';
|
||||||
|
export { EarlyAccessNotice } from './early-access-notice';
|
||||||
|
export { L3EmptyFallback } from './l3-empty-fallback';
|
||||||
|
|
||||||
export { DetailCTASection } from './detail-cta-section';
|
export { DetailCTASection } from './detail-cta-section';
|
||||||
|
|
||||||
export { CaseStudyCard } from './detail-case-study';
|
export { CaseStudyCard } from './detail-case-study';
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L3EmptyFallback — L3 信任层「数据空」兜底组件
|
||||||
|
*
|
||||||
|
* 设计意图:
|
||||||
|
* - 详情页 L3 由 3 类内容构成:caseStudies / certifications / dataProofs。
|
||||||
|
* 任一非空时由对应本地 section 渲染真实内容;**三者全空时整段 L3 消失** =
|
||||||
|
* 用户感受「这个详情页没有信任层」。
|
||||||
|
* - 零编造约束下,**禁止**伪造案例/认证/数据。本组件提供合规替代:
|
||||||
|
* 全空时渲染 L3SignalsSlot(4 类可验证信号卡)+ EarlyAccessNotice(共创占位),
|
||||||
|
* 既保持 L3 结构完整,又不违背「零虚构客户案例」原则。
|
||||||
|
*
|
||||||
|
* 与本地 CaseStudiesSection / CertificationsSection 的协作:
|
||||||
|
* - 三者全空 → 本组件渲染 signals + notice
|
||||||
|
* - 任一非空 → 本组件 return null,让真实 section 渲染(保留本地视觉细节)
|
||||||
|
*
|
||||||
|
* @see /Users/zhangxiang/Codes/Novalon/novalon-website/.workbuddy/memory/MEMORY.md
|
||||||
|
* "内容真实性约束" + "Layer 3 替代信号"
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { L3SignalsSlot } from './l3-signals-slot';
|
||||||
|
import { EarlyAccessNotice } from './early-access-notice';
|
||||||
|
|
||||||
|
export function L3EmptyFallback({
|
||||||
|
caseStudies,
|
||||||
|
certifications,
|
||||||
|
dataProofs,
|
||||||
|
}: {
|
||||||
|
caseStudies?: readonly unknown[];
|
||||||
|
certifications?: readonly unknown[];
|
||||||
|
dataProofs?: readonly unknown[];
|
||||||
|
}) {
|
||||||
|
const hasContent =
|
||||||
|
(caseStudies?.length ?? 0) > 0 ||
|
||||||
|
(certifications?.length ?? 0) > 0 ||
|
||||||
|
(dataProofs?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
if (hasContent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<L3SignalsSlot />
|
||||||
|
<EarlyAccessNotice />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L3SignalsSlot — L3 信任层「替代信号」槽
|
||||||
|
*
|
||||||
|
* 设计意图:
|
||||||
|
* - 真实数据(客户案例 / 资质认证 / 数据佐证 / 证言)需获得客户授权后才能公开;
|
||||||
|
* 在那之前,详情页 L3 必须有可验证的兜底内容(避免 L3 整段消失 = 信任真空)。
|
||||||
|
* - 四类替代信号(方法论透明度 / 团队资历 / 里程碑 / 服务承诺)均为站点内
|
||||||
|
* 可访问的真实路由(/methodology · /team · /about/brand · /contact),
|
||||||
|
* 不存在编造。
|
||||||
|
*
|
||||||
|
* 接入位置:详情页 L3 区块(案例 / 认证 / 数据)之前,由 L3EmptyFallback 在三者
|
||||||
|
* 全空时统一包裹渲染;任一非空时本组件不渲染,让真实 section 自行展示。
|
||||||
|
*
|
||||||
|
* 设计约束(CLAUDE.md / CONTEXT.md):
|
||||||
|
* - 品牌红 ≥3 处触达点(eyebrow 短线 + Icon 容器 + 卡片 hover 边)
|
||||||
|
* - 动效 ≤700ms ease-ink(ScrollReveal/StaggerReveal 默认 400ms,符合"动效四原则 fast")
|
||||||
|
* - WCAG AA:text-secondary 在 bg-primary 4.5:1+;hover 状态焦点环保留
|
||||||
|
*
|
||||||
|
* @see /Users/zhangxiang/Codes/Novalon/novalon-website/.workbuddy/memory/MEMORY.md
|
||||||
|
* "内容真实性约束"(零编造前提下的 L3 替代信号)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {
|
||||||
|
ArrowUpRight,
|
||||||
|
Compass,
|
||||||
|
Milestone,
|
||||||
|
ShieldCheck,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ScrollReveal, StaggerReveal } from '@/components/ui/scroll-reveal';
|
||||||
|
import { SectionLabel } from '@/components/ui/page-decoration';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface L3Signal {
|
||||||
|
href: string;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
Icon: React.ComponentType<{ className?: string }>;
|
||||||
|
/**
|
||||||
|
* 服务承诺卡:文案散落 5 处但无可复用 L3 section,
|
||||||
|
* 待业务确认后才有具体承诺条款。当前显示「待业务确认」占位(无 href 跳转)。
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNALS: readonly L3Signal[] = [
|
||||||
|
{
|
||||||
|
href: '/methodology',
|
||||||
|
eyebrow: '方法论',
|
||||||
|
title: '交付方法论',
|
||||||
|
desc: '从诊断评估到持续优化,四阶段可量化路径;不依赖案例也能证明过程可控。',
|
||||||
|
Icon: Compass,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/team',
|
||||||
|
eyebrow: '团队',
|
||||||
|
title: '团队资历',
|
||||||
|
desc: '创始团队与核心成员的背景、承诺宣言与协作方式。',
|
||||||
|
Icon: Users,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/about/brand',
|
||||||
|
eyebrow: '历程',
|
||||||
|
title: '发展历程',
|
||||||
|
desc: '2026 年 1 月成立至今的关键节点与里程碑。',
|
||||||
|
Icon: Milestone,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/contact',
|
||||||
|
eyebrow: '承诺',
|
||||||
|
title: '服务承诺',
|
||||||
|
desc: '具体承诺条款待业务确认后公开;当前阶段以共创协议为准,欢迎垂询。',
|
||||||
|
Icon: ShieldCheck,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function L3SignalsSlot({
|
||||||
|
heading = '在数据之外,我们用透明度建立信任',
|
||||||
|
description = '客户案例与资质认证需获得授权后才能公开;在那之前,以下四个锚点是我们可验证的承诺。',
|
||||||
|
}: {
|
||||||
|
heading?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-labelledby="l3-signals-heading"
|
||||||
|
className="relative py-16 sm:py-20 md:py-24 overflow-hidden bg-bg-secondary"
|
||||||
|
>
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-px bg-border-primary" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-px bg-border-primary" />
|
||||||
|
|
||||||
|
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||||
|
<ScrollReveal className="text-center max-w-3xl mx-auto mb-12">
|
||||||
|
<SectionLabel align="center">信任锚点</SectionLabel>
|
||||||
|
<h2
|
||||||
|
id="l3-signals-heading"
|
||||||
|
className="text-2xl sm:text-3xl md:text-4xl font-bold text-ink tracking-tight leading-tight"
|
||||||
|
>
|
||||||
|
{heading}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-base text-text-secondary leading-relaxed">{description}</p>
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
|
{/* grid 类直接挂在 StaggerReveal 上:StaggerReveal 会为每个子项包一层 motion.div,
|
||||||
|
若把 grid 放在外层容器,包装层会成为 grid 的唯一子项,卡片将堆成单列 */}
|
||||||
|
<StaggerReveal
|
||||||
|
staggerDelay={0.06}
|
||||||
|
delayChildren={0.05}
|
||||||
|
className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 sm:gap-6"
|
||||||
|
>
|
||||||
|
{SIGNALS.map((s) => (
|
||||||
|
<SignalCard key={s.eyebrow} signal={s} />
|
||||||
|
))}
|
||||||
|
</StaggerReveal>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignalCard({ signal }: { signal: L3Signal }) {
|
||||||
|
const { href, eyebrow, title, desc, Icon, disabled } = signal;
|
||||||
|
|
||||||
|
const cardBody = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group relative h-full p-6 sm:p-8 rounded-lg border border-border-primary bg-bg-primary',
|
||||||
|
'transition-all duration-300 ease-out',
|
||||||
|
disabled
|
||||||
|
? 'opacity-70 cursor-not-allowed'
|
||||||
|
: 'hover:border-brand/40 hover:shadow-md hover:-translate-y-1'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-block text-[11px] tracking-[0.15em] uppercase font-bold text-brand mb-4">
|
||||||
|
{eyebrow}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 mb-4 flex items-center justify-center rounded-md bg-brand-soft text-brand"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-base font-bold text-ink mb-2 flex items-center gap-1.5">
|
||||||
|
{title}
|
||||||
|
{!disabled && (
|
||||||
|
<ArrowUpRight
|
||||||
|
className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-sm text-text-secondary leading-relaxed">{desc}</p>
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<p className="mt-4 text-xs text-text-muted italic">待业务确认</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<div aria-disabled="true" className="block rounded-lg">
|
||||||
|
{cardBody}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2 focus-visible:ring-offset-bg-secondary"
|
||||||
|
>
|
||||||
|
{cardBody}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -159,8 +159,13 @@ describe('Header', () => {
|
|||||||
|
|
||||||
it('should render logo', () => {
|
it('should render logo', () => {
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
const logo = screen.getByAltText('四川睿新致远科技有限公司');
|
// Logo 明暗双版(commit 2593599):浅色 logo.svg + 深色 logo-white.svg 同时挂载,
|
||||||
expect(logo).toBeInTheDocument();
|
// 由 globals.css 按 html[data-theme] 切换显隐(jsdom 不加载 CSS,两图都在 DOM)
|
||||||
|
const logos = screen.getAllByAltText('四川睿新致远科技有限公司');
|
||||||
|
expect(logos).toHaveLength(2);
|
||||||
|
const srcs = logos.map((img) => img.getAttribute('src'));
|
||||||
|
expect(srcs).toContain('/logo.svg');
|
||||||
|
expect(srcs).toContain('/logo-white.svg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render desktop navigation', () => {
|
it('should render desktop navigation', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user