From 879c32c7ee74c5fd192d421cf9a19a4430ce7844 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 17 Jul 2026 16:51:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=99=BB=E5=BD=95=E9=A1=B5?= =?UTF-8?q?=E9=87=8D=E8=AE=BE=E8=AE=A1=20=E2=80=94=20=E6=B7=B1=E8=89=B2?= =?UTF-8?q?=E6=B2=89=E6=B5=B8=E5=88=86=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 参考 sundynix-micro-admin 的分栏沉浸布局,提取精华适配青蓝品牌 - 左品牌区 + 右表单玻璃卡片,深色沉浸背景 - canvas 青蓝粒子网络(呼应 logo 节点网络),品牌光晕,零重依赖 - 密码显示切换、青蓝渐变登录按钮、登录中 loading 态 Co-Authored-By: Claude Opus 4.8 --- admin/src/components/login-backdrop.tsx | 97 +++++++++++++ admin/src/pages/login.tsx | 181 ++++++++++++++++++------ 2 files changed, 231 insertions(+), 47 deletions(-) create mode 100644 admin/src/components/login-backdrop.tsx 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 ( +