feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径

This commit is contained in:
yuzhiran-dev
2026-05-18 09:48:51 +08:00
commit 11bb86854c
277 changed files with 37755 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
'use client';
import { useEffect, useState } from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
import Link from 'next/link';
import { apiFetch, isLoggedIn, clearTokens } from '../../lib/auth';
import { useRouter } from 'next/navigation';
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 router = useRouter();
const [activeTab, setActiveTab] = useState<Tab>('progress');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [stats, setStats] = useState<Stats | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [courses, setCourses] = useState<CourseProgress[]>([]);
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([]);
const [favorites, setFavorites] = useState<PromptFavorite[]>([]);
const [nickname, setNickname] = useState('');
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState('');
useEffect(() => {
if (!isLoggedIn()) {
router.push('/auth');
return;
}
loadAll();
}, []);
async function loadAll() {
setLoading(true);
setError('');
try {
const [statsRes, progressRes, favRes, profileRes] = await Promise.all([
apiFetch('/dashboard/stats'),
apiFetch('/dashboard/progress'),
apiFetch('/dashboard/favorites'),
apiFetch('/dashboard/profile'),
]);
if (!statsRes.ok || !progressRes.ok || !favRes.ok || !profileRes.ok) {
throw new Error('加载数据失败');
}
const statsData = await statsRes.json();
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 || '');
} catch (e: any) {
if (e.message?.includes('401') || e.message?.includes('Unauthorized')) {
clearTokens();
router.push('/auth');
}
setError(e.message || '加载失败');
} finally {
setLoading(false);
}
}
async function handleSaveProfile() {
setSaving(true);
setSaveMsg('');
try {
const res = await apiFetch('/dashboard/profile', {
method: 'PUT',
body: JSON.stringify({ nickname }),
});
if (!res.ok) throw new Error('保存失败');
setSaveMsg('保存成功');
setUserInfo(prev => prev ? { ...prev, nickname } : prev);
} catch {
setSaveMsg('保存失败');
} finally {
setSaving(false);
setTimeout(() => setSaveMsg(''), 2000);
}
}
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 (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>
);
}
const tabs: { key: Tab; label: string }[] = [
{ key: 'progress', label: '学习进度' },
{ key: 'favorites', label: '收藏夹' },
{ key: 'profile', label: '个人设置' },
];
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>
</div>
<button
onClick={handleLogout}
className="text-sm text-muted-foreground hover:text-red-500 transition-colors mt-1"
>
退
</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="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'
}`}
>
{tab.label}
</button>
))}
</nav>
</div>
<div className="flex-1 min-w-0">
{activeTab === 'progress' && (
<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>
</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>
<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>
</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>
<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>
</div>
<span className="text-xs text-muted-foreground">{new Date(r.updatedAt).toLocaleDateString()}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
)}
{activeTab === 'favorites' && (
<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>
</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"
>
<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>
<span className="ml-auto">{new Date(fav.favoritedAt).toLocaleDateString()}</span>
</div>
</Link>
))}
</div>
)}
</div>
)}
{activeTab === 'profile' && (
<div className="bg-card rounded-xl border border-border p-6">
<h3 className="text-base font-semibold text-foreground mb-6"></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)}
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="输入昵称"
/>
</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>
</div>
{userInfo?.memberExpire && (
<div>
<label className="block text-sm font-medium text-foreground mb-1"></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>
<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>
{saveMsg && (
<span className={`ml-3 text-sm ${saveMsg === '保存成功' ? 'text-green-600' : 'text-red-500'}`}>
{saveMsg}
</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}