Files
2026-05-27 19:43:15 +08:00

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>
);
}