diff --git a/scripts/audit/l3-fallback-verify.mjs b/scripts/audit/l3-fallback-verify.mjs new file mode 100644 index 0000000..80abc0f --- /dev/null +++ b/scripts/audit/l3-fallback-verify.mjs @@ -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); +}); diff --git a/src/app/(marketing)/products/erp-upgrade-v3/page.tsx b/src/app/(marketing)/products/erp-upgrade-v3/page.tsx index ab617d2..cdafa3a 100644 --- a/src/app/(marketing)/products/erp-upgrade-v3/page.tsx +++ b/src/app/(marketing)/products/erp-upgrade-v3/page.tsx @@ -6,6 +6,7 @@ import { DetailHero } from '@/components/detail/detail-hero'; import { ProductValueSection } from '@/components/detail/detail-product-value'; import { DetailTrustSection } from '@/components/detail/detail-trust-section'; import { DetailCTASection } from '@/components/detail/detail-cta-section'; +import { L3EmptyFallback } from '@/components/detail'; import type { HeroTheme } from '@/lib/constants/hero-themes'; import type { Product, CaseStudy, DataProof, Certification } from '@/lib/constants/products'; @@ -70,6 +71,7 @@ export default async function ERPUpgradeV3Page() { + = { '睿新ERP管理系统': , @@ -453,6 +454,7 @@ export default function ProductDetailContentV3({ product }: ProductDetailContent categories={product.techStack} /> )} + {product.dataProofs && product.dataProofs.length > 0 && ( )} + + diff --git a/src/app/(marketing)/solutions/solution-detail-content-v3.test.tsx b/src/app/(marketing)/solutions/solution-detail-content-v3.test.tsx index 8498d5e..2f17952 100644 --- a/src/app/(marketing)/solutions/solution-detail-content-v3.test.tsx +++ b/src/app/(marketing)/solutions/solution-detail-content-v3.test.tsx @@ -63,6 +63,11 @@ jest.mock('lucide-react', () => { Route: mockIcon('route'), Shield: mockIcon('shield'), 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; 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(); + // 真实数据 section 仍不渲染 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', () => { diff --git a/src/app/(marketing)/solutions/solution-detail-content-v3.tsx b/src/app/(marketing)/solutions/solution-detail-content-v3.tsx index 5bbd818..95e55b3 100644 --- a/src/app/(marketing)/solutions/solution-detail-content-v3.tsx +++ b/src/app/(marketing)/solutions/solution-detail-content-v3.tsx @@ -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 type { Solution } from '@/lib/constants/solutions'; import type { Product } from '@/lib/constants/products'; +import { L3EmptyFallback } from '@/components/detail'; const INDUSTRY_ICONS: Record = { 制造业: , @@ -514,6 +515,7 @@ export default function SolutionDetailContentV3({ solution, products = [] }: Sol + diff --git a/src/components/detail/early-access-notice.tsx b/src/components/detail/early-access-notice.tsx new file mode 100644 index 0000000..01a5564 --- /dev/null +++ b/src/components/detail/early-access-notice.tsx @@ -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 ( + + + + + + + + ); +} \ No newline at end of file diff --git a/src/components/detail/index.ts b/src/components/detail/index.ts index b41f0da..ca71ed5 100644 --- a/src/components/detail/index.ts +++ b/src/components/detail/index.ts @@ -4,6 +4,10 @@ export { ProductValueSection } from './detail-product-value'; 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 { CaseStudyCard } from './detail-case-study'; diff --git a/src/components/detail/l3-empty-fallback.tsx b/src/components/detail/l3-empty-fallback.tsx new file mode 100644 index 0000000..58408c5 --- /dev/null +++ b/src/components/detail/l3-empty-fallback.tsx @@ -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 ( + <> + + + > + ); +} \ No newline at end of file diff --git a/src/components/detail/l3-signals-slot.tsx b/src/components/detail/l3-signals-slot.tsx new file mode 100644 index 0000000..fe04152 --- /dev/null +++ b/src/components/detail/l3-signals-slot.tsx @@ -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 ( + + + + + + + 信任锚点 + + {heading} + + {description} + + + {/* grid 类直接挂在 StaggerReveal 上:StaggerReveal 会为每个子项包一层 motion.div, + 若把 grid 放在外层容器,包装层会成为 grid 的唯一子项,卡片将堆成单列 */} + + {SIGNALS.map((s) => ( + + ))} + + + + ); +} + +function SignalCard({ signal }: { signal: L3Signal }) { + const { href, eyebrow, title, desc, Icon, disabled } = signal; + + const cardBody = ( + + + {eyebrow} + + + + + + + + {title} + {!disabled && ( + + )} + + + {desc} + + {disabled && ( + 待业务确认 + )} + + ); + + if (disabled) { + return ( + + {cardBody} + + ); + } + + return ( + + {cardBody} + + ); +} \ No newline at end of file diff --git a/src/components/layout/header.test.tsx b/src/components/layout/header.test.tsx index 646fd43..856b074 100644 --- a/src/components/layout/header.test.tsx +++ b/src/components/layout/header.test.tsx @@ -159,8 +159,13 @@ describe('Header', () => { it('should render logo', () => { render(); - const logo = screen.getByAltText('四川睿新致远科技有限公司'); - expect(logo).toBeInTheDocument(); + // Logo 明暗双版(commit 2593599):浅色 logo.svg + 深色 logo-white.svg 同时挂载, + // 由 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', () => {
{description}
{desc}
待业务确认