mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-15 16:56:56 +08:00
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 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -24,13 +24,19 @@ export default function Typewriter({
|
|||||||
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
|
const sanitizedLines = useMemo(() => lines.map((l) => String(l ?? '')), [lines])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 重置状态
|
||||||
|
lineIndexRef.current = 0
|
||||||
|
charIndexRef.current = 0
|
||||||
|
setTypedLines([''])
|
||||||
|
|
||||||
function typeNext() {
|
function typeNext() {
|
||||||
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
|
const currentLine = sanitizedLines[lineIndexRef.current] ?? ''
|
||||||
if (charIndexRef.current < currentLine.length) {
|
if (charIndexRef.current < currentLine.length) {
|
||||||
|
const ch = currentLine.charAt(charIndexRef.current)
|
||||||
setTypedLines((prev) => {
|
setTypedLines((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
const ch = currentLine.charAt(charIndexRef.current)
|
const lastIndex = next.length - 1
|
||||||
next[next.length - 1] = (next[next.length - 1] || '') + ch
|
next[lastIndex] = (next[lastIndex] ?? '') + ch
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
charIndexRef.current += 1
|
charIndexRef.current += 1
|
||||||
@@ -49,7 +55,8 @@ export default function Typewriter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
typeNext()
|
// 延迟一帧开始打字,确保状态已重置
|
||||||
|
timerRef.current = window.setTimeout(typeNext, 0)
|
||||||
|
|
||||||
// 光标闪烁
|
// 光标闪烁
|
||||||
blinkRef.current = window.setInterval(() => {
|
blinkRef.current = window.setInterval(() => {
|
||||||
@@ -60,7 +67,7 @@ export default function Typewriter({
|
|||||||
if (timerRef.current) window.clearTimeout(timerRef.current)
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
if (blinkRef.current) window.clearInterval(blinkRef.current)
|
if (blinkRef.current) window.clearInterval(blinkRef.current)
|
||||||
}
|
}
|
||||||
}, [lines, typingSpeed, lineDelay])
|
}, [sanitizedLines, typingSpeed, lineDelay])
|
||||||
|
|
||||||
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
|
const displayText = useMemo(() => typedLines.join('\n').replace(/undefined/g, ''), [typedLines])
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { motion, useScroll, useTransform, useAnimation } from 'framer-motion'
|
import { motion, useScroll, useTransform, useAnimation } from 'framer-motion'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles } from 'lucide-react'
|
||||||
import { t, Language } from '../../i18n/translations'
|
import { t, Language } from '../../i18n/translations'
|
||||||
|
import { useGitHubStats } from '../../hooks/useGitHubStats'
|
||||||
|
import { useCounterAnimation } from '../../hooks/useCounterAnimation'
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
language: Language
|
language: Language
|
||||||
@@ -11,6 +13,14 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
|||||||
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
|
const opacity = useTransform(scrollYProgress, [0, 0.2], [1, 0])
|
||||||
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
|
const scale = useTransform(scrollYProgress, [0, 0.2], [1, 0.8])
|
||||||
const handControls = useAnimation()
|
const handControls = useAnimation()
|
||||||
|
const { stars, daysOld, isLoading } = useGitHubStats('NoFxAiOS', 'nofx')
|
||||||
|
|
||||||
|
// 动画数字 - 仅对 stars 添加动画
|
||||||
|
const animatedStars = useCounterAnimation({
|
||||||
|
start: 0,
|
||||||
|
end: stars,
|
||||||
|
duration: 2000,
|
||||||
|
})
|
||||||
|
|
||||||
const fadeInUp = {
|
const fadeInUp = {
|
||||||
initial: { opacity: 0, y: 60 },
|
initial: { opacity: 0, y: 60 },
|
||||||
@@ -33,7 +43,20 @@ export default function HeroSection({ language }: HeroSectionProps) {
|
|||||||
>
|
>
|
||||||
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
|
<Sparkles className='w-4 h-4' style={{ color: 'var(--brand-yellow)' }} />
|
||||||
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
|
<span className='text-sm font-semibold' style={{ color: 'var(--brand-yellow)' }}>
|
||||||
{t('githubStarsInDays', language)}
|
{isLoading ? (
|
||||||
|
t('githubStarsInDays', language)
|
||||||
|
) : language === 'zh' ? (
|
||||||
|
<>
|
||||||
|
{daysOld} 天内{' '}
|
||||||
|
<span className='inline-block tabular-nums'>{(animatedStars / 1000).toFixed(1)}</span>
|
||||||
|
K+ GitHub Stars
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className='inline-block tabular-nums'>{(animatedStars / 1000).toFixed(1)}</span>
|
||||||
|
K+ GitHub Stars in {daysOld} days
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
51
web/src/hooks/useCounterAnimation.ts
Normal file
51
web/src/hooks/useCounterAnimation.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
61
web/src/hooks/useGitHubStats.ts
Normal file
61
web/src/hooks/useGitHubStats.ts
Normal file
@@ -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<GitHubStats>({
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -802,9 +802,9 @@ export const translations = {
|
|||||||
nofxDescription5: '贡献获积分奖励)。',
|
nofxDescription5: '贡献获积分奖励)。',
|
||||||
youFullControl: '你 100% 掌控',
|
youFullControl: '你 100% 掌控',
|
||||||
fullControlDesc: '完全掌控 AI 提示词和资金',
|
fullControlDesc: '完全掌控 AI 提示词和资金',
|
||||||
startupMessages1: ' 启动自动交易系统...',
|
startupMessages1: '启动自动交易系统...',
|
||||||
startupMessages2: ' API服务器启动在端口 8080',
|
startupMessages2: 'API服务器启动在端口 8080',
|
||||||
startupMessages3: ' Web 控制台 http://localhost:3000',
|
startupMessages3: 'Web 控制台 http://localhost:3000',
|
||||||
|
|
||||||
// How It Works Section
|
// How It Works Section
|
||||||
howToStart: '如何开始使用 NOFX',
|
howToStart: '如何开始使用 NOFX',
|
||||||
|
|||||||
Reference in New Issue
Block a user