diff --git a/backend/.env.example b/backend/.env.example index 0a62bab..2965f48 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,7 @@ # 应用配置 # ========================================== APP_NAME=MuMuAINovel -APP_VERSION=1.3.4 +APP_VERSION=1.3.5 APP_HOST=0.0.0.0 APP_PORT=8000 DEBUG=false diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index 85e73a2..5082286 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -550,10 +550,6 @@ async def generate_organization_stream( appearance=organization_data.get("appearance", ""), organization_type=organization_data.get("organization_type"), organization_purpose=organization_data.get("organization_purpose"), - organization_members=json.dumps( - organization_data.get("organization_members", []), - ensure_ascii=False - ), traits=json.dumps( organization_data.get("traits", []), ensure_ascii=False diff --git a/backend/app/database.py b/backend/app/database.py index 29afc91..539f498 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -205,7 +205,7 @@ async def get_db(request: Request): _session_stats["active"] -= 1 _session_stats["last_check"] = datetime.now().isoformat() - logger.debug(f"📊 会话关闭 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}, 错误:{_session_stats['errors']}") + # logger.debug(f"📊 会话关闭 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}, 错误:{_session_stats['errors']}") # 使用优化后的会话监控阈值 if _session_stats["active"] > settings.database_session_leak_threshold: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1667569..4a1740e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index ffe308d..f556e6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b8356fa..7ed56a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( + {/* 🧧 春节喜庆装饰 */} + = 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(() => { + 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([]); + const fireworksIntervalRef = useRef | null>(null); + const fallingIntervalRef = useRef | null>(null); + const lanternIntervalRef = useRef | null>(null); + const idCounterRef = useRef(0); + + // 按钮拖动相关状态 + const [btnPos, setBtnPos] = useState(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(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 ( + <> + {/* 控制按钮 - 始终显示,可拖动 */} + + + {visible && ( + <> + {/* 新春祝福横幅 */} + {showBanner && ( +
setShowBanner(false)}> +
+ 🧧 + + {bannerText} + + 🧧 +
+
+ )} + + {/* 灯笼 - 左侧(往中间靠拢),可点击 */} +
+
+
+
+
+
+ + {lanternChars[0] || '福'} + +
+
+
+
+
+
+
+
+
+
+ + {lanternChars[1] || '春'} + +
+
+
+
+
+
+ + {/* 灯笼 - 右侧(往中间靠拢),可点击 */} +
+
+
+
+
+
+ + {lanternChars[2] || '喜'} + +
+
+
+
+
+
+
+
+
+
+ + {lanternChars[3] || '乐'} + +
+
+
+
+
+
+ + {/* 飘落装饰物 */} +
+ {fallingItems.map(item => ( + + {item.emoji} + + ))} +
+ + {/* 顶部红色装饰条 */} +
+ + )} + + ); +}