From d1f7ced7e16dae7f40843fc0243104df58e85657 Mon Sep 17 00:00:00 2001 From: Ember <197652334@qq.com> Date: Wed, 5 Nov 2025 10:51:20 +0800 Subject: [PATCH] fix: resolve Web UI display issues (#365) ## Fixes ### 1. Typewriter Component - Missing First Character - Fix character loss issue where first character of each line was missing - Add proper state reset logic before starting typing animation - Extract character before setState to avoid closure issues - Add setTimeout(0) to ensure state is updated before typing starts - Change dependency from `lines` to `sanitizedLines` for correct updates - Use `??` instead of `||` for safer null handling ### 2. Chinese Translation - Leading Spaces - Remove leading spaces from startupMessages1/2/3 in Chinese translations - Ensures proper display of startup messages in terminal simulation ### 3. Dynamic GitHub Stats with Animation - Add useGitHubStats hook to fetch real-time GitHub repository data - Add useCounterAnimation hook with easeOutExpo easing for smooth number animation - Display dynamic star count with smooth counter animation (2s duration) - Display dynamic days count (static, no animation) - Support bilingual display (EN/ZH) with proper formatting ## Changes - web/src/components/Typewriter.tsx: Fix first character loss bug - web/src/i18n/translations.ts: Remove leading spaces in Chinese messages - web/src/components/landing/HeroSection.tsx: Add dynamic GitHub stats - web/src/hooks/useGitHubStats.ts: New hook for GitHub API integration - web/src/hooks/useCounterAnimation.ts: New hook for number animations Fixes #365 Co-Authored-By: tinkle-community --- web/src/components/Typewriter.tsx | 15 ++++-- web/src/components/landing/HeroSection.tsx | 25 ++++++++- web/src/hooks/useCounterAnimation.ts | 51 ++++++++++++++++++ web/src/hooks/useGitHubStats.ts | 61 ++++++++++++++++++++++ web/src/i18n/translations.ts | 6 +-- 5 files changed, 150 insertions(+), 8 deletions(-) create mode 100644 web/src/hooks/useCounterAnimation.ts create mode 100644 web/src/hooks/useGitHubStats.ts diff --git a/web/src/components/Typewriter.tsx b/web/src/components/Typewriter.tsx index 3d1ff93f..4729eb9f 100644 --- a/web/src/components/Typewriter.tsx +++ b/web/src/components/Typewriter.tsx @@ -24,13 +24,19 @@ export default function Typewriter({ const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines]) useEffect(() => { + // 重置状态 + lineIndexRef.current = 0 + charIndexRef.current = 0 + setTypedLines(['']) + function typeNext() { const currentLine = sanitizedLines[lineIndexRef.current] ?? '' if (charIndexRef.current < currentLine.length) { + const ch = currentLine.charAt(charIndexRef.current) setTypedLines((prev) => { const next = [...prev] - const ch = currentLine.charAt(charIndexRef.current) - next[next.length - 1] = (next[next.length - 1] || '') + ch + const lastIndex = next.length - 1 + next[lastIndex] = (next[lastIndex] ?? '') + ch return next }) charIndexRef.current += 1 @@ -49,7 +55,8 @@ export default function Typewriter({ } } - typeNext() + // 延迟一帧开始打字,确保状态已重置 + timerRef.current = window.setTimeout(typeNext, 0) // 光标闪烁 blinkRef.current = window.setInterval(() => { @@ -60,7 +67,7 @@ export default function Typewriter({ if (timerRef.current) window.clearTimeout(timerRef.current) if (blinkRef.current) window.clearInterval(blinkRef.current) } - }, [lines, typingSpeed, lineDelay]) + }, [sanitizedLines, typingSpeed, lineDelay]) const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines]) diff --git a/web/src/components/landing/HeroSection.tsx b/web/src/components/landing/HeroSection.tsx index 52656bf6..ecc93f0a 100644 --- a/web/src/components/landing/HeroSection.tsx +++ b/web/src/components/landing/HeroSection.tsx @@ -1,6 +1,8 @@ import { motion, useScroll, useTransform, useAnimation } from 'framer-motion' import { Sparkles } from 'lucide-react' import { t, Language } from '../../i18n/translations' +import { useGitHubStats } from '../../hooks/useGitHubStats' +import { useCounterAnimation } from '../../hooks/useCounterAnimation' interface HeroSectionProps { language: Language @@ -11,6 +13,14 @@ export default function HeroSection({ language }: HeroSectionProps) { const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0]) const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8]) const handControls = useAnimation() + const { stars, daysOld, isLoading } = useGitHubStats('NoFxAiOS', 'nofx') + + // 动画数字 - 仅对 stars 添加动画 + const animatedStars = useCounterAnimation({ + start: 0, + end: stars, + duration: 2000, + }) const fadeInUp = { initial: { opacity: 0, y: 60 }, @@ -33,7 +43,20 @@ export default function HeroSection({ language }: HeroSectionProps) { > -{t('githubStarsInDays', language)} + {isLoading ? ( + t('githubStarsInDays', language) + ) : language === 'zh' ? ( + <> + {daysOld} 天内{' '} + {(animatedStars / 1000).toFixed(1)} + K+ GitHub Stars + + ) : ( + <> + {(animatedStars / 1000).toFixed(1)} + K+ GitHub Stars in {daysOld} days + + )} diff --git a/web/src/hooks/useCounterAnimation.ts b/web/src/hooks/useCounterAnimation.ts new file mode 100644 index 00000000..e7d7d6a0 --- /dev/null +++ b/web/src/hooks/useCounterAnimation.ts @@ -0,0 +1,51 @@ +import { useEffect, useState } from 'react' + +interface UseCounterAnimationOptions { + start?: number + end: number + duration?: number + decimals?: number +} + +export function useCounterAnimation({ + start = 0, + end, + duration = 2000, + decimals = 0, +}: UseCounterAnimationOptions): number { + const [count, setCount] = useState(start) + + useEffect(() => { + if (end === 0) return + + let startTime: number | null = null + let animationFrame: number + + const animate = (currentTime: number) => { + if (startTime === null) startTime = currentTime + const progress = Math.min((currentTime - startTime) / duration, 1) + + // 使用 easeOutExpo 缓动函数,让数字快速启动后缓慢停止 + const easeOutExpo = progress === 1 ? 1 : 1 - Math.pow(2, -10 * progress) + + const currentCount = start + (end - start) * easeOutExpo + setCount(currentCount) + + if (progress < 1) { + animationFrame = requestAnimationFrame(animate) + } else { + setCount(end) + } + } + + animationFrame = requestAnimationFrame(animate) + + return () => { + if (animationFrame) { + cancelAnimationFrame(animationFrame) + } + } + }, [start, end, duration]) + + return decimals > 0 ? parseFloat(count.toFixed(decimals)) : Math.floor(count) +} diff --git a/web/src/hooks/useGitHubStats.ts b/web/src/hooks/useGitHubStats.ts new file mode 100644 index 00000000..7843e468 --- /dev/null +++ b/web/src/hooks/useGitHubStats.ts @@ -0,0 +1,61 @@ +import { useState, useEffect } from 'react' + +interface GitHubStats { + stars: number + forks: number + createdAt: string + daysOld: number + isLoading: boolean + error: string | null +} + +export function useGitHubStats(owner: string, repo: string): GitHubStats { + const [stats, setStats] = useState({ + stars: 0, + forks: 0, + createdAt: '', + daysOld: 0, + isLoading: true, + error: null, + }) + + useEffect(() => { + const fetchGitHubStats = async () => { + try { + const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`) + + if (!response.ok) { + throw new Error('Failed to fetch GitHub stats') + } + + const data = await response.json() + + // Calculate days since creation + const createdDate = new Date(data.created_at) + const now = new Date() + const diffTime = Math.abs(now.getTime() - createdDate.getTime()) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + + setStats({ + stars: data.stargazers_count, + forks: data.forks_count, + createdAt: data.created_at, + daysOld: diffDays, + isLoading: false, + error: null, + }) + } catch (error) { + console.error('Error fetching GitHub stats:', error) + setStats(prev => ({ + ...prev, + isLoading: false, + error: error instanceof Error ? error.message : 'Unknown error', + })) + } + } + + fetchGitHubStats() + }, [owner, repo]) + + return stats +} diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts index f69ca1f1..37b269e7 100644 --- a/web/src/i18n/translations.ts +++ b/web/src/i18n/translations.ts @@ -802,9 +802,9 @@ export const translations = { nofxDescription5: '贡献获积分奖励)。', youFullControl: '你 100% 掌控', fullControlDesc: '完全掌控 AI 提示词和资金', - startupMessages1: ' 启动自动交易系统...', - startupMessages2: ' API服务器启动在端口 8080', - startupMessages3: ' Web 控制台 http://localhost:3000', + startupMessages1: '启动自动交易系统...', + startupMessages2: 'API服务器启动在端口 8080', + startupMessages3: 'Web 控制台 http://localhost:3000', // How It Works Section howToStart: '如何开始使用 NOFX',