feat: Phase 1-3 全部完成 — 沙盒增强、学情分析、学习路径
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user