6850ceff56
- 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
158 lines
6.5 KiB
TypeScript
Executable File
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">
|
|
← {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>
|
|
);
|
|
}
|