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
+37 -17
View File
@@ -1,10 +1,11 @@
'use client';
import { useState, useRef, useEffect, FormEvent } from 'react';
import { useState, useRef, useEffect, FormEvent, Suspense } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { getToken, apiFetch } from '@/lib/auth';
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
import { DEFAULT_MODEL } from '@/lib/models';
import { ModelSelector } from '@/components/ui/model-selector';
import { useT } from '@/i18n';
@@ -35,18 +36,30 @@ function extractCodeBlocks(content: string): string[] {
return blocks;
}
function sceneName(t: any, id: string) {
return ({ general: t.sandbox.sceneGeneral, coding: t.sandbox.sceneCoding, writing: t.sandbox.sceneWriting, study: t.sandbox.sceneStudy, english: t.sandbox.sceneEnglish } as Record<string, string>)[id]
export default function SandboxPageWrapper() {
return (
<Suspense fallback={
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="h-8 w-48 bg-muted rounded-lg animate-pulse mb-4" />
<div className="h-[65vh] bg-muted rounded-2xl animate-pulse" />
</div>
}>
<SandboxPage />
</Suspense>
);
}
export default function SandboxPage() {
function SandboxPage() {
const searchParams = useSearchParams();
const t = useT();
const [messages, setMessages] = useState<Message[]>([
{ role: 'assistant', content: '你好!我是宇之然 AI 助手。你可以问我任何问题,我会尽力帮你解答。\n\n试试问我关于 AI、编程、写作、办公效率等方面的问题!' },
]);
const [input, setInput] = useState('');
const [model, setModel] = useState(DEFAULT_MODEL);
const [scene, setScene] = useState('general');
const [scene, setScene] = useState('');
const [SCENES, setSCENES] = useState<{ id: string; name: string; icon: string; systemPrompt: string; starters: string[] }[]>([]);
const [skillsLoading, setSkillsLoading] = useState(true);
const [sending, setSending] = useState(false);
const [showParams, setShowParams] = useState(false);
const [temperature, setTemperature] = useState(0.7);
@@ -63,14 +76,6 @@ export default function SandboxPage() {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { isLoggedIn } = useAuth();
const SCENES = [
{ id: 'general', icon: '💬', systemPrompt: '你是一个智能 AI 助手,请友好、准确地回答用户的问题。', starters: ['介绍一下你自己', '今天天气怎么样', '讲个笑话'] },
{ id: 'coding', icon: '💻', systemPrompt: '你是一名资深软件工程师,擅长编程教学。请用清晰的代码示例和通俗的语言解释技术概念。回答时优先提供可运行的代码。', starters: ['用 Python 写一个二分查找', 'React 和 Vue 有什么区别', '帮我 Debug 这段代码'] },
{ id: 'writing', icon: '✍️', systemPrompt: '你是一名专业的写作顾问,擅长各类文体写作。请根据用户需求提供高质量的文字内容,注意逻辑清晰、表达准确。', starters: ['帮我写一篇产品介绍', '润色这段文字', '写一封工作邮件'] },
{ id: 'study', icon: '📚', systemPrompt: '你是一名耐心且知识渊博的老师。请用通俗易懂的方式解释复杂概念,善用类比和例子,鼓励用户深入提问。', starters: ['解释什么是机器学习', '讲一下 TCP/IP 协议', '怎么理解量子计算'] },
{ id: 'english', icon: '🌍', systemPrompt: 'You are an English tutor. Help users improve their English. Respond primarily in Chinese but provide English examples. Correct grammar and offer better expressions.', starters: ['"However" 和 "Although" 的区别', '帮我翻译这段话', '检查语法错误'] },
];
useEffect(() => {
const tk = getToken();
if (tk) {
@@ -83,6 +88,19 @@ export default function SandboxPage() {
}
}, [isLoggedIn]);
useEffect(() => {
fetch(`${API_BASE}/skills`)
.then(r => r.json())
.then(data => {
const scenes = (data.items || []).map((s: any) => ({ id: s.id, name: s.name, icon: s.icon, systemPrompt: s.systemPrompt, starters: s.starters }));
setSCENES(scenes);
const skillParam = searchParams.get('skill');
const initialScene = scenes.find((s: any) => s.id === skillParam) ? skillParam : (scenes[0]?.id || '');
setScene(initialScene);
})
.finally(() => setSkillsLoading(false));
}, []);
useEffect(() => {
if (messages.some(m => m.role === 'user') && messagesContainerRef.current) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
@@ -102,8 +120,9 @@ export default function SandboxPage() {
function handleSceneChange(sceneId: string) {
setScene(sceneId);
const s = SCENES.find(x => x.id === sceneId);
setMessages([
{ role: 'assistant', content: `欢迎来到 **${sceneName(t, sceneId)}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
{ role: 'assistant', content: `欢迎来到 **${s?.name || sceneId}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
]);
}
@@ -195,8 +214,9 @@ export default function SandboxPage() {
function newChat() {
setConversationId(crypto.randomUUID());
setCurrentSessionId(null);
const s = SCENES.find(x => x.id === scene);
setMessages([
{ role: 'assistant', content: `欢迎来到 **${sceneName(t, scene)}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
{ role: 'assistant', content: `欢迎来到 **${s?.name || scene}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
]);
}
@@ -332,7 +352,7 @@ export default function SandboxPage() {
: 'bg-card text-muted-foreground border-border hover:border-brand-400 hover:text-foreground'
}`}>
<span>{s.icon}</span>
<span>{sceneName(t, s.id)}</span>
<span>{s.name}</span>
</button>
))}
</div>
+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>
);
}
+10
View File
@@ -0,0 +1,10 @@
import SkillDetailClient from './client';
export function generateStaticParams() {
const skillIds = ['general-chat', 'coding', 'writing', 'study', 'english', 'prompt-engineering', 'data-analysis', 'career'];
return skillIds.map(id => ({ id }));
}
export default function SkillDetailPage() {
return <SkillDetailClient />;
}
+111
View File
@@ -0,0 +1,111 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
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 Skill {
id: string; name: string; description: string; icon: string;
category: string; difficulty: string;
starters: string[]; tags: string[];
}
export default function SkillsPage() {
const t = useT();
const [skills, setSkills] = useState<Skill[]>([]);
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(true);
const [category, setCategory] = useState('');
const [difficulty, setDifficulty] = useState('');
const [search, setSearch] = useState('');
useEffect(() => {
Promise.all([
fetch(`${API_BASE}/skills`).then(r => r.json()),
fetch(`${API_BASE}/skills/categories`).then(r => r.json()),
]).then(([skillsData, cats]) => {
setSkills(skillsData.items || []);
setCategories(cats || []);
}).finally(() => setLoading(false));
}, []);
useEffect(() => {
const params = new URLSearchParams();
if (category) params.set('category', category);
if (difficulty) params.set('difficulty', difficulty);
if (search) params.set('search', search);
fetch(`${API_BASE}/skills?${params}`)
.then(r => r.json())
.then(data => setSkills(data.items || []));
}, [category, difficulty, search]);
const difficulties = [
{ id: 'beginner', name: t.skills.beginner },
{ id: 'intermediate', name: t.skills.intermediate },
{ id: 'advanced', name: t.skills.advanced },
];
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">{t.skills.title}</h1>
<p className="mt-2 text-muted-foreground">{t.skills.desc}</p>
</div>
<div className="flex flex-wrap gap-3 mb-8">
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
placeholder={t.skills.search}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground w-48" />
<select value={category} onChange={e => setCategory(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.skills.allCategories}</option>
{categories.map(c => <option key={c.id} value={c.id}>{(t.skills.categories as any)[c.id] || c.name}</option>)}
</select>
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
<option value="">{t.skills.allDifficulties}</option>
{difficulties.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
</select>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-40 rounded-2xl" />)}
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{skills.map(skill => (
<Link key={skill.id} href={`/skills/${skill.id}`}
className="bg-card rounded-2xl border border-border p-6 hover:shadow-md transition-all hover:-translate-y-0.5 group">
<div className="flex items-start gap-3 mb-3">
<span className="text-2xl">{skill.icon}</span>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-foreground group-hover:text-brand-600 transition-colors">{skill.name}</h3>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
</div>
</div>
<div className="flex items-center gap-2 mb-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 className="flex flex-wrap gap-1">
{skill.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{tag}</span>
))}
</div>
</Link>
))}
</div>
)}
</div>
);
}