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>