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
+20
View File
@@ -0,0 +1,20 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/users`, {
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
const users = data.items || [];
if (users.length === 0) return [{ id: '1' }];
return users.map((u: any) => ({ id: String(u.id) }));
} catch {
return [{ id: '1' }];
}
}
import UserProfilePage from './user-profile';
export default function Page() {
return <UserProfilePage />;
}
@@ -0,0 +1,215 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
interface UserProfile {
id: number; nickname: string; avatar?: string; bio?: string;
followerCount: number; followingCount: number; postCount: number;
createdAt: string;
}
interface Post {
id: number; title: string; content: string; tags?: string;
viewCount: number; likeCount: number; commentCount: number;
createdAt: string;
}
export default function UserProfilePage() {
const params = useParams();
const userId = Number(params.id);
const [profile, setProfile] = useState<UserProfile | null>(null);
const [posts, setPosts] = useState<Post[]>([]);
const [isFollowing, setIsFollowing] = useState(false);
const [activeTab, setActiveTab] = useState<'posts' | 'followers' | 'following'>('posts');
const [followers, setFollowers] = useState<any[]>([]);
const [following, setFollowing] = useState<any[]>([]);
useEffect(() => { loadData(); }, [userId]);
function getToken() { return localStorage.getItem('token'); }
function apiHeaders() {
const h: Record<string, string> = { 'Content-Type': 'application/json' };
const t = getToken();
if (t) h['Authorization'] = `Bearer ${t}`;
return h;
}
async function loadData() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const [profileRes, postsRes] = await Promise.all([
fetch(`${base}/api/v1/community/users/${userId}/profile`),
fetch(`${base}/api/v1/community/posts?userId=${userId}`),
]);
if (profileRes.ok) setProfile(await profileRes.json());
if (postsRes.ok) {
const d = await postsRes.json();
setPosts(d.items || []);
}
const token = getToken();
if (token) {
const followRes = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
headers: { Authorization: `Bearer ${token}` },
});
if (followRes.ok) {
const d = await followRes.json();
setIsFollowing(d.followed);
}
}
} catch (e) { console.error(e) }
}
async function toggleFollow() {
const token = getToken();
if (!token) return;
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/users/${userId}/follow`, {
method: isFollowing ? 'DELETE' : 'POST',
headers: apiHeaders(),
});
if (res.ok) {
setIsFollowing(!isFollowing);
setProfile(prev => prev ? {
...prev,
followerCount: prev.followerCount + (isFollowing ? -1 : 1),
} : prev);
}
} catch (e) { console.error(e) }
}
async function loadFollowers() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/users/${userId}/followers`);
if (res.ok) {
const d = await res.json();
setFollowers(d.items || []);
}
} catch (e) { console.error(e) }
}
async function loadFollowing() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/users/${userId}/following`);
if (res.ok) {
const d = await res.json();
setFollowing(d.items || []);
}
} catch (e) { console.error(e) }
}
useEffect(() => {
if (activeTab === 'followers') loadFollowers();
if (activeTab === 'following') loadFollowing();
}, [activeTab]);
if (!profile) 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="bg-card rounded-xl border border-border p-8 mb-8">
<div className="flex items-start justify-between">
<div className="flex items-center gap-6">
<div className="w-20 h-20 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-2xl font-bold">
{profile.nickname?.[0] || 'U'}
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">{profile.nickname || '用户'}</h1>
{profile.bio && <p className="text-muted-foreground mt-1">{profile.bio}</p>}
<div className="flex gap-6 mt-3 text-sm text-muted-foreground">
<span><strong className="text-foreground">{profile.postCount}</strong> </span>
<span><strong className="text-foreground">{profile.followerCount}</strong> </span>
<span><strong className="text-foreground">{profile.followingCount}</strong> </span>
</div>
</div>
</div>
<button onClick={toggleFollow}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
isFollowing ? 'bg-muted text-muted-foreground hover:bg-accent' : 'bg-brand-600 text-white hover:bg-brand-700'
}`}>
{isFollowing ? '已关注' : '关注'}
</button>
</div>
</div>
<div className="flex gap-1 mb-6 bg-muted rounded-lg p-1">
{(['posts', 'followers', 'following'] as const).map(tab => (
<button key={tab} onClick={() => setActiveTab(tab)}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-muted-foreground'
}`}>
{tab === 'posts' ? '帖子' : tab === 'followers' ? '粉丝' : '关注'}
</button>
))}
</div>
{activeTab === 'posts' && (
<div className="space-y-4">
{posts.map(post => (
<Link key={post.id} href={`/community/${post.id}`}
className="bg-card rounded-xl border border-border p-6 hover:shadow-md transition-shadow block">
<h3 className="font-semibold text-foreground mb-2">{post.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">{post.content}</p>
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<span>👁 {post.viewCount}</span>
<span> {post.likeCount}</span>
<span>💬 {post.commentCount}</span>
</div>
</Link>
))}
{posts.length === 0 && <p className="text-center text-muted-foreground py-12"></p>}
</div>
)}
{activeTab === 'followers' && (
<div className="bg-card rounded-xl border border-border divide-y">
{followers.map((u: any) => (
<Link key={u.id} href={`/users/${u.id}`}
className="flex items-center gap-3 px-6 py-4 hover:bg-muted/50">
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-sm font-bold">
{u.nickname?.[0] || 'U'}
</div>
<div>
<div className="font-medium text-foreground">{u.nickname || '用户'}</div>
<div className="text-xs text-muted-foreground">{u.followerCount} </div>
</div>
</Link>
))}
{followers.length === 0 && <p className="text-center text-muted-foreground py-8"></p>}
</div>
)}
{activeTab === 'following' && (
<div className="bg-card rounded-xl border border-border divide-y">
{following.map((u: any) => (
<Link key={u.id} href={`/users/${u.id}`}
className="flex items-center gap-3 px-6 py-4 hover:bg-muted/50">
<div className="w-10 h-10 rounded-full bg-brand-100 flex items-center justify-center text-brand-600 text-sm font-bold">
{u.nickname?.[0] || 'U'}
</div>
<div>
<div className="font-medium text-foreground">{u.nickname || '用户'}</div>
<div className="text-xs text-muted-foreground">{u.followerCount} </div>
</div>
</Link>
))}
{following.length === 0 && <p className="text-center text-muted-foreground py-8"></p>}
</div>
)}
</div>
);
}