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