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