Files
ai-learning-platform/frontend/src/app/skills/[id]/client.tsx
T
TradeMate Dev 6850ceff56 fix(seo): 详情页服务端渲染写入静态HTML并修复skills预渲染报错
- courses/skills/contents/practices 详情页改为服务端取数 + initialData 渲染,正文进入静态 HTML(解决百度弱 JS 收录盲点)
- contents 的 markdown 由 useEffect+setState 改为 useMemo 同步输出
- 四个 generateStaticParams 改用 API 真实 id;getX 校验 data.id 防后端 200+error 体被当有效数据导致 undefined.map 报错
- 更新进度文档至 v1.3.0
2026-07-11 17:46:31 +08:00

158 lines
6.5 KiB
TypeScript
Executable File

'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n';
import { API_BASE } from '@/lib/config';
interface SkillTask { label: string; prompt: string }
interface Skill {
id: string; name: string; description: string; icon: string;
category: string; difficulty: string; systemPrompt: string;
starters: string[]; tasks: SkillTask[]; tags: string[];
prerequisites?: string[];
}
interface AffiliateLink { id: number; title: string; description: string | null; url: string }
export default function SkillDetailPage({ initialSkill }: { initialSkill?: Skill | null }) {
const params = useParams();
const t = useT();
const [skill, setSkill] = useState<Skill | null>(initialSkill ?? null);
const [loading, setLoading] = useState(!initialSkill);
const [links, setLinks] = useState<AffiliateLink[]>([]);
useEffect(() => {
if (initialSkill) return;
if (!params.id) return;
fetch(`${API_BASE}/skills/${params.id}`)
.then((r) => r.json())
.then((data) => { if (data.id) setSkill(data); })
.finally(() => setLoading(false));
}, [params.id, initialSkill]);
useEffect(() => {
if (!skill?.id) return;
fetch(`${API_BASE}/affiliate/links?skillId=${skill.id}`)
.then(r => r.json())
.then(data => setLinks(data.links || []))
.catch(() => {});
}, [skill?.id]);
async function handlePromote(link: AffiliateLink) {
try {
const res = await fetch(`${API_BASE}/affiliate/click/${link.id}`, { method: 'POST' });
const data = await res.json();
if (data.redirectUrl) window.open(data.redirectUrl, '_blank', 'noopener,noreferrer');
} catch {
window.open(link.url, '_blank', 'noopener,noreferrer');
}
}
if (loading) return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Skeleton className="h-8 w-48 mb-6" />
<Skeleton className="h-64 rounded-2xl" />
</div>
);
if (!skill) return (
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
<p className="text-muted-foreground"></p>
<Link href="/skills" className="text-brand-600 hover:underline text-sm mt-4 inline-block"></Link>
</div>
);
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<Link href="/skills" className="text-sm text-muted-foreground hover:text-brand-600 mb-4 inline-block">
&larr; {t.skills.title}
</Link>
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
<div className="flex items-start gap-4 mb-4">
<span className="text-4xl">{skill.icon}</span>
<div className="flex-1">
<h1 className="text-2xl font-bold text-foreground">{skill.name}</h1>
<p className="text-muted-foreground mt-1">{skill.description}</p>
<div className="flex items-center gap-3 mt-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>{(t.skills as any)[skill.difficulty]}</span>
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
</div>
</div>
</div>
<Link href={`/sandbox?skill=${skill.id}`}
className="w-full sm:w-auto inline-flex px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors justify-center">
{t.skills.apply}
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-3">{t.skills.starters}</h2>
<div className="space-y-2">
{skill.starters.map((q, i) => (
<div key={i} className="p-3 bg-muted rounded-xl text-sm text-muted-foreground">{q}</div>
))}
</div>
</div>
<div className="bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-3">{t.skills.tasks}</h2>
<div className="space-y-3">
{skill.tasks.map((task, i) => (
<div key={i} className="p-3 border border-border rounded-xl">
<div className="text-sm font-medium text-foreground mb-1">{task.label}</div>
<div className="text-xs text-muted-foreground font-mono">{task.prompt}</div>
</div>
))}
</div>
</div>
</div>
{skill.prerequisites && skill.prerequisites.length > 0 && (
<div className="mt-6 bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-3">{t.skills.prerequisites}</h2>
<div className="flex flex-wrap gap-2">
{skill.prerequisites.map(pre => (
<Link key={pre} href={`/skills/${pre}`}
className="px-3 py-1.5 text-sm bg-muted text-foreground rounded-lg hover:bg-accent transition-colors">
{pre}
</Link>
))}
</div>
</div>
)}
{links.length > 0 && (
<div className="mt-6 bg-card rounded-2xl border border-border p-6">
<h2 className="text-lg font-semibold text-foreground mb-3">{t.affiliate.recommendForSkill}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{links.map(link => (
<div key={link.id} className="flex items-center justify-between gap-3 p-3 border border-border rounded-xl">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground truncate">{link.title}</div>
{link.description && (
<div className="text-xs text-muted-foreground line-clamp-1">{link.description}</div>
)}
</div>
<button
onClick={() => handlePromote(link)}
className="shrink-0 px-3 py-1.5 text-xs bg-brand-600 hover:bg-brand-700 text-white rounded-lg transition-colors">
{t.affiliate.promoteLink}
</button>
</div>
))}
</div>
</div>
)}
</div>
);
}