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 />;
}