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
+119
View File
@@ -0,0 +1,119 @@
'use client';
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Skeleton } from '@/components/ui/skeleton';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Content {
id: number; title: string; summary?: string; content?: string; cover?: string;
contentType: string; tags?: string; authorName?: string; viewCount: number;
isAiGenerated: boolean; publishedAt: string; createdAt: string;
category?: { name: string };
}
export default function ContentDetailClient() {
const params = useParams();
const [content, setContent] = useState<Content | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
if (!params.id) return;
setLoading(true);
fetch(`${API_BASE}/contents/${params.id}`)
.then(r => r.json())
.then(data => {
if (!data || !data.id) throw new Error('内容不存在');
setContent(data);
})
.catch(e => setError(e.message))
.finally(() => setLoading(false));
}, [params.id]);
if (loading) return (
<div className="max-w-3xl 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>
);
if (error || !content) return (
<div className="max-w-3xl mx-auto px-4 py-20 text-center">
<div className="w-16 h-16 bg-red-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h1 className="text-xl font-bold text-foreground mb-2">{error || '内容不存在'}</h1>
<Link href="/" className="text-brand-600 hover:underline text-sm"></Link>
</div>
);
return (
<article className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3">
{content.category && (
<span className="bg-brand-50 text-brand-600 px-2 py-0.5 rounded">{content.category.name}</span>
)}
<span>{content.contentType === 'tutorial' ? '教程' : content.contentType === 'news' ? '资讯' : '文章'}</span>
<span>{content.publishedAt ? new Date(content.publishedAt).toLocaleDateString() : new Date(content.createdAt).toLocaleDateString()}</span>
<span>{content.viewCount} </span>
{content.isAiGenerated && (
<span className="text-xs text-yellow-600 bg-yellow-50 px-2 py-0.5 rounded">AI </span>
)}
</div>
<h1 className="text-3xl font-bold text-foreground leading-tight">{content.title}</h1>
{content.authorName && (
<p className="mt-2 text-sm text-muted-foreground">{content.authorName}</p>
)}
{content.summary && (
<p className="mt-4 text-lg text-muted-foreground leading-relaxed">{content.summary}</p>
)}
{content.tags && (
<div className="flex gap-2 mt-4 flex-wrap">
{content.tags.split(',').map(tag => (
<span key={tag} className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded">{tag.trim()}</span>
))}
</div>
)}
</div>
{content.cover && (
<div className="mb-8 rounded-xl overflow-hidden bg-muted aspect-video flex items-center justify-center text-muted-foreground">
{content.cover.startsWith('http') ? (
<img src={content.cover} alt={content.title} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-brand-50 to-blue-50">
<svg className="w-16 h-16 text-brand-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
)}
</div>
)}
<div className="prose prose-gray max-w-none">
{content.content ? (
<div className="text-foreground leading-relaxed whitespace-pre-wrap text-base">
{content.content}
</div>
) : (
<p className="text-muted-foreground italic py-8 text-center"></p>
)}
</div>
<div className="mt-12 pt-8 border-t border-border">
<Link href="/" className="text-brand-600 hover:text-brand-700 text-sm font-medium">
&larr;
</Link>
</div>
</article>
);
}
+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/contents`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return [{ id: '1' }];
return items.map((c: any) => ({ id: String(c.id) }));
} catch {
return [{ id: '1' }];
}
}
import ContentDetailClient from './client';
export default function ContentDetailPage() {
return <ContentDetailClient />;
}
+87
View File
@@ -0,0 +1,87 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { FileText, Eye } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
interface Content {
id: number; title: string; summary: string | null; cover: string | null;
contentType: string; tags: string | null; authorName: string | null;
viewCount: number; publishedAt: string; category?: { name: string };
}
function ContentSkeleton() {
return (
<Card className="overflow-hidden">
<Skeleton className="h-44 w-full" />
<div className="p-5 space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-32" />
</div>
</Card>
);
}
export default function ContentsPage() {
const [contents, setContents] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_BASE}/contents`)
.then(r => r.json()).then(data => setContents(data.items || []))
.catch(() => {}).finally(() => setLoading(false));
}, []);
const typeLabels: Record<string, string> = { article: '文章', tutorial: '教程', news: '资讯' };
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-10">
<h1 className="text-3xl font-bold text-foreground"></h1>
<p className="mt-2 text-muted-foreground">AI </p>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[1,2,3].map(i => <ContentSkeleton key={i} />)}
</div>
) : contents.length === 0 ? (
<div className="text-center py-20 text-muted-foreground">
<FileText className="w-12 h-12 mx-auto mb-4 opacity-30" />
<p></p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{contents.map((item) => (
<Link key={item.id} href={`/contents/${item.id}`} className="block group">
<Card className="overflow-hidden hover:shadow-lg transition-all hover:-translate-y-0.5">
<div className="h-44 bg-gradient-to-br from-brand-50 to-blue-50 dark:from-brand-950/30 dark:to-blue-950/20 flex items-center justify-center">
<FileText className="w-12 h-12 text-brand-300 dark:text-brand-600" />
</div>
<div className="p-5">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-2">
{item.category && <Badge variant="secondary">{item.category.name}</Badge>}
<span>{item.publishedAt ? new Date(item.publishedAt).toLocaleDateString() : ''}</span>
</div>
<h3 className="font-semibold group-hover:text-brand-600 transition-colors">{item.title}</h3>
{item.summary && <p className="text-sm text-muted-foreground mt-1 line-clamp-2">{item.summary}</p>}
<div className="flex items-center gap-3 mt-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Eye className="w-3.5 h-3.5" />{item.viewCount} </span>
{item.authorName && <span>{item.authorName}</span>}
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
);
}