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',