chore:春节装饰组件、依赖更新及日志优化
This commit is contained in:
@@ -27,11 +27,14 @@ import Login from './pages/Login';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import AppFooter from './components/AppFooter';
|
||||
import SpringFestival from './components/SpringFestival';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
{/* 🧧 春节喜庆装饰 */}
|
||||
<SpringFestival />
|
||||
<BrowserRouter
|
||||
future={{
|
||||
v7_startTransition: true,
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* 🧧 春节喜庆装饰样式
|
||||
*
|
||||
* 包含:灯笼、飘落物、横幅、装饰条等
|
||||
*/
|
||||
|
||||
/* ===== 控制按钮 ===== */
|
||||
.sf-toggle-btn {
|
||||
z-index: 9999;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #FFD700;
|
||||
background: linear-gradient(135deg, #FF0000 0%, #CC0000 100%);
|
||||
color: white;
|
||||
font-size: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 16px rgba(255, 0, 0, 0.4), 0 0 20px rgba(255, 215, 0, 0.3);
|
||||
animation: sf-btn-glow 2s ease-in-out infinite;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.sf-toggle-btn:hover {
|
||||
box-shadow: 0 6px 24px rgba(255, 0, 0, 0.5), 0 0 30px rgba(255, 215, 0, 0.5);
|
||||
}
|
||||
|
||||
.sf-toggle-btn.sf-dragging {
|
||||
animation: none;
|
||||
box-shadow: 0 8px 32px rgba(255, 0, 0, 0.6), 0 0 40px rgba(255, 215, 0, 0.6);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
@keyframes sf-btn-glow {
|
||||
0%, 100% { box-shadow: 0 4px 16px rgba(255, 0, 0, 0.4), 0 0 20px rgba(255, 215, 0, 0.3); }
|
||||
50% { box-shadow: 0 4px 20px rgba(255, 0, 0, 0.6), 0 0 30px rgba(255, 215, 0, 0.5); }
|
||||
}
|
||||
|
||||
/* ===== 新春祝福横幅 ===== */
|
||||
.sf-banner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9998;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
animation: sf-banner-slide-in 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
|
||||
}
|
||||
|
||||
.sf-banner-content {
|
||||
background: linear-gradient(135deg, #B22222 0%, #DC143C 30%, #FF0000 50%, #DC143C 70%, #B22222 100%);
|
||||
color: #FFD700;
|
||||
padding: 10px 40px;
|
||||
border-radius: 0 0 20px 20px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
box-shadow:
|
||||
0 4px 20px rgba(178, 34, 34, 0.5),
|
||||
0 0 40px rgba(255, 215, 0, 0.2),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 215, 0, 0.4);
|
||||
border-top: none;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
animation: sf-banner-glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sf-banner-icon {
|
||||
font-size: 24px;
|
||||
animation: sf-banner-icon-bounce 1s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.sf-banner-text {
|
||||
font-family: 'STKaiti', 'KaiTi', 'SimSun', serif;
|
||||
}
|
||||
|
||||
@keyframes sf-banner-slide-in {
|
||||
from { transform: translateY(-100%); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes sf-banner-glow {
|
||||
0%, 100% { box-shadow: 0 4px 20px rgba(178, 34, 34, 0.5), 0 0 40px rgba(255, 215, 0, 0.2); }
|
||||
50% { box-shadow: 0 4px 30px rgba(178, 34, 34, 0.7), 0 0 60px rgba(255, 215, 0, 0.4); }
|
||||
}
|
||||
|
||||
@keyframes sf-banner-icon-bounce {
|
||||
from { transform: translateY(0) rotate(-5deg); }
|
||||
to { transform: translateY(-4px) rotate(5deg); }
|
||||
}
|
||||
|
||||
/* ===== 灯笼组 ===== */
|
||||
.sf-lantern-group {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 9990;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 灯笼位置靠中间 */
|
||||
.sf-lantern-left {
|
||||
left: calc(50% - 500px);
|
||||
}
|
||||
|
||||
.sf-lantern-right {
|
||||
right: calc(50% - 500px);
|
||||
}
|
||||
|
||||
/* ===== 灯笼本体 ===== */
|
||||
.sf-lantern {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
animation: sf-lantern-swing 3s ease-in-out infinite;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.sf-lantern-1 { animation-delay: 0s; }
|
||||
.sf-lantern-2 { animation-delay: 0.5s; }
|
||||
.sf-lantern-3 { animation-delay: 0.3s; }
|
||||
.sf-lantern-4 { animation-delay: 0.8s; }
|
||||
|
||||
/* 灯笼线 */
|
||||
.sf-lantern-line {
|
||||
width: 2px;
|
||||
height: 30px;
|
||||
background: linear-gradient(to bottom, #FFD700, #DAA520);
|
||||
}
|
||||
|
||||
/* 灯笼主体容器 */
|
||||
.sf-lantern-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
filter: drop-shadow(0 4px 12px rgba(255, 0, 0, 0.5));
|
||||
}
|
||||
|
||||
/* 灯笼顶部装饰 */
|
||||
.sf-lantern-top {
|
||||
width: 24px;
|
||||
height: 8px;
|
||||
background: linear-gradient(to bottom, #FFD700, #DAA520);
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
/* 灯笼中间部分(主体) */
|
||||
.sf-lantern-middle {
|
||||
width: 44px;
|
||||
height: 52px;
|
||||
background: radial-gradient(ellipse at center, #FF4500 0%, #FF0000 40%, #CC0000 70%, #B22222 100%);
|
||||
border-radius: 50% / 55%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
inset 0 0 15px rgba(255, 200, 0, 0.4),
|
||||
0 0 20px rgba(255, 69, 0, 0.6);
|
||||
animation: sf-lantern-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sf-lantern-middle span {
|
||||
color: #FFD700;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
font-family: 'STKaiti', 'KaiTi', 'SimSun', serif;
|
||||
text-shadow: 0 0 8px rgba(255, 215, 0, 0.8);
|
||||
}
|
||||
|
||||
/* 灯笼文字淡入淡出过渡 */
|
||||
.sf-lantern-char {
|
||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.sf-char-fade-in {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.sf-char-fade-out {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
|
||||
/* 灯笼底部装饰 */
|
||||
.sf-lantern-bottom {
|
||||
width: 24px;
|
||||
height: 8px;
|
||||
background: linear-gradient(to top, #FFD700, #DAA520);
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
|
||||
/* 灯笼穗子 */
|
||||
.sf-lantern-tassel {
|
||||
width: 2px;
|
||||
height: 20px;
|
||||
background: linear-gradient(to bottom, #FFD700, #FF6347);
|
||||
position: relative;
|
||||
animation: sf-tassel-swing 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sf-lantern-tassel::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
background: linear-gradient(to bottom, #FFD700, #FF4500);
|
||||
border-radius: 0 0 50% 50%;
|
||||
}
|
||||
|
||||
/* 灯笼动画 */
|
||||
@keyframes sf-lantern-swing {
|
||||
0%, 100% { transform: rotate(-5deg); }
|
||||
50% { transform: rotate(5deg); }
|
||||
}
|
||||
|
||||
@keyframes sf-lantern-glow {
|
||||
0%, 100% {
|
||||
box-shadow: inset 0 0 15px rgba(255, 200, 0, 0.4), 0 0 20px rgba(255, 69, 0, 0.6);
|
||||
}
|
||||
50% {
|
||||
box-shadow: inset 0 0 25px rgba(255, 200, 0, 0.6), 0 0 35px rgba(255, 69, 0, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sf-tassel-swing {
|
||||
0%, 100% { transform: rotate(3deg); }
|
||||
50% { transform: rotate(-3deg); }
|
||||
}
|
||||
|
||||
/* ===== 灯笼可点击交互 ===== */
|
||||
.sf-lantern-clickable {
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sf-lantern-clickable:hover .sf-lantern-middle {
|
||||
box-shadow:
|
||||
inset 0 0 30px rgba(255, 200, 0, 0.7),
|
||||
0 0 40px rgba(255, 69, 0, 0.9);
|
||||
transform: scale(1.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.sf-lantern-clickable:active .sf-lantern-middle {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
|
||||
/* ===== 飘落装饰物 ===== */
|
||||
.sf-falling-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 9985;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sf-falling-item {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
animation: sf-fall linear forwards;
|
||||
opacity: 0.8;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes sf-fall {
|
||||
0% {
|
||||
transform: translateY(0) rotate(0deg) translateX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
5% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(calc(100vh + 60px)) rotate(720deg) translateX(50px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 顶部装饰红线 ===== */
|
||||
.sf-top-border {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#FFD700 0%,
|
||||
#FF0000 15%,
|
||||
#FFD700 30%,
|
||||
#FF0000 45%,
|
||||
#FFD700 50%,
|
||||
#FF0000 55%,
|
||||
#FFD700 70%,
|
||||
#FF0000 85%,
|
||||
#FFD700 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: sf-border-flow 4s linear infinite;
|
||||
z-index: 9995;
|
||||
box-shadow: 0 2px 8px rgba(255, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@keyframes sf-border-flow {
|
||||
0% { background-position: 0% 0%; }
|
||||
100% { background-position: 200% 0%; }
|
||||
}
|
||||
|
||||
/* ===== 移动端适配 ===== */
|
||||
@media (max-width: 768px) {
|
||||
/* 灯笼缩小并调整位置 */
|
||||
.sf-lantern-left {
|
||||
left: calc(50% - 160px);
|
||||
}
|
||||
|
||||
.sf-lantern-right {
|
||||
right: calc(50% - 160px);
|
||||
}
|
||||
|
||||
.sf-lantern-middle {
|
||||
width: 32px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.sf-lantern-middle span {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sf-lantern-top,
|
||||
.sf-lantern-bottom {
|
||||
width: 18px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.sf-lantern-line {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.sf-lantern-tassel {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.sf-lantern-group {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 横幅缩小 */
|
||||
.sf-banner-content {
|
||||
padding: 8px 20px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 4px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sf-banner-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 控制按钮调整 */
|
||||
.sf-toggle-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 减少飘落物 */
|
||||
.sf-falling-item:nth-child(n+8) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 更小屏幕 */
|
||||
@media (max-width: 576px) {
|
||||
.sf-lantern-left {
|
||||
left: calc(50% - 100px);
|
||||
}
|
||||
|
||||
.sf-lantern-right {
|
||||
right: calc(50% - 100px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 减少动画偏好 ===== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sf-lantern {
|
||||
animation: none;
|
||||
}
|
||||
.sf-falling-item {
|
||||
display: none;
|
||||
}
|
||||
.sf-banner {
|
||||
animation: none;
|
||||
}
|
||||
.sf-lantern-middle {
|
||||
animation: none;
|
||||
}
|
||||
.sf-lantern-tassel {
|
||||
animation: none;
|
||||
}
|
||||
.sf-top-border {
|
||||
animation: none;
|
||||
}
|
||||
.sf-toggle-btn {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* 🧧 春节喜庆装饰组件
|
||||
*
|
||||
* 包含以下元素:
|
||||
* - 🏮 悬挂灯笼(左右各两个)
|
||||
* - 🎆 烟花效果(canvas-confetti)
|
||||
* - 🌸 飘落装饰物(梅花、福字等)
|
||||
* - 🧧 新春祝福横幅
|
||||
* - 可通过右侧浮动按钮控制开关(支持拖动+自动贴边)
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import './SpringFestival.css';
|
||||
|
||||
// 春节日期范围检测(农历新年前后各15天左右)
|
||||
function isSpringFestivalSeason(): boolean {
|
||||
// 简单判断:每年1月15日 ~ 3月5日期间显示
|
||||
const now = new Date();
|
||||
const month = now.getMonth() + 1; // 1-12
|
||||
const day = now.getDate();
|
||||
return (month === 1 && day >= 15) || month === 2 || (month === 3 && day <= 5);
|
||||
}
|
||||
|
||||
// 飘落装饰物配置
|
||||
const FALLING_ITEMS = ['🌸', '✨', '🧧', '💮', '🎐', '❄️', '🏮'];
|
||||
const SPRING_COUPLETS = [
|
||||
'马年大吉',
|
||||
'恭喜发财',
|
||||
'红包拿来',
|
||||
'万事如意',
|
||||
'阖家欢乐',
|
||||
'新春快乐',
|
||||
'福星高照',
|
||||
];
|
||||
|
||||
interface FallingItem {
|
||||
id: number;
|
||||
emoji: string;
|
||||
left: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface BtnPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
side: 'left' | 'right';
|
||||
}
|
||||
|
||||
// 默认按钮位置:右侧贴边居中
|
||||
function getDefaultBtnPosition(): BtnPosition {
|
||||
return {
|
||||
x: window.innerWidth - 22, // 贴右边
|
||||
y: window.innerHeight / 2,
|
||||
side: 'right',
|
||||
};
|
||||
}
|
||||
|
||||
// 从 localStorage 读取保存的位置
|
||||
function loadBtnPosition(): BtnPosition {
|
||||
try {
|
||||
const saved = localStorage.getItem('sf-btn-position');
|
||||
if (saved) {
|
||||
const pos = JSON.parse(saved) as BtnPosition;
|
||||
// 确保在可视区域内
|
||||
pos.y = Math.max(22, Math.min(window.innerHeight - 22, pos.y));
|
||||
pos.x = pos.side === 'left' ? 22 : window.innerWidth - 22;
|
||||
return pos;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return getDefaultBtnPosition();
|
||||
}
|
||||
|
||||
export default function SpringFestival() {
|
||||
const [visible, setVisible] = useState(() => {
|
||||
const saved = localStorage.getItem('spring-festival-visible');
|
||||
if (saved !== null) return saved === 'true';
|
||||
return isSpringFestivalSeason();
|
||||
});
|
||||
|
||||
const [showBanner, setShowBanner] = useState(true);
|
||||
const [bannerText] = useState(() => {
|
||||
return SPRING_COUPLETS[Math.floor(Math.random() * SPRING_COUPLETS.length)];
|
||||
});
|
||||
|
||||
// 灯笼文字:从 SPRING_COUPLETS 中取四字词,定时轮换
|
||||
const [lanternChars, setLanternChars] = useState<string[]>(() => {
|
||||
const text = SPRING_COUPLETS[Math.floor(Math.random() * SPRING_COUPLETS.length)];
|
||||
return text.split('');
|
||||
});
|
||||
const [lanternFading, setLanternFading] = useState(false);
|
||||
const lanternIndexRef = useRef(Math.floor(Math.random() * SPRING_COUPLETS.length));
|
||||
|
||||
const [fallingItems, setFallingItems] = useState<FallingItem[]>([]);
|
||||
const fireworksIntervalRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fallingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lanternIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const idCounterRef = useRef(0);
|
||||
|
||||
// 按钮拖动相关状态
|
||||
const [btnPos, setBtnPos] = useState<BtnPosition>(loadBtnPosition);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hasDragged, setHasDragged] = useState(false);
|
||||
const dragStartRef = useRef<{ startX: number; startY: number; startBtnX: number; startBtnY: number } | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// 生成飘落物
|
||||
const createFallingItem = useCallback((): FallingItem => {
|
||||
idCounterRef.current += 1;
|
||||
return {
|
||||
id: idCounterRef.current,
|
||||
emoji: FALLING_ITEMS[Math.floor(Math.random() * FALLING_ITEMS.length)],
|
||||
left: Math.random() * 100,
|
||||
delay: 0,
|
||||
duration: 6 + Math.random() * 8,
|
||||
size: 12 + Math.random() * 16,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 烟花效果
|
||||
const launchFirework = useCallback(() => {
|
||||
if (!visible) return;
|
||||
|
||||
const colors = ['#FF0000', '#FFD700', '#FF6347', '#FF4500', '#FFA500', '#DC143C'];
|
||||
|
||||
confetti({
|
||||
particleCount: 30 + Math.floor(Math.random() * 30),
|
||||
spread: 60 + Math.random() * 40,
|
||||
origin: {
|
||||
x: 0.1 + Math.random() * 0.8,
|
||||
y: 0.2 + Math.random() * 0.4,
|
||||
},
|
||||
colors: colors.slice(0, 3 + Math.floor(Math.random() * 3)),
|
||||
shapes: ['circle', 'square'],
|
||||
gravity: 0.8,
|
||||
scalar: 0.8 + Math.random() * 0.4,
|
||||
drift: (Math.random() - 0.5) * 0.5,
|
||||
ticks: 200,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
}, [visible]);
|
||||
|
||||
// 初始烟花欢迎效果
|
||||
const launchWelcomeFireworks = useCallback(() => {
|
||||
const positions = [
|
||||
{ x: 0.2, y: 0.3 },
|
||||
{ x: 0.5, y: 0.2 },
|
||||
{ x: 0.8, y: 0.3 },
|
||||
];
|
||||
|
||||
positions.forEach((pos, i) => {
|
||||
setTimeout(() => {
|
||||
confetti({
|
||||
particleCount: 60,
|
||||
spread: 80,
|
||||
origin: pos,
|
||||
colors: ['#FF0000', '#FFD700', '#FF6347', '#FF4500', '#DC143C', '#FFA500'],
|
||||
shapes: ['circle', 'square'],
|
||||
gravity: 0.7,
|
||||
scalar: 1,
|
||||
ticks: 250,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
}, i * 400);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 管理飘落物和烟花
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
setFallingItems([]);
|
||||
if (fireworksIntervalRef.current) {
|
||||
clearTimeout(fireworksIntervalRef.current);
|
||||
fireworksIntervalRef.current = null;
|
||||
}
|
||||
if (fallingIntervalRef.current) {
|
||||
clearInterval(fallingIntervalRef.current);
|
||||
fallingIntervalRef.current = null;
|
||||
}
|
||||
if (lanternIntervalRef.current) {
|
||||
clearInterval(lanternIntervalRef.current);
|
||||
lanternIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始生成一批飘落物
|
||||
const initialItems: FallingItem[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const item = createFallingItem();
|
||||
item.delay = Math.random() * 8;
|
||||
initialItems.push(item);
|
||||
}
|
||||
setFallingItems(initialItems);
|
||||
|
||||
// 初始欢迎烟花
|
||||
setTimeout(launchWelcomeFireworks, 1000);
|
||||
|
||||
// 定期添加新飘落物
|
||||
fallingIntervalRef.current = setInterval(() => {
|
||||
setFallingItems(prev => {
|
||||
const kept = prev.slice(-15);
|
||||
return [...kept, createFallingItem()];
|
||||
});
|
||||
}, 3000);
|
||||
|
||||
// 定期发射烟花(每20-40秒一次)
|
||||
const scheduleFirework = () => {
|
||||
const delay = 20000 + Math.random() * 20000;
|
||||
fireworksIntervalRef.current = setTimeout(() => {
|
||||
launchFirework();
|
||||
scheduleFirework();
|
||||
}, delay);
|
||||
};
|
||||
scheduleFirework();
|
||||
|
||||
// 灯笼文字定时轮换(每10秒)
|
||||
lanternIntervalRef.current = setInterval(() => {
|
||||
// 先触发淡出
|
||||
setLanternFading(true);
|
||||
// 500ms 后切换文字并淡入
|
||||
setTimeout(() => {
|
||||
lanternIndexRef.current = (lanternIndexRef.current + 1) % SPRING_COUPLETS.length;
|
||||
const newText = SPRING_COUPLETS[lanternIndexRef.current];
|
||||
setLanternChars(newText.split(''));
|
||||
setLanternFading(false);
|
||||
}, 500);
|
||||
}, 10000);
|
||||
|
||||
return () => {
|
||||
if (fireworksIntervalRef.current) {
|
||||
clearTimeout(fireworksIntervalRef.current);
|
||||
fireworksIntervalRef.current = null;
|
||||
}
|
||||
if (fallingIntervalRef.current) {
|
||||
clearInterval(fallingIntervalRef.current);
|
||||
fallingIntervalRef.current = null;
|
||||
}
|
||||
if (lanternIntervalRef.current) {
|
||||
clearInterval(lanternIntervalRef.current);
|
||||
lanternIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [visible, createFallingItem, launchFirework, launchWelcomeFireworks]);
|
||||
|
||||
// 横幅自动隐藏
|
||||
useEffect(() => {
|
||||
if (visible && showBanner) {
|
||||
const timer = setTimeout(() => setShowBanner(false), 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [visible, showBanner]);
|
||||
|
||||
// ===== 按钮拖动逻辑 =====
|
||||
|
||||
// 自动贴边
|
||||
const snapToEdge = useCallback((x: number, y: number): BtnPosition => {
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const btnRadius = 22;
|
||||
const clampedY = Math.max(btnRadius, Math.min(vh - btnRadius, y));
|
||||
|
||||
// 根据距离左右边缘决定贴哪边
|
||||
const side: 'left' | 'right' = x < vw / 2 ? 'left' : 'right';
|
||||
const snapX = side === 'left' ? btnRadius : vw - btnRadius;
|
||||
|
||||
return { x: snapX, y: clampedY, side };
|
||||
}, []);
|
||||
|
||||
// 鼠标/触摸按下
|
||||
const handleDragStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
dragStartRef.current = {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
startBtnX: btnPos.x,
|
||||
startBtnY: btnPos.y,
|
||||
};
|
||||
setIsDragging(true);
|
||||
setHasDragged(false);
|
||||
}, [btnPos]);
|
||||
|
||||
// 鼠标/触摸移动
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!dragStartRef.current) return;
|
||||
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
|
||||
const dx = clientX - dragStartRef.current.startX;
|
||||
const dy = clientY - dragStartRef.current.startY;
|
||||
|
||||
// 移动超过5px才算拖动
|
||||
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
|
||||
setHasDragged(true);
|
||||
}
|
||||
|
||||
const newX = dragStartRef.current.startBtnX + dx;
|
||||
const newY = dragStartRef.current.startBtnY + dy;
|
||||
|
||||
setBtnPos({
|
||||
x: newX,
|
||||
y: Math.max(22, Math.min(window.innerHeight - 22, newY)),
|
||||
side: newX < window.innerWidth / 2 ? 'left' : 'right',
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
|
||||
// 自动贴边
|
||||
setBtnPos(prev => {
|
||||
const snapped = snapToEdge(prev.x, prev.y);
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('sf-btn-position', JSON.stringify(snapped));
|
||||
return snapped;
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMove);
|
||||
window.addEventListener('mouseup', handleEnd);
|
||||
window.addEventListener('touchmove', handleMove, { passive: false });
|
||||
window.addEventListener('touchend', handleEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMove);
|
||||
window.removeEventListener('mouseup', handleEnd);
|
||||
window.removeEventListener('touchmove', handleMove);
|
||||
window.removeEventListener('touchend', handleEnd);
|
||||
};
|
||||
}, [isDragging, snapToEdge]);
|
||||
|
||||
// 窗口大小变化时重新贴边
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setBtnPos(prev => snapToEdge(prev.side === 'left' ? 22 : window.innerWidth - 22, prev.y));
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [snapToEdge]);
|
||||
|
||||
// ===== 鼠标交互效果 =====
|
||||
|
||||
// 鼠标点击页面时发射小烟花
|
||||
const handlePageClick = useCallback((e: MouseEvent) => {
|
||||
if (!visible) return;
|
||||
// 忽略按钮和灯笼区域的点击(避免与其他交互冲突)
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.sf-toggle-btn') || target.closest('.sf-banner')) return;
|
||||
|
||||
const x = e.clientX / window.innerWidth;
|
||||
const y = e.clientY / window.innerHeight;
|
||||
|
||||
confetti({
|
||||
particleCount: 15 + Math.floor(Math.random() * 15),
|
||||
spread: 40 + Math.random() * 30,
|
||||
origin: { x, y },
|
||||
colors: ['#FF0000', '#FFD700', '#FF6347', '#FF4500'],
|
||||
shapes: ['circle'],
|
||||
gravity: 1.2,
|
||||
scalar: 0.6 + Math.random() * 0.3,
|
||||
ticks: 120,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
}, [visible]);
|
||||
|
||||
// 绑定全局鼠标点击事件
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
window.addEventListener('click', handlePageClick);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('click', handlePageClick);
|
||||
};
|
||||
}, [visible, handlePageClick]);
|
||||
|
||||
// 点击灯笼:爆发烟花 + 立即切换祝福语
|
||||
const handleLanternClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 获取灯笼位置发射烟花
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = (rect.left + rect.width / 2) / window.innerWidth;
|
||||
const y = (rect.top + rect.height / 2) / window.innerHeight;
|
||||
|
||||
confetti({
|
||||
particleCount: 50,
|
||||
spread: 70,
|
||||
origin: { x, y },
|
||||
colors: ['#FF0000', '#FFD700', '#FF6347', '#FF4500', '#DC143C'],
|
||||
shapes: ['circle', 'square'],
|
||||
gravity: 0.8,
|
||||
scalar: 0.9,
|
||||
ticks: 200,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
|
||||
// 立即切换祝福语(带淡入淡出)
|
||||
setLanternFading(true);
|
||||
setTimeout(() => {
|
||||
lanternIndexRef.current = (lanternIndexRef.current + 1) % SPRING_COUPLETS.length;
|
||||
const newText = SPRING_COUPLETS[lanternIndexRef.current];
|
||||
setLanternChars(newText.split(''));
|
||||
setLanternFading(false);
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
// 切换显示状态(只有未拖动时才触发)
|
||||
const handleBtnClick = () => {
|
||||
if (hasDragged) return; // 拖动过就不触发点击
|
||||
const next = !visible;
|
||||
setVisible(next);
|
||||
localStorage.setItem('spring-festival-visible', String(next));
|
||||
if (next) {
|
||||
setShowBanner(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 计算按钮样式
|
||||
const btnStyle: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
left: btnPos.x - 22,
|
||||
top: btnPos.y - 22,
|
||||
transition: isDragging ? 'none' : 'left 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.1s ease',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
touchAction: 'none',
|
||||
userSelect: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 控制按钮 - 始终显示,可拖动 */}
|
||||
<button
|
||||
ref={btnRef}
|
||||
className={`sf-toggle-btn ${isDragging ? 'sf-dragging' : ''}`}
|
||||
style={btnStyle}
|
||||
onMouseDown={handleDragStart}
|
||||
onTouchStart={handleDragStart}
|
||||
onClick={handleBtnClick}
|
||||
title={visible ? '关闭春节装饰' : '开启春节装饰'}
|
||||
>
|
||||
{visible ? '🧨' : '🏮'}
|
||||
</button>
|
||||
|
||||
{visible && (
|
||||
<>
|
||||
{/* 新春祝福横幅 */}
|
||||
{showBanner && (
|
||||
<div className="sf-banner" onClick={() => setShowBanner(false)}>
|
||||
<div className="sf-banner-content">
|
||||
<span className="sf-banner-icon">🧧</span>
|
||||
<span className="sf-banner-text">
|
||||
{bannerText}
|
||||
</span>
|
||||
<span className="sf-banner-icon">🧧</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 灯笼 - 左侧(往中间靠拢),可点击 */}
|
||||
<div className="sf-lantern-group sf-lantern-left sf-lantern-clickable" onClick={handleLanternClick}>
|
||||
<div className="sf-lantern sf-lantern-1">
|
||||
<div className="sf-lantern-line"></div>
|
||||
<div className="sf-lantern-body">
|
||||
<div className="sf-lantern-top"></div>
|
||||
<div className="sf-lantern-middle">
|
||||
<span className={`sf-lantern-char ${lanternFading ? 'sf-char-fade-out' : 'sf-char-fade-in'}`}>
|
||||
{lanternChars[0] || '福'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sf-lantern-bottom"></div>
|
||||
<div className="sf-lantern-tassel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sf-lantern sf-lantern-2">
|
||||
<div className="sf-lantern-line"></div>
|
||||
<div className="sf-lantern-body">
|
||||
<div className="sf-lantern-top"></div>
|
||||
<div className="sf-lantern-middle">
|
||||
<span className={`sf-lantern-char ${lanternFading ? 'sf-char-fade-out' : 'sf-char-fade-in'}`}>
|
||||
{lanternChars[1] || '春'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sf-lantern-bottom"></div>
|
||||
<div className="sf-lantern-tassel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 灯笼 - 右侧(往中间靠拢),可点击 */}
|
||||
<div className="sf-lantern-group sf-lantern-right sf-lantern-clickable" onClick={handleLanternClick}>
|
||||
<div className="sf-lantern sf-lantern-3">
|
||||
<div className="sf-lantern-line"></div>
|
||||
<div className="sf-lantern-body">
|
||||
<div className="sf-lantern-top"></div>
|
||||
<div className="sf-lantern-middle">
|
||||
<span className={`sf-lantern-char ${lanternFading ? 'sf-char-fade-out' : 'sf-char-fade-in'}`}>
|
||||
{lanternChars[2] || '喜'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sf-lantern-bottom"></div>
|
||||
<div className="sf-lantern-tassel"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sf-lantern sf-lantern-4">
|
||||
<div className="sf-lantern-line"></div>
|
||||
<div className="sf-lantern-body">
|
||||
<div className="sf-lantern-top"></div>
|
||||
<div className="sf-lantern-middle">
|
||||
<span className={`sf-lantern-char ${lanternFading ? 'sf-char-fade-out' : 'sf-char-fade-in'}`}>
|
||||
{lanternChars[3] || '乐'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="sf-lantern-bottom"></div>
|
||||
<div className="sf-lantern-tassel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 飘落装饰物 */}
|
||||
<div className="sf-falling-container">
|
||||
{fallingItems.map(item => (
|
||||
<span
|
||||
key={item.id}
|
||||
className="sf-falling-item"
|
||||
style={{
|
||||
left: `${item.left}%`,
|
||||
animationDelay: `${item.delay}s`,
|
||||
animationDuration: `${item.duration}s`,
|
||||
fontSize: `${item.size}px`,
|
||||
}}
|
||||
>
|
||||
{item.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 顶部红色装饰条 */}
|
||||
<div className="sf-top-border"></div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user