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
+158
View File
@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link';
import { apiFetch } from '@/lib/auth';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
interface PostDetail {
id: number; title: string; content: string; tags?: string;
viewCount: number; likeCount: number; commentCount: number;
createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
liked: boolean;
comments: Comment[];
}
interface Comment {
id: number; content: string; createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
}
export default function CommunityPostDetail() {
const params = useParams();
const router = useRouter();
const [post, setPost] = useState<PostDetail | null>(null);
const [loading, setLoading] = useState(true);
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => { loadPost(); }, [params.id]);
async function loadPost() {
try {
const res = await apiFetch(`/community/posts/${params.id}`);
if (res.ok) setPost(await res.json());
} catch (e) { console.error(e) }
setLoading(false);
}
async function handleComment(e: React.FormEvent) {
e.preventDefault();
if (!commentText.trim()) return;
setSubmitting(true);
try {
const res = await apiFetch(`/community/posts/${params.id}/comments`, {
method: 'POST',
body: JSON.stringify({ content: commentText }),
});
if (res.ok) { setCommentText(''); loadPost(); }
} catch (e) { console.error(e) }
setSubmitting(false);
}
async function handleLike() {
try {
await apiFetch(`/community/posts/${params.id}/like`, { method: 'POST' });
loadPost();
} catch (e) { console.error(e) }
}
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-6" />
<Skeleton className="h-4 w-32 mb-8" />
<Skeleton className="h-32 w-full mb-4" />
</div>
);
if (!post) return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center">
<p className="text-muted-foreground mb-4"></p>
<Link href="/community"><Button variant="outline"></Button></Link>
</div>
);
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href="/community" className="text-sm text-muted-foreground hover:text-foreground mb-6 inline-block">&larr; </Link>
<Card className="p-6 sm:p-8 mb-8">
<div className="flex items-center gap-3 mb-4">
<Link href={`/users/${post.user.id}`} className="flex items-center gap-3 group">
<Avatar className="w-9 h-9">
<AvatarFallback className="bg-brand-100 dark:bg-brand-900/40 text-brand-600 dark:text-brand-400 text-sm font-bold">{post.user.nickname?.[0] || 'U'}</AvatarFallback>
</Avatar>
<div>
<div className="text-sm font-medium text-foreground group-hover:text-brand-600">{post.user.nickname}</div>
<div className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleDateString()}</div>
</div>
</Link>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">{post.title}</h1>
<div className="prose prose-sm dark:prose-invert max-w-none mb-6 whitespace-pre-wrap text-foreground leading-relaxed">{post.content}</div>
{post.tags && (
<div className="flex gap-2 mb-6">
{post.tags.split(',').map(tag => (
<span key={tag} className="text-xs px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded">{tag.trim()}</span>
))}
</div>
)}
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t border-border pt-4">
<button onClick={handleLike} className="flex items-center gap-1.5 hover:text-red-500 transition-colors">
{post.liked ? '❤️' : '🤍'} {post.likeCount}
</button>
<span className="flex items-center gap-1.5">💬 {post.commentCount}</span>
<span className="flex items-center gap-1.5">👁 {post.viewCount}</span>
</div>
</Card>
<h2 className="text-lg font-semibold text-foreground mb-4"> ({post.comments?.length || 0})</h2>
<form onSubmit={handleComment} className="mb-6">
<div className="flex gap-2">
<input value={commentText} onChange={e => setCommentText(e.target.value)}
placeholder="写下你的评论..." disabled={submitting}
className="flex-1 px-4 py-2.5 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<Button type="submit" disabled={submitting || !commentText.trim()}>
{submitting ? '发送中...' : '评论'}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-2">
<Link href="/ai-agreement" className="text-brand-600 hover:underline ml-1">AI </Link>
</p>
</form>
<div className="space-y-4">
{post.comments?.map(c => (
<Card key={c.id} className="p-4">
<div className="flex gap-3">
<Avatar className="w-7 h-7">
<AvatarFallback className="text-xs">{c.user.nickname?.[0] || 'U'}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-foreground">{c.user.nickname}</span>
<span className="text-xs text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</span>
</div>
<p className="text-sm text-muted-foreground">{c.content}</p>
</div>
</div>
</Card>
))}
{(!post.comments || post.comments.length === 0) && (
<p className="text-center text-muted-foreground py-8"></p>
)}
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export async function generateStaticParams() {
try {
const base = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${base}/api/v1/community/posts`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((p: any) => ({ id: String(p.id) }));
} catch {
return [{ id: '1' }];
}
}
import ClientPage from './client';
export default function Page() {
return <ClientPage />;
}
+274
View File
@@ -0,0 +1,274 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "../../lib/auth";
import { useAuth } from "@/lib/auth-context";
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
interface Post {
id: number;
title: string;
content: string;
tags?: string | null;
viewCount: number;
likeCount: number;
commentCount: number;
createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
comments?: Comment[];
}
interface Comment {
id: number;
content: string;
createdAt: string;
user: { id: number; nickname: string; avatar: string | null };
}
export default function CommunityPage() {
const router = useRouter();
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tags, setTags] = useState("");
const [submitting, setSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<'latest' | 'feed'>('latest');
const [followedUsers, setFollowedUsers] = useState<Set<number>>(new Set());
const { isLoggedIn } = useAuth();
const loadPosts = useCallback(async () => {
setLoading(true);
try {
const token = localStorage.getItem('token');
const url = activeTab === 'feed' && token
? '/community/feed'
: '/community/posts';
const res = await apiFetch(url);
const data = await res.json();
setPosts(data.items || []);
} catch (e) { console.error(e) }
setLoading(false);
}, [activeTab]);
useEffect(() => { loadPosts(); }, [loadPosts]);
useEffect(() => { loadFollowStatus(); }, [posts]);
async function loadFollowStatus() {
const token = localStorage.getItem('token');
if (!token) return;
const followed = new Set<number>();
for (const post of posts) {
if (post.user?.id) {
try {
const res = await apiFetch(`/community/users/${post.user.id}/follow`);
if (res.ok) {
const d = await res.json();
if (d.followed) followed.add(post.user.id);
}
} catch (e) { console.error(e) }
}
}
setFollowedUsers(followed);
}
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !content.trim()) return;
setSubmitting(true);
try {
await apiFetch("/community/posts", {
method: "POST",
body: JSON.stringify({ title, content, tags: tags || undefined }),
});
setTitle(""); setContent(""); setTags("");
setShowForm(false);
loadPosts();
} catch (e) { console.error(e) }
setSubmitting(false);
}
async function handleLike(postId: number) {
try {
await apiFetch(`/community/posts/${postId}/like`, { method: "POST" });
loadPosts();
} catch (e) { console.error(e) }
}
async function handleFollow(userId: number) {
const token = localStorage.getItem('token');
if (!token) return;
try {
const isFollowed = followedUsers.has(userId);
await apiFetch(`/community/users/${userId}/follow`, {
method: isFollowed ? 'DELETE' : 'POST',
});
setFollowedUsers(prev => {
const next = new Set(prev);
isFollowed ? next.delete(userId) : next.add(userId);
return next;
});
} catch (e) { console.error(e) }
}
function PostCard({ post }: { post: Post }) {
const [expanded, setExpanded] = useState(false);
const [comment, setComment] = useState("");
const [submittingComment, setSubmittingComment] = useState(false);
async function handleComment(e: React.FormEvent) {
e.preventDefault();
if (!comment.trim()) return;
setSubmittingComment(true);
try {
await apiFetch(`/community/posts/${post.id}/comments`, {
method: "POST",
body: JSON.stringify({ content: comment }),
});
setComment("");
loadPosts();
} catch (e) { console.error(e) }
setSubmittingComment(false);
}
return (
<div className="bg-card rounded-xl border border-border p-6 mb-6">
<div className="flex items-center gap-3 mb-3">
<Link href={`/users/${post.user.id}`} className="flex items-center gap-3 group">
<Avatar className="w-8 h-8">
<AvatarFallback className="bg-brand-100 dark:bg-brand-900/40 text-brand-600 dark:text-brand-400 text-xs font-bold">{post.user.nickname?.[0] || "U"}</AvatarFallback>
</Avatar>
<div>
<div className="text-sm font-medium text-foreground group-hover:text-brand-600">{post.user.nickname}</div>
<div className="text-xs text-muted-foreground">{new Date(post.createdAt).toLocaleDateString()}</div>
</div>
</Link>
{isLoggedIn && (
<button onClick={() => handleFollow(post.user.id)}
className={`ml-auto text-xs px-2 py-1 rounded transition-colors ${
followedUsers.has(post.user.id)
? 'bg-muted text-muted-foreground hover:bg-accent'
: 'bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 hover:bg-brand-100 dark:hover:bg-brand-900/50'
}`}>
{followedUsers.has(post.user.id) ? '已关注' : '+ 关注'}
</button>
)}
</div>
<Link href={`/community/${post.id}`}>
<h3 className="text-lg font-semibold text-foreground mb-2 hover:text-brand-600">{post.title}</h3>
</Link>
<p className="text-muted-foreground leading-relaxed mb-4 whitespace-pre-wrap">{post.content}</p>
{post.tags && (
<div className="flex gap-2 mb-4">
{post.tags.split(",").map((tag: string) => (
<span key={tag} className="text-xs px-2 py-0.5 bg-brand-50 dark:bg-brand-900/30 text-brand-600 dark:text-brand-400 rounded">{tag.trim()}</span>
))}
</div>
)}
<div className="flex items-center gap-6 text-sm text-muted-foreground">
<button onClick={() => handleLike(post.id)} className="flex items-center gap-1 hover:text-brand-600 transition-colors">
{post.likeCount}
</button>
<button onClick={() => setExpanded(!expanded)} className="hover:text-brand-600 transition-colors">
💬 {post.commentCount}
</button>
<span>👁 {post.viewCount}</span>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t border-border">
{post.comments?.map((c: Comment) => (
<div key={c.id} className="flex gap-3 mb-3">
<Avatar className="w-6 h-6">
<AvatarFallback className="text-[10px]">{c.user.nickname?.[0] || "U"}</AvatarFallback>
</Avatar>
<div className="flex-1">
<div className="text-xs text-muted-foreground mb-1">{c.user.nickname} · {new Date(c.createdAt).toLocaleDateString()}</div>
<p className="text-sm text-muted-foreground">{c.content}</p>
</div>
</div>
))}
<form onSubmit={handleComment} className="mt-3 flex gap-2">
<input value={comment} onChange={e => setComment(e.target.value)}
placeholder="写下你的评论..."
className="flex-1 px-3 py-2 bg-background border border-input rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
<button type="submit" disabled={submittingComment}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm hover:bg-brand-700 disabled:opacity-50">
{submittingComment ? "发送中..." : "评论"}
</button>
</form>
</div>
)}
</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-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="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground"> AI </p>
</div>
<button onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 transition-colors">
{showForm ? "取消" : "+ 发帖"}
</button>
</div>
<div className="flex gap-1 mb-6 bg-muted rounded-lg p-1">
<button onClick={() => setActiveTab('latest')}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'latest' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}></button>
<button onClick={() => setActiveTab('feed')}
className={`flex-1 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === 'feed' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
}`}></button>
</div>
{showForm && (
<form onSubmit={handleCreate} className="bg-card rounded-xl border border-border p-6 mb-6">
<h3 className="text-lg font-semibold text-foreground mb-4"></h3>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="标题"
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring" />
<textarea value={content} onChange={e => setContent(e.target.value)}
placeholder="分享你的 AI 学习心得、实战经验..." rows={4}
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring resize-none" />
<input value={tags} onChange={e => setTags(e.target.value)}
placeholder="标签(逗号分隔,如:AI,提示词)"
className="w-full px-3 py-2 bg-background border border-input rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-ring" />
<div className="flex justify-end">
<button type="submit" disabled={submitting}
className="px-4 py-2 bg-brand-600 text-white rounded-lg text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{submitting ? "发布中..." : "发布"}
</button>
</div>
</form>
)}
{posts.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<p>{activeTab === 'feed' ? '关注更多用户,发现精彩内容' : '还没有帖子,来发第一帖吧!'}</p>
</div>
) : (
<div>{posts.map(post => <PostCard key={post.id} post={post} />)}</div>
)}
</div>
);
}