chore:春节装饰组件、依赖更新及日志优化

This commit is contained in:
xiamuceer-j
2026-02-12 12:41:13 +08:00
parent 79128cb3e2
commit ff148c291e
8 changed files with 1006 additions and 26 deletions
+18 -18
View File
@@ -12,8 +12,10 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@types/canvas-confetti": "^1.9.0",
"antd": "^5.27.6",
"axios": "^1.12.2",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-diff-viewer-continued": "^3.4.0",
@@ -1852,6 +1854,12 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2465,6 +2473,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4951,24 +4969,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.3.4",
"version": "1.3.5",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,8 +14,10 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@types/canvas-confetti": "^1.9.0",
"antd": "^5.27.6",
"axios": "^1.12.2",
"canvas-confetti": "^1.9.4",
"dayjs": "^1.11.13",
"react": "^18.3.1",
"react-diff-viewer-continued": "^3.4.0",
@@ -37,4 +39,4 @@
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}
}
+3
View File
@@ -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,
+426
View File
@@ -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;
}
}
+553
View File
@@ -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>
</>
)}
</>
);
}