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
+4
View File
@@ -71,6 +71,10 @@
| `PATCH /api/v1/notifications/read-all` | 全部已读(需 JWT |
| `GET /api/v1/learning/analytics` | 学情分析(需 JWT,返回知识领域掌握度) |
| `GET /api/v1/learning/path` | 学习路径进度(需 JWT,返回阶段任务完成情况) |
| `GET /api/v1/skills` | 技能列表(支持 ?category= / ?difficulty= / ?search= 过滤) |
| `GET /api/v1/skills/:id` | 技能详情(含 system prompt、练习任务、starter 问题) |
| `GET /api/v1/skills/categories` | 技能分类列表 |
| `GET /api/v1/skills/difficulties` | 难度等级列表 |
## 关键上下文(Critical Context
+2
View File
@@ -20,6 +20,7 @@ import { CommunityModule } from './modules/community/community.module';
import { EnterpriseModule } from './modules/enterprise/enterprise.module';
import { NotificationModule } from './modules/notifications/notification.module';
import { LearningModule } from './modules/learning/learning.module';
import { SkillsModule } from './modules/skills/skills.module';
@Module({
imports: [
@@ -44,6 +45,7 @@ import { LearningModule } from './modules/learning/learning.module';
EnterpriseModule,
NotificationModule,
LearningModule,
SkillsModule,
],
})
export class AppModule {}
@@ -0,0 +1,31 @@
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { SkillsService, Skill } from './skills.service';
@ApiTags('技能')
@Controller('skills')
export class SkillsController {
constructor(private skillsService: SkillsService) {}
@Get()
findAll(@Query() query: { category?: string; difficulty?: string; search?: string; tag?: string }) {
return this.skillsService.findAll(query);
}
@Get('categories')
getCategories() {
return this.skillsService.getCategories();
}
@Get('difficulties')
getDifficulties() {
return this.skillsService.getDifficulties();
}
@Get(':id')
findById(@Param('id') id: string) {
const skill = this.skillsService.findById(id);
if (!skill) return { error: '技能不存在' };
return skill;
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SkillsController } from './skills.controller';
import { SkillsService } from './skills.service';
@Module({
controllers: [SkillsController],
providers: [SkillsService],
exports: [SkillsService],
})
export class SkillsModule {}
@@ -0,0 +1,177 @@
import { Injectable } from '@nestjs/common';
export interface SkillTask {
label: string;
prompt: string;
}
export interface Skill {
id: string;
name: string;
description: string;
icon: string;
category: string;
difficulty: 'beginner' | 'intermediate' | 'advanced';
systemPrompt: string;
starters: string[];
tasks: SkillTask[];
tags: string[];
prerequisites?: string[];
}
const SKILLS: Skill[] = [
{
id: 'general-chat',
name: '通用对话',
description: '日常问答,无所不谈',
icon: '💬',
category: 'basic',
difficulty: 'beginner',
systemPrompt: '你是一个智能 AI 助手,请友好、准确地回答用户的问题。',
starters: ['介绍一下你自己', '今天天气怎么样', '讲个笑话'],
tasks: [
{ label: '发起一次对话', prompt: '你好' },
{ label: '追问细节', prompt: '能详细说说吗' },
],
tags: ['入门', '通用'],
},
{
id: 'coding',
name: '编程助手',
description: '写代码、Debug、学编程',
icon: '💻',
category: 'technical',
difficulty: 'intermediate',
systemPrompt: '你是一名资深软件工程师,擅长编程教学。请用清晰的代码示例和通俗的语言解释技术概念。回答时优先提供可运行的代码。',
starters: ['用 Python 写一个二分查找', 'React 和 Vue 有什么区别', '帮我 Debug 这段代码'],
tasks: [
{ label: '要求代码示例', prompt: '用 Python 写一个二分查找' },
{ label: 'Debug 练习', prompt: '这段代码为什么报错:\n```python\ndef foo():\n return 1/0\n```' },
],
tags: ['编程', 'Python', 'Debug'],
},
{
id: 'writing',
name: '写作助手',
description: '文章、文案、报告润色',
icon: '✍️',
category: 'creative',
difficulty: 'beginner',
systemPrompt: '你是一名专业的写作顾问,擅长各类文体写作。请根据用户需求提供高质量的文字内容,注意逻辑清晰、表达准确。',
starters: ['帮我写一篇产品介绍', '润色这段文字', '写一封工作邮件'],
tasks: [
{ label: '练习写作', prompt: '帮我写一篇产品介绍' },
{ label: '润色修改', prompt: '请帮我润色这段文字,让它更专业' },
],
tags: ['写作', '文案', '润色'],
},
{
id: 'study',
name: '学习辅导',
description: '概念讲解、知识总结',
icon: '📚',
category: 'education',
difficulty: 'beginner',
systemPrompt: '你是一名耐心且知识渊博的老师。请用通俗易懂的方式解释复杂概念,善用类比和例子,鼓励用户深入提问。',
starters: ['解释什么是机器学习', '讲一下 TCP/IP 协议', '怎么理解量子计算'],
tasks: [
{ label: '学习概念', prompt: '用简单的类比解释什么是机器学习' },
{ label: '深入提问', prompt: '机器学习和深度学习有什么区别' },
],
tags: ['学习', '概念讲解'],
},
{
id: 'english',
name: '英语学习',
description: '翻译、语法、口语练习',
icon: '🌍',
category: 'education',
difficulty: 'intermediate',
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" 的区别', '帮我翻译这段话', '检查语法错误'],
tasks: [
{ label: '语法练习', prompt: '"I have went to school" 有什么语法错误' },
{ label: '翻译练习', prompt: '把这段话翻译成英文:今天天气很好' },
],
tags: ['英语', '翻译', '语法'],
},
{
id: 'prompt-engineering',
name: '提示词工程',
description: '学习编写高质量提示词',
icon: '🎯',
category: 'advanced',
difficulty: 'advanced',
systemPrompt: '你是一名提示词工程专家。帮助用户理解如何构建有效的提示词,包括角色设定、任务描述、输出格式和约束条件。给出具体示例和最佳实践。',
starters: ['如何写好一个提示词', '什么是 Chain-of-Thought', '示例:角色提示词'],
tasks: [
{ label: '学习提示词结构', prompt: '一个好的提示词应该包含哪些要素' },
{ label: '实践编写', prompt: '帮我设计一个提示词,角色是资深编辑,任务是润色文章' },
],
tags: ['提示词', 'Prompt', '进阶'],
prerequisites: ['general-chat'],
},
{
id: 'data-analysis',
name: '数据分析',
description: '数据处理、可视化、报表',
icon: '📊',
category: 'technical',
difficulty: 'intermediate',
systemPrompt: '你是一名数据分析师。擅长使用 Python、SQL 等工具进行数据分析和可视化。请提供清晰的代码和图表描述。',
starters: ['用 Python 分析这份数据', '这个 SQL 查询怎么优化', '帮我做个数据可视化'],
tasks: [
{ label: '数据分析入门', prompt: '用 Python pandas 读取 CSV 文件并做基本统计' },
{ label: '可视化练习', prompt: '帮我用 matplotlib 画一个柱状图' },
],
tags: ['数据分析', 'Python', 'SQL'],
},
{
id: 'career',
name: '职业发展',
description: '简历优化、面试准备',
icon: '🚀',
category: 'career',
difficulty: 'beginner',
systemPrompt: '你是一名职业发展顾问。帮助用户优化简历、准备面试、规划职业发展。提供具体、可操作的建议。',
starters: ['帮我优化简历', '面试注意事项', '职业规划建议'],
tasks: [
{ label: '简历优化', prompt: '帮我优化这段工作经历描述,让它更有吸引力' },
{ label: '模拟面试', prompt: '模拟一次前端工程师的技术面试' },
],
tags: ['职业', '简历', '面试'],
},
];
@Injectable()
export class SkillsService {
findAll(params: { category?: string; difficulty?: string; search?: string; tag?: string }) {
let filtered = [...SKILLS];
if (params.category) filtered = filtered.filter(s => s.category === params.category);
if (params.difficulty) filtered = filtered.filter(s => s.difficulty === params.difficulty);
if (params.search) {
const q = params.search.toLowerCase();
filtered = filtered.filter(s => s.name.includes(q) || s.description.includes(q) || s.tags.some(t => t.includes(q)));
}
if (params.tag) filtered = filtered.filter(s => s.tags.includes(params.tag!));
return { items: filtered, total: filtered.length };
}
findById(id: string) {
return SKILLS.find(s => s.id === id) || null;
}
getCategories() {
const cats = [...new Set(SKILLS.map(s => s.category))];
const labels: Record<string, string> = { basic: '基础', technical: '技术', creative: '创意', education: '教育', advanced: '进阶', career: '职业' };
return cats.map(c => ({ id: c, name: labels[c] || c }));
}
getDifficulties() {
return [
{ id: 'beginner', name: '入门' },
{ id: 'intermediate', name: '中级' },
{ id: 'advanced', name: '高级' },
];
}
}
File diff suppressed because one or more lines are too long
+11 -8
View File
@@ -1,18 +1,21 @@
# 项目进度追踪
## 当前阶段:P0 — 全局优化
## 当前阶段:P0 全部完成 ✅
### Phase 1-3 — 全部完成 ✅
### P0 — 全局优化(当前阶段
### P0 — 全局优化(全部完成 ✅
| 子项 | 说明 | 状态 |
|------|------|------|
| **P0a** | i18n 基础设施 + 翻译文件 + LanguageProvider + useT hook | ✅ 完成 |
| **P0b** | UI 统一 — Design Token + 共享组件库 | ✅ 完成 |
| **P0c** | 逐页翻译 + 语言切换 — 沙盒页面 useT 化 | ✅ 完成 |
| **P0d** | 会员定价页重构(用量可视化) | ⏳ 待开始 |
| **P0e** | 模型选择器升级(卡片式 + 能力标签) | ⏳ 待开始 |
| **P0f** | 会话分享链接 | ⏳ 待开始 |
| **P0a** | i18n 基础设施 + 翻译文件 + LanguageProvider + useT hook | ✅ |
| **P0b** | UI 统一 — Design Token + 共享组件库 | ✅ |
| **P0c** | 逐页翻译 + 语言切换 — 沙盒页面 useT 化 | ✅ |
| **P0d** | 会员定价页重构(用量可视化) | |
| **P0e** | 模型选择器升级(卡片式 + 能力标签) | |
| **P0f** | 会话分享链接 — HMAC 签名 + 分享查看页 | ✅ |
| **全栈验证** | 后端 13 suites / 92 tests / 前端 61 pages | ✅ |
### P1 — 待定义
### 参考来源
- opencode 源码克隆到 `/tmp/opencode-source/`21 packages, 147MB
+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>
);
}
@@ -15,6 +15,7 @@ const navItems = [
{ href: '/', label: '首页' },
{ href: '/courses', label: '专题' },
{ href: '/sandbox', label: '沙盒' },
{ href: '/skills', label: '技能' },
{ href: '/models', label: '模型' },
{ href: '/prompts', label: '提示词' },
{ href: '/contents', label: '文章' },
+22
View File
@@ -150,6 +150,28 @@ const en: Translations = {
three: 'Three.js',
console: 'Console Output',
},
skills: {
title: 'Skill Library',
desc: 'Composable AI learning skill modules',
search: 'Search skills...',
allCategories: 'All Categories',
allDifficulties: 'All Levels',
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced',
tasks: 'Practice Tasks',
starters: 'Try these questions',
prerequisites: 'Prerequisites',
apply: 'Use this skill',
categories: {
basic: 'Basic',
technical: 'Technical',
creative: 'Creative',
education: 'Education',
advanced: 'Advanced',
career: 'Career',
},
},
promptWorkshop: {
title: 'Prompt Workshop',
desc: 'Write, test, and optimize your prompts',
+22
View File
@@ -148,6 +148,28 @@ const zh = {
three: '3D (Three.js)',
console: '控制台输出',
},
skills: {
title: '技能库',
desc: '可组合的 AI 学习技能模块',
search: '搜索技能...',
allCategories: '全部分类',
allDifficulties: '全部难度',
beginner: '入门',
intermediate: '中级',
advanced: '高级',
tasks: '练习任务',
starters: '试试这些问题',
prerequisites: '前置技能',
apply: '使用此技能',
categories: {
basic: '基础',
technical: '技术',
creative: '创意',
education: '教育',
advanced: '进阶',
career: '职业',
},
},
promptWorkshop: {
title: '提示词工坊',
desc: '编写、测试、优化你的提示词',