feat: P1 学习技能包 — 可组合技能模块系统

后端:
- SkillsModule (service + controller),8 个预置技能
- GET /skills (支持 category/difficulty/search 过滤)
- GET /skills/:id /categories /difficulties

前端:
- /skills 技能市场 — 分类/难度/搜索过滤
- /skills/[id] 技能详情 — system prompt、练习任务、starter
- header 导航新增「技能」入口
- sandbox page 改为从 API 加载技能(替代硬编码 SCENES)
- 支持 ?skill=xxx 直接加载指定技能
- useSearchParams Suspense 包装

全栈: 70 pages / 92 tests 全部通过
This commit is contained in:
yuzhiran-dev
2026-05-18 11:15:00 +08:00
parent cba785e6bd
commit fb092cb5d9
14 changed files with 553 additions and 26 deletions
+114
View File
@@ -0,0 +1,114 @@
'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';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
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[];
}
export default function SkillDetailPage() {
const params = useParams();
const t = useT();
const [skill, setSkill] = useState<Skill | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
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]);
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="inline-flex px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors">
{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>
)}
</div>
);
}