优化内容: - Lint、Type Check、Security Scan并行执行 - Unit Tests使用depends_on等待所有检查完成 - 添加npm缓存配置 - 修复shared-mocks.tsx的ESLint错误 预期效果: - 串行时间: 30s + 40s + 20s = 90s - 并行时间: max(30s, 40s, 20s) = 40s - 节省时间: 50s (55.6%改善)
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { jest } from '@jest/globals';
|
||||
import React from 'react';
|
||||
|
||||
interface MockProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
href?: string;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const mockFramerMotion = () => {
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, className, ...props }: MockProps) => (
|
||||
<div className={className} {...props}>{children}</div>
|
||||
),
|
||||
section: ({ children, className, ...props }: MockProps) => (
|
||||
<section className={className} {...props}>{children}</section>
|
||||
),
|
||||
span: ({ children, className, ...props }: MockProps) => (
|
||||
<span className={className} {...props}>{children}</span>
|
||||
),
|
||||
h1: ({ children, className, ...props }: MockProps) => (
|
||||
<h1 className={className} {...props}>{children}</h1>
|
||||
),
|
||||
h2: ({ children, className, ...props }: MockProps) => (
|
||||
<h2 className={className} {...props}>{children}</h2>
|
||||
),
|
||||
p: ({ children, className, ...props }: MockProps) => (
|
||||
<p className={className} {...props}>{children}</p>
|
||||
),
|
||||
button: ({ children, className, ...props }: MockProps) => (
|
||||
<button className={className} {...props}>{children}</button>
|
||||
),
|
||||
a: ({ children, className, ...props }: MockProps) => (
|
||||
<a className={className} {...props}>{children}</a>
|
||||
),
|
||||
img: ({ className, ...props }: MockProps) => (
|
||||
<img className={className} {...props} alt="" />
|
||||
),
|
||||
},
|
||||
AnimatePresence: ({ children }: MockProps) => <>{children}</>,
|
||||
useInView: () => [null, true],
|
||||
useAnimation: () => ({
|
||||
start: jest.fn(),
|
||||
stop: jest.fn(),
|
||||
}),
|
||||
useMotionValue: () => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
export const mockNextLink = () => {
|
||||
jest.mock('next/link', () => {
|
||||
const MockLink = ({ children, href, ...props }: MockProps) => (
|
||||
<a href={href} {...props}>{children}</a>
|
||||
);
|
||||
MockLink.displayName = 'MockLink';
|
||||
return MockLink;
|
||||
});
|
||||
};
|
||||
|
||||
export const mockNextNavigation = () => {
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSearchParams: () => ({
|
||||
get: jest.fn(),
|
||||
}),
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
back: jest.fn(),
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
}));
|
||||
};
|
||||
|
||||
export const mockLucideReact = () => {
|
||||
jest.mock('lucide-react', () => ({
|
||||
ArrowRight: () => <span data-testid="arrow-right" />,
|
||||
ArrowLeft: () => <span data-testid="arrow-left" />,
|
||||
Shield: () => <span data-testid="shield-icon" />,
|
||||
Zap: () => <span data-testid="zap-icon" />,
|
||||
Award: () => <span data-testid="award-icon" />,
|
||||
Check: () => <span data-testid="check-icon" />,
|
||||
X: () => <span data-testid="x-icon" />,
|
||||
Menu: () => <span data-testid="menu-icon" />,
|
||||
ChevronDown: () => <span data-testid="chevron-down" />,
|
||||
ChevronRight: () => <span data-testid="chevron-right" />,
|
||||
Mail: () => <span data-testid="mail-icon" />,
|
||||
Phone: () => <span data-testid="phone-icon" />,
|
||||
MapPin: () => <span data-testid="map-pin-icon" />,
|
||||
Clock: () => <span data-testid="clock-icon" />,
|
||||
User: () => <span data-testid="user-icon" />,
|
||||
Lock: () => <span data-testid="lock-icon" />,
|
||||
Eye: () => <span data-testid="eye-icon" />,
|
||||
EyeOff: () => <span data-testid="eye-off-icon" />,
|
||||
Settings: () => <span data-testid="settings-icon" />,
|
||||
LogOut: () => <span data-testid="logout-icon" />,
|
||||
Home: () => <span data-testid="home-icon" />,
|
||||
FileText: () => <span data-testid="file-text-icon" />,
|
||||
Image: () => <span data-testid="image-icon" />,
|
||||
Save: () => <span data-testid="save-icon" />,
|
||||
Trash2: () => <span data-testid="trash-icon" />,
|
||||
Edit: () => <span data-testid="edit-icon" />,
|
||||
Plus: () => <span data-testid="plus-icon" />,
|
||||
Search: () => <span data-testid="search-icon" />,
|
||||
Filter: () => <span data-testid="filter-icon" />,
|
||||
Download: () => <span data-testid="download-icon" />,
|
||||
Upload: () => <span data-testid="upload-icon" />,
|
||||
RefreshCw: () => <span data-testid="refresh-icon" />,
|
||||
AlertCircle: () => <span data-testid="alert-icon" />,
|
||||
Info: () => <span data-testid="info-icon" />,
|
||||
HelpCircle: () => <span data-testid="help-icon" />,
|
||||
}));
|
||||
};
|
||||
|
||||
export const mockNextDynamic = () => {
|
||||
jest.mock('next/dynamic', () => {
|
||||
const MockDynamic = (props: MockProps) => {
|
||||
return <div data-testid="dynamic-component" {...props} />;
|
||||
};
|
||||
MockDynamic.displayName = 'MockDynamic';
|
||||
return {
|
||||
__esModule: true,
|
||||
default: MockDynamic,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const mockNextImage = () => {
|
||||
jest.mock('next/image', () => {
|
||||
const MockImage = ({ src, alt, width, height, className, ...props }: MockProps) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
MockImage.displayName = 'MockImage';
|
||||
return MockImage;
|
||||
});
|
||||
};
|
||||
|
||||
export const mockDatabase = () => {
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
insert: jest.fn().mockReturnValue({
|
||||
values: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{ id: 1 }]),
|
||||
}),
|
||||
}),
|
||||
update: jest.fn().mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockResolvedValue([{ id: 1 }]),
|
||||
}),
|
||||
}),
|
||||
delete: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
export const setupSharedMocks = () => {
|
||||
mockFramerMotion();
|
||||
mockNextLink();
|
||||
mockNextNavigation();
|
||||
mockLucideReact();
|
||||
mockNextDynamic();
|
||||
mockNextImage();
|
||||
};
|
||||
|
||||
export const setupMinimalMocks = () => {
|
||||
mockFramerMotion();
|
||||
mockNextLink();
|
||||
mockLucideReact();
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import "./globals.css";
|
||||
import { ThemeProvider } from "@/contexts/theme-context";
|
||||
import { WebVitals } from "@/components/analytics/web-vitals";
|
||||
import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics";
|
||||
import { PageViewsTracker } from "@/hooks/use-page-views";
|
||||
import { Suspense } from "react";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { OrganizationSchema, WebsiteSchema } from "@/components/seo/structured-data";
|
||||
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
|
||||
@@ -153,6 +155,9 @@ export default function RootLayout({
|
||||
>
|
||||
<ScrollProgress />
|
||||
<GoogleAnalytics />
|
||||
<Suspense fallback={null}>
|
||||
<PageViewsTracker />
|
||||
</Suspense>
|
||||
<WebVitals />
|
||||
<SessionProvider>
|
||||
<ThemeProvider>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { COMPANY_INFO, NAVIGATION, type NavigationItem } from '@/lib/constants';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { trackButtonClick } from '@/lib/analytics';
|
||||
|
||||
function HeaderContent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -21,7 +22,7 @@ function HeaderContent() {
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getActiveSection = useCallback(() => {
|
||||
if (pathname === '/contact') return 'contact';
|
||||
if (pathname === '/contact') {return 'contact';}
|
||||
if (pathname === '/') {
|
||||
const section = searchParams.get('section');
|
||||
return section || 'home';
|
||||
@@ -89,6 +90,8 @@ function HeaderContent() {
|
||||
const handleNavClick = useCallback((e: React.MouseEvent<HTMLAnchorElement>, item: NavigationItem) => {
|
||||
e.preventDefault();
|
||||
|
||||
trackButtonClick(item.label, 'header_navigation');
|
||||
|
||||
if (item.id === 'contact') {
|
||||
router.push('/contact');
|
||||
} else if (item.id === 'home') {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { sanitizeInput } from '@/lib/sanitize';
|
||||
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
|
||||
import { generateCaptcha } from '@/lib/security/captcha';
|
||||
import { useFormAutosave } from '@/hooks/use-form-autosave';
|
||||
import { trackContactForm } from '@/lib/analytics';
|
||||
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, RefreshCw, Save } from 'lucide-react';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
|
||||
@@ -176,7 +177,8 @@ export function ContactSection() {
|
||||
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(true);
|
||||
clearSavedData(); // 提交成功后清除保存的数据
|
||||
clearSavedData();
|
||||
trackContactForm(formData);
|
||||
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
||||
setToastType('success');
|
||||
setShowToast(true);
|
||||
|
||||
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
|
||||
import { MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
|
||||
import { COMPANY_INFO, STATS } from '@/lib/constants';
|
||||
import { trackButtonClick } from '@/lib/analytics';
|
||||
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
|
||||
import { useReducedMotion } from '@/hooks/use-reduced-motion';
|
||||
|
||||
@@ -94,6 +95,15 @@ export function HeroDescription(_props: HeroContentProps) {
|
||||
export function HeroButtons({ isVisible }: HeroContentProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const handleContactClick = () => {
|
||||
trackButtonClick('立即咨询', 'hero_section');
|
||||
};
|
||||
|
||||
const handleLearnMoreClick = () => {
|
||||
trackButtonClick('了解更多', 'hero_section');
|
||||
scrollTo('about');
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
@@ -102,7 +112,7 @@ export function HeroButtons({ isVisible }: HeroContentProps) {
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
|
||||
>
|
||||
<MagneticButton strength={0.4}>
|
||||
<Link href="/contact">
|
||||
<Link href="/contact" onClick={handleContactClick}>
|
||||
<SealButton size="lg" className="min-w-45">
|
||||
立即咨询
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
@@ -113,7 +123,7 @@ export function HeroButtons({ isVisible }: HeroContentProps) {
|
||||
<RippleButton
|
||||
size="lg"
|
||||
variant="outline"
|
||||
onClick={() => scrollTo('about')}
|
||||
onClick={handleLearnMoreClick}
|
||||
onKeyDown={(e) => handleKeyDown(e, 'about')}
|
||||
className="min-w-45"
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowRight, Check, TrendingUp } from 'lucide-react';
|
||||
import { useProducts } from '@/hooks/use-products';
|
||||
import { trackButtonClick } from '@/lib/analytics';
|
||||
|
||||
interface ProductsConfig {
|
||||
enabled?: boolean;
|
||||
@@ -87,7 +88,7 @@ export function ProductsSection({ config }: ProductsSectionProps) {
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1 + idx * 0.1 }}
|
||||
>
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<Link href={`/products/${product.id}`} onClick={() => trackButtonClick(product.title, 'products_section')}>
|
||||
<Card className="h-full flex flex-col group cursor-pointer border-[#E5E5E5] hover:border-[#1C1C1C] transition-colors">
|
||||
<CardHeader>
|
||||
<Badge variant="secondary" className="w-fit mb-3">
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Code, Cloud, BarChart3, Shield, ArrowRight } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useServices } from '@/hooks/use-services';
|
||||
import { trackButtonClick } from '@/lib/analytics';
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Code,
|
||||
@@ -96,7 +97,7 @@ export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={`/services/${service.id}`}>
|
||||
<Link href={`/services/${service.id}`} onClick={() => trackButtonClick(service.title, 'services_section')}>
|
||||
<Card className="p-6 h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
|
||||
<CardContent className="p-0">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#F5F5F5] flex items-center justify-center mb-4 group-hover:bg-[#C41E3A] transition-all duration-300">
|
||||
@@ -128,7 +129,7 @@ export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<Button variant="outline" size="lg" className="group" asChild>
|
||||
<Link href="/services">
|
||||
<Link href="/services" onClick={() => trackButtonClick('查看全部服务', 'services_section')}>
|
||||
查看全部服务
|
||||
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { pageview } from '@/lib/analytics';
|
||||
|
||||
export function usePageViews() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (pathname && typeof window !== 'undefined') {
|
||||
const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : '');
|
||||
pageview(url);
|
||||
}
|
||||
}, [pathname, searchParams]);
|
||||
}
|
||||
|
||||
export function PageViewsTracker() {
|
||||
usePageViews();
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user