监听滑动动画

This commit is contained in:
小潘
2026-06-08 17:36:30 +08:00
parent ec6222d183
commit 92c84e9dab
7 changed files with 224 additions and 155 deletions
+40
View File
@@ -11,4 +11,44 @@
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>
<script>
window.difyChatbotConfig = {
token: 'DJdSTFBWmkh0OOAb',
baseUrl: 'https://szdify.2009xc.com',
inputs: {
// You can define the inputs from the Start node here
// key is the variable name
// e.g.
// name: "NAME"
},
systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
},
userVariables: {
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
// name: 'YOU CAN DEFINE USER NAME HERE',
},
}
</script>
<script src="https://szdify.2009xc.com/embed.min.js" id="DJdSTFBWmkh0OOAb" defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: #043b8f !important;
position: fixed !important;
bottom: 36px !important;
right: 24px !important;
z-index: 9999 !important;
}
#dify-chatbot-bubble-window {
width: 24rem !important;
height: 40rem !important;
position: fixed !important;
bottom: 80px !important;
right: 24px !important;
z-index: 9999 !important;
}
</style>
</html> </html>
+73 -86
View File
@@ -1,85 +1,85 @@
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { useEffect, useRef } from 'react'; // import { useEffect, useRef } from 'react';
function ParticleNetwork() { // function ParticleNetwork() {
const canvasRef = useRef(null); // const canvasRef = useRef(null);
useEffect(() => { // useEffect(() => {
const canvas = canvasRef.current; // const canvas = canvasRef.current;
if (!canvas) return; // if (!canvas) return;
const ctx = canvas.getContext('2d'); // const ctx = canvas.getContext('2d');
let animId; // let animId;
let particles = []; // let particles = [];
const COUNT = 35; // const COUNT = 35;
const CONNECT_DIST = 150; // const CONNECT_DIST = 150;
function resize() { // function resize() {
canvas.width = canvas.offsetWidth * window.devicePixelRatio; // canvas.width = canvas.offsetWidth * window.devicePixelRatio;
canvas.height = canvas.offsetHeight * window.devicePixelRatio; // canvas.height = canvas.offsetHeight * window.devicePixelRatio;
ctx.scale(window.devicePixelRatio, window.devicePixelRatio); // ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
} // }
function init() { // function init() {
resize(); // resize();
particles = []; // particles = [];
for (let i = 0; i < COUNT; i++) { // for (let i = 0; i < COUNT; i++) {
particles.push({ // particles.push({
x: Math.random() * canvas.offsetWidth, // x: Math.random() * canvas.offsetWidth,
y: Math.random() * canvas.offsetHeight, // y: Math.random() * canvas.offsetHeight,
vx: (Math.random() - 0.5) * 0.15, // vx: (Math.random() - 0.5) * 0.15,
vy: (Math.random() - 0.5) * 0.15, // vy: (Math.random() - 0.5) * 0.15,
r: Math.random() * 1 + 0.3, // r: Math.random() * 1 + 0.3,
color: Math.random() > 0.8 ? '#f08519' : '#004ec4', // color: Math.random() > 0.8 ? '#f08519' : '#004ec4',
}); // });
} // }
} // }
function draw() { // function draw() {
const w = canvas.offsetWidth; // const w = canvas.offsetWidth;
const h = canvas.offsetHeight; // const h = canvas.offsetHeight;
ctx.clearRect(0, 0, w, h); // ctx.clearRect(0, 0, w, h);
for (let i = 0; i < particles.length; i++) { // for (let i = 0; i < particles.length; i++) {
const p = particles[i]; // const p = particles[i];
p.x += p.vx; // p.x += p.vx;
p.y += p.vy; // p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx *= -1; // if (p.x < 0 || p.x > w) p.vx *= -1;
if (p.y < 0 || p.y > h) p.vy *= -1; // if (p.y < 0 || p.y > h) p.vy *= -1;
ctx.beginPath(); // ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); // ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color; // ctx.fillStyle = p.color;
ctx.globalAlpha = 0.15; // ctx.globalAlpha = 0.15;
ctx.fill(); // ctx.fill();
for (let j = i + 1; j < particles.length; j++) { // for (let j = i + 1; j < particles.length; j++) {
const p2 = particles[j]; // const p2 = particles[j];
const dx = p.x - p2.x; // const dx = p.x - p2.x;
const dy = p.y - p2.y; // const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy); // const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CONNECT_DIST) { // if (dist < CONNECT_DIST) {
ctx.beginPath(); // ctx.beginPath();
ctx.moveTo(p.x, p.y); // ctx.moveTo(p.x, p.y);
ctx.lineTo(p2.x, p2.y); // ctx.lineTo(p2.x, p2.y);
ctx.strokeStyle = '#004ec4'; // ctx.strokeStyle = '#004ec4';
ctx.globalAlpha = 0.03 * (1 - dist / CONNECT_DIST); // ctx.globalAlpha = 0.03 * (1 - dist / CONNECT_DIST);
ctx.lineWidth = 0.5; // ctx.lineWidth = 0.5;
ctx.stroke(); // ctx.stroke();
} // }
} // }
} // }
ctx.globalAlpha = 1; // ctx.globalAlpha = 1;
animId = requestAnimationFrame(draw); // animId = requestAnimationFrame(draw);
} // }
init(); // init();
draw(); // draw();
window.addEventListener('resize', () => { resize(); }); // window.addEventListener('resize', () => { resize(); });
return () => cancelAnimationFrame(animId); // return () => cancelAnimationFrame(animId);
}, []); // }, []);
return <canvas ref={canvasRef} className="absolute inset-0 w-full h-full z-[1]" />; // return <canvas ref={canvasRef} className="absolute inset-0 w-full h-full z-[1] border-amber-500 " />;
} // }
export default function HeroSection() { export default function HeroSection() {
return ( return (
@@ -108,7 +108,7 @@ export default function HeroSection() {
style={{ animation: 'pulse-slow 12s ease-in-out 3s infinite' }} /> style={{ animation: 'pulse-slow 12s ease-in-out 3s infinite' }} />
</div> </div>
<ParticleNetwork />
{/* Top & bottom edge lines */} {/* Top & bottom edge lines */}
<div className="absolute top-0 left-0 right-0 z-10 h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" /> <div className="absolute top-0 left-0 right-0 z-10 h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" />
@@ -230,20 +230,7 @@ export default function HeroSection() {
</div> </div>
</div> </div>
{/* Scroll indicator */}
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-10"
style={{ animation: 'fadeIn 1s ease-out 1.8s both' }}>
<a href="#data" className="flex flex-col items-center gap-3 group">
<span className="text-[9px] font-extralight text-white/15 group-hover:text-white/30 transition-colors duration-700"
style={{ letterSpacing: '0.6em' }}>
SCROLL
</span>
<div className="w-px h-10 bg-white/[0.06] relative overflow-hidden rounded-full">
<div className="absolute w-full h-4 bg-gradient-to-b from-[#004ec4]/60 to-transparent rounded-full"
style={{ animation: 'scrollFlow 2.5s ease-in-out infinite' }} />
</div>
</a>
</div>
</section> </section>
); );
} }
+40 -15
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom'
export default function ScrollReveal({ export default function ScrollReveal({
children, children,
className = '', className = '',
@@ -14,26 +14,51 @@ export default function ScrollReveal({
}) { }) {
const ref = useRef(null); const ref = useRef(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const { pathname } = useLocation()
useEffect(() => {
const timer = setTimeout(() => {
setVisible(false);
}, 0);
return () => {
clearTimeout(timer);
};
}, [pathname]);
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const observer = new IntersectionObserver( let observer;
([entry]) => { let cancelled = false;
if (entry.isIntersecting) {
setVisible(true);
if (once) observer.unobserve(el);
} else if (!once) {
setVisible(false);
}
},
{ threshold, rootMargin: '0px 0px -60px 0px' }
);
observer.observe(el); const timer = setTimeout(() => {
return () => observer.disconnect(); if (cancelled) return;
}, [once, threshold]);
observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
if (once) observer.unobserve(el);
} else if (!once) {
setVisible(false);
}
},
{ threshold, rootMargin: '0px 0px -60px 0px' }
);
observer.observe(el);
}, 0);
return () => {
cancelled = true;
clearTimeout(timer);
if (observer) observer.disconnect();
};
}, [once, threshold, pathname]);
const transforms = { const transforms = {
up: `translateY(${distance}px)`, up: `translateY(${distance}px)`,
+43
View File
@@ -0,0 +1,43 @@
import { useState, useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
/* ─── Animated counter ─── */
export default function StatNumber({ target, suffix }) {
const [count, setCount] = useState(0)
const ref = useRef(null)
const started = useRef(false)
const { pathname } = useLocation()
useEffect(() => {
started.current = false
setTimeout(() => setCount(0), 0)
}, [pathname])
useEffect(() => {
const el = ref.current
if (!el) return
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting && !started.current) {
started.current = true
const dur = 2000
const start = performance.now()
const num = parseInt(target)
function tick(now) {
const p = Math.min((now - start) / dur, 1)
setCount(Math.round((1 - Math.pow(1 - p, 3)) * num))
if (p < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
obs.unobserve(el)
}
}, { threshold: 0.3 })
obs.observe(el)
return () => obs.disconnect()
}, [target, pathname])
return <span ref={ref}>{count}{suffix}</span>
}
+12 -12
View File
@@ -15,9 +15,9 @@ export const products = [
brief: '面向职业院校实习就业管理的专业化平台,覆盖实习计划制定、过程跟踪、数据采集、鉴定评价全流程,已服务广西多所职业院校,帮助学校轻松应对教育厅数据上报要求。', brief: '面向职业院校实习就业管理的专业化平台,覆盖实习计划制定、过程跟踪、数据采集、鉴定评价全流程,已服务广西多所职业院校,帮助学校轻松应对教育厅数据上报要求。',
tags: ['实习计划管理', 'GPS签到+电子围栏', '问卷式日志', '多维鉴定评价'], tags: ['实习计划管理', 'GPS签到+电子围栏', '问卷式日志', '多维鉴定评价'],
stats: [ stats: [
{ num: '20+所', label: '覆盖学校' }, { num: '20', suffix: '+所', label: '覆盖学校' },
{ num: '50,000+', label: '服务学生' }, { num: '50,000', suffix: '+', label: '服务学生' },
{ num: '100万+', label: '累计签到' }, { num: '100', suffix: '万+', label: '累计签到' },
], ],
features: [ features: [
{ emoji: '📋', title: '实习计划管理', desc: '按学期、专业、班级灵活制定实习计划,绑定不同实习规则,实现多批次差异化管理' }, { emoji: '📋', title: '实习计划管理', desc: '按学期、专业、班级灵活制定实习计划,绑定不同实习规则,实现多批次差异化管理' },
@@ -53,9 +53,9 @@ export const products = [
brief: '面向高校及科研院所的全生命周期科研业务管理平台。覆盖项目申报、评审立项、经费管控、成果登记到数据统计的全流程数字化,已帮助多所高校实现科研管理从纸质流转到"一网通办"的跨越。', brief: '面向高校及科研院所的全生命周期科研业务管理平台。覆盖项目申报、评审立项、经费管控、成果登记到数据统计的全流程数字化,已帮助多所高校实现科研管理从纸质流转到"一网通办"的跨越。',
tags: ['在线申报审批', '智能查重防重复', '经费精细管控', '成果自动赋分'], tags: ['在线申报审批', '智能查重防重复', '经费精细管控', '成果自动赋分'],
stats: [ stats: [
{ num: '10+所', label: '服务高校' }, { num: '10', suffix: '+所', label: '服务高校' },
{ num: '3,000+', label: '管理项目' }, { num: '3000', suffix: '+', label: '管理项目' },
{ num: '8,000+', label: '登记成果' }, { num: '8000', suffix: '+', label: '登记成果' },
], ],
features: [ features: [
{ emoji: '📝', title: '在线申报审批', desc: '项目申报、审核、立项全流程在线办理,智能校验引擎自动检查相似度和限项规则' }, { emoji: '📝', title: '在线申报审批', desc: '项目申报、审核、立项全流程在线办理,智能校验引擎自动检查相似度和限项规则' },
@@ -93,9 +93,9 @@ export const products = [
brief: '面向高校及职业院校各级党组织的智慧党建管理系统。以"党业融合、指标驱动、全程留痕"为核心理念,将三会一课、党员活动、工作记录等日常工作自动关联考核指标,实现年终考核"零重复填报"。', brief: '面向高校及职业院校各级党组织的智慧党建管理系统。以"党业融合、指标驱动、全程留痕"为核心理念,将三会一课、党员活动、工作记录等日常工作自动关联考核指标,实现年终考核"零重复填报"。',
tags: ['三级考核指标', '三会一课数字化', '万能工作记录', '实时进度看板'], tags: ['三级考核指标', '三会一课数字化', '万能工作记录', '实时进度看板'],
stats: [ stats: [
{ num: '150+', label: '服务支部' }, { num: '150', suffix: '+所', label: '服务支部' },
{ num: '5,000+', label: '管理党员' }, { num: '5000', suffix: '+', label: '管理党员' },
{ num: '200+', label: '考核指标' }, { num: '200', suffix: '+', label: '考核指标' },
], ],
features: [ features: [
{ emoji: '📊', title: '三级考核指标', desc: '灵活构建校—院—支部三级指标树,分值自动汇总,考核部门与执行部门逐一对应' }, { emoji: '📊', title: '三级考核指标', desc: '灵活构建校—院—支部三级指标树,分值自动汇总,考核部门与执行部门逐一对应' },
@@ -141,9 +141,9 @@ export const products = [
brief: '专为职业院校商科专业群打造的实训项目管理系统。以"项目—模块—任务"三级结构为骨架,以"咖豆积分+技能画像"为引擎,将抽象的五级进阶培养模式变成可量化、可追踪、可激励的线上实战平台。', brief: '专为职业院校商科专业群打造的实训项目管理系统。以"项目—模块—任务"三级结构为骨架,以"咖豆积分+技能画像"为引擎,将抽象的五级进阶培养模式变成可量化、可追踪、可激励的线上实战平台。',
tags: ['三级精细管理', '咖豆积分引擎', '五级自动晋级', '动态技能画像'], tags: ['三级精细管理', '咖豆积分引擎', '五级自动晋级', '动态技能画像'],
stats: [ stats: [
{ num: '500+', label: '实训项目库' }, { num: '500', suffix: '+', label: '实训项目库' },
{ num: '15+', label: '覆盖专业' }, { num: '15', suffix: '+', label: '覆盖专业' },
{ num: '200万+', label: '累计咖豆' }, { num: '200', suffix: '万+', label: '累计咖豆' },
], ],
features: [ features: [
{ emoji: '📦', title: '三级精细管理', desc: '项目→模块→任务三级结构,权重灵活分配,实训教学内容标准化、可复用' }, { emoji: '📦', title: '三级精细管理', desc: '项目→模块→任务三级结构,权重灵活分配,实训教学内容标准化、可复用' },
+5 -34
View File
@@ -5,7 +5,8 @@ import ScrollReveal from '../components/ScrollReveal'
import { products } from '../data/products' import { products } from '../data/products'
import RosePieChart from '../components/RosePieChart' import RosePieChart from '../components/RosePieChart'
import hunBg from '../assets/images/hun.jpg' import hunBg from '../assets/images/hun.jpg'
import {ArrowRight, Play, Users, Building2} from 'lucide-react' import { ArrowRight, Play, Users, Building2 } from 'lucide-react'
import StatNumber from '../components/StatNumber'
/* ─── Section 3: 数据亮点 ─── */ /* ─── Section 3: 数据亮点 ─── */
const stats = [ const stats = [
@@ -79,38 +80,9 @@ const customerCards = [
/* ─── Animated counter ─── */
function StatNumber({ target, suffix }) {
const [count, setCount] = useState(0)
const ref = useRef(null)
const started = useRef(false)
useEffect(() => {
const el = ref.current
if (!el) return
const obs = new IntersectionObserver(([e]) => {
if (e.isIntersecting && !started.current) {
started.current = true
const dur = 2000
const start = performance.now()
const num = parseInt(target)
function tick(now) {
const p = Math.min((now - start) / dur, 1)
setCount(Math.round((1 - Math.pow(1 - p, 3)) * num))
if (p < 1) requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
obs.unobserve(el)
}
}, { threshold: 0.3 })
obs.observe(el)
return () => obs.disconnect()
}, [target])
return <span ref={ref}>{count}{suffix}</span> import { useState, useEffect } from 'react'
}
import { useState, useEffect, useRef } from 'react'
export default function Home() { export default function Home() {
const [activeCapability, setActiveCapability] = useState(0) const [activeCapability, setActiveCapability] = useState(0)
@@ -226,11 +198,10 @@ export default function Home() {
{c.tip && ( {c.tip && (
<div className="mt-5 flex flex-wrap gap-2"> <div className="mt-5 flex flex-wrap gap-2">
{c.tip.split('、').map((t, ti) => ( {c.tip.split('、').map((t, ti) => (
<span key={ti} className={`px-3 py-1 xs:px-4 xs:py-1.5 text-xs xs:text-sm font-medium rounded-md transition-all duration-200 ${ <span key={ti} className={`px-3 py-1 xs:px-4 xs:py-1.5 text-xs xs:text-sm font-medium rounded-md transition-all duration-200 ${activeCapability === idx
activeCapability === idx
? 'text-[#043b8f] bg-[#043b8f]/[0.08]' ? 'text-[#043b8f] bg-[#043b8f]/[0.08]'
: 'text-[#94a3b8] bg-gray-50' : 'text-[#94a3b8] bg-gray-50'
}`}> }`}>
{t} {t}
</span> </span>
))} ))}
+4 -1
View File
@@ -2,6 +2,7 @@ import { useParams, Link } from 'react-router-dom'
import { ChevronDown, ArrowRight, Shield, Clock, Headphones, AlertCircle } from 'lucide-react' import { ChevronDown, ArrowRight, Shield, Clock, Headphones, AlertCircle } from 'lucide-react'
import { getProduct } from '../data/products' import { getProduct } from '../data/products'
import ScrollReveal from '../components/ScrollReveal' import ScrollReveal from '../components/ScrollReveal'
import StatNumber from '../components/StatNumber'
export default function ProductPage() { export default function ProductPage() {
const { slug } = useParams() const { slug } = useParams()
@@ -54,7 +55,9 @@ export default function ProductPage() {
style={{ backgroundColor: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)' }}> style={{ backgroundColor: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)' }}>
{product.stats.map((s, i) => ( {product.stats.map((s, i) => (
<div key={i} className={`flex flex-col items-center justify-center px-6 md:px-10 py-5 ${i < product.stats.length - 1 ? 'border-r border-white/10' : ''}`}> <div key={i} className={`flex flex-col items-center justify-center px-6 md:px-10 py-5 ${i < product.stats.length - 1 ? 'border-r border-white/10' : ''}`}>
<div className="text-2xl md:text-3xl font-extrabold tracking-tight text-white">{s.num}</div> <div className="text-2xl md:text-3xl font-extrabold tracking-tight text-white">
<StatNumber target={s.num} suffix={s.suffix} />
</div>
<div className="text-xs text-white/50 mt-2 font-medium">{s.label}</div> <div className="text-xs text-white/50 mt-2 font-medium">{s.label}</div>
</div> </div>
))} ))}