diff --git a/src/components/seo/structured-data.tsx b/src/components/seo/structured-data.tsx
index 8cb391e..9a52c9c 100644
--- a/src/components/seo/structured-data.tsx
+++ b/src/components/seo/structured-data.tsx
@@ -99,3 +99,66 @@ export function BreadcrumbSchema({ items }: { items: BreadcrumbItem[] }) {
/>
);
}
+
+export function LocalBusinessSchema() {
+ const schema = {
+ "@context": "https://schema.org",
+ "@type": "LocalBusiness",
+ "@id": "https://www.novalon.cn/#business",
+ "name": COMPANY_INFO.name,
+ "image": "https://www.novalon.cn/og-image.jpg",
+ "url": "https://www.novalon.cn",
+ "email": COMPANY_INFO.email,
+ "description": "专注于企业数字化转型服务,提供软件开发、云计算、数据分析、信息安全等一站式解决方案",
+ "address": {
+ "@type": "PostalAddress",
+ "streetAddress": "成都市高新区",
+ "addressLocality": "成都市",
+ "addressRegion": "四川省",
+ "postalCode": "610000",
+ "addressCountry": "CN"
+ },
+ "geo": {
+ "@type": "GeoCoordinates",
+ "latitude": 30.5728,
+ "longitude": 104.0668
+ },
+ "openingHoursSpecification": [
+ {
+ "@type": "OpeningHoursSpecification",
+ "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
+ "opens": "09:00",
+ "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"
+ ]
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index d5e7fc9..5cb0193 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -3,26 +3,25 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
-
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation",
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-white min-h-[44px] min-w-[44px] touch-manipulation relative overflow-hidden",
{
variants: {
variant: {
default:
- "bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
+ "bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-brand-primary-rgb),0.3)] hover:-translate-y-0.5 active:scale-[0.97] group/btn before:absolute before:inset-0 before:bg-gradient-to-r before:from-transparent before:via-white/10 before:to-transparent before:-translate-x-full hover:before:translate-x-full before:transition-transform before:duration-700 before:ease-out",
secondary:
- "bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_12px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.98]",
+ "bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] hover:shadow-[0_4px_16px_rgba(var(--color-primary-rgb),0.25)] hover:-translate-y-0.5 active:scale-[0.97]",
destructive:
- "bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)]",
+ "bg-[var(--color-brand-primary)] text-white hover:bg-[var(--color-brand-primary-hover)] focus-visible:ring-[var(--color-brand-primary)] active:scale-[0.97]",
outline:
- "border-2 border-[var(--color-primary)] bg-transparent text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:-translate-y-0.5 active:scale-[0.98]",
+ "border-2 border-[var(--color-primary)] bg-transparent text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-white hover:-translate-y-0.5 hover:border-[var(--color-brand-primary)] active:scale-[0.97] hover:shadow-[0_4px_12px_rgba(var(--color-brand-primary-rgb),0.15)]",
ghost:
- "text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-primary)]",
+ "text-[var(--color-primary-light)] hover:bg-[var(--color-primary-lighter)] hover:text-[var(--color-brand-primary)]",
link:
- "text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)]",
+ "text-[var(--color-primary)] underline-offset-4 hover:underline hover:text-[var(--color-brand-primary)] after:absolute after:bottom-0 after:left-0 after:h-[1.5px] after:w-0 hover:after:w-full after:bg-[var(--color-brand-primary)] after:transition-all after:duration-300",
},
size: {
default: "h-11 px-4 py-2",
@@ -45,14 +44,45 @@ export interface ButtonProps
}
const Button = React.forwardRef
(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
+ ({ className, variant, size, asChild = false, onClick, children, ...props }, ref) => {
+ const [ripples, setRipples] = React.useState>([])
+
+ function handleClick(e: React.MouseEvent) {
+ const rect = e.currentTarget.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+ const id = Date.now()
+ setRipples(prev => [...prev, { id, x, y }])
+ setTimeout(() => {
+ setRipples(prev => prev.filter(r => r.id !== id))
+ }, 600)
+ onClick?.(e)
+ }
+
const Comp = asChild ? Slot : "button"
return (
+ >
+ {children}
+ {ripples.map(ripple => (
+
+ ))}
+
)
}
)
diff --git a/src/components/ui/hero-ink-background.tsx b/src/components/ui/hero-ink-background.tsx
new file mode 100644
index 0000000..8ab8184
--- /dev/null
+++ b/src/components/ui/hero-ink-background.tsx
@@ -0,0 +1,295 @@
+'use client';
+
+import { useEffect, useRef, useMemo, useSyncExternalStore } from 'react';
+import { motion, useScroll, useTransform } from 'framer-motion';
+import { useReducedMotion } from '@/hooks/use-reduced-motion';
+
+const PARTICLE_COUNT = 24;
+
+interface Particle {
+ x: number;
+ y: number;
+ size: number;
+ duration: number;
+ delay: number;
+ opacity: number;
+}
+
+function generateParticles(): Particle[] {
+ const particles: Particle[] = [];
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ particles.push({
+ x: Math.random() * 100,
+ y: Math.random() * 100,
+ size: Math.max(1.5, Math.random() * 3.5 + 0.5),
+ duration: 14 + Math.random() * 20,
+ delay: Math.random() * -20,
+ opacity: 0.15 + Math.random() * 0.35,
+ });
+ }
+ return particles;
+}
+
+function ParallaxLayer({ children }: { children: React.ReactNode }) {
+ const containerRef = useRef(null);
+
+ const { scrollYProgress } = useScroll({
+ target: containerRef,
+ offset: ['start start', 'end start'],
+ });
+
+ const y = useTransform(scrollYProgress, [0, 1], [0, 120]);
+ const opacity = useTransform(scrollYProgress, [0, 0.6, 1], [1, 0.6, 0]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+function InkCanvas({ shouldReduceMotion }: { shouldReduceMotion: boolean }) {
+ const canvasRef = useRef(null);
+
+ useEffect(() => {
+ if (shouldReduceMotion || !canvasRef.current) {
+ return undefined;
+ }
+
+ const canvas = canvasRef.current;
+ const rawCtx = canvas.getContext('2d');
+ if (!rawCtx) {
+ return undefined;
+ }
+ const ctx = rawCtx;
+
+ let animFrameId: number;
+ let time = 0;
+
+ function resize() {
+ if (!canvasRef.current) {
+ return;
+ }
+ const dpr = window.devicePixelRatio || 1;
+ const rect = canvasRef.current.getBoundingClientRect();
+ canvas.width = rect.width * dpr;
+ canvas.height = rect.height * dpr;
+ ctx.scale(dpr, dpr);
+ }
+
+ resize();
+ window.addEventListener('resize', resize);
+
+ const inkBlobs = [
+ { cx: 0.18, cy: 0.35, r: 0.28, speed: 0.0004, phase: 0 },
+ { cx: 0.82, cy: 0.22, r: 0.22, speed: 0.0003, phase: 2 },
+ { cx: 0.55, cy: 0.78, r: 0.32, speed: 0.00035, phase: 4 },
+ ];
+
+ function draw() {
+ const w = canvas.getBoundingClientRect().width;
+ const h = canvas.getBoundingClientRect().height;
+ ctx.clearRect(0, 0, w, h);
+
+ inkBlobs.forEach((blob) => {
+ const pulse = Math.sin(time * blob.speed + blob.phase) * 0.08 + 1;
+ const rx = blob.r * w * pulse;
+ const ry = blob.r * h * pulse;
+ const gx = blob.cx * w + Math.sin(time * 0.0006 + blob.phase) * w * 0.03;
+ const gy = blob.cy * h + Math.cos(time * 0.0005 + blob.phase) * h * 0.03;
+
+ const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, Math.max(rx, ry));
+ grad.addColorStop(0, 'rgba(196, 30, 58, 0.045)');
+ grad.addColorStop(0.5, 'rgba(196, 30, 58, 0.02)');
+ grad.addColorStop(1, 'rgba(196, 30, 58, 0)');
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, w, h);
+ });
+
+ const particleGrad = ctx.createRadialGradient(w * 0.5, h * 0.4, 0, w * 0.5, h * 0.4, w * 0.6);
+ particleGrad.addColorStop(0, 'rgba(28, 28, 28, 0.025)');
+ particleGrad.addColorStop(1, 'rgba(28, 28, 28, 0)');
+ ctx.fillStyle = particleGrad;
+ ctx.fillRect(0, 0, w, h);
+
+ time += 16;
+ animFrameId = requestAnimationFrame(draw);
+ }
+
+ animFrameId = requestAnimationFrame(draw);
+
+ return () => {
+ cancelAnimationFrame(animFrameId);
+ window.removeEventListener('resize', resize);
+ };
+ }, [shouldReduceMotion]);
+
+ return (
+
+ );
+}
+
+function InkParticles({ particles }: { particles: Particle[] }) {
+ return (
+
+ );
+}
+
+function useIsMounted() {
+ return useSyncExternalStore(
+ (callback) => {
+ window.addEventListener('resize', callback);
+ return () => window.removeEventListener('resize', callback);
+ },
+ () => true,
+ () => false
+ );
+}
+
+export function HeroInkBackground() {
+ const shouldReduceMotion = useReducedMotion();
+ const mounted = useIsMounted();
+
+ const particles = useMemo(() => {
+ if (!mounted) {return [];}
+ return generateParticles();
+ }, [mounted]);
+
+ if (shouldReduceMotion) {
+ return (
+
+ );
+ }
+
+ if (!mounted) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/loading-skeleton.tsx b/src/components/ui/loading-skeleton.tsx
index 3358ea8..e0ccad6 100644
--- a/src/components/ui/loading-skeleton.tsx
+++ b/src/components/ui/loading-skeleton.tsx
@@ -10,7 +10,7 @@ export function Skeleton({ className }: SkeletonProps) {
return (
diff --git a/src/components/ui/page-transition.tsx b/src/components/ui/page-transition.tsx
new file mode 100644
index 0000000..31029a9
--- /dev/null
+++ b/src/components/ui/page-transition.tsx
@@ -0,0 +1,48 @@
+'use client';
+
+import { motion, AnimatePresence } from 'framer-motion';
+import { usePathname } from 'next/navigation';
+import { ReactNode, useSyncExternalStore } from 'react';
+import { useReducedMotion } from '@/hooks/use-reduced-motion';
+
+interface PageTransitionProps {
+ children: ReactNode;
+}
+
+function useIsMounted() {
+ return useSyncExternalStore(
+ (callback) => {
+ window.addEventListener('resize', callback);
+ return () => window.removeEventListener('resize', callback);
+ },
+ () => true,
+ () => false
+ );
+}
+
+export function PageTransition({ children }: PageTransitionProps) {
+ const pathname = usePathname();
+ const shouldReduceMotion = useReducedMotion();
+ const mounted = useIsMounted();
+
+ if (shouldReduceMotion || !mounted) {
+ return <>{children}>;
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/ui/scroll-progress.tsx b/src/components/ui/scroll-progress.tsx
index 9442fa1..c3bb089 100644
--- a/src/components/ui/scroll-progress.tsx
+++ b/src/components/ui/scroll-progress.tsx
@@ -8,8 +8,7 @@ export function ScrollProgress() {
const [isVisible, setIsVisible] = useState(false);
const shouldReduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll();
-
- // 使用弹簧动画使进度条移动更平滑
+
const scaleX = useSpring(scrollYProgress, {
stiffness: 100,
damping: 30,
@@ -17,9 +16,8 @@ export function ScrollProgress() {
});
useEffect(() => {
- // 当滚动超过 100px 时显示进度条
const handleScroll = () => {
- setIsVisible(window.scrollY > 100);
+ setIsVisible(window.scrollY > 80);
};
window.addEventListener('scroll', handleScroll, { passive: true });
@@ -30,22 +28,58 @@ export function ScrollProgress() {
return (
+
+ {!shouldReduceMotion && (
+ <>
+
+
+ >
+ )}
+
+
);