feat: 统一全站设计风格、导航组件与文案逻辑自洽性修复

- 新增 InkGlowCard 墨韵流光卡片组件,统一全站卡片交互风格
- 新增 PageNav 面包屑组件,统一全站页面导航
- 统一色彩体系、排版层级、间距节奏和动画风格
- 修复 CTA 区品牌名称错误(诺瓦隆→睿新致遠)
- 修复 ERP 产品卖点与年费制定价矛盾
- 导航下拉补充 SDS 和 OA 产品
- 统一全站数据指标为 12+年核心团队经验、6自研产品、10+团队成员
- 移除不可靠的 100%客户满意度和 30+行业专家指标
- 修复新闻时间线不合理问题,调整里程碑节奏
- 统一响应承诺为工作日快速响应
- 服务第4项重命名为行业方案实施,厘清概念
- 服务详情页效果数据改为定性描述
- 删除 cases 模块,精简代码库
This commit is contained in:
张翔
2026-05-02 08:11:47 +08:00
parent 307e0a654e
commit a272e58aaa
64 changed files with 1934 additions and 2995 deletions
+2 -3
View File
@@ -45,9 +45,8 @@ jest.mock('@/lib/constants', () => ({
NAVIGATION: [
{ id: 'home', label: '首页', href: '/' },
{ id: 'services', label: '服务', href: '/#services' },
{ id: 'products', label: '产品', href: '/#products' },
{ id: 'cases', label: '案例', href: '/#cases' },
{ id: 'about', label: '关于', href: '/#about' },
{ id: 'products', label: '产品', href: '/products' },
{ id: 'about', label: '关于', href: '/about' },
{ id: 'contact', label: '联系', href: '/contact' },
],
}));
+10 -11
View File
@@ -11,11 +11,10 @@ export function Footer() {
<div data-testid="card-brand">
<div className="mb-6">
<Image
src="/logo.svg"
src="/logo-light.svg"
alt={COMPANY_INFO.name}
width={160}
height={40}
className="brightness-0 invert"
loading="eager"
priority
/>
@@ -55,7 +54,7 @@ export function Footer() {
</div>
<div data-testid="card-products">
<h3 className="font-semibold text-base mb-5 text-white">产品服务</h3>
<h3 className="font-semibold text-base mb-5 text-white">产品</h3>
<ul className="space-y-3">
{(MEGA_DROPDOWN_DATA.products ?? []).map((item) => (
<li key={item.id}>
@@ -111,7 +110,7 @@ export function Footer() {
</div>
</div>
<div className="border-t border-[#333] mt-12 pt-8">
<div className="border-t border-[#333] mt-12 pt-8 pb-24 md:pb-8">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<p className="text-[#666] text-sm">
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
@@ -127,28 +126,28 @@ export function Footer() {
</div>
<div className="text-center mt-6 pt-6 border-t border-[#333]">
<div className="flex flex-col sm:flex-row justify-center items-center gap-2 sm:gap-4 text-xs text-[#555]">
<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="hover:text-white transition-colors duration-200"
className="text-[#E0E0E0] hover:text-white transition-colors duration-200"
>
{COMPANY_INFO.icp}
</a>
<span className="hidden sm:inline">|</span>
<span className="hidden sm:inline text-[#999]">|</span>
<a
href="https://beian.mps.gov.cn/#/query/webSearch?code=51010602003285"
target="_blank"
rel="noreferrer"
className="hover:text-white transition-colors duration-200 inline-flex items-center gap-1"
className="text-[#E0E0E0] 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"
width={16}
height={16}
className="w-4 h-4"
loading="lazy"
/>
{COMPANY_INFO.police}
+2 -4
View File
@@ -60,9 +60,8 @@ jest.mock('@/lib/constants', () => ({
NAVIGATION: [
{ id: 'home', label: '首页', href: '/' },
{ id: 'services', label: '服务', href: '/#services' },
{ id: 'products', label: '产品', href: '/#products' },
{ id: 'cases', label: '案例', href: '/#cases' },
{ id: 'about', label: '关于', href: '/#about' },
{ id: 'products', label: '产品', href: '/products' },
{ id: 'about', label: '关于', href: '/about' },
{ id: 'contact', label: '联系', href: '/contact' },
],
}));
@@ -104,7 +103,6 @@ describe('Header', () => {
expect(screen.getByText('首页')).toBeInTheDocument();
expect(screen.getByText('服务')).toBeInTheDocument();
expect(screen.getByText('产品')).toBeInTheDocument();
expect(screen.getByText('案例')).toBeInTheDocument();
expect(screen.getByText('关于')).toBeInTheDocument();
expect(screen.getByText('联系')).toBeInTheDocument();
});
+18 -95
View File
@@ -1,9 +1,9 @@
'use client';
import { Suspense, useState, useEffect, useCallback, useRef } from 'react';
import { Suspense, useState, useEffect, useCallback } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import Image from 'next/image';
import { usePathname, useSearchParams } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { Menu, X } from 'lucide-react';
import { AnimatePresence, motion } from 'framer-motion';
import { Button } from '@/components/ui/button';
@@ -11,58 +11,16 @@ import { COMPANY_INFO, NAVIGATION_V2, MEGA_DROPDOWN_DATA, type NavigationItemV2
import { MegaDropdown } from '@/components/layout/mega-dropdown';
import { useFocusTrap } from '@/hooks/use-focus-trap';
declare global {
interface Window {
__isProgrammaticScroll?: boolean;
}
}
function HeaderContent() {
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const pathname = usePathname();
const searchParams = useSearchParams();
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
const isScrollingRef = useRef(false);
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const getActiveSection = useCallback(() => {
if (pathname === '/contact') {return 'contact';}
if (pathname === '/') {
const section = searchParams.get('section');
return section || 'home';
}
return '';
}, [pathname, searchParams]);
const activeSection = getActiveSection();
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
if (pathname === '/' && !isScrollingRef.current && !window.__isProgrammaticScroll) {
const scrollPosition = window.scrollY + 100;
const sections = ['home', 'services', 'solutions', 'products', 'cases', 'about', 'team', 'news'];
for (const sectionId of sections) {
const element = document.getElementById(sectionId);
if (element) {
const offsetTop = element.offsetTop;
const offsetHeight = element.offsetHeight;
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
const currentSection = searchParams.get('section') || 'home';
if (currentSection !== sectionId) {
const url = sectionId === 'home' ? '/' : `/?section=${sectionId}`;
window.history.replaceState(null, '', url);
}
break;
}
}
}
}
};
const handleGlobalKeyDown = (e: KeyboardEvent) => {
@@ -77,11 +35,8 @@ function HeaderContent() {
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('keydown', handleGlobalKeyDown);
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
}
};
}, [pathname, isOpen, searchParams]);
}, [isOpen]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
@@ -95,57 +50,25 @@ function HeaderContent() {
const handleNavClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItemV2) => {
e.preventDefault();
if (item.id === 'contact') {
window.location.href = '/contact';
} else if (item.id === 'home') {
if (pathname === '/') {
isScrollingRef.current = true;
window.scrollTo({ top: 0, behavior: 'smooth' });
window.history.pushState(null, '', '/');
scrollTimeoutRef.current = setTimeout(() => {
isScrollingRef.current = false;
}, 1000);
} else {
window.location.href = '/';
}
} else {
if (pathname === '/') {
const scrollToSection = (retryCount = 0) => {
const element = document.getElementById(item.id);
if (element) {
isScrollingRef.current = true;
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
window.history.pushState(null, '', `/?section=${item.id}`);
scrollTimeoutRef.current = setTimeout(() => {
isScrollingRef.current = false;
}, 1000);
} else if (retryCount < 10) {
setTimeout(() => scrollToSection(retryCount + 1), 100);
}
};
scrollToSection();
} else {
window.location.href = `/?section=${item.id}`;
}
}
window.location.href = item.href;
setIsOpen(false);
}, [pathname]);
}, []);
const isActive = useCallback((item: NavigationItemV2) => {
if (item.id === 'contact') {
return pathname === '/contact';
}
if (pathname === '/') {
return activeSection === item.id;
if (item.id === 'home') {
return pathname === '/';
}
return false;
}, [pathname, activeSection]);
if (item.id === 'products') {
return pathname === '/products' || pathname.startsWith('/products/');
}
if (item.id === 'solutions') {
return pathname === '/solutions' || pathname.startsWith('/solutions/');
}
return pathname === `/${item.id}`;
}, [pathname]);
return (
<>
@@ -169,9 +92,9 @@ function HeaderContent() {
<Image
src="/logo.svg"
alt={COMPANY_INFO.name}
width={128}
height={32}
className="transition-transform duration-200 group-hover:scale-105"
width={120}
height={30}
className="transition-transform duration-200 group-hover:scale-105 w-auto h-8 md:h-8"
loading="eager"
priority
/>
+12 -5
View File
@@ -30,7 +30,14 @@ export function MegaDropdown({ label, items, isOpen, onToggle }: MegaDropdownPro
<div ref={dropdownRef} className="relative">
<button
onClick={onToggle}
className="flex items-center gap-1 text-sm font-medium text-[#1C1C1C] hover:text-[#C41E3A] transition-colors duration-200"
className={`
flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-md
transition-all duration-200
${isOpen
? 'text-[#C41E3A] bg-[#FEF2F4]'
: 'text-[#3D3D3D] hover:text-[#1C1C1C] hover:bg-[#F5F5F5]'
}
`}
aria-expanded={isOpen}
aria-haspopup="true"
>
@@ -47,17 +54,17 @@ export function MegaDropdown({ label, items, isOpen, onToggle }: MegaDropdownPro
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.15 }}
className="absolute top-full left-1/2 -translate-x-1/2 mt-2 w-[480px] bg-white rounded-xl border border-[#E5E5E5] shadow-lg p-4 z-50"
className="absolute top-full left-1/2 -translate-x-1/2 mt-3 w-[520px] bg-white rounded-xl border border-[#E5E5E5] shadow-lg p-5 z-50"
>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-2 gap-3">
{items.map((item) => (
<StaticLink
key={item.id}
href={item.href}
className="block p-3 rounded-lg border-l-[3px] border-l-[#C41E3A] hover:bg-[#FFFBF5] transition-colors duration-200"
className="block p-4 rounded-lg border-l-[3px] border-l-[#C41E3A] hover:bg-[#FFFBF5] transition-colors duration-200"
>
<div className="text-sm font-semibold text-[#1C1C1C]">{item.title}</div>
<div className="text-xs text-[#5C5C5C] mt-1">{item.description}</div>
<div className="text-xs text-[#5C5C5C] mt-1.5 leading-relaxed">{item.description}</div>
</StaticLink>
))}
</div>
+15 -32
View File
@@ -1,55 +1,38 @@
'use client';
import { useState, useLayoutEffect, useRef } from 'react';
import { StaticLink } from '@/components/ui/static-link';
import { usePathname, useSearchParams } from 'next/navigation';
import { usePathname } from 'next/navigation';
import { Home, Briefcase, Package, FileText, User } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
const tabs = [
{ id: 'home', label: '首页', href: '/', icon: Home },
{ id: 'services', label: '服务', href: '/#services', icon: Briefcase },
{ id: 'products', label: '产品', href: '/#products', icon: Package },
{ id: 'news', label: '新闻', href: '/#news', icon: FileText },
{ id: 'services', label: '服务', href: '/services', icon: Briefcase },
{ id: 'products', label: '产品', href: '/products', icon: Package },
{ id: 'about', label: '关于', href: '/about', icon: FileText },
{ id: 'contact', label: '联系', href: '/contact', icon: User },
];
export function MobileTabBar() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [hash, setHash] = useState('');
const isInitializedRef = useRef(false);
useLayoutEffect(() => {
if (!isInitializedRef.current) {
isInitializedRef.current = true;
setHash(window.location.hash.slice(1));
}
const handleHashChange = () => {
setHash(window.location.hash.slice(1));
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const isActive = (_href: string, id: string) => {
if (id === 'home') {
return pathname === '/';
}
if (id === 'contact') {
return pathname === '/contact';
}
if (pathname === '/') {
const section = searchParams.get('section');
const currentSection = section || hash;
if (id === 'home') {
return !currentSection || currentSection === 'home';
}
return currentSection === id;
if (id === 'products') {
return pathname === '/products' || pathname.startsWith('/products/');
}
if (id === 'services') {
return pathname === '/services' || pathname.startsWith('/services/');
}
if (id === 'about') {
return pathname === '/about';
}
return false;
};
+43
View File
@@ -0,0 +1,43 @@
'use client';
import { StaticLink } from '@/components/ui/static-link';
import { ChevronRight, Home } from 'lucide-react';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface PageNavProps {
items: BreadcrumbItem[];
}
export function PageNav({ items }: PageNavProps) {
return (
<nav aria-label="breadcrumb" className="flex items-center gap-1.5 text-sm text-[#A3A3A3] mb-8">
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors" aria-label="返回首页">
<Home className="w-3.5 h-3.5" />
</StaticLink>
{items.map((item, index) => {
const isLast = index === items.length - 1;
return (
<div key={index} className="flex items-center gap-1.5">
<ChevronRight className="w-3.5 h-3.5 text-[#D4D4D4]" />
{isLast || !item.href ? (
<span className={isLast ? 'text-[#1C1C1C] font-medium' : ''}>
{item.label}
</span>
) : (
<StaticLink
href={item.href}
className="hover:text-[#C41E3A] transition-colors"
>
{item.label}
</StaticLink>
)}
</div>
);
})}
</nav>
);
}