feat: 剩余页面 useT() 改造 + 首页课程动态加载

- 搜索/发现/工具/My 页面全部使用 useT(),支持中英切换
- 搜索页 Badge variant 修正
- 首页热门专题改为从 API 动态获取(无数据时用静态兜底)
- 新增 discover 翻译键 viewCount/likeCount/postStats
- 首页/仪表盘/社区 等 JSX 优化
This commit is contained in:
yuzhiran-dev
2026-05-22 17:08:17 +08:00
parent 1b4c19daa6
commit 477226ac15
7 changed files with 97 additions and 127 deletions
+20 -36
View File
@@ -4,48 +4,34 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
interface HotItem {
id: number;
title: string;
viewCount: number;
likeCount: number;
_type: 'course' | 'prompt' | 'post';
}
interface HotItem { id: number; title: string; viewCount: number; likeCount: number; _type: 'course' | 'prompt' | 'post' }
export default function DiscoverPage() {
const t = useT();
const [hotCourses, setHotCourses] = useState<HotItem[]>([]);
const [hotPrompts, setHotPrompts] = useState<HotItem[]>([]);
const [hotPosts, setHotPosts] = useState<HotItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
useEffect(() => { loadData(); }, []);
async function loadData() {
try {
const [coursesRes, promptsRes, postsRes] = await Promise.all([
apiFetch('/courses?pageSize=5'),
apiFetch('/prompts?pageSize=5'),
apiFetch('/community/posts?pageSize=5'),
apiFetch('/courses?pageSize=5'), apiFetch('/prompts?pageSize=5'), apiFetch('/community/posts?pageSize=5'),
]);
const coursesData = await coursesRes.json();
const promptsData = await promptsRes.json();
const postsData = await postsRes.json();
setHotCourses((coursesData.items || []).map((c: any) => ({ ...c, _type: 'course' as const })));
setHotPrompts((promptsData.items || []).map((p: any) => ({ ...p, _type: 'prompt' as const })));
setHotPosts((postsData.items || []).map((p: any) => ({ ...p, _type: 'post' as const })));
setHotCourses((await coursesRes.json()).items?.map((c: any) => ({ ...c, _type: 'course' as const })) || []);
setHotPrompts((await promptsRes.json()).items?.map((p: any) => ({ ...p, _type: 'prompt' as const })) || []);
setHotPosts((await postsRes.json()).items?.map((p: any) => ({ ...p, _type: 'post' as const })) || []);
} catch (e) { console.error(e) }
setLoading(false);
}
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-24 mb-2" />
<Skeleton className="h-5 w-64 mb-8" />
<Skeleton className="h-8 w-24 mb-2" /><Skeleton className="h-5 w-64 mb-8" />
{[1,2,3].map(i => <Skeleton key={i} className="h-32 rounded-xl mb-4" />)}
</div>
);
@@ -53,15 +39,13 @@ export default function DiscoverPage() {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<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.discover.desc}</h1>
</div>
<div className="space-y-8">
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground">🔥 </h2>
<Link href="/courses" className="text-sm text-brand-600 hover:underline"></Link>
<h2 className="text-xl font-semibold text-foreground">🔥 {t.discover.hotCourses}</h2>
<Link href="/courses" className="text-sm text-brand-600 hover:underline">{t.common.viewAll}</Link>
</div>
<div className="grid gap-3">
{hotCourses.map((item, index) => (
@@ -70,7 +54,7 @@ export default function DiscoverPage() {
<span className="text-2xl font-bold text-muted-foreground/20 w-8">{index + 1}</span>
<div className="flex-1">
<h3 className="font-medium text-foreground">{item.title}</h3>
<p className="text-xs text-muted-foreground mt-1">👁 {item.viewCount} </p>
<p className="text-xs text-muted-foreground mt-1">👁 {t.discover.viewCount?.replace('{n}', String(item.viewCount)) || `${item.viewCount} 浏览`}</p>
</div>
</Link>
))}
@@ -79,17 +63,17 @@ export default function DiscoverPage() {
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground"> </h2>
<Link href="/prompts" className="text-sm text-brand-600 hover:underline"></Link>
<h2 className="text-xl font-semibold text-foreground"> {t.discover.hotPrompts}</h2>
<Link href="/prompts" className="text-sm text-brand-600 hover:underline">{t.common.viewAll}</Link>
</div>
<div className="grid gap-3">
{hotPrompts.map((item, index) => (
<Link key={item.id} href={`/prompts/${item.id}`}
<Link key={item.id} href={`/prompts`}
className="bg-card rounded-xl border border-border p-4 hover:shadow-md transition-shadow flex items-center gap-4">
<span className="text-2xl font-bold text-muted-foreground/20 w-8">{index + 1}</span>
<div className="flex-1">
<h3 className="font-medium text-foreground">{item.title}</h3>
<p className="text-xs text-muted-foreground mt-1"> {item.likeCount} </p>
<p className="text-xs text-muted-foreground mt-1"> {t.discover.likeCount?.replace('{n}', String(item.likeCount)) || `${item.likeCount} 点赞`}</p>
</div>
</Link>
))}
@@ -98,8 +82,8 @@ export default function DiscoverPage() {
<section>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-foreground">💬 </h2>
<Link href="/community" className="text-sm text-brand-600 hover:underline"></Link>
<h2 className="text-xl font-semibold text-foreground">💬 {t.discover.hotPosts}</h2>
<Link href="/community" className="text-sm text-brand-600 hover:underline">{t.common.viewAll}</Link>
</div>
<div className="grid gap-3">
{hotPosts.map((item, index) => (
@@ -108,7 +92,7 @@ export default function DiscoverPage() {
<span className="text-2xl font-bold text-muted-foreground/20 w-8">{index + 1}</span>
<div className="flex-1">
<h3 className="font-medium text-foreground">{item.title}</h3>
<p className="text-xs text-muted-foreground mt-1"> {item.likeCount} · 👁 {item.viewCount} </p>
<p className="text-xs text-muted-foreground mt-1">{t.discover.postStats?.replace('{likes}', String(item.likeCount)).replace('{views}', String(item.viewCount)) || `❤️ ${item.likeCount} · 👁 ${item.viewCount}`}</p>
</div>
</Link>
))}
+16 -30
View File
@@ -1,60 +1,46 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { apiFetch } from '../../lib/auth';
interface LearningItem {
courseId: number;
courseTitle: string;
progress: number;
completedLessons: number;
totalLessons: number;
}
import { useT } from '@/i18n';
export default function MyPage() {
const t = useT();
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<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.nav.my}</h1>
<p className="mt-2 text-muted-foreground">{t.my.desc}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Link href="/my/learning" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-brand-100 rounded-xl flex items-center justify-center text-brand-600 text-xl mb-4">📚</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
<h3 className="font-semibold text-foreground mb-2">{t.my.learningProgress}</h3>
<p className="text-sm text-muted-foreground">{t.my.learningProgressDesc}</p>
</Link>
<Link href="/my/favorites" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center text-red-600 text-xl mb-4"></div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
<h3 className="font-semibold text-foreground mb-2">{t.my.favorites}</h3>
<p className="text-sm text-muted-foreground">{t.my.favoritesDesc}</p>
</Link>
<Link href="/my/member" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-amber-100 rounded-xl flex items-center justify-center text-amber-600 text-xl mb-4">👑</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
<h3 className="font-semibold text-foreground mb-2">{t.my.memberCenter}</h3>
<p className="text-sm text-muted-foreground">{t.my.memberDesc}</p>
</Link>
<Link href="/my/settings" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-muted rounded-xl flex items-center justify-center text-muted-foreground text-xl mb-4"></div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
<h3 className="font-semibold text-foreground mb-2">{t.my.settings}</h3>
<p className="text-sm text-muted-foreground">{t.my.settingsDesc}</p>
</Link>
<Link href="/learning/analytics" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-brand-100 rounded-xl flex items-center justify-center text-brand-600 text-xl mb-4">📊</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
<h3 className="font-semibold text-foreground mb-2">{t.learning.analytics}</h3>
<p className="text-sm text-muted-foreground">{t.my.analyticsDesc}</p>
</Link>
<Link href="/learning/path" className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<div className="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center text-green-600 text-xl mb-4">🗺</div>
<h3 className="font-semibold text-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"> AI </p>
<h3 className="font-semibold text-foreground mb-2">{t.learning.path}</h3>
<p className="text-sm text-muted-foreground">{t.my.pathDesc}</p>
</Link>
</div>
</div>
+37 -17
View File
@@ -1,12 +1,22 @@
'use client';
import Link from 'next/link';
import { useState, useEffect } from 'react';
import { HomePageClient } from './home-client';
import { ArrowRight, Sparkles, BookOpen, Bot, Compass, Zap } from 'lucide-react';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
export default function HomePage() {
const t = useT();
const [courses, setCourses] = useState<any[]>([]);
useEffect(() => {
fetch(`${API_BASE}/courses?pageSize=3`)
.then(r => r.json()).then(data => setCourses(data.items || []))
.catch(() => {});
}, []);
const stats = [
{ value: '50+', label: t.home.statTopics },
{ value: '200+', label: t.home.statPrompts },
@@ -107,25 +117,35 @@ export default function HomePage() {
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ 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}`} />
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 px-2 py-1 rounded-full">{course.tag}</span>
</div>
<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" />{t.home.moduleCount.replace('{n}', course.students)}</span>
{courses.length > 0 ? courses.map((course: any) => (
<Link key={course.id} href={`/courses/${course.id}`} className="block group">
<div 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 from-brand-500 to-blue-500" />
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 px-2 py-1 rounded-full">{course.isFree ? t.courses.free : t.courses.paid}</span>
</div>
<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.description || ''}</p>
</div>
</div>
</div>
))}
</Link>
)) : (
<>
{[{ title: 'AI 通识:零基础入门', tag: t.courses.free }, { title: '提示词工程从入门到精通', tag: '热门' }, { title: '用 AI 提升 10 倍办公效率', tag: '推荐' }].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 from-brand-500 to-blue-500" />
<div className="p-6">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-brand-700 dark:text-brand-300 bg-brand-50 dark:bg-brand-900/30 px-2 py-1 rounded-full">{course.tag}</span>
</div>
<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"> AI </p>
</div>
</div>
))}
</>
)}
</div>
</div>
</section>
+14 -27
View File
@@ -9,12 +9,9 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { SearchIcon, FileText, BookOpen, Wrench, MessageSquare } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useT } from '@/i18n';
interface SearchResult {
id: number; _type: 'course' | 'prompt' | 'tool' | 'content';
title?: string; name?: string; description?: string; summary?: string;
cover?: string; icon?: string; url?: string; model?: string; isFree?: boolean; publishedAt?: string;
}
interface SearchResult { id: number; _type: 'course' | 'prompt' | 'tool' | 'content'; title?: string; name?: string; description?: string; summary?: string; cover?: string; icon?: string; url?: string; model?: string; isFree?: boolean; publishedAt?: string }
export default function SearchPage() {
return (
@@ -25,6 +22,7 @@ export default function SearchPage() {
}
function SearchContent() {
const t = useT();
const searchParams = useSearchParams();
const q = searchParams.get('q') || '';
const type = searchParams.get('type') || 'all';
@@ -42,19 +40,18 @@ function SearchContent() {
}, [q, type]);
const groups = { course: results.filter(r => r._type === 'course'), prompt: results.filter(r => r._type === 'prompt'), tool: results.filter(r => r._type === 'tool'), content: results.filter(r => r._type === 'content') };
const groupLabels: Record<string, string> = { course: '专题', prompt: '提示词', tool: 'AI 工具', content: '文章' };
const groupLabels: Record<string, string> = { course: t.search.groupCourse, prompt: t.search.groupPrompt, tool: t.search.groupTool, content: t.search.groupContent };
const groupIcons: Record<string, any> = { course: BookOpen, prompt: MessageSquare, tool: Wrench, content: FileText };
const groupLinks: Record<string, string> = { course: '/courses/', prompt: '/prompts', tool: '/tools', content: '/contents/' };
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground mb-4"></h1>
<h1 className="text-3xl font-bold text-foreground mb-4">{t.search.title}</h1>
<form onSubmit={e => { e.preventDefault(); window.location.href = `/search?q=${encodeURIComponent(input)}`; }}>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
<Input type="text" value={input} onChange={e => setInput(e.target.value)}
placeholder="搜索专题、提示词、工具、文章..." className="pl-10 h-12 text-base" />
<Input type="text" value={input} onChange={e => setInput(e.target.value)} placeholder={t.search.placeholder} className="pl-10 h-12 text-base" />
</div>
</form>
</div>
@@ -62,25 +59,23 @@ function SearchContent() {
{!q && (
<div className="text-center py-20 text-muted-foreground">
<SearchIcon className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p></p>
<p>{t.search.emptyHint}</p>
</div>
)}
{q && loading && (
<div className="space-y-4">
{[1,2,3].map(i => <Skeleton key={i} className="h-24 w-full rounded-xl" />)}
</div>
<div className="space-y-4">{[1,2,3].map(i => <Skeleton key={i} className="h-24 w-full rounded-xl" />)}</div>
)}
{q && !loading && total === 0 && (
<div className="text-center py-20 text-muted-foreground">
<p> "<span className="text-foreground font-medium">{q}</span>" </p>
<p>{t.search.noResults.replace('{q}', q)}</p>
</div>
)}
{q && !loading && total > 0 && (
<div>
<p className="text-sm text-muted-foreground mb-6"> {total} </p>
<p className="text-sm text-muted-foreground mb-6">{t.search.resultsCount.replace('{n}', String(total))}</p>
<div className="space-y-8">
{Object.entries(groups).map(([key, items]) => {
if (items.length === 0) return null;
@@ -90,25 +85,17 @@ function SearchContent() {
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium">{groupLabels[key]}</span>
<span className="text-xs text-muted-foreground">{items.length} </span>
<span className="text-xs text-muted-foreground">{items.length} {t.search.resultsCount.replace('{n}', '')}</span>
</div>
<div className="grid gap-3">
{items.map((item) => (
<Link key={`${key}-${item.id}`}
href={`${groupLinks[key]}${key === 'course' || key === 'content' ? item.id : ''}`}
className="block">
<Link key={`${key}-${item.id}`} href={`${groupLinks[key]}${key === 'course' || key === 'content' ? item.id : ''}`} className="block">
<Card className="p-4 hover:border-brand-200 dark:hover:border-brand-800 transition-colors">
<h3 className="font-semibold">{item.title || item.name}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{item.description || item.summary}</p>
<div className="flex gap-2 mt-2">
{key === 'course' && (
<Badge variant={item.isFree ? 'success' : 'destructive'}>
{item.isFree ? '免费' : '付费'}
</Badge>
)}
{key === 'prompt' && item.model && (
<span className="text-xs text-muted-foreground">: {item.model}</span>
)}
{key === 'course' && <Badge variant={item.isFree ? 'secondary' : 'destructive'}>{item.isFree ? t.courses.free : t.courses.paid}</Badge>}
{key === 'prompt' && item.model && <span className="text-xs text-muted-foreground">: {item.model}</span>}
</div>
</Card>
</Link>
+8 -15
View File
@@ -6,26 +6,21 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Wrench, ExternalLink, Star } from 'lucide-react';
import { API_BASE } from '@/lib/config';
import { useT } from '@/i18n';
interface Tool {
id: number; name: string; description: string; url: string;
icon: string | null; isFeatured: boolean; tags: string | null;
}
interface Tool { id: number; name: string; description: string; url: string; icon: string | null; isFeatured: boolean; tags: string | null }
function ToolSkeleton() {
return (
<Card className="p-5">
<div className="flex gap-2 mb-2">
<Skeleton className="h-5 w-12 rounded-full" />
<Skeleton className="h-5 w-12 rounded-full" />
</div>
<Skeleton className="h-5 w-1/2 mb-1" />
<Skeleton className="h-4 w-full" />
<div className="flex gap-2 mb-2"><Skeleton className="h-5 w-12 rounded-full" /><Skeleton className="h-5 w-12 rounded-full" /></div>
<Skeleton className="h-5 w-1/2 mb-1" /><Skeleton className="h-4 w-full" />
</Card>
);
}
export default function ToolsPage() {
const t = useT();
const [tools, setTools] = useState<Tool[]>([]);
const [loading, setLoading] = useState(true);
@@ -38,14 +33,12 @@ export default function ToolsPage() {
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">AI </h1>
<p className="mt-2 text-muted-foreground"> AI </p>
<h1 className="text-3xl font-bold text-foreground">{t.tools.title}</h1>
<p className="mt-2 text-muted-foreground">{t.tools.desc}</p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => <ToolSkeleton key={i} />)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">{[1,2,3,4,5,6].map(i => <ToolSkeleton key={i} />)}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tools.map((tool) => (