feat(layout): 重构布局组件体系与数据层
- 重构 Header 导航、Footer 页脚、MobileMenu 移动端菜单 - 新增 MobileTabBar 移动端底部导航栏 - 更新 MegaDropdown 大型下拉导航 - 重构 SEO 结构化数据组件 - 更新数据层常量 (products, services, solutions, navigation 等) - 新增 useCountUp 数字递增 Hook - 修复 .gitignore 中 src/lib 被错误忽略的问题
This commit is contained in:
@@ -138,7 +138,7 @@ export function CookieConsent() {
|
||||
继续使用即表示您同意我们的{' '}
|
||||
<a
|
||||
href="/privacy"
|
||||
className="text-[var(--color-brand-primary)] hover:text-[var(--color-brand-primary-hover)] underline font-medium"
|
||||
className="text-[var(--color-brand)] hover:text-[var(--color-brand-hover)] underline font-medium"
|
||||
>
|
||||
隐私政策
|
||||
</a>
|
||||
@@ -149,21 +149,21 @@ export function CookieConsent() {
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
管理偏好
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRejectAll}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
仅必要
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAcceptAll}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand)] rounded-lg hover:bg-[var(--color-brand-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
接受所有
|
||||
</button>
|
||||
@@ -185,12 +185,12 @@ export function CookieConsent() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked
|
||||
disabled
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-not-allowed"
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand)] focus:ring-[var(--color-brand)] cursor-not-allowed"
|
||||
aria-label="必要 Cookie"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
@@ -204,12 +204,12 @@ export function CookieConsent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.analytics}
|
||||
onChange={() => handleTogglePreference('analytics')}
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand)] focus:ring-[var(--color-brand)] cursor-pointer"
|
||||
aria-label="分析 Cookie"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
@@ -220,12 +220,12 @@ export function CookieConsent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-primary-lighter)] rounded-lg opacity-50">
|
||||
<div className="flex items-start gap-3 p-3 bg-[var(--color-brand-lighter)] rounded-lg opacity-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferences.marketing}
|
||||
onChange={() => handleTogglePreference('marketing')}
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand-primary)] focus:ring-[var(--color-brand-primary)] cursor-pointer"
|
||||
className="mt-1 h-4 w-4 rounded border-[var(--color-border-secondary)] text-[var(--color-brand)] focus:ring-[var(--color-brand)] cursor-pointer"
|
||||
aria-label="营销 Cookie"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
@@ -241,14 +241,14 @@ export function CookieConsent() {
|
||||
<button
|
||||
onClick={() => setShowSettings(false)}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-primary-lighter)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-[var(--color-text-secondary)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg hover:bg-[var(--color-brand-lighter)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveCustom}
|
||||
disabled={isAnimating}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand-primary)] rounded-lg hover:bg-[var(--color-brand-primary-hover)] transition-colors disabled:opacity-50"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-[var(--color-brand)] rounded-lg hover:bg-[var(--color-brand-hover)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
保存偏好
|
||||
</button>
|
||||
@@ -276,7 +276,7 @@ export function CookieSettingsButton() {
|
||||
const event = new CustomEvent('open-cookie-settings');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-primary-lighter)] transition-colors"
|
||||
className="fixed bottom-4 right-4 z-[9997] px-3 py-2 text-xs font-medium text-[var(--color-text-muted)] bg-[var(--color-bg-primary)] border border-[var(--color-border-secondary)] rounded-lg shadow-sm hover:bg-[var(--color-brand-lighter)] transition-colors"
|
||||
aria-label="Cookie 设置"
|
||||
>
|
||||
Cookie 设置
|
||||
|
||||
@@ -14,16 +14,20 @@ interface BreadcrumbProps {
|
||||
|
||||
export function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="flex items-center space-x-1 text-sm text-[var(--color-text-placeholder)] py-4">
|
||||
<StaticLink href="/" className="flex items-center hover:text-[var(--color-brand-primary)] transition-colors" aria-label="返回首页">
|
||||
<Home className="w-4 h-4" />
|
||||
<nav aria-label="breadcrumb" className="flex items-center space-x-1 text-sm text-text-muted py-4">
|
||||
<StaticLink
|
||||
href="/"
|
||||
className="flex items-center hover:text-text-primary transition-colors duration-fast"
|
||||
aria-label="返回首页"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5" />
|
||||
</StaticLink>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<ChevronRight className="w-4 h-4 text-[var(--color-border-primary)]" />
|
||||
<ChevronRight className="w-3.5 h-3.5 text-border-secondary" />
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="ml-1 hover:text-[var(--color-brand-primary)] transition-colors"
|
||||
className="ml-1 hover:text-text-primary transition-colors duration-fast"
|
||||
>
|
||||
{item.label}
|
||||
</StaticLink>
|
||||
|
||||
@@ -10,7 +10,9 @@ interface ClientLayoutProps {
|
||||
export function ClientLayout({ children }: ClientLayoutProps) {
|
||||
return (
|
||||
<PageTransition>
|
||||
{children}
|
||||
<main id="main-content">
|
||||
{children}
|
||||
</main>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,42 @@ jest.mock('lucide-react', () => ({
|
||||
MapPin: () => <span data-testid="map-pin-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/site-config', () => ({
|
||||
useSiteConfig: () => ({
|
||||
name: '四川睿新致远科技有限公司',
|
||||
shortName: '睿新致遠',
|
||||
displayName: '睿新致远',
|
||||
description: '以智慧连接数字趋势,以伙伴身份陪您成长',
|
||||
email: 'contact@novalon.cn',
|
||||
address: '中国四川省成都市龙泉驿区幸福路12号',
|
||||
icp: '蜀ICP备XXXXXXXX号',
|
||||
police: '川公网安备XXXXXXXXXXX号',
|
||||
megaDropdown: {
|
||||
products: [
|
||||
{
|
||||
id: 'group1',
|
||||
title: '企业套装',
|
||||
items: [
|
||||
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
|
||||
{ id: 'crm', title: 'CRM 客户管理', description: '线索·商机·合同·服务', href: '/products/crm' },
|
||||
],
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{
|
||||
id: 'group1',
|
||||
title: '行业解决方案',
|
||||
items: [
|
||||
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
|
||||
{ id: 'retail', title: '零售业', description: '全渠道·会员·精准营销', href: '/solutions/retail' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
SiteConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/constants', () => ({
|
||||
COMPANY_INFO: {
|
||||
name: '四川睿新致远科技有限公司',
|
||||
@@ -44,21 +80,26 @@ jest.mock('@/lib/constants', () => ({
|
||||
icp: '蜀ICP备XXXXXXXX号',
|
||||
police: '川公网安备XXXXXXXXXXX号',
|
||||
},
|
||||
NAVIGATION_V2: [
|
||||
{ id: 'products', label: '产品', href: '/products' },
|
||||
{ id: 'solutions', label: '解决方案', href: '/solutions' },
|
||||
{ id: 'services', label: '服务', href: '/services' },
|
||||
{ id: 'about', label: '关于我们', href: '/about' },
|
||||
{ id: 'contact', label: '联系我们', href: '/contact' },
|
||||
],
|
||||
MEGA_DROPDOWN_DATA: {
|
||||
products: [
|
||||
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
|
||||
{ id: 'crm', title: 'CRM 客户管理', description: '线索·商机·合同·服务', href: '/products/crm' },
|
||||
{
|
||||
id: 'group1',
|
||||
title: '企业套装',
|
||||
items: [
|
||||
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
|
||||
{ id: 'crm', title: 'CRM 客户管理', description: '线索·商机·合同·服务', href: '/products/crm' },
|
||||
],
|
||||
},
|
||||
],
|
||||
solutions: [
|
||||
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
|
||||
{ id: 'retail', title: '零售业', description: '全渠道·会员·精准营销', href: '/solutions/retail' },
|
||||
{
|
||||
id: 'group1',
|
||||
title: '行业解决方案',
|
||||
items: [
|
||||
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
|
||||
{ id: 'retail', title: '零售业', description: '全渠道·会员·精准营销', href: '/solutions/retail' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}));
|
||||
@@ -87,26 +128,18 @@ describe('Footer', () => {
|
||||
expect(screen.getByText('以智慧连接数字趋势,以伙伴身份陪您成长')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render quick links section', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('快速导航')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render products section', () => {
|
||||
render(<Footer />);
|
||||
const productHeadings = screen.getAllByText('产品');
|
||||
expect(productHeadings.length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('产品')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render solutions section', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('解决方案')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact information section', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('联系方式')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render navigation links', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('服务')).toBeInTheDocument();
|
||||
expect(screen.getByText('关于我们')).toBeInTheDocument();
|
||||
expect(screen.getByText('联系我们')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -116,6 +149,12 @@ describe('Footer', () => {
|
||||
expect(screen.getByText('CRM 客户管理')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render solution links', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('制造业')).toBeInTheDocument();
|
||||
expect(screen.getByText('零售业')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact details', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('contact@novalon.cn')).toBeInTheDocument();
|
||||
@@ -124,10 +163,10 @@ describe('Footer', () => {
|
||||
});
|
||||
|
||||
describe('Card Layout', () => {
|
||||
it('should render three card sections', () => {
|
||||
it('should render four card sections', () => {
|
||||
render(<Footer />);
|
||||
const cards = screen.getAllByTestId(/card/);
|
||||
expect(cards.length).toBeGreaterThanOrEqual(3);
|
||||
expect(cards.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it('should render brand card with logo and description', () => {
|
||||
@@ -136,17 +175,16 @@ describe('Footer', () => {
|
||||
expect(screen.getByText('以智慧连接数字趋势,以伙伴身份陪您成长')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render navigation card with quick links and products', () => {
|
||||
it('should render products and solutions cards', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('快速导航')).toBeInTheDocument();
|
||||
const productHeadings = screen.getAllByText('产品');
|
||||
expect(productHeadings.length).toBeGreaterThan(0);
|
||||
expect(screen.getByTestId('card-products')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('card-solutions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact card with contact info and QR code', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('联系方式')).toBeInTheDocument();
|
||||
expect(screen.getByText('企业微信业务咨询')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('card-contact')).toBeInTheDocument();
|
||||
expect(screen.getByText('关注公众号')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,16 +259,5 @@ describe('Footer', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('关注公众号')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Enterprise WeChat QR code image', () => {
|
||||
render(<Footer />);
|
||||
const qrCode = screen.getByAltText('企业微信业务咨询二维码');
|
||||
expect(qrCode).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Enterprise WeChat QR code description', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('企业微信业务咨询')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+101
-106
@@ -1,76 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import Image from 'next/image';
|
||||
import { Mail, MapPin } from 'lucide-react';
|
||||
import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA } from '@/lib/constants';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
|
||||
export function Footer() {
|
||||
const config = useSiteConfig();
|
||||
const productItems = (config.megaDropdown.products ?? [])
|
||||
.flatMap(group => group.items)
|
||||
.filter(item => item.href !== '#');
|
||||
|
||||
const solutionItems = (config.megaDropdown.solutions ?? [])
|
||||
.flatMap(group => group.items);
|
||||
|
||||
return (
|
||||
<footer className="bg-[var(--color-footer-bg)] text-[var(--color-footer-text)] py-12 md:py-16 relative" data-testid="footer" role="contentinfo">
|
||||
{/* Ink texture overlay */}
|
||||
<div className="absolute inset-0 bg-paper-texture opacity-30 pointer-events-none" />
|
||||
<div className="container-wide">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-10 lg:gap-8">
|
||||
<div data-testid="card-brand" className="lg:col-span-2 relative z-10">
|
||||
<footer className="bg-[#0A0E14] text-white relative overflow-hidden" data-testid="footer" role="contentinfo">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-12 gap-10 lg:gap-12 py-16 lg:py-20">
|
||||
<div data-testid="card-brand" className="col-span-2 lg:col-span-4">
|
||||
<div className="mb-6">
|
||||
<Image
|
||||
src="/logo-light.svg"
|
||||
alt={COMPANY_INFO.name}
|
||||
width={160}
|
||||
height={40}
|
||||
loading="eager"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[var(--color-footer-text)] text-sm leading-relaxed mb-6">
|
||||
{COMPANY_INFO.description}
|
||||
</p>
|
||||
<div className="pt-6 border-t border-[var(--color-footer-border)]">
|
||||
<p className="text-sm text-[var(--color-footer-text)] mb-3">关注公众号</p>
|
||||
<div className="inline-block p-2 rounded-lg border border-[var(--color-footer-border)]">
|
||||
<StaticLink href="/" className="inline-block group" aria-label="返回首页">
|
||||
<Image
|
||||
src="/images/qrcode_for_gh_a297181ff548_258.jpg"
|
||||
alt="微信公众号二维码"
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-25 h-25"
|
||||
loading="lazy"
|
||||
src="/logo.svg"
|
||||
alt={config.name}
|
||||
width={180}
|
||||
height={45}
|
||||
className="w-auto h-10 md:h-12 transition-transform duration-normal ease-out group-hover:scale-[1.02]"
|
||||
loading="eager"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</StaticLink>
|
||||
</div>
|
||||
<p className="text-gray-300 text-sm leading-relaxed max-w-md">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div data-testid="card-navigation" className="relative z-10">
|
||||
<h3 className="font-semibold text-base mb-5 text-[var(--color-footer-text-link)]">快速导航</h3>
|
||||
<ul className="space-y-3">
|
||||
{NAVIGATION_V2.map((item) => (
|
||||
<div data-testid="card-products" className="lg:col-span-2">
|
||||
<h3 className="font-semibold text-sm mb-5 text-white tracking-widest uppercase">产品</h3>
|
||||
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
|
||||
{productItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="text-[var(--color-footer-text)] hover:text-[var(--color-footer-text-link)] transition-colors duration-200 text-sm" >
|
||||
{item.label}
|
||||
</StaticLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div data-testid="card-products" className="relative z-10">
|
||||
<h3 className="font-semibold text-base mb-5 text-[var(--color-footer-text-link)]">产品与方案</h3>
|
||||
<ul className="space-y-3">
|
||||
{(MEGA_DROPDOWN_DATA.products ?? []).flatMap(group => group.items).filter(item => item.href !== '#').map((item) => (
|
||||
<li key={item.id}>
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="text-[var(--color-footer-text)] hover:text-[var(--color-footer-text-link)] transition-colors duration-200 text-sm" >
|
||||
{item.title}
|
||||
</StaticLink>
|
||||
</li>
|
||||
))}
|
||||
{(MEGA_DROPDOWN_DATA.solutions ?? []).flatMap(group => group.items).map((item) => (
|
||||
<li key={item.id}>
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="text-[var(--color-footer-text)] hover:text-[var(--color-footer-text-link)] transition-colors duration-200 text-sm" >
|
||||
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm whitespace-nowrap"
|
||||
>
|
||||
{item.title}
|
||||
</StaticLink>
|
||||
</li>
|
||||
@@ -78,27 +53,48 @@ export function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div data-testid="card-contact" className="relative z-10">
|
||||
<h3 className="font-semibold text-base mb-5 text-[var(--color-footer-text-link)]">联系方式</h3>
|
||||
<ul className="space-y-4">
|
||||
<div data-testid="card-solutions" className="lg:col-span-2">
|
||||
<h3 className="font-semibold text-sm mb-5 text-white tracking-widest uppercase">解决方案</h3>
|
||||
<ul className="space-y-3 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
|
||||
{solutionItems.map((item) => (
|
||||
<li key={item.id}>
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm whitespace-nowrap"
|
||||
>
|
||||
{item.title}
|
||||
</StaticLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div data-testid="card-contact" className="col-span-2 lg:col-span-4">
|
||||
<h3 className="font-semibold text-sm mb-5 text-white tracking-widest uppercase">联系我们</h3>
|
||||
<ul className="space-y-4 mb-8 [&_li]:p-0 [&_li]:m-0 [&_li]:static [&_li::before]:hidden">
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPin className="w-4 h-4 text-[var(--color-brand-primary)] mt-0.5 shrink-0" />
|
||||
<span className="text-[var(--color-footer-text)] text-sm">{COMPANY_INFO.address}</span>
|
||||
<MapPin className="w-4 h-4 text-gray-400 mt-0.5 shrink-0" />
|
||||
<span className="text-gray-300 text-sm leading-relaxed">{config.address}</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3">
|
||||
<Mail className="w-4 h-4 text-[var(--color-brand-primary)] shrink-0" />
|
||||
<span className="text-[var(--color-footer-text)] text-sm">{COMPANY_INFO.email}</span>
|
||||
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
<a
|
||||
href={`mailto:${config.email}`}
|
||||
className="text-gray-300 hover:text-white transition-colors duration-200 text-sm"
|
||||
>
|
||||
{config.email}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-6 pt-6 border-t border-[var(--color-footer-border)]">
|
||||
<p className="text-sm text-[var(--color-footer-text)] mb-3">企业微信业务咨询</p>
|
||||
<div className="inline-block p-2 rounded-lg border border-[var(--color-footer-border)]">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-3 tracking-wide">关注公众号</p>
|
||||
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
|
||||
<Image
|
||||
src="/images/149A1D2F-D9FD-49C7-B139-142C50C5FE8B_1_201_a.jpeg"
|
||||
alt="企业微信业务咨询二维码"
|
||||
width={100}
|
||||
height={100}
|
||||
className="w-25 h-25"
|
||||
src="/images/qrcode.webp"
|
||||
alt="微信公众号二维码"
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-24 h-24"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
@@ -106,48 +102,47 @@ export function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--color-footer-border)] mt-12 pt-8 pb-24 md:pb-8 relative z-10" style={{ paddingBottom: 'calc(5rem + env(safe-area-inset-bottom, 0px))' }}>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<p className="text-[var(--color-footer-text-muted)] text-sm">
|
||||
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
|
||||
<div className="border-t border-gray-800 pt-6 pb-[calc(8rem+env(safe-area-inset-bottom,0px))] md:pb-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 mb-6">
|
||||
<p className="text-gray-300 text-xs">
|
||||
© {new Date().getFullYear()} {config.name}. All rights reserved.
|
||||
</p>
|
||||
<div className="flex gap-6">
|
||||
<StaticLink href="/privacy" className="text-[var(--color-footer-text-muted)] hover:text-[var(--color-footer-text-link)] text-sm transition-colors duration-200">
|
||||
<StaticLink href="/privacy" className="text-gray-300 hover:text-white text-xs transition-colors duration-200">
|
||||
隐私政策
|
||||
</StaticLink>
|
||||
<StaticLink href="/terms" className="text-[var(--color-footer-text-muted)] hover:text-[var(--color-footer-text-link)] text-sm transition-colors duration-200">
|
||||
<StaticLink href="/terms" className="text-gray-300 hover:text-white text-xs transition-colors duration-200">
|
||||
服务条款
|
||||
</StaticLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-6 pt-6 border-t border-[var(--color-footer-border)]">
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[var(--color-footer-text-link)] hover:text-white transition-colors duration-200" >
|
||||
{COMPANY_INFO.icp}
|
||||
</a>
|
||||
<span className="hidden sm:inline text-[var(--color-footer-text-dim)]">|</span>
|
||||
<a
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[var(--color-footer-text-link)] hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Image
|
||||
src="/images/beian-icon.png"
|
||||
alt="公安备案"
|
||||
width={16}
|
||||
height={16}
|
||||
className="w-4 h-4"
|
||||
loading="lazy"
|
||||
/>
|
||||
{COMPANY_INFO.police}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 pt-4 text-[0.8125rem]">
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-300 hover:text-white transition-colors duration-200 inline-flex items-center"
|
||||
>
|
||||
{config.icp}
|
||||
</a>
|
||||
<span className="hidden sm:inline text-gray-500">|</span>
|
||||
<a
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-gray-300 hover:text-white transition-colors duration-200 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Image
|
||||
src="/images/beian-icon.png"
|
||||
alt="公安备案"
|
||||
width={14}
|
||||
height={14}
|
||||
className="w-3.5 h-3.5 opacity-80"
|
||||
loading="lazy"
|
||||
/>
|
||||
{config.police}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,21 @@ jest.mock('framer-motion', () => ({
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
header: ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => (
|
||||
<header className={className} {...props}>
|
||||
{children}
|
||||
</header>
|
||||
),
|
||||
a: ({ children, className, href, ...props }: { children: React.ReactNode; className?: string; href?: string; [key: string]: unknown }) => (
|
||||
<a className={className} href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
nav: ({ children, className, ...props }: { children: React.ReactNode; className?: string; [key: string]: unknown }) => (
|
||||
<nav className={className} {...props}>
|
||||
{children}
|
||||
</nav>
|
||||
),
|
||||
},
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
@@ -46,6 +61,7 @@ jest.mock('framer-motion', () => ({
|
||||
jest.mock('lucide-react', () => ({
|
||||
Menu: () => <span data-testid="menu-icon" />,
|
||||
X: () => <span data-testid="x-icon" />,
|
||||
MessageCircle: () => <span data-testid="message-circle-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/button', () => {
|
||||
@@ -58,6 +74,31 @@ jest.mock('@/components/ui/button', () => {
|
||||
return { Button: MockButton };
|
||||
});
|
||||
|
||||
jest.mock('@/lib/site-config', () => ({
|
||||
useSiteConfig: () => ({
|
||||
name: '四川睿新致远科技有限公司',
|
||||
shortName: '睿新致遠',
|
||||
displayName: '睿新致远',
|
||||
mainNav: [
|
||||
{ id: 'products', label: '产品', href: '/products', hasDropdown: true, dropdownKey: 'products' },
|
||||
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
|
||||
{ id: 'services', label: '服务', href: '/services' },
|
||||
{ id: 'about', label: '关于我们', href: '/about' },
|
||||
{ id: 'contact', label: '联系我们', href: '/contact' },
|
||||
],
|
||||
megaDropdown: {
|
||||
products: [
|
||||
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
|
||||
{ id: 'crm', title: 'CRM 客户管理', description: '线索·商机·合同·服务', href: '/products/crm' },
|
||||
],
|
||||
solutions: [
|
||||
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
SiteConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/constants', () => ({
|
||||
COMPANY_INFO: {
|
||||
name: '四川睿新致远科技有限公司',
|
||||
@@ -87,10 +128,10 @@ jest.mock('@/hooks/use-focus-trap', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('@/components/layout/mega-dropdown', () => ({
|
||||
MegaDropdown: ({ label, items }: { label: string; items: Array<{ id: string; title: string; description: string; href: string }> }) => (
|
||||
MegaDropdown: ({ label, groups }: { label: string; groups?: Array<{ id: string; title: string; description: string; href: string }> }) => (
|
||||
<div data-testid="mega-dropdown">
|
||||
<span>{label}</span>
|
||||
{items.map(item => (
|
||||
{(groups ?? []).map(item => (
|
||||
<a key={item.id} href={item.href}>{item.title}</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,7 @@ import { usePathname } from 'next/navigation';
|
||||
import { Menu, X, MessageCircle } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import { useTheme } from '@/contexts/theme-context';
|
||||
import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA, type NavigationItemV2 } from '@/lib/constants';
|
||||
import { useSiteConfig, type NavigationItem } from '@/lib/site-config';
|
||||
import { MegaDropdown } from '@/components/layout/mega-dropdown';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
|
||||
@@ -19,7 +17,6 @@ function HeaderContent() {
|
||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||
const pathname = usePathname();
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -51,13 +48,14 @@ function HeaderContent() {
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleNavClick = useCallback((_e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItemV2) => {
|
||||
const config = useSiteConfig();
|
||||
|
||||
const handleNavClick = useCallback((_e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
|
||||
setIsOpen(false);
|
||||
// StaticLink 会 e.preventDefault(),需要手动执行导航
|
||||
window.location.href = item.href;
|
||||
}, []);
|
||||
|
||||
const isActive = useCallback((item: NavigationItemV2) => {
|
||||
const isActive = useCallback((item: NavigationItem) => {
|
||||
if (item.id === 'contact') {
|
||||
return pathname === '/contact';
|
||||
}
|
||||
@@ -70,49 +68,56 @@ function HeaderContent() {
|
||||
if (item.id === 'solutions') {
|
||||
return pathname === '/solutions' || pathname.startsWith('/solutions/');
|
||||
}
|
||||
if (item.id === 'cases') {
|
||||
return pathname === '/cases' || pathname.startsWith('/cases/');
|
||||
}
|
||||
if (item.id === 'services') {
|
||||
return pathname === '/services' || pathname.startsWith('/services/');
|
||||
}
|
||||
return pathname === `/${item.id}`;
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
<motion.header
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isScrolled ? 64 : 80,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`
|
||||
fixed top-0 left-0 right-0 z-50
|
||||
transition-all duration-300 ease-out
|
||||
${isScrolled
|
||||
? 'bg-[var(--color-bg-primary)]/90 backdrop-blur-xl border-b border-[var(--color-border-primary)] shadow-sm'
|
||||
: 'bg-transparent'
|
||||
}
|
||||
bg-white border-b border-border-primary
|
||||
`}
|
||||
>
|
||||
<div className="container-wide">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="max-w-container mx-auto px-4 sm:px-6 lg:px-10">
|
||||
<div className="flex items-center justify-between h-16 md:h-full">
|
||||
<StaticLink
|
||||
href="/"
|
||||
className="relative flex items-center group/logo"
|
||||
aria-label="返回首页"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-lg bg-[var(--color-brand-primary)] opacity-0 group-hover/logo:opacity-5 blur-xl transition-all duration-500 scale-75 group-hover/logo:scale-100" />
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt={COMPANY_INFO.name}
|
||||
width={120}
|
||||
height={30}
|
||||
className={`relative transition-all duration-300 group-hover/logo:scale-105 w-auto h-8 md:h-8 ${resolvedTheme === 'dark' ? 'dark:invert dark:brightness-0 dark:hue-rotate-180' : ''}`}
|
||||
alt={config.name}
|
||||
width={160}
|
||||
height={40}
|
||||
className="relative transition-all duration-300 ease-out group-hover/logo:scale-[1.02] w-auto h-8 md:h-10"
|
||||
loading="eager"
|
||||
priority
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
</StaticLink>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
|
||||
{NAVIGATION_V2.map((item) => (
|
||||
<nav className="hidden md:flex items-center gap-0.5" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
|
||||
{config.mainNav.map((item) => (
|
||||
item.hasDropdown ? (
|
||||
<MegaDropdown
|
||||
key={item.id}
|
||||
label={item.label}
|
||||
groups={MEGA_DROPDOWN_DATA[item.dropdownKey!] ?? []}
|
||||
groups={config.megaDropdown[item.dropdownKey!] ?? []}
|
||||
isOpen={openDropdown === item.id}
|
||||
isActive={isActive(item)}
|
||||
onToggle={() => {
|
||||
setOpenDropdown((prev) => prev === item.id ? null : item.id);
|
||||
}}
|
||||
@@ -120,16 +125,16 @@ function HeaderContent() {
|
||||
onClose={() => setOpenDropdown((prev) => prev === item.id ? null : prev)}
|
||||
/>
|
||||
) : (
|
||||
<div key={item.id} className="-mx-2 px-2 py-2">
|
||||
<div key={item.id} className="px-1 py-2">
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item)}
|
||||
className={`
|
||||
relative block px-3 py-1.5 text-sm font-medium
|
||||
transition-all duration-300 rounded-md
|
||||
relative block px-3 py-2 text-sm font-medium
|
||||
transition-all duration-300 ease-out
|
||||
${isActive(item)
|
||||
? 'text-[var(--color-text-primary)] bg-[var(--color-brand-primary-bg)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
|
||||
? 'text-text-primary'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
|
||||
}
|
||||
`}
|
||||
aria-current={isActive(item) ? 'page' : undefined}
|
||||
@@ -137,8 +142,8 @@ function HeaderContent() {
|
||||
{item.label}
|
||||
<span
|
||||
className={`
|
||||
absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 bg-[var(--color-brand-primary)] rounded-full
|
||||
transition-all duration-200 ease-out
|
||||
absolute -bottom-0.5 left-3 right-3 h-0.5 bg-brand
|
||||
transition-all duration-300 ease-out
|
||||
${isActive(item)
|
||||
? 'opacity-100 scale-x-100'
|
||||
: 'opacity-0 scale-x-0'
|
||||
@@ -152,28 +157,20 @@ function HeaderContent() {
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
className="hidden lg:flex"
|
||||
>
|
||||
<StaticLink href="/contact" data-testid="consult-button">
|
||||
<MessageCircle className="w-4 h-4 mr-1.5" />
|
||||
咨询
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
<span className="hidden lg:inline">立即咨询</span>
|
||||
<span className="lg:hidden" aria-hidden="true">咨询</span>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
asChild
|
||||
className="lg:hidden"
|
||||
>
|
||||
<StaticLink href="/contact" data-testid="consult-button">立即咨询</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="md:hidden p-3 -mr-3 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)] rounded-lg transition-all duration-200 active:scale-95"
|
||||
className="md:hidden p-2 -mr-2 text-text-secondary hover:text-text-primary hover:bg-bg-secondary rounded-sm transition-all duration-300 ease-out active:scale-95"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-expanded={isOpen}
|
||||
@@ -182,11 +179,11 @@ function HeaderContent() {
|
||||
data-testid="mobile-menu-button"
|
||||
style={{ minWidth: '44px', minHeight: '44px' }}
|
||||
>
|
||||
{isOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
|
||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</motion.header>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen && (
|
||||
@@ -199,7 +196,7 @@ function HeaderContent() {
|
||||
className="fixed inset-0 z-40 md:hidden"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--color-primary)]/30 backdrop-blur-sm"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -207,15 +204,15 @@ function HeaderContent() {
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className="absolute top-16 right-0 bottom-0 left-0 bg-[var(--color-bg-primary)]/98 backdrop-blur-xl shadow-2xl overflow-y-auto"
|
||||
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="absolute top-16 right-0 bottom-0 left-0 bg-white/98 backdrop-blur-xl overflow-y-auto"
|
||||
id="mobile-menu"
|
||||
role="navigation"
|
||||
aria-label="移动端导航"
|
||||
data-testid="mobile-navigation"
|
||||
>
|
||||
<nav className="container-wide py-6">
|
||||
{NAVIGATION_V2.map((item, index) => (
|
||||
<nav className="max-w-container mx-auto px-4 sm:px-6 py-6">
|
||||
{config.mainNav.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
initial={{ x: 20, opacity: 0 }}
|
||||
@@ -226,11 +223,11 @@ function HeaderContent() {
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item)}
|
||||
className={`
|
||||
block px-4 py-4 text-base font-medium rounded-lg
|
||||
transition-all duration-200
|
||||
block px-4 py-4 text-base font-medium
|
||||
transition-all duration-300 ease-out
|
||||
${isActive(item)
|
||||
? 'text-[var(--color-text-primary)] bg-[var(--color-brand-primary-bg)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-primary-lighter)]'
|
||||
? 'text-text-primary bg-bg-secondary'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
|
||||
}
|
||||
`}
|
||||
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
|
||||
@@ -239,18 +236,14 @@ function HeaderContent() {
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="mt-6 px-4 pt-6 border-t border-[var(--color-border-primary)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-sm text-[var(--color-text-muted)]">外观模式</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<div className="mt-6 px-4 pt-6 border-t border-border-primary">
|
||||
<Button
|
||||
className="w-full"
|
||||
asChild
|
||||
size="lg"
|
||||
>
|
||||
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
<MessageCircle className="w-4 h-4" />
|
||||
咨询专家
|
||||
</StaticLink>
|
||||
</Button>
|
||||
@@ -267,15 +260,15 @@ function HeaderContent() {
|
||||
function HeaderFallback() {
|
||||
return (
|
||||
<header className="fixed top-0 left-0 right-0 z-50 bg-transparent">
|
||||
<div className="container-wide">
|
||||
<div className="container-x">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="h-8 w-8 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
|
||||
<div className="h-8 w-24 bg-bg-tertiary animate-skeleton-pulse rounded-sm" />
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-6 w-16 bg-[var(--color-skeleton-bg)] animate-pulse rounded mx-1" />
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="h-6 w-16 bg-bg-tertiary animate-skeleton-pulse rounded-sm mx-1" />
|
||||
))}
|
||||
</nav>
|
||||
<div className="h-9 w-20 bg-[var(--color-skeleton-bg)] animate-pulse rounded" />
|
||||
<div className="h-9 w-20 bg-bg-tertiary animate-skeleton-pulse rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export { Header } from './header';
|
||||
export { Footer } from './footer';
|
||||
export { Breadcrumb } from './breadcrumb';
|
||||
export { MobileTabBar } from './mobile-tab-bar';
|
||||
export { MegaDropdown } from './mega-dropdown';
|
||||
export { PageNav } from './page-nav';
|
||||
export { ClientLayout } from './client-layout';
|
||||
@@ -10,12 +10,13 @@ interface MegaDropdownProps {
|
||||
label: string;
|
||||
groups: MegaDropdownGroup[];
|
||||
isOpen: boolean;
|
||||
isActive?: boolean;
|
||||
onToggle: () => void;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function MegaDropdown({ label, groups, isOpen, onToggle, onOpen, onClose }: MegaDropdownProps) {
|
||||
export function MegaDropdown({ label, groups, isOpen, isActive, onToggle, onOpen, onClose }: MegaDropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
@@ -25,7 +26,7 @@ export function MegaDropdown({ label, groups, isOpen, onToggle, onOpen, onClose
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(onClose, 150);
|
||||
timeoutRef.current = setTimeout(onClose, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,21 +38,38 @@ export function MegaDropdown({ label, groups, isOpen, onToggle, onOpen, onClose
|
||||
<div className="-mx-2 px-2 py-2">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md
|
||||
transition-all duration-200
|
||||
${isOpen
|
||||
? 'text-[var(--color-brand-primary)] bg-[var(--color-brand-primary-bg)]'
|
||||
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]'
|
||||
relative flex items-center gap-1 px-3 py-2 text-sm font-medium
|
||||
transition-all duration-300 ease-out
|
||||
${isOpen || isActive
|
||||
? 'text-text-primary'
|
||||
: 'text-text-secondary hover:text-text-primary hover:bg-bg-secondary'
|
||||
}
|
||||
`}
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
<span
|
||||
className={`
|
||||
absolute -bottom-0.5 left-3 right-3 h-0.5 bg-brand
|
||||
transition-all duration-300 ease-out
|
||||
${isActive
|
||||
? 'opacity-100 scale-x-100'
|
||||
: 'opacity-0 scale-x-0'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -64,16 +82,16 @@ export function MegaDropdown({ label, groups, isOpen, onToggle, onOpen, onClose
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-1/2 -translate-x-1/2 pt-2 w-[640px] z-50"
|
||||
>
|
||||
<div className="bg-[var(--color-bg-primary)] rounded-xl border border-[var(--color-border-primary)] shadow-lg p-6">
|
||||
<div className="bg-white backdrop-blur-xl rounded-none border border-border-primary p-6">
|
||||
{groups.map((group, groupIndex) => (
|
||||
<div key={group.id} className={groupIndex > 0 ? 'mt-6 pt-6 border-t border-[var(--color-border-primary)]' : ''}>
|
||||
<div key={group.id} className={groupIndex > 0 ? 'mt-6 pt-6 border-t border-border-primary' : ''}>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<h3 className="text-sm font-bold text-[var(--color-text-primary)]">{group.title}</h3>
|
||||
<h3 className="text-sm font-bold text-text-primary">{group.title}</h3>
|
||||
{group.description && (
|
||||
<span className="text-xs text-[var(--color-text-muted)]">{group.description}</span>
|
||||
<span className="text-xs text-text-muted">{group.description}</span>
|
||||
)}
|
||||
{group.highlight && (
|
||||
<Sparkles className="w-3.5 h-3.5 text-[var(--color-brand-primary)]" />
|
||||
<Sparkles className="w-3.5 h-3.5 text-brand" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -82,28 +100,28 @@ export function MegaDropdown({ label, groups, isOpen, onToggle, onOpen, onClose
|
||||
<StaticLink
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
className={`block p-3 rounded-lg border transition-all duration-200 group ${
|
||||
className={`block p-3 border transition-all duration-300 ease-out group ${
|
||||
item.href === '#'
|
||||
? 'border-[var(--color-border-primary)]/50 bg-[var(--color-bg-secondary)] cursor-not-allowed opacity-70'
|
||||
: 'border-transparent hover:border-[var(--color-brand-primary)]/10 hover:bg-[var(--color-brand-primary-bg)]'
|
||||
? 'border-border-primary bg-bg-secondary cursor-not-allowed opacity-50'
|
||||
: 'border-transparent hover:border-border-primary hover:bg-bg-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{item.title}</div>
|
||||
<div className="text-sm font-semibold text-text-primary">{item.title}</div>
|
||||
{item.badge && (
|
||||
<span className={`shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
<span className={`shrink-0 px-1.5 py-0.5 text-[10px] font-medium ${
|
||||
item.badge === '敬请期待'
|
||||
? 'bg-gray-100 text-gray-500'
|
||||
: 'bg-[var(--color-brand-primary-bg)] text-[var(--color-brand-primary)]'
|
||||
? 'bg-bg-tertiary text-text-muted'
|
||||
: 'bg-brand/10 text-brand'
|
||||
}`}>
|
||||
{item.badge === '敬请期待' && <Lock className="w-2.5 h-2.5 inline mr-0.5" />}
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-placeholder)] mt-1 leading-relaxed">{item.description}</div>
|
||||
<div className="text-xs text-text-secondary mt-1 leading-relaxed">{item.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</StaticLink>
|
||||
@@ -112,10 +130,10 @@ export function MegaDropdown({ label, groups, isOpen, onToggle, onOpen, onClose
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-[var(--color-border-primary)]">
|
||||
<div className="mt-4 pt-4 border-t border-border-primary">
|
||||
<StaticLink
|
||||
href={`/${label === '产品' ? 'products' : label === '解决方案' ? 'solutions' : 'services'}`}
|
||||
className="flex items-center justify-center gap-1 text-xs font-medium text-[var(--color-brand-primary)] hover:underline"
|
||||
className="flex items-center justify-center gap-1 text-xs font-medium text-text-muted hover:text-text-primary transition-colors"
|
||||
>
|
||||
查看全部{label}
|
||||
<ChevronDown className="w-3 h-3 rotate-[-90deg]" />
|
||||
|
||||
@@ -7,6 +7,27 @@ jest.mock('@/hooks/use-focus-trap', () => ({
|
||||
useFocusTrap: () => ({ current: null }),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/site-config', () => ({
|
||||
useSiteConfig: () => ({
|
||||
mainNav: [
|
||||
{ id: 'products', label: '产品', href: '/products', hasDropdown: true, dropdownKey: 'products' },
|
||||
{ id: 'solutions', label: '解决方案', href: '/solutions', hasDropdown: true, dropdownKey: 'solutions' },
|
||||
{ id: 'services', label: '服务', href: '/services' },
|
||||
{ id: 'about', label: '关于我们', href: '/about' },
|
||||
{ id: 'contact', label: '联系我们', href: '/contact' },
|
||||
],
|
||||
megaDropdown: {
|
||||
products: [
|
||||
{ id: 'erp', title: 'ERP 管理系统', description: '财务·采购·销售·库存·生产', href: '/products/erp' },
|
||||
],
|
||||
solutions: [
|
||||
{ id: 'manufacturing', title: '制造业', description: '智能制造·MES·质量管控', href: '/solutions/manufacturing' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
SiteConfigProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/constants', () => ({
|
||||
NAVIGATION_V2: [
|
||||
{ id: 'products', label: '产品', href: '/products', hasDropdown: true, dropdownKey: 'products' },
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Menu, X, ChevronDown } from 'lucide-react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from '@/lib/constants';
|
||||
import { useSiteConfig } from '@/lib/site-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
|
||||
@@ -12,6 +12,7 @@ interface MobileMenuProps {
|
||||
}
|
||||
|
||||
export function MobileMenu({ className }: MobileMenuProps) {
|
||||
const config = useSiteConfig();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [expandedDropdown, setExpandedDropdown] = useState<string | null>(null);
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
|
||||
@@ -46,7 +47,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onKeyDown={(e) => handleKeyDown(e)}
|
||||
className="p-3 rounded-md hover:bg-[var(--color-primary-lighter)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
className="p-3 rounded-md hover:bg-[var(--color-brand-lighter)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-offset-2 min-w-[48px] min-h-[48px] flex items-center justify-center"
|
||||
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="mobile-menu-panel"
|
||||
@@ -61,7 +62,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
{isOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-[var(--color-primary)]/20 backdrop-blur-sm z-40"
|
||||
className="fixed inset-0 bg-[var(--color-brand)]/20 backdrop-blur-sm z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -74,14 +75,14 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
>
|
||||
<div className="container-wide py-4">
|
||||
<ul className="space-y-1" role="list">
|
||||
{NAVIGATION_V2.map((item) => (
|
||||
{config.mainNav.map((item) => (
|
||||
<li key={item.id}>
|
||||
{item.hasDropdown && item.dropdownKey ? (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => toggleDropdown(item.dropdownKey!)}
|
||||
onKeyDown={(e) => handleKeyDown(e, () => toggleDropdown(item.dropdownKey!))}
|
||||
className="flex items-center justify-between w-full text-left px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-primary-bg)] hover:text-[var(--color-brand-primary)] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-primary)] focus:ring-inset min-h-[48px]"
|
||||
className="flex items-center justify-between w-full text-left px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-bg)] hover:text-[var(--color-brand)] rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] focus:ring-inset min-h-[48px]"
|
||||
aria-expanded={expandedDropdown === item.dropdownKey}
|
||||
>
|
||||
{item.label}
|
||||
@@ -94,12 +95,12 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
</button>
|
||||
{expandedDropdown === item.dropdownKey && (
|
||||
<ul className="pl-4 space-y-1 mt-1 mb-2" role="list">
|
||||
{(MEGA_DROPDOWN_DATA[item.dropdownKey] ?? []).flatMap(group => group.items).filter(sub => sub.href !== '#').map((sub) => (
|
||||
{(config.megaDropdown[item.dropdownKey] ?? []).flatMap(group => group.items).filter(sub => sub.href !== '#').map((sub) => (
|
||||
<li key={sub.id}>
|
||||
<StaticLink
|
||||
href={sub.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand-primary)] hover:bg-[var(--color-brand-primary-bg)] rounded-md transition-colors"
|
||||
className="block px-4 py-3 text-sm text-[var(--color-text-muted)] hover:text-[var(--color-brand)] hover:bg-[var(--color-brand-bg)] rounded-md transition-colors"
|
||||
>
|
||||
<span className="font-medium text-[var(--color-text-primary)]">{sub.title}</span>
|
||||
<span className="block text-xs text-[var(--color-text-hint)] mt-0.5">{sub.description}</span>
|
||||
@@ -113,7 +114,7 @@ export function MobileMenu({ className }: MobileMenuProps) {
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="block px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-primary-bg)] hover:text-[var(--color-brand-primary)] rounded-md transition-colors min-h-[48px]"
|
||||
className="block px-4 py-4 text-[var(--color-text-primary)] hover:bg-[var(--color-brand-bg)] hover:text-[var(--color-brand)] rounded-md transition-colors min-h-[48px]"
|
||||
>
|
||||
{item.label}
|
||||
</StaticLink>
|
||||
|
||||
@@ -39,8 +39,8 @@ describe('MobileTabBar', () => {
|
||||
it('should render all tabs', () => {
|
||||
render(<MobileTabBar />);
|
||||
expect(screen.getByText('首页')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务')).toBeInTheDocument();
|
||||
expect(screen.getByText('产品')).toBeInTheDocument();
|
||||
expect(screen.getByText('方案')).toBeInTheDocument();
|
||||
expect(screen.getByText('关于')).toBeInTheDocument();
|
||||
expect(screen.getByText('联系')).toBeInTheDocument();
|
||||
});
|
||||
@@ -61,8 +61,9 @@ describe('MobileTabBar', () => {
|
||||
|
||||
it('should show active indicator', () => {
|
||||
render(<MobileTabBar />);
|
||||
const activeIndicator = document.querySelector('.bg-\\[var\\(--color-brand-primary\\)\\]');
|
||||
expect(activeIndicator).toBeInTheDocument();
|
||||
// The active tab (home at '/') has brand-colored elements
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,10 +74,10 @@ describe('MobileTabBar', () => {
|
||||
expect(homeLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('should have correct href for services', () => {
|
||||
it('should have correct href for solutions', () => {
|
||||
render(<MobileTabBar />);
|
||||
const servicesLink = screen.getByText('服务').closest('a');
|
||||
expect(servicesLink).toHaveAttribute('href', '/services');
|
||||
const solutionsLink = screen.getByText('方案').closest('a');
|
||||
expect(solutionsLink).toHaveAttribute('href', '/solutions');
|
||||
});
|
||||
|
||||
it('should have correct href for products', () => {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function MobileTabBar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-[var(--color-bg-primary)]/95 backdrop-blur-xl border-t border-[var(--color-border-primary)]" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 md:hidden bg-bg-primary/95 backdrop-blur-xl border-t border-border-primary" style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}>
|
||||
<div className="flex items-center justify-around h-16">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
@@ -53,20 +53,20 @@ export function MobileTabBar() {
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId="activeTabTop"
|
||||
className="absolute -top-0 w-6 h-[3px] bg-[var(--color-brand-primary)] rounded-full"
|
||||
className="absolute -top-0 w-6 h-[3px] bg-brand"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-6 h-6 transition-colors',
|
||||
active ? 'text-[var(--color-brand-primary)]' : 'text-[var(--color-text-placeholder)] group-hover:text-[var(--color-text-primary)]'
|
||||
'w-6 h-6 transition-colors duration-300',
|
||||
active ? 'text-brand' : 'text-text-muted group-hover:text-text-secondary'
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs mt-1 transition-colors',
|
||||
active ? 'text-[var(--color-brand-primary)] font-medium' : 'text-[var(--color-text-placeholder)]'
|
||||
'text-xs mt-1 transition-colors duration-300',
|
||||
active ? 'text-brand font-medium' : 'text-text-muted'
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -74,7 +74,7 @@ export function MobileTabBar() {
|
||||
{active && (
|
||||
<motion.div
|
||||
layoutId="activeTabBottom"
|
||||
className="absolute -bottom-1 w-8 h-0.5 bg-[var(--color-brand-primary)] rounded-full"
|
||||
className="absolute -bottom-1 w-8 h-0.5 bg-brand"
|
||||
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ interface PageNavProps {
|
||||
export function PageNav({ items }: PageNavProps) {
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="flex items-center gap-[3px] md:gap-1 text-[11px] md:text-sm text-[var(--color-text-subtle)] mb-2 md:mb-8 -ml-0.5">
|
||||
<StaticLink href="/" className="flex items-center w-fit hover:text-[var(--color-brand-primary)] transition-colors" aria-label="返回首页">
|
||||
<StaticLink href="/" className="flex items-center w-fit hover:text-[var(--color-brand)] transition-colors" aria-label="返回首页">
|
||||
<Home className="w-2.5 h-2.5 md:w-3.5 md:h-3.5" />
|
||||
</StaticLink>
|
||||
{items.map((item, index) => {
|
||||
@@ -30,7 +30,7 @@ export function PageNav({ items }: PageNavProps) {
|
||||
) : (
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="hover:text-[var(--color-brand-primary)] transition-colors"
|
||||
className="hover:text-[var(--color-brand)] transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</StaticLink>
|
||||
|
||||
@@ -10,6 +10,15 @@ export function OrganizationSchema() {
|
||||
"logo": "https://www.novalon.cn/logo.svg",
|
||||
"description": "专注于企业数字化转型服务,提供软件开发、云计算、数据分析、信息安全等一站式解决方案",
|
||||
"foundingDate": "2026",
|
||||
"foundingLocation": {
|
||||
"@type": "Place",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressCountry": "CN",
|
||||
"addressLocality": "成都",
|
||||
"addressRegion": "四川省",
|
||||
}
|
||||
},
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressCountry": "CN",
|
||||
@@ -17,9 +26,25 @@ export function OrganizationSchema() {
|
||||
"addressRegion": "四川省",
|
||||
"streetAddress": "成都市高新区"
|
||||
},
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"email": COMPANY_INFO.email,
|
||||
"contactType": "customer service",
|
||||
"availableLanguage": ["Chinese", "English"],
|
||||
"areaServed": "CN",
|
||||
},
|
||||
"sameAs": [
|
||||
"https://www.novalon.cn"
|
||||
]
|
||||
],
|
||||
"knowsAbout": [
|
||||
"数字化转型",
|
||||
"企业软件",
|
||||
"ERP系统",
|
||||
"云计算",
|
||||
"数据分析",
|
||||
"信息安全",
|
||||
"金融科技",
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,6 +61,11 @@ export function WebsiteSchema() {
|
||||
"@type": "WebSite",
|
||||
"name": COMPANY_INFO.name,
|
||||
"url": "https://www.novalon.cn",
|
||||
"inLanguage": "zh-CN",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": COMPANY_INFO.name,
|
||||
},
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": "https://www.novalon.cn/search?q={search_term_string}",
|
||||
@@ -51,20 +81,106 @@ export function WebsiteSchema() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ServiceSchema() {
|
||||
interface ServiceSchemaProps {
|
||||
name?: string;
|
||||
description?: string;
|
||||
areaServed?: string;
|
||||
}
|
||||
|
||||
export function ServiceSchema({ name, description, areaServed }: ServiceSchemaProps = {}) {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Service",
|
||||
"serviceType": "企业数字化转型服务",
|
||||
"name": name || "企业数字化转型服务",
|
||||
"serviceType": name || "企业数字化转型服务",
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": COMPANY_INFO.name
|
||||
"name": COMPANY_INFO.name,
|
||||
"url": "https://www.novalon.cn",
|
||||
},
|
||||
"description": "提供软件开发、云计算、数据分析、信息安全等一站式数字化转型解决方案",
|
||||
"description": description || "提供软件开发、云计算、数据分析、信息安全等一站式数字化转型解决方案",
|
||||
"areaServed": {
|
||||
"@type": "Country",
|
||||
"name": "China"
|
||||
}
|
||||
"name": areaServed || "China"
|
||||
},
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "数字化转型服务",
|
||||
"itemListElement": [
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "技术咨询" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "软件开发服务" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "云计算解决方案" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "数据分析与BI" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "信息安全咨询" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "企业IT架构设计" } },
|
||||
]
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProductSchemaProps {
|
||||
name: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export function ProductSchema({ name, description, image, category }: ProductSchemaProps) {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"applicationCategory": category || "BusinessApplication",
|
||||
"operatingSystem": "Web, Windows, macOS, Linux, iOS, Android",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"priceCurrency": "CNY",
|
||||
"availability": "https://schema.org/InStock",
|
||||
"seller": {
|
||||
"@type": "Organization",
|
||||
"name": COMPANY_INFO.name,
|
||||
}
|
||||
},
|
||||
"provider": {
|
||||
"@type": "Organization",
|
||||
"name": COMPANY_INFO.name,
|
||||
},
|
||||
...(image && { image: `https://www.novalon.cn${image}` }),
|
||||
};
|
||||
|
||||
return (
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FAQItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export function FAQSchema({ items }: { items: FAQItem[] }) {
|
||||
const schema = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": items.map((item) => ({
|
||||
"@type": "Question",
|
||||
"name": item.question,
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": item.answer,
|
||||
}
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -110,6 +226,9 @@ export function LocalBusinessSchema() {
|
||||
"url": "https://www.novalon.cn",
|
||||
"email": COMPANY_INFO.email,
|
||||
"description": "专注于企业数字化转型服务,提供软件开发、云计算、数据分析、信息安全等一站式解决方案",
|
||||
"priceRange": "$$",
|
||||
"currenciesAccepted": "CNY",
|
||||
"paymentAccepted": "Cash, Credit Card, Bank Transfer",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "成都市高新区",
|
||||
@@ -131,25 +250,11 @@ export function LocalBusinessSchema() {
|
||||
"closes": "18:00"
|
||||
}
|
||||
],
|
||||
"priceRange": "$$",
|
||||
"currenciesAccepted": "CNY",
|
||||
"paymentAccepted": "Cash, Credit Card, Bank Transfer",
|
||||
"areaServed": [
|
||||
{ "@type": "City", "name": "成都" },
|
||||
{ "@type": "State", "name": "四川" },
|
||||
{ "@type": "Country", "name": "中国" }
|
||||
],
|
||||
"hasOfferCatalog": {
|
||||
"@type": "OfferCatalog",
|
||||
"name": "数字化转型服务",
|
||||
"itemListElement": [
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "软件开发服务" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "云计算解决方案" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "数据分析与BI" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "信息安全咨询" } },
|
||||
{ "@type": "Offer", "itemOffered": { "@type": "Service", "name": "企业IT架构设计" } },
|
||||
]
|
||||
},
|
||||
"sameAs": [
|
||||
"https://www.novalon.cn"
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user