feat: i18n 翻译覆盖 + DB 动态数据 + 模型选择器 API

This commit is contained in:
yuzhiran-dev
2026-05-22 16:55:18 +08:00
parent 23edb74bce
commit 1b4c19daa6
16 changed files with 763 additions and 1036 deletions
+30 -28
View File
@@ -10,12 +10,14 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Skeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useAuth } from '@/lib/auth-context';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
function AuthForm() {
const searchParams = useSearchParams();
const router = useRouter();
const { login } = useAuth();
const t = useT();
const [tab, setTab] = useState<'login' | 'register'>(() =>
searchParams.get('tab') === 'register' ? 'register' : 'login'
);
@@ -28,7 +30,7 @@ function AuthForm() {
async function handleLogin(e: FormEvent) {
e.preventDefault();
setError('');
if (!loginForm.account || !loginForm.password) { setError('请填写账号和密码'); return; }
if (!loginForm.account || !loginForm.password) { setError(t.auth.fillAccountAndPassword); return; }
setLoading(true);
try {
const res = await fetch(`${API_BASE}/auth/login`, {
@@ -36,9 +38,9 @@ function AuthForm() {
body: JSON.stringify({ account: loginForm.account, password: loginForm.password }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '登录失败');
if (!res.ok) throw new Error(data.message || t.auth.loginFailed);
login(data.accessToken, data.refreshToken);
toast.success('登录成功', { description: '欢迎回来!' });
toast.success(t.auth.loginSuccess);
router.push('/');
} catch (err: any) { setError(err.message); }
finally { setLoading(false); }
@@ -48,10 +50,10 @@ function AuthForm() {
e.preventDefault();
setError('');
const { phone, email, password, confirmPassword, nickname } = registerForm;
if (!phone && !email) { setError('请填写手机号或邮箱'); return; }
if (!password) { setError('请填写密码'); return; }
if (password.length < 6) { setError('密码至少 6 位'); return; }
if (password !== confirmPassword) { setError('两次密码不一致'); return; }
if (!phone && !email) { setError(t.auth.fillPhoneOrEmail); return; }
if (!password) { setError(t.auth.fillPassword); return; }
if (password.length < 6) { setError(t.auth.passwordMinLength); return; }
if (password !== confirmPassword) { setError(t.auth.passwordsNotMatch); return; }
setLoading(true);
try {
const body: Record<string, string> = { password };
@@ -62,9 +64,9 @@ function AuthForm() {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.message || '注册失败');
if (!res.ok) throw new Error(data.message || t.auth.registerFailed);
login(data.accessToken, data.refreshToken);
toast.success('注册成功', { description: '欢迎加入宇之然!' });
toast.success(t.auth.registerSuccess);
router.push('/');
} catch (err: any) { setError(err.message); }
finally { setLoading(false); }
@@ -77,16 +79,16 @@ function AuthForm() {
<div className="mx-auto mb-3 w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-2xl flex items-center justify-center">
<span className="text-white font-bold text-lg">Y</span>
</div>
<CardTitle className="text-xl">{tab === 'login' ? '欢迎回来' : '加入宇之然'}</CardTitle>
<CardTitle className="text-xl">{tab === 'login' ? t.auth.welcomeBack : t.auth.joinTitle}</CardTitle>
<CardDescription>
{tab === 'login' ? '登录继续你的 AI 探索之旅' : '免费注册,开始探索 AI'}
{tab === 'login' ? t.auth.loginSubtitle : t.auth.registerSubtitle}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={tab} onValueChange={(v) => { setTab(v as 'login' | 'register'); setError(''); }}>
<TabsList className="w-full mb-6">
<TabsTrigger value="login" className="flex-1"></TabsTrigger>
<TabsTrigger value="register" className="flex-1"></TabsTrigger>
<TabsTrigger value="login" className="flex-1">{t.auth.loginTitle}</TabsTrigger>
<TabsTrigger value="register" className="flex-1">{t.auth.registerTitle}</TabsTrigger>
</TabsList>
{error && (
@@ -97,38 +99,38 @@ function AuthForm() {
<TabsContent value="login">
<form onSubmit={handleLogin} className="space-y-4">
<Input type="text" placeholder="手机号 / 邮箱" value={loginForm.account}
<Input type="text" placeholder={t.auth.accountPlaceholder} value={loginForm.account}
onChange={(e) => setLoginForm({ ...loginForm, account: e.target.value })} />
<Input type="password" placeholder="密码" value={loginForm.password}
<Input type="password" placeholder={t.auth.password} value={loginForm.password}
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })} />
<Button type="submit" disabled={loading} className="w-full">
{loading ? '登录中...' : '登录'}
{loading ? t.auth.loggingIn : t.auth.loginTitle}
</Button>
</form>
</TabsContent>
<TabsContent value="register">
<form onSubmit={handleRegister} className="space-y-4">
<Input type="text" placeholder="手机号(选填)" value={registerForm.phone}
<Input type="text" placeholder={t.auth.phone} value={registerForm.phone}
onChange={(e) => setRegisterForm({ ...registerForm, phone: e.target.value })} />
<Input type="email" placeholder="邮箱(选填,与手机号至少填一项)" value={registerForm.email}
<Input type="email" placeholder="Email" value={registerForm.email}
onChange={(e) => setRegisterForm({ ...registerForm, email: e.target.value })} />
<Input type="text" placeholder="昵称(选填)" value={registerForm.nickname}
<Input type="text" placeholder={t.auth.nicknameOptional} value={registerForm.nickname}
onChange={(e) => setRegisterForm({ ...registerForm, nickname: e.target.value })} />
<Input type="password" placeholder="密码(至少 6 位)" value={registerForm.password}
<Input type="password" placeholder={t.auth.passwordHint} value={registerForm.password}
onChange={(e) => setRegisterForm({ ...registerForm, password: e.target.value })} />
<Input type="password" placeholder="确认密码" value={registerForm.confirmPassword}
<Input type="password" placeholder={t.auth.confirmPassword} value={registerForm.confirmPassword}
onChange={(e) => setRegisterForm({ ...registerForm, confirmPassword: e.target.value })} />
<Button type="submit" disabled={loading} className="w-full">
{loading ? '注册中...' : '注册'}
{loading ? t.auth.registering : t.auth.registerTitle}
</Button>
<p className="text-xs text-muted-foreground text-center leading-relaxed">
{' '}
<Link href="/terms" className="text-brand-600 hover:underline dark:text-brand-400"></Link>
{' '}{' '}
<Link href="/privacy" className="text-brand-600 hover:underline dark:text-brand-400"></Link>
{' '}{' '}
<Link href="/ai-agreement" className="text-brand-600 hover:underline dark:text-brand-400">AI </Link>
{t.auth.agreePrefix}{' '}
<Link href="/terms" className="text-brand-600 hover:underline dark:text-brand-400">{t.auth.termsOfService}</Link>
{' '}{' '}
<Link href="/privacy" className="text-brand-600 hover:underline dark:text-brand-400">{t.auth.privacyPolicy}</Link>
{' '}{' '}
<Link href="/ai-agreement" className="text-brand-600 hover:underline dark:text-brand-400">{t.auth.aiAgreement}</Link>
</p>
</form>
</TabsContent>
+38 -98
View File
@@ -7,28 +7,13 @@ import { apiFetch } from "../../lib/auth";
import { useAuth } from "@/lib/auth-context";
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
interface Post {
id: number;
title: string;
content: string;
tags?: string | null;
viewCount: number;
likeCount: number;
commentCount: number;
createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
comments?: Comment[];
}
interface Comment {
id: number;
content: string;
createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
}
interface Post { id: number; title: string; content: string; tags?: string | null; viewCount: number; likeCount: number; commentCount: number; createdAt: string; user: { id: number; nickname: string; avatar: string | null }; comments?: Comment[] }
interface Comment { id: number; content: string; createdAt: string; user: { id: number; nickname: string; avatar: string | null } }
export default function CommunityPage() {
const t = useT();
const router = useRouter();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
@@ -45,9 +30,7 @@ export default function CommunityPage() {
setLoading(true);
try {
const token = localStorage.getItem('token');
const url = activeTab === 'feed' && token
? '/community/feed'
: '/community/posts';
const url = activeTab === 'feed' && token ? '/community/feed' : '/community/posts';
const res = await apiFetch(url);
const data = await res.json();
setPosts(data.items || []);
@@ -66,10 +49,7 @@ export default function CommunityPage() {
if (post.user?.id) {
try {
const res = await apiFetch(`/community/users/${post.user.id}/follow`);
if (res.ok) {
const d = await res.json();
if (d.followed) followed.add(post.user.id);
}
if (res.ok) { const d = await res.json(); if (d.followed) followed.add(post.user.id); }
} catch (e) { console.error(e) }
}
}
@@ -81,37 +61,23 @@ export default function CommunityPage() {
if (!title.trim() || !content.trim()) return;
setSubmitting(true);
try {
await apiFetch("/community/posts", {
method: "POST",
body: JSON.stringify({ title, content, tags: tags || undefined }),
});
setTitle(""); setContent(""); setTags("");
setShowForm(false);
await apiFetch("/community/posts", { method: "POST", body: JSON.stringify({ title, content, tags: tags || undefined }) });
setTitle(""); setContent(""); setTags(""); setShowForm(false);
loadPosts();
} catch (e) { console.error(e) }
setSubmitting(false);
}
async function handleLike(postId: number) {
try {
await apiFetch(`/community/posts/${postId}/like`, { method: "POST" });
loadPosts();
} catch (e) { console.error(e) }
try { await apiFetch(`/community/posts/${postId}/like`, { method: "POST" }); loadPosts(); } catch (e) { console.error(e) }
}
async function handleFollow(userId: number) {
const token = localStorage.getItem('token');
if (!token) return;
const token = localStorage.getItem('token'); if (!token) return;
try {
const isFollowed = followedUsers.has(userId);
await apiFetch(`/community/users/${userId}/follow`, {
method: isFollowed ? 'DELETE' : 'POST',
});
setFollowedUsers(prev => {
const next = new Set(prev);
isFollowed ? next.delete(userId) : next.add(userId);
return next;
});
await apiFetch(`/community/users/${userId}/follow`, { method: isFollowed ? 'DELETE' : 'POST' });
setFollowedUsers(prev => { const next = new Set(prev); isFollowed ? next.delete(userId) : next.add(userId); return next; });
} catch (e) { console.error(e) }
}
@@ -125,12 +91,8 @@ export default function CommunityPage() {
if (!comment.trim()) return;
setSubmittingComment(true);
try {
await apiFetch(`/community/posts/${post.id}/comments`, {
method: "POST",
body: JSON.stringify({ content: comment }),
});
setComment("");
loadPosts();
await apiFetch(`/community/posts/${post.id}/comments`, { method: "POST", body: JSON.stringify({ content: comment }) });
setComment(""); loadPosts();
} catch (e) { console.error(e) }
setSubmittingComment(false);
}
@@ -149,12 +111,8 @@ export default function CommunityPage() {
</Link>
{isLoggedIn && (
<button onClick={() => handleFollow(post.user.id)}
className={`ml-auto text-xs px-2 py-1 rounded transition-colors ${
followedUsers.has(post.user.id)
? 'bg-muted text-muted-foreground hover:bg-accent'
: 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 hover:bg-brand-100 dark:hover:bg-brand-900/50'
}`}>
{followedUsers.has(post.user.id) ? '已关注' : '+ 关注'}
className={`ml-auto text-xs px-2 py-1 rounded transition-colors ${followedUsers.has(post.user.id) ? 'bg-muted text-muted-foreground hover:bg-accent' : 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 hover:bg-brand-100 dark:hover:bg-brand-900/50'}`}>
{followedUsers.has(post.user.id) ? t.community.followed : t.community.follow}
</button>
)}
</div>
@@ -170,21 +128,15 @@ export default function CommunityPage() {
</div>
)}
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<button onClick={() => handleLike(post.id)} className="flex items-center gap-1 hover:text-brand-600 transition-colors">
{post.likeCount}
</button>
<button onClick={() => setExpanded(!expanded)} className="hover:text-brand-600 transition-colors">
💬 {post.commentCount}
</button>
<button onClick={() => handleLike(post.id)} className="flex items-center gap-1 hover:text-brand-600 transition-colors"> {post.likeCount}</button>
<button onClick={() => setExpanded(!expanded)} className="hover:text-brand-600 transition-colors">💬 {post.commentCount}</button>
<span>👁 {post.viewCount}</span>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t border-border">
{post.comments?.map((c: Comment) => (
<div key={c.id} className="flex gap-3 mb-3">
<Avatar className="w-6 h-6">
<AvatarFallback className="text-[10px]">{c.user.nickname?.[0] || "U"}</AvatarFallback>
</Avatar>
<Avatar className="w-6 h-6"><AvatarFallback className="text-[10px]">{c.user.nickname?.[0] || "U"}</AvatarFallback></Avatar>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">{c.user.nickname} · {new Date(c.createdAt).toLocaleDateString()}</div>
<p className="text-sm text-muted-foreground">{c.content}</p>
@@ -192,12 +144,11 @@ export default function CommunityPage() {
</div>
))}
<form onSubmit={handleComment} className="mt-3 flex gap-2">
<input value={comment} onChange={e => setComment(e.target.value)}
placeholder="写下你的评论..."
<input value={comment} onChange={e => setComment(e.target.value)} placeholder={t.community.commentPlaceholder}
className="flex-1 px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<button type="submit" disabled={submittingComment}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700 disabled:opacity-50">
{submittingComment ? "发送中..." : "评论"}
{submittingComment ? t.community.sending : t.community.comment}
</button>
</form>
</div>
@@ -206,57 +157,46 @@ export default function CommunityPage() {
);
}
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
</div>
);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" /><Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" /><Skeleton className="h-4 w-full mb-2" /><Skeleton className="h-4 w-3/4" />
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> AI </p>
<h1 className="text-3xl font-bold text-foreground">{t.community.title}</h1>
<p className="mt-2 text-muted-foreground">{t.community.desc}</p>
</div>
<button onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors">
{showForm ? "取消" : "+ 发帖"}
{showForm ? t.common.cancel : t.community.createPost}
</button>
</div>
<div className="flex gap-1 mb-6 bg-muted rounded-lg p-1">
<button onClick={() => setActiveTab('latest')}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'latest' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}></button>
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${activeTab === 'latest' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{t.community.latest}</button>
<button onClick={() => setActiveTab('feed')}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'feed' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}></button>
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${activeTab === 'feed' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}>{t.community.following}</button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-card rounded-xl border border-border p-6 mb-6">
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="标题"
<h3 className="text-lg font-semibold text-foreground mb-4">{t.community.newPost}</h3>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder={t.community.postTitle}
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring" />
<textarea value={content} onChange={e => setContent(e.target.value)}
placeholder="分享你的 AI 学习心得、实战经验..." rows={4}
<textarea value={content} onChange={e => setContent(e.target.value)} placeholder={t.community.contentPlaceholder} rows={4}
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
<input value={tags} onChange={e => setTags(e.target.value)}
placeholder="标签(逗号分隔,如:AI,提示词)"
<input value={tags} onChange={e => setTags(e.target.value)} placeholder={t.community.tagsPlaceholder}
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring" />
<div className="flex justify-end">
<button type="submit" disabled={submitting}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{submitting ? "发布中..." : "发布"}
{submitting ? t.community.publishing : t.community.publish}
</button>
</div>
</form>
@@ -264,7 +204,7 @@ export default function CommunityPage() {
{posts.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<p>{activeTab === 'feed' ? '关注更多用户,发现精彩内容' : '还没有帖子,来发第一帖吧!'}</p>
<p>{activeTab === 'feed' ? t.community.feedEmpty : t.community.postsEmpty}</p>
</div>
) : (
<div>{posts.map(post => <PostCard key={post.id} post={post} />)}</div>
+12 -15
View File
@@ -5,13 +5,11 @@ import Link from 'next/link';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { BookOpen, Users } from 'lucide-react';
import { BookOpen } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useT } from '@/i18n';
interface Course {
id: number; title: string; description: string; cover: string | null;
isFree: boolean; chapters?: { lessons: any[] }[];
}
interface Course { id: number; title: string; description: string; cover: string | null; isFree: boolean; chapters?: { lessons: any[] }[] }
function CourseSkeleton() {
return (
@@ -25,6 +23,7 @@ function CourseSkeleton() {
}
export default function CoursesPage() {
const t = useT();
const [courses, setCourses] = useState<Course[]>([]);
const [loading, setLoading] = useState(true);
@@ -37,18 +36,16 @@ export default function CoursesPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> AI</p>
<h1 className="text-3xl font-bold text-foreground">{t.courses.title}</h1>
<p className="mt-2 text-muted-foreground">{t.courses.desc}</p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1,2,3,4,5,6].map(i => <CourseSkeleton key={i} />)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">{[1,2,3,4,5,6].map(i => <CourseSkeleton key={i} />)}</div>
) : courses.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<BookOpen className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p></p>
<p>{t.courses.empty}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -63,15 +60,15 @@ export default function CoursesPage() {
</div>
)}
<div className="p-5">
<Badge variant={course.isFree ? 'success' : 'destructive'} className="mb-3">
{course.isFree ? '免费' : '付费'}
<Badge variant={course.isFree ? 'secondary' : 'destructive'} className="mb-3">
{course.isFree ? t.courses.free : t.courses.paid}
</Badge>
<h3 className="font-semibold group-hover:text-brand-600 transition-colors mb-2">{course.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">{course.description}</p>
{course.chapters && (
<div className="flex items-center gap-2 mt-3 text-xs text-muted-foreground">
<Users className="w-3.5 h-3.5" />
<span>{course.chapters.reduce((s, ch) => s + (ch.lessons?.length || 0), 0)} </span>
<BookOpen className="w-3.5 h-3.5" />
<span>{t.courses.moduleCount.replace('{n}', String(course.chapters.reduce((s, ch) => s + (ch.lessons?.length || 0), 0)))}</span>
</div>
)}
</div>
+86 -212
View File
@@ -6,56 +6,18 @@ import { Progress } from '@/components/ui/progress';
import Link from 'next/link';
import { apiFetch, isLoggedIn, clearTokens } from '../../lib/auth';
import { useRouter } from 'next/navigation';
import { useT } from '@/i18n';
interface Stats {
inProgressCourses: number;
completedLessons: number;
favoritePrompts: number;
studyDays: number;
todayLearned: number;
}
interface UserInfo {
nickname: string;
avatar: string | null;
memberPlan: string;
memberExpire: string | null;
sandboxDaily: number;
joinedAt: string;
}
interface CourseProgress {
course: { id: number; title: string; cover: string | null };
progress: number;
completedCount: number;
totalCount: number;
recentLessons: { id: number; title: string; completed: boolean; progress: number; updatedAt: string }[];
}
interface RecentRecord {
lessonId: number;
lessonTitle: string;
courseId: number;
courseTitle: string;
completed: boolean;
progress: number;
updatedAt: string;
}
interface PromptFavorite {
id: number;
promptId: number;
title: string;
description: string | null;
model: string | null;
viewCount: number;
likeCount: number;
favoritedAt: string;
}
interface Stats { inProgressCourses: number; completedLessons: number; favoritePrompts: number; studyDays: number; todayLearned: number }
interface UserInfo { nickname: string; avatar: string | null; memberPlan: string; memberExpire: string | null; sandboxDaily: number; joinedAt: string }
interface CourseProgress { course: { id: number; title: string; cover: string | null }; progress: number; completedCount: number; totalCount: number; recentLessons: { id: number; title: string; completed: boolean; progress: number; updatedAt: string }[] }
interface RecentRecord { lessonId: number; lessonTitle: string; courseId: number; courseTitle: string; completed: boolean; progress: number; updatedAt: string }
interface PromptFavorite { id: number; promptId: number; title: string; description: string | null; model: string | null; viewCount: number; likeCount: number; favoritedAt: string }
type Tab = 'progress' | 'favorites' | 'profile';
export default function DashboardPage() {
const t = useT();
const router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('progress');
const [loading, setLoading] = useState(true);
@@ -70,149 +32,97 @@ export default function DashboardPage() {
const [saveMsg, setSaveMsg] = useState('');
useEffect(() => {
if (!isLoggedIn()) {
router.push('/auth');
return;
}
if (!isLoggedIn()) { router.push('/auth'); return; }
loadAll();
}, []);
async function loadAll() {
setLoading(true);
setError('');
setLoading(true); setError('');
try {
const [statsRes, progressRes, favRes, profileRes] = await Promise.all([
apiFetch('/dashboard/stats'),
apiFetch('/dashboard/progress'),
apiFetch('/dashboard/favorites'),
apiFetch('/dashboard/profile'),
apiFetch('/dashboard/stats'), apiFetch('/dashboard/progress'), apiFetch('/dashboard/favorites'), apiFetch('/dashboard/profile'),
]);
if (!statsRes.ok || !progressRes.ok || !favRes.ok || !profileRes.ok) {
throw new Error('加载数据失败');
}
if (!statsRes.ok || !progressRes.ok || !favRes.ok || !profileRes.ok) throw new Error(t.dashboard.loadFailed);
const statsData = await statsRes.json();
setStats(statsData.stats); setUserInfo(statsData.user);
const progressData = await progressRes.json();
const favData = await favRes.json();
const profileData = await profileRes.json();
setStats(statsData.stats);
setUserInfo(statsData.user);
setCourses(progressData.courses || []);
setRecentRecords(progressData.recentRecords || []);
setFavorites(favData || []);
setNickname(profileData.nickname || '');
setFavorites((await favRes.json()) || []);
setNickname((await profileRes.json()).nickname || '');
} catch (e: any) {
if (e.message?.includes('401') || e.message?.includes('Unauthorized')) {
clearTokens();
router.push('/auth');
}
setError(e.message || '加载失败');
} finally {
setLoading(false);
}
if (e.message?.includes('401') || e.message?.includes('Unauthorized')) { clearTokens(); router.push('/auth'); }
setError(e.message || t.dashboard.loadFailed);
} finally { setLoading(false); }
}
async function handleSaveProfile() {
setSaving(true);
setSaveMsg('');
setSaving(true); setSaveMsg('');
try {
const res = await apiFetch('/dashboard/profile', {
method: 'PUT',
body: JSON.stringify({ nickname }),
});
if (!res.ok) throw new Error('保存失败');
setSaveMsg('保存成功');
const res = await apiFetch('/dashboard/profile', { method: 'PUT', body: JSON.stringify({ nickname }) });
if (!res.ok) throw new Error(t.dashboard.saveFailed);
setSaveMsg(t.dashboard.saveSuccess);
setUserInfo(prev => prev ? { ...prev, nickname } : prev);
} catch {
setSaveMsg('保存失败');
} finally {
setSaving(false);
setTimeout(() => setSaveMsg(''), 2000);
}
} catch { setSaveMsg(t.dashboard.saveFailed); }
finally { setSaving(false); setTimeout(() => setSaveMsg(''), 2000); }
}
function handleLogout() {
clearTokens();
router.push('/');
}
function handleLogout() { clearTokens(); router.push('/'); }
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-2" />
<Skeleton className="h-5 w-72 mb-8" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{[1,2,3,4].map(i => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
);
}
if (loading) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-2" /><Skeleton className="h-5 w-72 mb-8" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">{[1,2,3,4].map(i => <Skeleton key={i} className="h-24 rounded-xl" />)}</div>
<Skeleton className="h-64 rounded-xl" />
</div>
);
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 py-20 text-center">
<p className="text-red-500 mb-4">{error}</p>
<button onClick={loadAll} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700">
</button>
</div>
);
}
if (error) return (
<div className="max-w-7xl mx-auto px-4 py-20 text-center">
<p className="text-red-500 mb-4">{error}</p>
<button onClick={loadAll} className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700">{t.common.retry}</button>
</div>
);
const tabs: { key: Tab; label: string }[] = [
{ key: 'progress', label: '学习进度' },
{ key: 'favorites', label: '收藏夹' },
{ key: 'profile', label: '个人设置' },
{ key: 'progress', label: t.dashboard.tabProgress },
{ key: 'favorites', label: t.dashboard.tabFavorites },
{ key: 'profile', label: t.dashboard.tabProfile },
];
const statItems = [
{ label: t.dashboard.inProgressCourses, value: stats?.inProgressCourses ?? 0 },
{ label: t.dashboard.completedLessons, value: stats?.completedLessons ?? 0 },
{ label: t.dashboard.favoritePrompts, value: stats?.favoritePrompts ?? 0 },
{ label: t.dashboard.studyDays, value: stats?.studyDays ?? 0 },
{ label: t.dashboard.todayLearned, value: stats?.todayLearned ?? 0 },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-start justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"></p>
<h1 className="text-3xl font-bold text-foreground">{t.dashboard.title}</h1>
<p className="mt-2 text-muted-foreground">{t.dashboard.desc}</p>
</div>
<button
onClick={handleLogout}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors mt-1"
>
退
</button>
<button onClick={handleLogout} className="text-sm text-muted-foreground hover:text-red-500 transition-colors mt-1">{t.common.logout}</button>
</div>
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
{[
{ label: '学习中课程', value: stats.inProgressCourses },
{ label: '已完成课时', value: stats.completedLessons },
{ label: '收藏提示词', value: stats.favoritePrompts },
{ label: '学习天数', value: stats.studyDays },
{ label: '今日学习', value: stats.todayLearned },
].map((item) => (
<div key={item.label} className="bg-card rounded-xl border border-border p-4 text-center">
<div className="text-2xl font-bold text-brand-600">{item.value}</div>
<div className="text-xs text-muted-foreground mt-1">{item.label}</div>
</div>
))}
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
{statItems.map((item) => (
<div key={item.label} className="bg-card rounded-xl border border-border p-4 text-center">
<div className="text-2xl font-bold text-brand-600">{item.value}</div>
<div className="text-xs text-muted-foreground mt-1">{item.label}</div>
</div>
))}
</div>
<div className="flex gap-6 flex-col lg:flex-row">
<div className="lg:w-48 flex-shrink-0">
<nav className="flex lg:flex-col gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-sm font-medium rounded-lg text-left transition-colors ${
activeTab === tab.key
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent'
}`}
>
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2.5 text-sm font-medium rounded-lg text-left transition-colors ${activeTab === tab.key ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-accent'}`}>
{tab.label}
</button>
))}
@@ -224,51 +134,32 @@ export default function DashboardPage() {
<div>
{courses.length === 0 ? (
<div className="bg-card rounded-xl border border-border p-12 text-center">
<p className="text-muted-foreground mb-4"></p>
<Link href="/courses" className="inline-flex px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
<p className="text-muted-foreground mb-4">{t.dashboard.noLearningRecords}</p>
<Link href="/courses" className="inline-flex px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">{t.dashboard.browseCourses}</Link>
</div>
) : (
<div className="space-y-6">
{courses.map((entry) => (
<div key={entry.course.id} className="bg-card rounded-xl border border-border p-6">
<Link href={`/courses/${entry.course.id}`} className="text-lg font-semibold text-foreground hover:text-brand-600">
{entry.course.title}
</Link>
<Link href={`/courses/${entry.course.id}`} className="text-lg font-semibold text-foreground hover:text-brand-600">{entry.course.title}</Link>
<div className="mt-3">
<div className="flex items-center justify-between text-sm text-muted-foreground mb-1.5">
<span></span>
<span>{entry.completedCount}/{entry.totalCount} ({entry.progress}%)</span>
<span>{t.dashboard.learningProgress}</span>
<span>{t.dashboard.lessonCount.replace('{completed}', String(entry.completedCount)).replace('{total}', String(entry.totalCount)).replace('{progress}', String(entry.progress))}</span>
</div>
<Progress value={entry.progress} className="h-2" />
</div>
{entry.recentLessons.length > 0 && (
<div className="mt-4 pt-4 border-t border-border">
<div className="text-xs text-muted-foreground mb-2"></div>
<div className="space-y-1.5">
{entry.recentLessons.map((lesson) => (
<div key={lesson.id} className="flex items-center gap-2 text-sm">
<span className={`w-1.5 h-1.5 rounded-full ${lesson.completed ? 'bg-green-500' : 'bg-brand-300'}`} />
<span className="text-muted-foreground">{lesson.title}</span>
</div>
))}
</div>
</div>
)}
</div>
))}
{recentRecords.length > 0 && (
<div className="bg-card rounded-xl border border-border p-6">
<h3 className="text-base font-semibold text-foreground mb-4"></h3>
<h3 className="text-base font-semibold text-foreground mb-4">{t.dashboard.recentLearning}</h3>
<div className="space-y-3">
{recentRecords.map((r, i) => (
<div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${r.completed ? 'bg-green-500' : 'bg-brand-300'}`} />
<span className="text-muted-foreground">{r.lessonTitle}</span>
<span className="text-muted-foreground">- {r.courseTitle}</span>
<span className="text-muted-foreground">{r.lessonTitle} - {r.courseTitle}</span>
</div>
<span className="text-xs text-muted-foreground">{new Date(r.updatedAt).toLocaleDateString()}</span>
</div>
@@ -285,25 +176,19 @@ export default function DashboardPage() {
<div>
{favorites.length === 0 ? (
<div className="bg-card rounded-xl border border-border p-12 text-center">
<p className="text-muted-foreground mb-4"></p>
<Link href="/prompts" className="inline-flex px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">
</Link>
<p className="text-muted-foreground mb-4">{t.dashboard.noFavorites}</p>
<Link href="/prompts" className="inline-flex px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700">{t.dashboard.browsePrompts}</Link>
</div>
) : (
<div className="grid gap-4">
{favorites.map((fav) => (
<Link
key={fav.id}
href={`/prompts`}
className="block bg-card rounded-xl border border-border p-5 hover:border-brand-200 transition-colors"
>
<Link key={fav.id} href="/prompts" className="block bg-card rounded-xl border border-border p-5 hover:border-brand-200 transition-colors">
<h3 className="font-semibold text-foreground">{fav.title}</h3>
{fav.description && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{fav.description}</p>}
<div className="flex items-center gap-4 mt-3 text-xs text-muted-foreground">
{fav.model && <span>: {fav.model}</span>}
<span>{fav.viewCount} </span>
<span>{fav.likeCount} </span>
{fav.model && <span>{t.dashboard.modelLabel.replace('{model}', fav.model)}</span>}
<span>{t.dashboard.viewCount.replace('{n}', String(fav.viewCount))}</span>
<span>{t.dashboard.likeCount.replace('{n}', String(fav.likeCount))}</span>
<span className="ml-auto">{new Date(fav.favoritedAt).toLocaleDateString()}</span>
</div>
</Link>
@@ -315,45 +200,34 @@ export default function DashboardPage() {
{activeTab === 'profile' && (
<div className="bg-card rounded-xl border border-border p-6">
<h3 className="text-base font-semibold text-foreground mb-6"></h3>
<h3 className="text-base font-semibold text-foreground mb-6">{t.dashboard.profile}</h3>
<div className="space-y-5 max-w-md">
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<input
type="text"
value={nickname}
onChange={e => setNickname(e.target.value)}
<label className="block text-sm font-medium text-foreground mb-1">{t.dashboard.nicknameLabel}</label>
<input type="text" value={nickname} onChange={e => setNickname(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent"
placeholder="输入昵称"
/>
placeholder={t.dashboard.nicknamePlaceholder} />
</div>
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<p className="text-sm text-muted-foreground">{userInfo?.memberPlan === 'FREE' ? '免费用户' : userInfo?.memberPlan}</p>
<label className="block text-sm font-medium text-foreground mb-1">{t.dashboard.memberPlan}</label>
<p className="text-sm text-muted-foreground">{userInfo?.memberPlan === 'FREE' ? t.dashboard.freeUser : userInfo?.memberPlan}</p>
</div>
{userInfo?.memberExpire && (
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<label className="block text-sm font-medium text-foreground mb-1">{t.dashboard.memberExpire}</label>
<p className="text-sm text-muted-foreground">{new Date(userInfo.memberExpire).toLocaleDateString()}</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<label className="block text-sm font-medium text-foreground mb-1">{t.dashboard.joinDate}</label>
<p className="text-sm text-muted-foreground">{userInfo?.joinedAt ? new Date(userInfo.joinedAt).toLocaleDateString() : '-'}</p>
</div>
<div>
<button
onClick={handleSaveProfile}
disabled={saving}
className="px-6 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50"
>
{saving ? '保存中...' : '保存'}
<button onClick={handleSaveProfile} disabled={saving}
className="px-6 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? t.common.loading : t.settings.saveChanges}
</button>
{saveMsg && (
<span className={`ml-3 text-sm ${saveMsg === '保存成功' ? 'text-green-600' : 'text-red-500'}`}>
{saveMsg}
</span>
)}
{saveMsg && <span className={`ml-3 text-sm ${saveMsg === t.dashboard.saveSuccess ? 'text-green-600' : 'text-red-500'}`}>{saveMsg}</span>}
</div>
</div>
</div>
+16 -50
View File
@@ -5,29 +5,17 @@ import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
interface Notification {
id: number;
type: 'like' | 'comment' | 'follow' | 'system';
title: string;
content?: string;
link?: string;
relatedId?: number;
isRead: boolean;
createdAt: string;
}
interface Notification { id: number; type: 'like' | 'comment' | 'follow' | 'system'; title: string; content?: string; link?: string; relatedId?: number; isRead: boolean; createdAt: string }
function NotificationIcon({ type }: { type: string }) {
const icons: Record<string, string> = {
like: '❤️',
comment: '💬',
follow: '👤',
system: '🔔',
};
const icons: Record<string, string> = { like: '❤️', comment: '💬', follow: '👤', system: '🔔' };
return <span className="text-lg">{icons[type] || '🔔'}</span>;
}
export default function NotificationsPage() {
const t = useT();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
@@ -35,11 +23,7 @@ export default function NotificationsPage() {
const load = useCallback(async () => {
try {
const res = await apiFetch('/notifications');
if (res.ok) {
const data = await res.json();
setNotifications(data.items || []);
setUnreadCount(data.unread || 0);
}
if (res.ok) { const data = await res.json(); setNotifications(data.items || []); setUnreadCount(data.unread || 0); }
} catch (e) { console.error(e) }
setLoading(false);
}, []);
@@ -64,11 +48,8 @@ export default function NotificationsPage() {
if (loading) return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-8 w-48 mb-6" /><Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-64 w-full mb-4" /><Skeleton className="h-4 w-full mb-2" /><Skeleton className="h-4 w-3/4" />
</div>
);
@@ -76,54 +57,39 @@ export default function NotificationsPage() {
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<h1 className="text-3xl font-bold text-foreground">{t.notifications.title}</h1>
<p className="mt-2 text-muted-foreground">
{unreadCount > 0 ? `你有 ${unreadCount} 条未读通知` : '暂无未读通知'}
{unreadCount > 0 ? t.notifications.unreadCount.replace('{n}', String(unreadCount)) : t.notifications.noUnread}
</p>
</div>
{unreadCount > 0 && (
<Button variant="outline" size="sm" onClick={markAllRead}>
</Button>
<Button variant="outline" size="sm" onClick={markAllRead}>{t.notifications.markAllRead}</Button>
)}
</div>
<div className="space-y-2">
{notifications.map(n => (
<div key={n.id}
className={`flex items-start gap-4 p-4 rounded-xl border transition-colors ${
n.isRead
? 'bg-card border-border'
: 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800'
}`}>
<div key={n.id} className={`flex items-start gap-4 p-4 rounded-xl border transition-colors ${n.isRead ? 'bg-card border-border' : 'bg-brand-50 dark:bg-brand-900/20 border-brand-200 dark:border-brand-800'}`}>
<div className="mt-1"><NotificationIcon type={n.type} /></div>
<div className="flex-1 min-w-0">
{n.link ? (
<Link href={n.link} onClick={() => { if (!n.isRead) markRead(n.id); }}
className="text-sm font-medium text-foreground hover:text-brand-600">
{n.title}
</Link>
<Link href={n.link} onClick={() => { if (!n.isRead) markRead(n.id); }} className="text-sm font-medium text-foreground hover:text-brand-600">{n.title}</Link>
) : (
<p className="text-sm font-medium text-foreground">{n.title}</p>
)}
{n.content && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{n.content}</p>}
<p className="text-xs text-muted-foreground mt-1">
{new Date(n.createdAt).toLocaleString('zh-CN')}
</p>
<p className="text-xs text-muted-foreground mt-1">{new Date(n.createdAt).toLocaleString('zh-CN')}</p>
</div>
{!n.isRead && (
<button onClick={() => markRead(n.id)}
className="text-xs text-muted-foreground hover:text-foreground shrink-0 px-2 py-1 rounded hover:bg-accent">
</button>
<button onClick={() => markRead(n.id)} className="text-xs text-muted-foreground hover:text-foreground shrink-0 px-2 py-1 rounded hover:bg-accent">{t.notifications.markRead}</button>
)}
</div>
))}
{notifications.length === 0 && (
<div className="text-center py-20 text-muted-foreground">
<p className="text-4xl mb-4">🔔</p>
<p></p>
<p className="text-sm mt-1"></p>
<p>{t.notifications.emptyTitle}</p>
<p className="text-sm mt-1">{t.notifications.emptyDesc}</p>
</div>
)}
</div>
+37 -109
View File
@@ -1,22 +1,24 @@
'use client';
import Link from 'next/link';
import { HomePageClient } from './home-client';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap } from 'lucide-react';
const stats = [
{ value: '50+', label: 'AI 专题' },
{ value: '200+', label: '精选提示词' },
{ value: '30+', label: 'AI 工具评测' },
{ value: '10,000+', label: '探索者' },
];
const features = [
{ icon: Compass, title: '分领域指南', desc: '按职业和场景分类内容,学即所用,精准提升 AI 应用能力' },
{ icon: Bot, title: 'AI 沙盒实战', desc: '内置 AI 对话沙盒,边学边练,在实践中掌握提示词技巧' },
{ icon: BookOpen, title: '提示词库', desc: '精选 200+ 提示词模板,覆盖办公、编程、创作等场景' },
{ icon: Zap, title: '持续更新', desc: '紧跟大模型迭代,内容实时更新,始终走在 AI 前沿' },
];
import { useT } from '@/i18n';
export default function HomePage() {
const t = useT();
const stats = [
{ value: '50+', label: t.home.statTopics },
{ value: '200+', label: t.home.statPrompts },
{ value: '30+', label: t.home.statTools },
{ value: '10,000+', label: t.home.statExplorers },
];
const features = [
{ icon: Compass, title: t.home.featureGuide, desc: t.home.featureGuideDesc },
{ icon: Bot, title: t.home.featureSandbox, desc: t.home.featureSandboxDesc },
{ icon: BookOpen, title: t.home.featurePrompts, desc: t.home.featurePromptsDesc },
{ icon: Zap, title: t.home.featureUpdate, desc: t.home.featureUpdateDesc },
];
return (
<HomePageClient>
{/* Hero */}
@@ -30,33 +32,25 @@ export default function HomePage() {
<div className="text-center max-w-3xl mx-auto animate-fade-in-up">
<span className="inline-flex items-center gap-1.5 px-4 py-1.5 text-sm font-medium text-brand-700 dark:text-brand-300 bg-brand-100 dark:bg-brand-900/50 rounded-full mb-8 border border-brand-200 dark:border-brand-800">
<Sparkles className="w-3.5 h-3.5" />
AI
{t.home.badge}
</span>
<h1 className="text-4xl md:text-6xl font-bold tracking-tight leading-tight">
<span className="bg-gradient-to-r from-brand-600 via-brand-500 to-blue-500 bg-clip-text text-transparent">
{t.home.heroHighlight}
</span>
<br /> AI
<br />{t.home.heroRest}
</h1>
<p className="mt-6 text-lg md:text-xl text-muted-foreground leading-relaxed max-w-2xl mx-auto">
AI AI
<br className="hidden sm:block" />
AI
{t.home.desc}
</p>
<div className="mt-10 flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/courses"
className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-all shadow-lg shadow-brand-200/50 dark:shadow-brand-900/30 hover:shadow-xl hover:-translate-y-0.5 active:scale-[0.98]"
>
<Link href="/courses" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-white bg-brand-600 rounded-xl hover:bg-brand-700 transition-all shadow-lg shadow-brand-200/50 dark:shadow-brand-900/30 hover:shadow-xl hover:-translate-y-0.5 active:scale-[0.98]">
<BookOpen className="w-5 h-5" />
{t.home.startExplore}
<ArrowRight className="w-4 h-4" />
</Link>
<Link
href="/auth?tab=register"
className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-foreground bg-card border border-border rounded-xl hover:bg-accent transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-sm"
>
<Link href="/auth?tab=register" className="inline-flex items-center justify-center gap-2 px-8 py-3 text-base font-medium text-foreground bg-card border border-border rounded-xl hover:bg-accent transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-sm">
{t.home.freeRegister}
</Link>
</div>
</div>
@@ -83,8 +77,8 @@ export default function HomePage() {
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold"></h2>
<p className="mt-4 text-lg text-muted-foreground"> AI</p>
<h2 className="text-3xl font-bold">{t.home.whyTitle}</h2>
<p className="mt-4 text-lg text-muted-foreground">{t.home.whyDesc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{features.map((feat) => (
@@ -105,18 +99,18 @@ export default function HomePage() {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-10">
<div>
<h2 className="text-3xl font-bold"></h2>
<p className="mt-2 text-muted-foreground"> AI</p>
<h2 className="text-3xl font-bold">{t.home.popularTopics}</h2>
<p className="mt-2 text-muted-foreground">{t.home.popularDesc}</p>
</div>
<Link href="/courses" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
{t.common.viewAll} <ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ title: 'AI 通识:零基础入门', lessons: '12 模块', students: '1,280', tag: '免费', gradient: 'from-brand-500 to-blue-500', desc: '面向零基础用户,带你了解 AI 的基本概念、发展历程和实际应用。' },
{ title: '提示词工程从入门到精通', lessons: '20 模块', students: '860', tag: '热门', gradient: 'from-violet-500 to-purple-500', desc: '系统学习提示词编写技巧,掌握与 AI 高效沟通的方法。' },
{ title: '用 AI 提升 10 倍办公效率', lessons: '15 模块', students: '2,150', tag: '推荐', gradient: 'from-amber-500 to-orange-500', desc: '学习使用 AI 工具处理文档、数据分析、演示制作等日常工作。' },
{ title: 'AI 通识:零基础入门', students: '1,280', tag: t.courses.free, gradient: 'from-brand-500 to-blue-500', desc: '面向零基础用户,带你了解 AI 的基本概念、发展历程和实际应用。' },
{ title: '提示词工程从入门到精通', students: '860', tag: '热门', gradient: 'from-violet-500 to-purple-500', desc: '系统学习提示词编写技巧,掌握与 AI 高效沟通的方法。' },
{ title: '用 AI 提升 10 倍办公效率', students: '2,150', tag: '推荐', gradient: 'from-amber-500 to-orange-500', desc: '学习使用 AI 工具处理文档、数据分析、演示制作等日常工作。' },
].map((course) => (
<div key={course.title} className="group bg-card rounded-xl border border-border overflow-hidden hover:shadow-xl transition-all hover:-translate-y-1">
<div className={`h-2 bg-gradient-to-r ${course.gradient}`} />
@@ -127,14 +121,7 @@ export default function HomePage() {
<h3 className="text-lg font-semibold mb-2 group-hover:text-brand-600 transition-colors">{course.title}</h3>
<p className="text-sm text-muted-foreground mb-4 line-clamp-2">{course.desc}</p>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="w-4 h-4" />
{course.lessons}
</span>
<span className="flex items-center gap-1">
<span className="text-lg leading-none">·</span>
{course.students}
</span>
<span className="flex items-center gap-1"><BookOpen className="w-4 h-4" />{t.home.moduleCount.replace('{n}', course.students)}</span>
</div>
</div>
</div>
@@ -143,73 +130,14 @@ export default function HomePage() {
</div>
</section>
{/* AI Sandbox Preview */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-10">
<div>
<h2 className="text-3xl font-bold">AI </h2>
<p className="mt-2 text-muted-foreground">线 AI </p>
</div>
<Link href="/sandbox" className="hidden sm:inline-flex items-center gap-1 text-brand-600 hover:text-brand-700 font-medium text-sm group">
<ArrowRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" />
</Link>
</div>
<div className="bg-card border border-border rounded-2xl overflow-hidden shadow-xl">
<div className="flex items-center gap-1.5 px-4 pt-3 pb-2 border-b border-border">
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-400" />
<span className="w-3 h-3 rounded-full bg-yellow-400" />
<span className="w-3 h-3 rounded-full bg-green-400" />
</div>
<span className="ml-2 text-xs text-muted-foreground">AI - 线</span>
</div>
<div className="p-4 space-y-4 bg-muted/30 dark:bg-muted/10">
<div className="flex items-start gap-3">
<span className="w-7 h-7 bg-brand-600 rounded-lg flex items-center justify-center text-white text-xs font-bold shrink-0">Y</span>
<div className="bg-card dark:bg-card rounded-xl rounded-tl-none px-3 py-2.5 text-sm shadow-sm max-w-[80%]">
AI AI
</div>
</div>
<div className="flex items-start gap-3 justify-end">
<div className="bg-brand-50 dark:bg-brand-900/30 rounded-xl rounded-tr-none px-3 py-2.5 text-sm max-w-[80%]">
Python Fibonacci
</div>
<span className="w-7 h-7 bg-muted-foreground/20 rounded-lg flex items-center justify-center text-xs font-bold shrink-0"></span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground pl-9">
<span className="w-2 h-2 bg-brand-500 rounded-full animate-pulse" />
...
</div>
</div>
<div className="border-t border-border p-3 bg-card">
<div className="flex gap-2">
<input
type="text"
placeholder="输入你的问题..."
readOnly
className="flex-1 bg-muted border-0 rounded-lg px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
<button className="px-4 py-2 bg-brand-600 text-white text-sm font-medium rounded-lg hover:bg-brand-700 transition-colors cursor-default">
</button>
</div>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="py-20 bg-gradient-to-r from-brand-600 to-brand-800 dark:from-brand-900 dark:to-brand-950 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMjAgMjB2MTBoLTEwVjIwaDEwek0yMCAwaDEwdjEwSDIwVjB6IiBmaWxsPSIjZmZmIiBmaWxsLW9wYWNpdHk9IjAuMDMiLz48L3N2Zz4=')] opacity-50" />
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative">
<h2 className="text-3xl font-bold text-white mb-4"> AI </h2>
<p className="text-brand-100/80 dark:text-brand-200/80 mb-8 text-lg"></p>
<Link
href="/auth?tab=register"
className="inline-flex items-center gap-2 px-8 py-3 text-base font-medium text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-xl"
>
<h2 className="text-3xl font-bold text-white mb-4">{t.home.ctaTitle}</h2>
<p className="text-brand-100/80 dark:text-brand-200/80 mb-8 text-lg">{t.home.ctaDesc}</p>
<Link href="/auth?tab=register" className="inline-flex items-center gap-2 px-8 py-3 text-base font-medium text-brand-600 bg-white rounded-xl hover:bg-brand-50 transition-all hover:-translate-y-0.5 active:scale-[0.98] shadow-xl">
{t.home.freeRegister}
<ArrowRight className="w-4 h-4" />
</Link>
</div>