From 4944d434c9db671cd17b2c469ef3d3d4e6c2d0e9 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 3 Jun 2026 21:32:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Login2=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=20=E2=80=94=20Canvas=20=E5=85=A8=E5=B1=8F=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E6=8B=93=E6=89=91=E5=8A=A8=E7=94=BB=E8=83=8C=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Login2Page.tsx,使用 Canvas 绘制全屏资产管理网络动画 - 动画包含:Hub 节点 + 资产节点 + 连接拓扑 + 数据包脉冲 + 扫描线 + 旋转六边形 - 鼠标交互光晕与粒子排斥效果 - 毛玻璃登录卡片居中,保留完整登录功能(表单验证/记住我/SSO/快捷账号) - 添加 /login2 路由 --- frontend/src/pages/auth/Login2Page.tsx | 638 +++++++++++++++++++++++++ frontend/src/router/index.tsx | 5 + 2 files changed, 643 insertions(+) create mode 100644 frontend/src/pages/auth/Login2Page.tsx diff --git a/frontend/src/pages/auth/Login2Page.tsx b/frontend/src/pages/auth/Login2Page.tsx new file mode 100644 index 000000000..77a8e75e5 --- /dev/null +++ b/frontend/src/pages/auth/Login2Page.tsx @@ -0,0 +1,638 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation } from '@tanstack/react-query'; +import { Eye, EyeOff, Lock, User, ShieldCheck, Package, Building2, Wrench, LogIn } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/Button'; +import { login } from '@/api/auth'; + +const loginSchema = z.object({ + username: z.string().min(1, '请输入用户名'), + password: z.string().min(1, '请输入密码'), +}); + +type LoginForm = z.infer; + +const isDev = import.meta.env.DEV; +const envDemoUser = import.meta.env.VITE_DEMO_USERNAME; +const envDemoPass = import.meta.env.VITE_DEMO_PASSWORD; + +const DEMO_ACCOUNTS = isDev + ? [ + { label: '系统管理员', username: envDemoUser || 'admin', password: envDemoPass || 'admin123', Icon: ShieldCheck }, + { label: '资产管理员', username: 'asset', password: 'asset123', Icon: Package }, + { label: '部门负责人', username: 'manager', password: 'manager123', Icon: Building2 }, + { label: '运维人员', username: 'staff', password: 'staff123', Icon: Wrench }, + ] + : envDemoUser && envDemoPass + ? [{ label: '演示账号', username: envDemoUser, password: envDemoPass, Icon: ShieldCheck }] + : []; + +/* ═══════════════════════════════════════════════════════════ + Canvas 全屏动画 — 资产管理网络拓扑 + ═══════════════════════════════════════════════════════════ */ + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + radius: number; + type: 'hub' | 'asset' | 'data'; + color: string; + alpha: number; + pulsePhase: number; + pulseSpeed: number; +} + +interface Pulse { + progress: number; + speed: number; + si: number; + ti: number; + color: string; + size: number; +} + +const PALETTE = ['#3b82f6', '#6366f1', '#06b6d4', '#8b5cf6', '#22d3ee']; +const CONNECTION_DIST = 180; +const HUB_COUNT = 6; +const ASSET_COUNT = 40; +const MAX_PULSES = 30; + +function createParticles(w: number, h: number): Particle[] { + const particles: Particle[] = []; + + for (let i = 0; i < HUB_COUNT; i++) { + particles.push({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.3, + vy: (Math.random() - 0.5) * 0.3, + radius: 4 + Math.random() * 3, + type: 'hub', + color: PALETTE[i % PALETTE.length], + alpha: 0.9, + pulsePhase: Math.random() * Math.PI * 2, + pulseSpeed: 0.015 + Math.random() * 0.01, + }); + } + + for (let i = 0; i < ASSET_COUNT; i++) { + particles.push({ + x: Math.random() * w, + y: Math.random() * h, + vx: (Math.random() - 0.5) * 0.6, + vy: (Math.random() - 0.5) * 0.6, + radius: 1.5 + Math.random() * 2, + type: 'asset', + color: PALETTE[Math.floor(Math.random() * PALETTE.length)], + alpha: 0.5 + Math.random() * 0.4, + pulsePhase: Math.random() * Math.PI * 2, + pulseSpeed: 0.02 + Math.random() * 0.02, + }); + } + + return particles; +} + +function drawHexagon(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, rotation: number) { + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i + rotation; + const px = cx + r * Math.cos(angle); + const py = cy + r * Math.sin(angle); + i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py); + } + ctx.closePath(); +} + +function NetworkCanvas() { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const pulsesRef = useRef([]); + const frameRef = useRef(0); + const mouseRef = useRef({ x: -9999, y: -9999 }); + const sizeRef = useRef({ w: 0, h: 0 }); + + const handleMouse = useCallback((e: MouseEvent) => { + mouseRef.current = { x: e.clientX, y: e.clientY }; + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const resize = () => { + const dpr = window.devicePixelRatio || 1; + const w = window.innerWidth; + const h = window.innerHeight; + canvas.width = w * dpr; + canvas.height = h * dpr; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + sizeRef.current = { w, h }; + if (particlesRef.current.length === 0) { + particlesRef.current = createParticles(w, h); + } + }; + + resize(); + window.addEventListener('resize', resize); + window.addEventListener('mousemove', handleMouse); + + let raf: number; + let hexRotation = 0; + + const animate = () => { + raf = requestAnimationFrame(animate); + const { w, h } = sizeRef.current; + const particles = particlesRef.current; + const pulses = pulsesRef.current; + const frame = frameRef.current++; + const mouse = mouseRef.current; + + ctx.clearRect(0, 0, w, h); + + // ── 背景渐变 ── + const bgGrad = ctx.createRadialGradient(w * 0.3, h * 0.3, 0, w * 0.5, h * 0.5, w * 0.8); + bgGrad.addColorStop(0, '#0c1a32'); + bgGrad.addColorStop(0.5, '#071225'); + bgGrad.addColorStop(1, '#040b18'); + ctx.fillStyle = bgGrad; + ctx.fillRect(0, 0, w, h); + + // ── 网格 ── + ctx.strokeStyle = 'rgba(59, 130, 246, 0.03)'; + ctx.lineWidth = 0.5; + const gridSize = 60; + for (let x = 0; x < w; x += gridSize) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + for (let y = 0; y < h; y += gridSize) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + // ── 旋转六边形装饰 ── + hexRotation += 0.001; + const hexPositions = [ + { x: w * 0.15, y: h * 0.2, r: 80 }, + { x: w * 0.85, y: h * 0.75, r: 100 }, + { x: w * 0.7, y: h * 0.15, r: 60 }, + { x: w * 0.25, y: h * 0.8, r: 70 }, + ]; + for (const hp of hexPositions) { + drawHexagon(ctx, hp.x, hp.y, hp.r, hexRotation); + ctx.strokeStyle = 'rgba(99, 102, 241, 0.06)'; + ctx.lineWidth = 1; + ctx.stroke(); + drawHexagon(ctx, hp.x, hp.y, hp.r * 0.6, -hexRotation * 1.5); + ctx.strokeStyle = 'rgba(6, 182, 212, 0.04)'; + ctx.stroke(); + } + + // ── 扫描线 ── + const scanY = ((frame * 0.5) % (h + 200)) - 100; + const scanGrad = ctx.createLinearGradient(0, scanY - 60, 0, scanY + 60); + scanGrad.addColorStop(0, 'rgba(6, 182, 212, 0)'); + scanGrad.addColorStop(0.5, 'rgba(6, 182, 212, 0.04)'); + scanGrad.addColorStop(1, 'rgba(6, 182, 212, 0)'); + ctx.fillStyle = scanGrad; + ctx.fillRect(0, scanY - 60, w, 120); + + // ── 更新粒子位置 ── + for (const p of particles) { + p.x += p.vx; + p.y += p.vy; + p.pulsePhase += p.pulseSpeed; + + if (p.x < -50) p.x = w + 50; + if (p.x > w + 50) p.x = -50; + if (p.y < -50) p.y = h + 50; + if (p.y > h + 50) p.y = -50; + + // 鼠标交互:轻微排斥 + const dx = p.x - mouse.x; + const dy = p.y - mouse.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 200 && dist > 0) { + const force = (200 - dist) / 200 * 0.15; + p.vx += (dx / dist) * force; + p.vy += (dy / dist) * force; + } + + // 速度衰减 + p.vx *= 0.998; + p.vy *= 0.998; + } + + // ── 绘制连接线 ── + for (let i = 0; i < particles.length; i++) { + for (let j = i + 1; j < particles.length; j++) { + const dx = particles[j].x - particles[i].x; + const dy = particles[j].y - particles[i].y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < CONNECTION_DIST) { + const alpha = (1 - dist / CONNECTION_DIST) * 0.25; + const isHub = particles[i].type === 'hub' || particles[j].type === 'hub'; + ctx.beginPath(); + ctx.moveTo(particles[i].x, particles[i].y); + ctx.lineTo(particles[j].x, particles[j].y); + ctx.strokeStyle = isHub + ? `rgba(99, 102, 241, ${alpha * 1.5})` + : `rgba(59, 130, 246, ${alpha})`; + ctx.lineWidth = isHub ? 1.2 : 0.6; + ctx.stroke(); + + // 随机生成数据包脉冲 + if (dist < CONNECTION_DIST * 0.7 && Math.random() < 0.0008 && pulses.length < MAX_PULSES) { + pulses.push({ + progress: 0, + speed: 0.005 + Math.random() * 0.008, + si: i, + ti: j, + color: particles[i].color, + size: 1.5 + Math.random() * 2, + }); + } + } + } + } + + // ── 更新 & 绘制数据包脉冲 ── + for (let k = pulses.length - 1; k >= 0; k--) { + const pulse = pulses[k]; + pulse.progress += pulse.speed; + if (pulse.progress >= 1) { + pulses.splice(k, 1); + continue; + } + const sp = particles[pulse.si]; + const tp = particles[pulse.ti]; + if (!sp || !tp) { pulses.splice(k, 1); continue; } + const px = sp.x + (tp.x - sp.x) * pulse.progress; + const py = sp.y + (tp.y - sp.y) * pulse.progress; + + ctx.beginPath(); + ctx.arc(px, py, pulse.size, 0, Math.PI * 2); + ctx.fillStyle = pulse.color; + ctx.globalAlpha = 1 - pulse.progress * 0.5; + ctx.fill(); + ctx.globalAlpha = 1; + + // 脉冲拖尾 + const tailLen = 0.06; + const tailStart = Math.max(0, pulse.progress - tailLen); + const tx = sp.x + (tp.x - sp.x) * tailStart; + const ty = sp.y + (tp.y - sp.y) * tailStart; + const trailGrad = ctx.createLinearGradient(tx, ty, px, py); + trailGrad.addColorStop(0, 'rgba(6, 182, 212, 0)'); + trailGrad.addColorStop(1, pulse.color); + ctx.beginPath(); + ctx.moveTo(tx, ty); + ctx.lineTo(px, py); + ctx.strokeStyle = trailGrad; + ctx.lineWidth = pulse.size * 0.6; + ctx.stroke(); + } + + // ── 绘制粒子 ── + for (const p of particles) { + const pulse = 1 + Math.sin(p.pulsePhase) * 0.3; + const r = p.radius * pulse; + + if (p.type === 'hub') { + // Hub 外发光 + const glow = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r * 6); + glow.addColorStop(0, p.color + '30'); + glow.addColorStop(0.5, p.color + '08'); + glow.addColorStop(1, 'transparent'); + ctx.fillStyle = glow; + ctx.fillRect(p.x - r * 6, p.y - r * 6, r * 12, r * 12); + + // Hub 环 + ctx.beginPath(); + ctx.arc(p.x, p.y, r * 2.5, 0, Math.PI * 2); + ctx.strokeStyle = p.color + '25'; + ctx.lineWidth = 0.8; + ctx.stroke(); + } + + // 粒子本体 + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + ctx.fillStyle = p.color; + ctx.globalAlpha = p.alpha; + ctx.fill(); + ctx.globalAlpha = 1; + + // 粒子核心高光 + if (p.type === 'hub') { + ctx.beginPath(); + ctx.arc(p.x, p.y, r * 0.4, 0, Math.PI * 2); + ctx.fillStyle = '#ffffff'; + ctx.globalAlpha = 0.7; + ctx.fill(); + ctx.globalAlpha = 1; + } + } + + // ── 底部数据流文字 ── + ctx.font = '10px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(59, 130, 246, 0.08)'; + const dataStrings = [ + 'ASSET_SYNC:OK', 'RFID_SCAN:ACTIVE', 'DEPRECIATION:CALC', + 'WORK_ORDER:FLOW', 'INVENTORY:COUNT', 'MAINTENANCE:QUEUE', + '0xA3F2:VALID', 'NODE_MESH:STABLE', 'AUDIT_LOG:STREAM', + ]; + for (let i = 0; i < 12; i++) { + const str = dataStrings[i % dataStrings.length]; + const x = ((frame * 0.3 + i * 160) % (w + 200)) - 100; + const y = h - 20 + Math.sin(frame * 0.01 + i) * 8; + ctx.fillText(str, x, y); + } + + // ── 鼠标附近光晕 ── + if (mouse.x > 0 && mouse.y > 0) { + const mouseGlow = ctx.createRadialGradient(mouse.x, mouse.y, 0, mouse.x, mouse.y, 150); + mouseGlow.addColorStop(0, 'rgba(59, 130, 246, 0.06)'); + mouseGlow.addColorStop(1, 'transparent'); + ctx.fillStyle = mouseGlow; + ctx.fillRect(mouse.x - 150, mouse.y - 150, 300, 300); + } + }; + + animate(); + + return () => { + cancelAnimationFrame(raf); + window.removeEventListener('resize', resize); + window.removeEventListener('mousemove', handleMouse); + }; + }, [handleMouse]); + + return ( + + ); +} + +/* ═══════════════════════════════════════════════════════════ + 登录页面主组件 + ═══════════════════════════════════════════════════════════ */ + +export default function Login2Page() { + const navigate = useNavigate(); + const [showPassword, setShowPassword] = useState(false); + const [errorMsg, setErrorMsg] = useState(null); + const [rememberMe, setRememberMe] = useState(false); + + const { register, handleSubmit, setValue, formState: { errors } } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { username: '', password: '' }, + }); + + useEffect(() => { + const saved = localStorage.getItem('remembered_username'); + if (saved) { + setValue('username', saved); + setRememberMe(true); + } + }, [setValue]); + + const loginMutation = useMutation({ + mutationFn: (data: LoginForm) => login(data), + onSuccess: (res) => { + const { token, userId, username, realName } = res; + if (!token) { + toast.error('登录响应缺少 token'); + return; + } + sessionStorage.setItem('auth_token', token); + sessionStorage.setItem('user_info', JSON.stringify({ userId, username, realName })); + if (rememberMe) { + localStorage.setItem('remembered_username', username); + } else { + localStorage.removeItem('remembered_username'); + } + navigate('/dashboard', { replace: true }); + }, + onError: (err: any) => { + const msg = err?.message || '网络错误,请检查网络后重试'; + toast.error(msg); + setErrorMsg(msg); + }, + }); + + const onSubmit = (data: LoginForm) => { + setErrorMsg(null); + loginMutation.mutate(data); + }; + + const fillAndLogin = (username: string, password: string) => { + setValue('username', username); + setValue('password', password); + setErrorMsg(null); + setTimeout(() => loginMutation.mutate({ username, password }), 200); + }; + + return ( +
+ {/* 全屏 Canvas 动画背景 */} + + + {/* 内容层 */} +
+
+ + {/* 顶部品牌标识 */} +
+
+ + forthAMS +
+

+ 资产管理系统 +

+

+ 统一资产、流程与审计的运营入口 +

+
+ + {/* 登录卡片 */} +
+ {/* 顶部光线 */} +
+ +
+ {/* 用户名 */} +
+ +
+ + +
+ {errors.username &&

{errors.username.message}

} +
+ + {/* 密码 */} +
+ +
+ + + +
+ {errors.password &&

{errors.password.message}

} +
+ + {/* 记住我 + 忘记密码 */} +
+ + +
+ + {/* 错误提示 */} + {errorMsg && ( +
+ {errorMsg} +
+ )} + + {/* 登录按钮 */} + +
+ + {/* MaxKey SSO */} + + + {/* 快捷账号 */} + {DEMO_ACCOUNTS.length > 0 && ( +
+
+

快捷账号

+ 演示环境 +
+
+ {DEMO_ACCOUNTS.map(({ label, username, password, Icon }) => ( + + ))} +
+
+ )} +
+ + {/* 底部 */} +
+

© 2026 forthAMS 资产管理系统

+
+
+
+ + {/* 动画 keyframes */} + +
+ ); +} diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index c98c7f300..dfeea9bd7 100644 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -93,6 +93,7 @@ function PermissionGuard({ roles, children }: { roles: string[]; children: React // ── 布局 ────────────────────────────────────────────────────────────────────── const AppLayout = React.lazy(() => import('@/layouts/AppLayout')); const LoginPage = React.lazy(() => import('@/pages/auth/LoginPage')); +const Login2Page = React.lazy(() => import('@/pages/auth/Login2Page')); const BigScreenPage = React.lazy(() => import('@/pages/bigscreen/BigScreenPage')); const BigScreen3DPage = React.lazy(() => import('@/pages/bigscreen/BigScreen3DPage')); // ── 新建完成的页面(直接导入)────────────────────────────────────────────────── @@ -154,6 +155,10 @@ const router = createBrowserRouter([ path: '/login', element: S(LoginPage), }, + { + path: '/login2', + element: S(Login2Page), + }, { path: '/forbidden', element: ,