diff --git a/admin/src/components/login-backdrop.tsx b/admin/src/components/login-backdrop.tsx new file mode 100644 index 0000000..959946c --- /dev/null +++ b/admin/src/components/login-backdrop.tsx @@ -0,0 +1,97 @@ +import { useEffect, useRef } from 'react' + +/** 登录页背景:青蓝粒子网络(呼应 logo 的节点网络),纯 canvas 无依赖 */ +export function LoginBackdrop() { + const ref = useRef(null) + + useEffect(() => { + const canvas = ref.current + if (!canvas) return + const ctx = canvas.getContext('2d') + if (!ctx) return + + const reduce = window.matchMedia( + '(prefers-reduced-motion: reduce)', + ).matches + const dpr = Math.min(window.devicePixelRatio || 1, 2) + let w = 0 + let h = 0 + let raf = 0 + const dots: { x: number; y: number; vx: number; vy: number }[] = [] + + const resize = () => { + w = canvas.clientWidth + h = canvas.clientHeight + canvas.width = w * dpr + canvas.height = h * dpr + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + } + + const init = () => { + dots.length = 0 + const n = Math.min(64, Math.floor((w * h) / 16000)) + for (let i = 0; i < n; i++) { + dots.push({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.28, + vy: (Math.random() - 0.5) * 0.28, + }) + } + } + + const frame = () => { + ctx.clearRect(0, 0, w, h) + for (const p of dots) { + p.x += p.vx + p.y += p.vy + if (p.x < 0 || p.x > w) p.vx *= -1 + if (p.y < 0 || p.y > h) p.vy *= -1 + } + for (let i = 0; i < dots.length; i++) { + for (let j = i + 1; j < dots.length; j++) { + const a = dots[i] + const b = dots[j] + const d = Math.hypot(a.x - b.x, a.y - b.y) + if (d < 130) { + ctx.strokeStyle = `rgba(56,189,248,${(1 - d / 130) * 0.16})` + ctx.lineWidth = 1 + ctx.beginPath() + ctx.moveTo(a.x, a.y) + ctx.lineTo(b.x, b.y) + ctx.stroke() + } + } + } + for (const p of dots) { + ctx.fillStyle = 'rgba(56,189,248,0.55)' + ctx.beginPath() + ctx.arc(p.x, p.y, 1.4, 0, Math.PI * 2) + ctx.fill() + } + if (!reduce) raf = requestAnimationFrame(frame) + } + + const onResize = () => { + resize() + init() + } + + resize() + init() + frame() + window.addEventListener('resize', onResize) + return () => { + cancelAnimationFrame(raf) + window.removeEventListener('resize', onResize) + } + }, []) + + return ( +