feat: 并行化CI代码质量检查步骤
ci/woodpecker/push/woodpecker Pipeline is running

优化内容:
- 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:
张翔
2026-03-29 11:41:30 +08:00
parent b5b207e5a1
commit 26aa13b5a4
80 changed files with 1113 additions and 4600 deletions
+189
View File
@@ -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();
};
+5
View File
@@ -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>
+4 -1
View File
@@ -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') {
+3 -1
View File
@@ -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);
+12 -2
View File
@@ -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"
>
+2 -1
View File
@@ -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">
+3 -2
View File
@@ -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>
+22
View File
@@ -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;
}