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
+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>