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:
@@ -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">
|
||||
← {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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user