812 lines
34 KiB
TypeScript
812 lines
34 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Bot,
|
|
Boxes,
|
|
Braces,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Compass,
|
|
Crown,
|
|
Edit,
|
|
FileCode2,
|
|
Globe,
|
|
LineChart,
|
|
MoreVertical,
|
|
PenTool,
|
|
Pencil,
|
|
PencilIcon,
|
|
Plus,
|
|
Search,
|
|
ShieldCheck,
|
|
Sparkles,
|
|
Target,
|
|
Trash2,
|
|
Upload,
|
|
Wand2,
|
|
Workflow,
|
|
X,
|
|
} from 'lucide-react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useTheme } from '../../contexts/ThemeContext';
|
|
import { useExpertAuthBootstrap } from '../../hooks';
|
|
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
|
|
import { MessagePlugin } from 'tdesign-react';
|
|
|
|
interface Skill {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
category: string;
|
|
highlight: string;
|
|
relatedCount: number;
|
|
version: string;
|
|
inputCount: number;
|
|
outputCount: number;
|
|
isNew?: boolean;
|
|
isMine: boolean;
|
|
source?: string;
|
|
}
|
|
|
|
interface ApiSkill {
|
|
id: number;
|
|
name: string;
|
|
description: string;
|
|
source?: string;
|
|
}
|
|
|
|
interface SkillsResponse {
|
|
items: ApiSkill[];
|
|
total: number;
|
|
page: number;
|
|
page_size: number;
|
|
}
|
|
|
|
interface CreateSkillThreadResponse {
|
|
id: number;
|
|
title: string;
|
|
archived: boolean;
|
|
expert_agent_id: number | null;
|
|
user_id: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
type CategoryKey = 'all' | 'writing' | 'analysis' | 'decision' | 'validation' | 'workflow';
|
|
|
|
const CATEGORY_LABELS: Record<CategoryKey, string> = {
|
|
all: '全部类型',
|
|
writing: '编辑类',
|
|
analysis: '分析类',
|
|
decision: '决策类',
|
|
validation: '校验类',
|
|
workflow: '流程类',
|
|
};
|
|
|
|
const categoryOrder: CategoryKey[] = ['all', 'writing', 'analysis', 'decision', 'validation', 'workflow'];
|
|
|
|
const AVATAR_STYLES = [
|
|
{
|
|
Icon: Bot,
|
|
wrapper: 'from-rose-100 via-red-50 to-white text-red-500',
|
|
glow: 'shadow-red-100/80',
|
|
ring: 'ring-red-100',
|
|
orb: 'bg-red-500/10',
|
|
},
|
|
{
|
|
Icon: Wand2,
|
|
wrapper: 'from-violet-100 via-fuchsia-50 to-white text-violet-500',
|
|
glow: 'shadow-violet-100/80',
|
|
ring: 'ring-violet-100',
|
|
orb: 'bg-violet-500/10',
|
|
},
|
|
{
|
|
Icon: LineChart,
|
|
wrapper: 'from-cyan-100 via-sky-50 to-white text-sky-500',
|
|
glow: 'shadow-sky-100/80',
|
|
ring: 'ring-sky-100',
|
|
orb: 'bg-sky-500/10',
|
|
},
|
|
{
|
|
Icon: ShieldCheck,
|
|
wrapper: 'from-emerald-100 via-green-50 to-white text-emerald-500',
|
|
glow: 'shadow-emerald-100/80',
|
|
ring: 'ring-emerald-100',
|
|
orb: 'bg-emerald-500/10',
|
|
},
|
|
{
|
|
Icon: Workflow,
|
|
wrapper: 'from-amber-100 via-yellow-50 to-white text-amber-500',
|
|
glow: 'shadow-amber-100/80',
|
|
ring: 'ring-amber-100',
|
|
orb: 'bg-amber-500/10',
|
|
},
|
|
{
|
|
Icon: FileCode2,
|
|
wrapper: 'from-indigo-100 via-blue-50 to-white text-indigo-500',
|
|
glow: 'shadow-indigo-100/80',
|
|
ring: 'ring-indigo-100',
|
|
orb: 'bg-indigo-500/10',
|
|
},
|
|
{
|
|
Icon: Target,
|
|
wrapper: 'from-pink-100 via-rose-50 to-white text-pink-500',
|
|
glow: 'shadow-pink-100/80',
|
|
ring: 'ring-pink-100',
|
|
orb: 'bg-pink-500/10',
|
|
},
|
|
{
|
|
Icon: Compass,
|
|
wrapper: 'from-orange-100 via-amber-50 to-white text-orange-500',
|
|
glow: 'shadow-orange-100/80',
|
|
ring: 'ring-orange-100',
|
|
orb: 'bg-orange-500/10',
|
|
},
|
|
];
|
|
|
|
const hashString = (value: string) =>
|
|
value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
|
|
|
const inferCategory = (skill: ApiSkill): CategoryKey => {
|
|
const text = `${skill.name} ${skill.description}`.toLowerCase();
|
|
|
|
if (/(summary|readme|writer|copy|frontend|narrative|edit|document)/.test(text)) return 'writing';
|
|
if (/(analysis|audience|risk|search|insight|research|data)/.test(text)) return 'analysis';
|
|
if (/(plan|decision|goal|strategy|logic|outreach)/.test(text)) return 'decision';
|
|
if (/(check|validate|review|guard|test|lint)/.test(text)) return 'validation';
|
|
return 'workflow';
|
|
};
|
|
|
|
const buildVersion = (id: number) => `v${1 + (id % 3)}.${(id % 8) + 1}`;
|
|
const buildCount = (id: number, offset: number) => 2 + ((id + offset) % 4);
|
|
const buildRelatedCount = (id: number) => 3 + (id % 4);
|
|
|
|
const mapApiSkillToUiSkill = (skill: ApiSkill, type: 'official' | 'mine'): Skill => ({
|
|
id: String(skill.id),
|
|
name: skill.name,
|
|
description: skill.description,
|
|
category: inferCategory(skill),
|
|
highlight: type === 'mine' ? '已接入' : '可添加',
|
|
relatedCount: buildRelatedCount(skill.id),
|
|
version: buildVersion(skill.id),
|
|
inputCount: buildCount(skill.id, 1),
|
|
outputCount: buildCount(skill.id, 2),
|
|
isNew: skill.id % 3 === 0,
|
|
isMine: type === 'mine',
|
|
});
|
|
|
|
const getAvatarStyle = (skillId: string) => AVATAR_STYLES[hashString(skillId) % AVATAR_STYLES.length];
|
|
|
|
export default function SkillsWorkshop() {
|
|
const navigate = useNavigate();
|
|
const { authedFetch } = useExpertAuthBootstrap();
|
|
const { theme } = useTheme();
|
|
const isDarkMode = theme === 'dark';
|
|
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'mine'>('all');
|
|
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
|
|
const [skills, setSkills] = useState<Skill[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
|
|
const [isCreatingSkillThread, setIsCreatingSkillThread] = useState(false);
|
|
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
|
|
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
|
|
const [addingSkillId, setAddingSkillId] = useState<string | null>(null);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [isCreatingByUpload, setIsCreatingByUpload] = useState(false);
|
|
const [isAiEditing, setIsAiEditing] = useState(false);
|
|
|
|
const createMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const cardMenuRef = useRef<HTMLDivElement | null>(null);
|
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
const createUploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
let canceled = false;
|
|
|
|
const fetchByType = async (type: 'official' | 'mine') => {
|
|
const params = new URLSearchParams({
|
|
type,
|
|
keyword: searchTerm.trim(),
|
|
page: '1',
|
|
page_size: '100',
|
|
});
|
|
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills?${params.toString()}`, {
|
|
method: 'GET',
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`加载技能失败: ${res.status} ${res.statusText}`);
|
|
}
|
|
const data = (await res.json()) as SkillsResponse;
|
|
return data.items.map((item) => mapApiSkillToUiSkill(item, type));
|
|
};
|
|
|
|
const loadSkills = async () => {
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const list =
|
|
selectedFilter === 'all'
|
|
? await Promise.all([fetchByType('official'), fetchByType('mine')])
|
|
: [await fetchByType('mine')];
|
|
|
|
if (canceled) return;
|
|
|
|
const merged = selectedFilter === 'all' ? [...list[0], ...list[1]] : list[0];
|
|
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
|
|
setSkills(deduped);
|
|
} catch {
|
|
if (canceled) return;
|
|
setError('加载技能失败,请稍后重试');
|
|
setSkills([]);
|
|
} finally {
|
|
if (!canceled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadSkills();
|
|
|
|
return () => {
|
|
canceled = true;
|
|
};
|
|
}, [searchTerm, selectedFilter, authedFetch]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
const target = event.target as Node;
|
|
if (createMenuRef.current && !createMenuRef.current.contains(target)) {
|
|
setIsCreateMenuOpen(false);
|
|
}
|
|
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
|
|
setOpenCardMenuId(null);
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, []);
|
|
|
|
const filteredSkills = useMemo(
|
|
() => skills.filter((skill) => selectedCategory === 'all' || skill.category === selectedCategory),
|
|
[selectedCategory, skills],
|
|
);
|
|
|
|
const categoryCounts = useMemo(
|
|
() =>
|
|
categoryOrder.reduce<Record<CategoryKey, number>>((acc, category) => {
|
|
acc[category] = category === 'all' ? skills.length : skills.filter((skill) => skill.category === category).length;
|
|
return acc;
|
|
}, {} as Record<CategoryKey, number>),
|
|
[skills],
|
|
);
|
|
|
|
const mySkillCount = skills.filter((skill) => skill.isMine).length;
|
|
const officialSkillCount = skills.filter((skill) => !skill.isMine).length;
|
|
|
|
const handlePublishSkill = async (skillId: string) => {
|
|
setOpenCardMenuId(null);
|
|
try {
|
|
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/skills/publish`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
skill_id: parseInt(skillId, 10),
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`发布失败: ${res.status} ${res.statusText}`);
|
|
}
|
|
} catch (err) {
|
|
console.error('发布技能失败', err);
|
|
setError('发布技能失败,请稍后重试');
|
|
}
|
|
};
|
|
|
|
const handleEditSkill = (skillId: string) => {
|
|
setOpenCardMenuId(null);
|
|
const skill = skills.find((item) => item.id === skillId) ?? null;
|
|
setEditingSkill(skill);
|
|
};
|
|
|
|
const handleDeleteSkill = async (skillId: string) => {
|
|
setOpenCardMenuId(null);
|
|
try {
|
|
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/${skillId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`删除失败: ${res.status} ${res.statusText}`);
|
|
}
|
|
setSkills((prev) => prev.filter((item) => item.id !== skillId));
|
|
MessagePlugin.success('操作成功');
|
|
} catch (err) {
|
|
console.error('删除技能失败', err);
|
|
setError('删除技能失败,请稍后重试');
|
|
}
|
|
};
|
|
|
|
const handleUploadSkillFile = async (file: File, skillId: string) => {
|
|
setIsUploading(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/${skillId}/upload`, {
|
|
method: 'PUT',
|
|
body: formData,
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
|
}
|
|
setEditingSkill(null);
|
|
} catch (err) {
|
|
console.error('上传技能失败', err);
|
|
setError('上传技能失败,请稍后重试');
|
|
} finally {
|
|
setIsUploading(false);
|
|
if (uploadInputRef.current) uploadInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleAddOfficialSkill = async (skillId: string) => {
|
|
if (addingSkillId) return;
|
|
setAddingSkillId(skillId);
|
|
try {
|
|
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/add`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ official_skill_id: parseInt(skillId, 10) }),
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`添加失败: ${res.status} ${res.statusText}`);
|
|
}
|
|
setSkills((prev) => prev.map((item) => (item.id === skillId ? { ...item, isMine: true, highlight: '已接入' } : item)));
|
|
MessagePlugin.success('添加成功');
|
|
} catch (err) {
|
|
console.error('添加技能失败', err);
|
|
setError('添加技能失败,请稍后重试');
|
|
} finally {
|
|
setAddingSkillId(null);
|
|
}
|
|
};
|
|
|
|
const handleAiEditSkill = async (skill: Skill) => {
|
|
if (isAiEditing) return;
|
|
setIsAiEditing(true);
|
|
try {
|
|
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: `编辑技能:${skill.name}`,
|
|
archived: true,
|
|
is_real_chat: true,
|
|
}),
|
|
});
|
|
if (!threadRes.ok) {
|
|
throw new Error(`创建会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
|
}
|
|
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
|
const threadId = threadData?.id;
|
|
if (!threadId) throw new Error('创建会话成功,但未返回会话 id');
|
|
|
|
const copyRes = await authedFetch(
|
|
`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${threadId}/copy-skill/${skill.id}`,
|
|
{ method: 'POST' },
|
|
);
|
|
if (!copyRes.ok) {
|
|
throw new Error(`复制技能失败: ${copyRes.status} ${copyRes.statusText}`);
|
|
}
|
|
|
|
setEditingSkill(null);
|
|
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
|
navigate(`/skill-creator?id=${threadId}`);
|
|
} catch (err) {
|
|
console.error('AI 辅助编辑失败', err);
|
|
setError('AI 辅助编辑失败,请稍后重试');
|
|
} finally {
|
|
setIsAiEditing(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateSkillByUpload = async (file: File) => {
|
|
setIsCreatingByUpload(true);
|
|
setIsCreateMenuOpen(false);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/upload`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
|
|
}
|
|
} catch (err) {
|
|
console.error('上传技能失败', err);
|
|
setError('上传技能失败,请稍后重试');
|
|
} finally {
|
|
setIsCreatingByUpload(false);
|
|
if (createUploadInputRef.current) createUploadInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleCreateSkillByAgent = async () => {
|
|
if (isCreatingSkillThread) return;
|
|
setIsCreatingSkillThread(true);
|
|
setIsCreateMenuOpen(false);
|
|
try {
|
|
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
title: '新建技能',
|
|
archived: true,
|
|
is_real_chat: true,
|
|
}),
|
|
});
|
|
|
|
if (!threadRes.ok) {
|
|
throw new Error(`创建技能会话失败: ${threadRes.status} ${threadRes.statusText}`);
|
|
}
|
|
|
|
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
|
|
const threadId = threadData?.id;
|
|
if (!threadId) {
|
|
throw new Error('创建技能会话成功,但未返回会话 id');
|
|
}
|
|
|
|
localStorage.setItem('skill_creator_thread_id', String(threadId));
|
|
navigate(`/qa-center/skill-creator?id=${threadId}`);
|
|
} catch (createError) {
|
|
console.error('创建技能会话失败', createError);
|
|
setError('创建技能会话失败,请稍后重试');
|
|
} finally {
|
|
setIsCreatingSkillThread(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`w-full min-h-[calc(100vh-140px)] ${isDarkMode ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'}`}>
|
|
{editingSkill && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
|
|
onMouseDown={(event) => {
|
|
if (event.target === event.currentTarget) setEditingSkill(null);
|
|
}}
|
|
>
|
|
<div className={`relative w-[440px] overflow-hidden rounded-[28px] border shadow-2xl ${isDarkMode ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-[#f3d6d6] bg-white text-gray-900'}`}>
|
|
<div className="flex items-center justify-between px-6 pb-4 pt-5">
|
|
<h2 className="text-base font-semibold">编辑技能</h2>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingSkill(null)}
|
|
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100' : 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<p className={`px-6 pb-4 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
|
|
选择编辑方式:<span className={`font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>{editingSkill.name}</span>
|
|
</p>
|
|
|
|
<div className={`mx-6 border-t ${isDarkMode ? 'border-slate-700' : 'border-[#f4e4e4]'}`} />
|
|
|
|
<div className="flex flex-col gap-3 p-4">
|
|
<button
|
|
type="button"
|
|
disabled={isAiEditing}
|
|
onClick={() => void handleAiEditSkill(editingSkill)}
|
|
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isAiEditing ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
>
|
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-600 text-white shadow-lg shadow-violet-500/30 ring-1 ring-white/20">
|
|
<Bot className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
|
{isAiEditing ? '处理中...' : 'AI 辅助编辑'}
|
|
</p>
|
|
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>通过对话开启 AI 帮助您优化技能内容</p>
|
|
</div>
|
|
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
|
</button>
|
|
|
|
<input
|
|
ref={uploadInputRef}
|
|
type="file"
|
|
accept=".md,.zip"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file && editingSkill) {
|
|
void handleUploadSkillFile(file, editingSkill.id);
|
|
}
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={isUploading}
|
|
onClick={() => uploadInputRef.current?.click()}
|
|
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isUploading ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
>
|
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-lg shadow-orange-400/30 ring-1 ring-white/20">
|
|
<Upload className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
|
|
{isUploading ? '上传中...' : '上传新版覆盖'}
|
|
</p>
|
|
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>上传 `.md` 或 `.zip` 文件替换当前内容</p>
|
|
</div>
|
|
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className={`flex items-center justify-end gap-3 px-6 pb-5 pt-1 ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditingSkill(null)}
|
|
className={`rounded-xl px-4 py-2 text-sm transition ${isDarkMode ? 'hover:bg-slate-800 hover:text-slate-200' : 'hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
|
|
>
|
|
取消
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mx-auto max-w-[1360px] px-6 py-8">
|
|
<section className={`mb-7 ${isDarkMode ? '' : ''}`}>
|
|
<div className="flex items-start justify-between gap-5">
|
|
<div>
|
|
<h1 className={`text-[22px] font-bold tracking-tight ${isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>技能工坊</h1>
|
|
<p className={`mt-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>统一管理系统中的技能资产,支持发现、接入、发布与编辑。</p>
|
|
</div>
|
|
|
|
<div className="relative" ref={createMenuRef}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsCreateMenuOpen((prev) => !prev)}
|
|
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
新建技能体
|
|
</button>
|
|
|
|
{isCreateMenuOpen && (
|
|
<div className={`absolute right-0 top-[calc(100%+10px)] z-20 w-60 rounded-2xl border py-2 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
|
<button
|
|
type="button"
|
|
disabled={isCreatingSkillThread}
|
|
onClick={() => void handleCreateSkillByAgent()}
|
|
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingSkillThread ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
>
|
|
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingSkillThread ? '创建中...' : '通过 Agent 构建'}</p>
|
|
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>通过对话构建和训练 AI 技能</p>
|
|
</button>
|
|
|
|
<input
|
|
ref={createUploadInputRef}
|
|
type="file"
|
|
accept=".md,.zip"
|
|
className="hidden"
|
|
onChange={(event) => {
|
|
const file = event.target.files?.[0];
|
|
if (file) void handleCreateSkillByUpload(file);
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
disabled={isCreatingByUpload}
|
|
onClick={() => createUploadInputRef.current?.click()}
|
|
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingByUpload ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
>
|
|
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingByUpload ? '上传中...' : '上传技能文件'}</p>
|
|
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>导入 `.md` 或 `.zip` 文件</p>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className={`mb-8 rounded-[28px] border px-6 py-6 ${isDarkMode ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'}`}>
|
|
<div className="flex flex-col gap-5">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
<div className={`flex min-w-0 flex-1 items-center gap-3 rounded-2xl border px-4 py-3 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
|
<Search className={`h-4 w-4 ${isDarkMode ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索技能名称..."
|
|
value={searchTerm}
|
|
onChange={(event) => setSearchTerm(event.target.value)}
|
|
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${isDarkMode ? 'text-slate-100 placeholder:text-slate-500' : 'text-gray-800 placeholder:text-[#9ca3af]'}`}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<div className={`inline-flex rounded-2xl border p-1 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedFilter('all')}
|
|
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'all' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
|
>
|
|
全部类型
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedFilter('mine')}
|
|
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'mine' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
|
|
>
|
|
我的技能
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* <div className="flex flex-wrap items-center gap-3">
|
|
<span className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#475467]'}`}>按类型筛选:</span>
|
|
{categoryOrder.slice(1).map((category) => {
|
|
const active = selectedCategory === category;
|
|
return (
|
|
<button
|
|
key={category}
|
|
type="button"
|
|
onClick={() => setSelectedCategory(active ? 'all' : category)}
|
|
className={`rounded-full border px-4 py-2 text-sm transition ${
|
|
active
|
|
? 'border-[#ffb5b5] bg-[#fff5f5] text-[#ef4444]'
|
|
: isDarkMode
|
|
? 'border-slate-700 bg-slate-900/40 text-slate-300 hover:border-slate-600'
|
|
: 'border-[#f3d0d0] bg-white text-[#667085] hover:border-[#ffcccc] hover:text-[#ef4444]'
|
|
}`}
|
|
>
|
|
{CATEGORY_LABELS[category]}
|
|
</button>
|
|
);
|
|
})}
|
|
</div> */}
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
{loading ? (
|
|
<div className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${isDarkMode ? 'border-slate-800 bg-slate-900/60 text-slate-300' : 'border-[#f5d4d4] bg-white text-[#667085]'}`}>
|
|
正在加载技能...
|
|
</div>
|
|
) : error ? (
|
|
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
|
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>加载失败</h3>
|
|
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
|
|
</div>
|
|
) : filteredSkills.length === 0 ? (
|
|
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
|
|
<div className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${isDarkMode ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'}`}>
|
|
<Search className="h-6 w-6" />
|
|
</div>
|
|
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>未找到匹配的技能</h3>
|
|
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>尝试调整搜索词或切换筛选条件。</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
|
{filteredSkills.map((skill) => {
|
|
const avatar = getAvatarStyle(skill.id);
|
|
const AvatarIcon = avatar.Icon;
|
|
const categoryLabel = CATEGORY_LABELS[skill.category as CategoryKey] ?? '流程类';
|
|
|
|
return (
|
|
<article
|
|
key={skill.id}
|
|
className={`group relative rounded-[22px] border px-5 py-5 transition-all duration-200 ${
|
|
isDarkMode
|
|
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
|
|
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
|
|
}`}
|
|
>
|
|
|
|
{!skill.isMine && selectedFilter === 'all' && (
|
|
<div className="absolute right-4 top-4 z-20 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<button
|
|
type="button"
|
|
disabled={addingSkillId === skill.id}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
void handleAddOfficialSkill(skill.id);
|
|
}}
|
|
className={`flex items-center justify-center rounded-lg bg-emerald-500 text-white shadow-sm transition hover:bg-emerald-600 ${addingSkillId === skill.id ? 'cursor-not-allowed opacity-50' : ''}`}
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{skill.isMine && (
|
|
<div className="absolute right-4 top-4 z-20" ref={openCardMenuId === skill.id ? cardMenuRef : null}>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setOpenCardMenuId((prev) => (prev === skill.id ? null : skill.id));
|
|
}}
|
|
className={`flex items-center justify-center rounded-lg transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'}`}
|
|
>
|
|
<MoreVertical className="h-4 w-4" />
|
|
</button>
|
|
|
|
{openCardMenuId === skill.id && (
|
|
<div className={`absolute right-0 top-[calc(100%+6px)] z-30 w-40 rounded-2xl border py-1 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
void handlePublishSkill(skill.id);
|
|
}}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-emerald-400 hover:bg-slate-800' : 'text-emerald-600 hover:bg-[#fff6f6]'}`}
|
|
>
|
|
<Globe className="h-4 w-4 shrink-0" />
|
|
发布到全部
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handleEditSkill(skill.id);
|
|
}}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-amber-600 hover:bg-[#fff6f6]'}`}
|
|
>
|
|
<Edit className="h-4 w-4 shrink-0" />
|
|
编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
void handleDeleteSkill(skill.id);
|
|
}}
|
|
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-rose-400 hover:bg-slate-800' : 'text-rose-500 hover:bg-[#fff6f6]'}`}
|
|
>
|
|
<Trash2 className="h-4 w-4 shrink-0" />
|
|
删除
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${avatar.wrapper} shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ${avatar.glow} ring-1 ${avatar.ring}`}>
|
|
<div className={`absolute inset-0 rounded-2xl ${avatar.orb}`} />
|
|
<AvatarIcon className="relative z-10 h-5 w-5" />
|
|
</div>
|
|
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<h3 className={`text-[17px] font-semibold ${skill.isMine ? 'text-[#111827]' : isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>{skill.name}</h3>
|
|
<span className="inline-flex items-center rounded-full bg-[#fff2f2] px-2.5 py-1 text-xs font-medium text-[#ef4444]">
|
|
{ skill.isMine ? '我的' : '官方' }
|
|
</span>
|
|
</div>
|
|
<p className={`mt-1 line-clamp-3 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{skill.description}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|