feat: 更新skill相关前端页面

- 新增 web/src/views/skill/skill.ts skill视图组件
- 更新 Agents.vue 页面
- 更新 Skill.vue 页面
- 移除旧的 useSkills.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 15:23:13 +08:00
parent 465fdf2e6c
commit e2c9bbd0d1
3 changed files with 1401 additions and 119 deletions

View File

@@ -1,5 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
const API_BASE = 'http://localhost:8082'
// Skills 接口
interface Skill {
id: string
skill_name: string
skill_type: string
skill_desc: string
path: string
status: string
}
// Agents 数据 // Agents 数据
const agents = ref([ const agents = ref([
@@ -16,27 +28,114 @@ const isCreating = ref(false)
const newAgent = ref({ const newAgent = ref({
name: '', name: '',
description: '', description: '',
skills: '', skillsMode: 'all' as 'all' | 'include' | 'exclude',
selectedSkills: [] as string[],
knowledge: '', knowledge: '',
prompt: '', prompt: '',
avatar: '🤖', avatar: '🤖',
}) })
// Skills 选择器状态
const showSkillsDropdown = ref(false)
const showSubSkillsDropdown = ref(false)
// Skills 模式选项
const skillsModeOptions = [
{ value: 'all', label: 'All Skills', desc: 'Use all available skills' },
{ value: 'include', label: 'Include Skills', desc: 'Select specific skills to use' },
{ value: 'exclude', label: 'Exclude Skills', desc: 'Select skills to exclude' },
]
// 切换技能模式下拉框
const toggleSkillsMode = () => {
showSkillsDropdown.value = !showSkillsDropdown.value
showSubSkillsDropdown.value = false
}
// 选择技能模式
const selectSkillsMode = (mode: 'all' | 'include' | 'exclude') => {
newAgent.value.skillsMode = mode
if (mode === 'all') {
newAgent.value.selectedSkills = []
}
showSkillsDropdown.value = false
if (mode === 'include' || mode === 'exclude') {
showSubSkillsDropdown.value = true
}
}
// 处理技能模式点击(用于模板)
const handleSkillsModeClick = (mode: 'all' | 'include' | 'exclude') => {
selectSkillsMode(mode)
}
// 切换子下拉框
const toggleSubSkillsDropdown = () => {
showSubSkillsDropdown.value = !showSubSkillsDropdown.value
}
// 关闭所有下拉框
const closeAllDropdowns = () => {
showSkillsDropdown.value = false
showSubSkillsDropdown.value = false
}
// 获取显示文本
const getSkillsDisplayText = () => {
if (newAgent.value.skillsMode === 'all') {
return 'All Skills'
}
const count = newAgent.value.selectedSkills.length
if (count === 0) {
return newAgent.value.skillsMode === 'include' ? 'Select skills to include...' : 'Select skills to exclude...'
}
return `${count} skill${count > 1 ? 's' : ''} ${newAgent.value.skillsMode === 'include' ? 'included' : 'excluded'}`
}
// 切换子下拉框中的技能选择
const toggleSkillSelection = (skillId: string) => {
const index = newAgent.value.selectedSkills.indexOf(skillId)
if (index > -1) {
newAgent.value.selectedSkills.splice(index, 1)
} else {
newAgent.value.selectedSkills.push(skillId)
}
}
// 头像选项 // 头像选项
const avatarOptions = [ const avatarOptions = [
'🤖', '🧠', '💻', '📊', '🔬', '🎧', '✨', '💬', '🔮', '🌙', '🤖', '🧠', '💻', '📊', '🔬', '🎧', '✨', '💬', '🔮', '🌙',
'🐉', '☁️', '🎨', '🎯', '🚀', '⚡', '🔥', '💡', '🎭', '🎪' '🐉', '☁️', '🎨', '🎯', '🚀', '⚡', '🔥', '💡', '🎭', '🎪'
] ]
// Skills 选项 // Skills 列表
const skillsOptions = [ const skillsList = ref<Skill[]>([])
{ value: 'research', label: 'Research' }, const skillsLoading = ref(false)
{ value: 'coder', label: 'Coder' },
{ value: 'review', label: 'Code Review' }, // 获取 skills 列表
{ value: 'writer', label: 'Writer' }, const fetchSkills = async () => {
{ value: 'analyst', label: 'Analyst' }, skillsLoading.value = true
{ value: 'assistant', label: 'Assistant' }, try {
] const response = await fetch(`${API_BASE}/skill/list`)
const result = await response.json()
if (result.list) {
skillsList.value = result.list
}
} catch (error) {
console.error('Failed to fetch skills:', error)
} finally {
skillsLoading.value = false
}
}
// Skills 选项(从真实列表生成)
const skillsOptions = computed(() => {
return skillsList.value.map(skill => ({
value: skill.id,
label: skill.skill_name,
desc: skill.skill_desc,
}))
})
// Knowledge 选项 // Knowledge 选项
const knowledgeOptions = [ const knowledgeOptions = [
@@ -46,20 +145,110 @@ const knowledgeOptions = [
{ value: 'api', label: 'API Reference' }, { value: 'api', label: 'API Reference' },
] ]
// Skills 选择器状态
const skillsSearch = ref('')
// 获取 skill 标签
const getSkillLabel = (id: string) => {
return skillsOptions.value.find(s => s.value === id)?.label || id
}
// 过滤后的 skills
const filteredSkills = computed(() => {
if (!skillsSearch.value) return skillsOptions.value
const search = skillsSearch.value.toLowerCase()
return skillsOptions.value.filter(s =>
s.label.toLowerCase().includes(search) ||
(s.desc && s.desc.toLowerCase().includes(search))
)
})
// 是否全选
const isAllSelected = computed(() => {
return skillsOptions.value.length > 0 && newAgent.value.selectedSkills.length === skillsOptions.value.length
})
// 是否半选
const isIndeterminate = computed(() => {
return newAgent.value.selectedSkills.length > 0 && newAgent.value.selectedSkills.length < skillsOptions.value.length
})
// 切换下拉框
const toggleSkillsDropdown = () => {
showSkillsDropdown.value = !showSkillsDropdown.value
if (!showSkillsDropdown.value) {
skillsSearch.value = ''
}
}
// 关闭下拉框
const closeSkillsDropdown = () => {
showSkillsDropdown.value = false
skillsSearch.value = ''
}
// 全选 skills
const selectAllSkills = () => {
newAgent.value.selectedSkills = skillsOptions.value.map(s => s.value)
}
// 清空 skills
const clearSkills = () => {
newAgent.value.selectedSkills = []
}
// 切换全选
const toggleSelectAll = () => {
if (isAllSelected.value) {
newAgent.value.selectedSkills = []
} else {
newAgent.value.selectedSkills = skillsOptions.value.map(s => s.value)
}
}
// 点击外部关闭下拉框
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.skills-selector')) {
closeSkillsDropdown()
}
}
// 页面加载时获取 skills
onMounted(() => {
fetchSkills()
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
// 打开创建弹窗 // 打开创建弹窗
const openCreateModal = () => { const openCreateModal = () => {
newAgent.value = { name: '', description: '', skills: '', knowledge: '', prompt: '', avatar: '🤖' } newAgent.value = {
name: '',
description: '',
skillsMode: 'all' as const,
selectedSkills: [],
knowledge: '',
prompt: '',
avatar: '🤖'
}
showCreateModal.value = true showCreateModal.value = true
} }
// 创建智能体 // 创建智能体
const createAgent = async () => { const createAgent = async () => {
if (!newAgent.value.name || !newAgent.value.skills || !newAgent.value.knowledge) { if (!newAgent.value.name || (newAgent.value.skillsMode !== 'all' && newAgent.value.selectedSkills.length === 0) || !newAgent.value.knowledge) {
return return
} }
isCreating.value = true isCreating.value = true
try { try {
// 处理 skills 标签
const skillsLabels = newAgent.value.selectedSkills.map((id: string) => getSkillLabel(id)).join(', ')
// 模拟创建 // 模拟创建
const newId = Math.max(...agents.value.map(a => a.id)) + 1 const newId = Math.max(...agents.value.map(a => a.id)) + 1
agents.value.unshift({ agents.value.unshift({
@@ -70,7 +259,7 @@ const createAgent = async () => {
accentColor: '#f97316', accentColor: '#f97316',
gradient: 'from-orange-500/20 to-amber-500/20', gradient: 'from-orange-500/20 to-amber-500/20',
status: 'stopped', status: 'stopped',
framework: skillsOptions.find(f => f.value === newAgent.value.skills)?.label || newAgent.value.skills, framework: skillsLabels || 'None',
model: knowledgeOptions.find(k => k.value === newAgent.value.knowledge)?.label || newAgent.value.knowledge, model: knowledgeOptions.find(k => k.value === newAgent.value.knowledge)?.label || newAgent.value.knowledge,
mcpServers: 0, mcpServers: 0,
createdAt: new Date().toISOString().split('T')[0], createdAt: new Date().toISOString().split('T')[0],
@@ -284,9 +473,138 @@ const deleteAgent = (id: number) => {
<div> <div>
<label class="block text-sm font-medium text-gray-300 mb-2">Skills *</label> <label class="block text-sm font-medium text-gray-300 mb-2">Skills *</label>
<el-select v-model="newAgent.skills" placeholder="Select skills" class="w-full" size="large" popper-class="dark-select-dropdown"> <!-- 技能模式选择 - 两级下拉框 -->
<el-option v-for="s in skillsOptions" :key="s.value" :label="s.label" :value="s.value" /> <div class="skills-selector">
</el-select> <!-- 已选 tags 显示区域 -->
<div class="selected-tags" @click="toggleSkillsDropdown">
<div v-if="newAgent.skillsMode === 'all'" class="placeholder">
All Skills
</div>
<div v-else class="tags-container">
<span
v-for="skillId in newAgent.selectedSkills.slice(0, 3)"
:key="skillId"
class="selected-tag"
>
{{ getSkillLabel(skillId) }}
</span>
<span v-if="newAgent.selectedSkills.length > 3" class="more-tag">
+{{ newAgent.selectedSkills.length - 3 }}
</span>
</div>
<svg class="dropdown-icon" :class="{ 'rotate-180': showSkillsDropdown }" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"/>
</svg>
</div>
<!-- 第一级下拉面板模式选择 -->
<Transition name="dropdown">
<div v-if="showSkillsDropdown" class="dropdown-panel">
<div class="skills-mode-options">
<div
v-for="option in skillsModeOptions"
:key="option.value"
class="skills-mode-item"
:class="{ 'active': newAgent.skillsMode === option.value }"
@click="handleSkillsModeClick(option.value as 'all' | 'include' | 'exclude')"
>
<div class="mode-radio">
<div v-if="newAgent.skillsMode === option.value" class="radio-dot"></div>
</div>
<div class="mode-content">
<span class="mode-label">{{ option.label }}</span>
<span class="mode-desc">{{ option.desc }}</span>
</div>
<!-- 显示展开图标 -->
<svg v-if="option.value !== 'all'" class="sub-arrow" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
</div>
</div>
</div>
</Transition>
<!-- 第二级下拉面板技能列表悬浮窗形式 -->
<Transition name="dropdown">
<div v-if="showSubSkillsDropdown" class="sub-dropdown-panel" @click.stop>
<!-- 标题 -->
<div class="sub-dropdown-header">
<span class="sub-dropdown-title">
{{ newAgent.skillsMode === 'include' ? 'Select skills to include' : 'Select skills to exclude' }}
</span>
<button @click="showSubSkillsDropdown = false" class="sub-dropdown-close">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
</div>
<!-- 搜索框 -->
<div class="search-box">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="search-icon">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
<input
v-model="skillsSearch"
type="text"
placeholder="Search skills..."
class="search-input"
>
</div>
<!-- 操作按钮 -->
<div class="action-bar">
<label class="checkbox-label cursor-pointer">
<input
type="checkbox"
:checked="isAllSelected"
:indeterminate="isIndeterminate"
@change="toggleSelectAll"
class="checkbox"
>
<span class="checkbox-text">Select All</span>
</label>
<button
v-if="newAgent.selectedSkills.length > 0"
type="button"
@click="clearSkills"
class="clear-btn"
>
Clear
</button>
</div>
<!-- 技能列表 -->
<div class="options-list">
<template v-if="filteredSkills.length > 0">
<label
v-for="skill in filteredSkills"
:key="skill.value"
class="option-item"
>
<input
type="checkbox"
:value="skill.value"
v-model="newAgent.selectedSkills"
class="checkbox"
>
<div class="option-content">
<span class="option-label">{{ skill.label }}</span>
<span v-if="skill.desc" class="option-desc">{{ skill.desc }}</span>
</div>
</label>
</template>
<div v-else class="no-results">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="no-results-icon">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
<span>No skills available</span>
</div>
</div>
</div>
</Transition>
</div>
</div> </div>
<div> <div>
@@ -316,7 +634,7 @@ const deleteAgent = (id: number) => {
</button> </button>
<button <button
@click="createAgent" @click="createAgent"
:disabled="isCreating || !newAgent.name || !newAgent.skills || !newAgent.knowledge" :disabled="isCreating || !newAgent.name || (newAgent.skillsMode !== 'all' && newAgent.selectedSkills.length === 0) || !newAgent.knowledge"
class="px-4 py-2 rounded-lg bg-gradient-to-r from-primary-orange to-red-500 text-white hover:from-orange-500 hover:to-red-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" class="px-4 py-2 rounded-lg bg-gradient-to-r from-primary-orange to-red-500 text-white hover:from-orange-500 hover:to-red-600 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
> >
<i v-if="isCreating" class="fa-solid fa-circle-notch fa-spin"></i> <i v-if="isCreating" class="fa-solid fa-circle-notch fa-spin"></i>
@@ -327,3 +645,388 @@ const deleteAgent = (id: number) => {
</div> </div>
</Teleport> </Teleport>
</template> </template>
<style scoped>
/* Skills Selector Styles */
.skills-selector {
position: relative;
}
/* Skills Mode Options */
.skills-mode-options {
padding: 8px;
border-bottom: 1px solid #4b5563;
}
.skills-mode-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.skills-mode-item:hover {
background: #374151;
}
.skills-mode-item.active {
background: rgba(249, 115, 22, 0.1);
}
.mode-radio {
width: 18px;
height: 18px;
border: 2px solid #6b7280;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.skills-mode-item.active .mode-radio {
border-color: #f97316;
}
.radio-dot {
width: 10px;
height: 10px;
background: #f97316;
border-radius: 50%;
}
.mode-content {
display: flex;
flex-direction: column;
gap: 2px;
}
.mode-label {
font-size: 14px;
font-weight: 500;
color: #f3f4f6;
}
.mode-desc {
font-size: 12px;
color: #9ca3af;
}
/* 子下拉框箭头 */
.sub-arrow {
margin-left: auto;
color: #9ca3af;
transition: transform 0.2s ease;
}
.skills-mode-item:hover .sub-arrow {
color: #f97316;
}
/* 子下拉面板 */
.sub-dropdown-panel {
position: absolute;
top: 0;
left: 100%;
margin-left: 8px;
width: 280px;
background: #374151;
border: 1px solid #4b5563;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
z-index: 100;
overflow: hidden;
}
.sub-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #4b5563;
background: #4b5563;
}
.sub-dropdown-title {
font-size: 13px;
font-weight: 500;
color: #f3f4f6;
}
.sub-dropdown-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
color: #9ca3af;
transition: all 0.2s ease;
}
.sub-dropdown-close:hover {
background: #374151;
color: #f3f4f6;
}
/* 无结果样式 */
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
color: #6b7280;
gap: 8px;
}
.no-results-icon {
opacity: 0.5;
}
.selected-tags {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
padding: 0 12px;
background: #374151;
border: 1px solid #4b5563;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.selected-tags:hover {
border-color: #f97316;
}
.selected-tags .placeholder {
color: #9ca3af;
font-size: 14px;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
overflow: hidden;
}
.selected-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: linear-gradient(135deg, #f97316 0%, #ea580c 100%);
color: white;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
}
.more-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
background: #374151;
color: #d1d5db;
font-size: 12px;
border-radius: 4px;
}
.dropdown-icon {
color: #9ca3af;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.dropdown-icon.rotate-180 {
transform: rotate(180deg);
}
/* Dropdown Panel */
.dropdown-panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: #374151;
border: 1px solid #4b5563;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
z-index: 100;
overflow: hidden;
}
/* Search Box */
.search-box {
position: relative;
padding: 12px;
border-bottom: 1px solid #4b5563;
}
.search-icon {
position: absolute;
left: 24px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
}
.search-input {
width: 100%;
padding: 8px 12px 8px 36px;
background: #111827;
border: 1px solid #374151;
border-radius: 6px;
color: white;
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
}
.search-input:focus {
border-color: #f97316;
}
.search-input::placeholder {
color: #6b7280;
}
/* Action Bar */
.action-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: #111827;
border-bottom: 1px solid #374151;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.checkbox {
width: 16px;
height: 16px;
accent-color: #f97316;
cursor: pointer;
}
.checkbox-text {
font-size: 13px;
font-weight: 500;
color: #f97316;
}
.clear-btn {
padding: 4px 10px;
background: transparent;
border: 1px solid #4b5563;
border-radius: 4px;
color: #9ca3af;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.clear-btn:hover {
background: #374151;
color: white;
}
/* Options List */
.options-list {
max-height: 240px;
overflow-y: auto;
padding: 8px;
}
.options-list::-webkit-scrollbar {
width: 6px;
}
.options-list::-webkit-scrollbar-track {
background: #374151;
}
.options-list::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 3px;
}
.option-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s ease;
}
.option-item:hover {
background: #374151;
}
.option-item .checkbox {
margin-top: 2px;
flex-shrink: 0;
}
.option-content {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.option-label {
font-size: 14px;
font-weight: 500;
color: white;
}
.option-desc {
font-size: 12px;
color: #9ca3af;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.no-results {
padding: 20px;
text-align: center;
color: #6b7280;
font-size: 14px;
}
/* Dropdown Transition */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-8px);
}
/* Indeterminate checkbox */
input[type="checkbox"]:indeterminate {
accent-color: #f97316;
}
</style>

View File

@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { onMounted } from 'vue'
import { useSkills } from './skill/useSkills' import { useSkills } from './skill/skill'
import { Edit, Trash2, Wand2, Plus, Search, X, FolderInput } from 'lucide-vue-next' import { Edit, Trash2, Wand2, Plus, Search, X, FolderInput, Play, Pause } from 'lucide-vue-next'
import { ElMessage } from 'element-plus'
import '@/views/database/database.css' import '@/views/database/database.css'
const { const {
@@ -11,104 +10,46 @@ const {
filterStatus, filterStatus,
isEditing, isEditing,
isCreating, isCreating,
isEditingContent,
editForm, editForm,
newSkillForm, newSkillForm,
newSkillContent,
filteredSkills, filteredSkills,
fetchSkills, fetchSkills,
openCreate, openCreate,
closeCreate, closeCreate,
goToEditContent,
closeEditContent,
saveNewSkill, saveNewSkill,
openEdit, openEdit,
closeEdit, closeEdit,
saveEdit, saveEdit,
toggleStatus,
deleteSkill, deleteSkill,
// 导入相关 - 从 useSkills 引入
fileInputRef,
isImporting,
isImportingDialog,
importFileName,
importSkillName,
importSkillDesc,
importSkillContent,
isImportStep2,
openImportDialog,
closeImportDialog,
handleFileChange,
submitImport,
handleFolderSelect,
// 下拉菜单
showDropdown,
onDropdownEnter,
onDropdownLeave,
} = useSkills() } = useSkills()
// 从本地导入
const fileInputRef = ref<HTMLInputElement | null>(null)
const isImporting = ref(false)
// 触发文件夹选择
const triggerImport = () => {
fileInputRef.value?.click()
}
// 处理文件夹选择
const handleFolderSelect = async (event: Event) => {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
isImporting.value = true
try {
// 获取第一个文件夹
const folder = files[0].webkitRelativePath?.split('/')[0] || files[0].name
console.log('选择的文件夹:', folder)
// 查找 SKILL.md 文件
let skillMdFile: File | null = null
for (let i = 0; i < files.length; i++) {
const file = files[i]
// 检查是否是 SKILL.md 文件
if (file.name === 'SKILL.md' || file.webkitRelativePath?.endsWith('/SKILL.md')) {
skillMdFile = file
break
}
}
if (!skillMdFile) {
ElMessage.error('导入失败:所选文件夹中未找到 SKILL.md 文件,请确保选择包含 SKILL.md 的文件夹')
return
}
// 读取 SKILL.md 内容
const content = await skillMdFile.text()
console.log('SKILL.md 内容:', content.substring(0, 100))
// 解析 SKILL.md 内容
// 格式:第一行是 skill_name后面是 skill_desc
const lines = content.trim().split('\n')
const skillName = lines[0]?.replace(/^#\s*/, '').trim() || folder
const skillDesc = lines.slice(1).join('\n').trim()
// 调用保存接口
const response = await fetch('http://localhost:8082/skill/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
skill_name: skillName,
skill_desc: skillDesc,
skill_type: 'user',
status: 'active'
})
})
if (response.ok) {
ElMessage.success(`Skill imported: ${skillName}`)
fetchSkills()
} else {
const err = await response.json()
ElMessage.error(err.message || '导入失败')
}
} catch (error) {
console.error('Import failed:', error)
ElMessage.error('导入失败,请检查文件夹格式是否正确')
} finally {
isImporting.value = false
// 清空 input 以便重新选择同一文件夹
input.value = ''
}
}
// 页面加载时获取技能列表 // 页面加载时获取技能列表
onMounted(() => { onMounted(() => {
fetchSkills() fetchSkills()
}) })
// 下拉菜单显示状态
const showDropdown = ref(false)
</script> </script>
<template> <template>
@@ -121,7 +62,7 @@ const showDropdown = ref(false)
</div> </div>
<!-- 新建按钮带下拉菜单 --> <!-- 新建按钮带下拉菜单 -->
<div class="relative" @mouseenter="showDropdown = true" @mouseleave="showDropdown = false"> <div class="relative" @mouseenter="onDropdownEnter" @mouseleave="onDropdownLeave">
<button class="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 text-white text-sm font-medium hover:from-orange-400 hover:to-amber-400 transition-all"> <button class="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-orange-500 to-amber-500 text-white text-sm font-medium hover:from-orange-400 hover:to-amber-400 transition-all">
<Plus class="w-4 h-4" /> <Plus class="w-4 h-4" />
New Skill New Skill
@@ -130,7 +71,7 @@ const showDropdown = ref(false)
</svg> </svg>
</button> </button>
<!-- 下拉菜单 --> <!-- 下拉菜单 -->
<div v-if="showDropdown" class="absolute right-0 top-full mt-2 w-48 bg-dark-700 border border-dark-500 rounded-lg shadow-xl overflow-hidden z-50"> <div v-if="showDropdown" class="absolute right-0 top-full mt-2 w-48 bg-dark-700 border border-dark-500 rounded-lg shadow-xl overflow-hidden z-50" @mouseenter="onDropdownEnter" @mouseleave="onDropdownLeave">
<button <button
@click="openCreate" @click="openCreate"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left" class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left"
@@ -142,9 +83,8 @@ const showDropdown = ref(false)
</div> </div>
</button> </button>
<button <button
@click="triggerImport" @click="openImportDialog"
:disabled="isImporting" class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left"
class="w-full flex items-center gap-3 px-4 py-3 hover:bg-dark-600 transition-colors text-left disabled:opacity-50"
> >
<FolderInput class="w-4 h-4 text-blue-400" /> <FolderInput class="w-4 h-4 text-blue-400" />
<div> <div>
@@ -154,16 +94,6 @@ const showDropdown = ref(false)
</button> </button>
</div> </div>
</div> </div>
<!-- 隐藏的文件选择器 -->
<input
ref="fileInputRef"
type="file"
webkitdirectory=""
directory=""
multiple
style="display: none"
@change="handleFolderSelect"
/>
</div> </div>
<!-- 搜索和筛选 --> <!-- 搜索和筛选 -->
@@ -222,6 +152,14 @@ const showDropdown = ref(false)
<td class="px-5 py-4 text-center text-gray-400 text-sm">{{ skill.created_at ? new Date(skill.created_at).toLocaleDateString() : '-' }}</td> <td class="px-5 py-4 text-center text-gray-400 text-sm">{{ skill.created_at ? new Date(skill.created_at).toLocaleDateString() : '-' }}</td>
<td class="px-5 py-4"> <td class="px-5 py-4">
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<button
@click="toggleStatus(skill)"
class="btn-icon"
:title="skill.status === 'active' ? 'Pause' : 'Start'"
>
<Pause v-if="skill.status === 'active'" class="w-4 h-4 text-gray-400 hover:text-yellow-400" />
<Play v-else class="w-4 h-4 text-gray-400 hover:text-green-400" />
</button>
<button @click="openEdit(skill)" class="btn-icon" title="Edit"> <button @click="openEdit(skill)" class="btn-icon" title="Edit">
<Edit class="w-4 h-4 text-gray-400 hover:text-white" /> <Edit class="w-4 h-4 text-gray-400 hover:text-white" />
</button> </button>
@@ -324,11 +262,156 @@ const showDropdown = ref(false)
<div class="flex items-center justify-end gap-3 p-5 border-t border-dark-600 bg-dark-700/50"> <div class="flex items-center justify-end gap-3 p-5 border-t border-dark-600 bg-dark-700/50">
<button @click="closeCreate" class="btn-secondary">Cancel</button> <button @click="closeCreate" class="btn-secondary">Cancel</button>
<button @click="saveNewSkill" :disabled="!newSkillForm.skill_name || !newSkillForm.skill_desc" class="btn-primary disabled:opacity-50 disabled:cursor-not-allowed">Create Skill</button> <button @click="goToEditContent" :disabled="!newSkillForm.skill_name || !newSkillForm.skill_desc" class="btn-primary disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
</div> </div>
</div> </div>
</div> </div>
</Teleport> </Teleport>
<!-- 第二步编辑 Skill 内容弹窗 -->
<Teleport to="body">
<div v-if="isEditingContent" class="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4 modal-overlay">
<div class="bg-dark-800 rounded-2xl w-full max-w-4xl border border-dark-600 shadow-2xl overflow-hidden modal-content" style="max-height: 90vh;">
<div class="flex items-center justify-between p-5 border-b border-dark-600 bg-dark-700/50">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-orange-500 to-red-500 flex items-center justify-center">
<Wand2 class="w-5 h-5 text-white" />
</div>
<div>
<h3 class="text-xl font-semibold text-white">Edit Skill Content</h3>
<p class="text-sm text-gray-400">Configure skill details in SKILL.md format</p>
</div>
</div>
<button @click="closeEditContent" class="btn-icon">
<X class="w-5 h-5" />
</button>
</div>
<div class="p-5" style="max-height: calc(90vh - 180px); overflow-y: auto;">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">SKILL.md Content</label>
<textarea v-model="newSkillContent" rows="25" placeholder="Write your skill content in markdown format..." class="input-field resize-none font-mono text-sm" style="min-height: 500px;"></textarea>
</div>
<div class="mt-3 text-xs text-gray-500">
<p>Tip: SKILL.md should contain YAML front matter with name and description, followed by the skill implementation in markdown.</p>
</div>
</div>
<div class="flex items-center justify-between gap-3 p-5 border-t border-dark-600 bg-dark-700/50">
<button @click="closeEditContent" class="btn-secondary">Back</button>
<button @click="saveNewSkill" class="btn-primary">Create Skill</button>
</div>
</div>
</div>
</Teleport>
<!-- 导入弹窗 -->
<Teleport to="body">
<div v-if="isImportingDialog" class="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4 modal-overlay">
<div class="bg-dark-800 rounded-2xl w-full max-w-2xl border border-dark-600 shadow-2xl overflow-hidden modal-content" style="max-height: 90vh;">
<!-- 第一步选择文件 -->
<div v-if="!isImportStep2">
<div class="flex items-center justify-between p-5 border-b border-dark-600 bg-dark-700/50">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-500 flex items-center justify-center">
<FolderInput class="w-5 h-5 text-white" />
</div>
<div>
<h3 class="text-xl font-semibold text-white">Import Skill</h3>
<p class="text-sm text-gray-400">Select a file or folder to import</p>
</div>
</div>
<button @click="closeImportDialog" class="btn-icon">
<X class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-4">
<!-- 导入文件选项 -->
<div
class="border-2 border-dashed border-dark-500 rounded-xl p-6 text-center cursor-pointer hover:border-blue-500 transition-colors"
@click="fileInputRef?.click?.()"
>
<FolderInput class="w-10 h-10 mx-auto text-gray-400 mb-2" />
<p class="text-white mb-1">Import File</p>
<p class="text-sm text-gray-500">Select a single SKILL.md file</p>
</div>
<input
ref="fileInputRef"
type="file"
accept=".md"
style="display: none"
@change="handleFileChange"
/>
<!-- 导入文件夹选项 -->
<input
type="file"
webkitdirectory=""
directory=""
multiple
style="display: none"
@change="handleFolderSelect"
id="folderInput"
/>
<label for="folderInput" class="block">
<div
class="border-2 border-dashed border-dark-500 rounded-xl p-6 text-center cursor-pointer hover:border-green-500 transition-colors"
>
<svg class="w-10 h-10 mx-auto text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg>
<p class="text-white mb-1">Import Folder</p>
<p class="text-sm text-gray-500">Select a folder containing SKILL.md</p>
</div>
</label>
</div>
<div class="flex items-center justify-end gap-3 p-5 border-t border-dark-600 bg-dark-700/50">
<button @click="closeImportDialog" class="btn-secondary">Cancel</button>
</div>
</template>
<!-- 第二步编辑内容 -->
<template v-else>
<div class="flex items-center justify-between p-5 border-b border-dark-600 bg-dark-700/50">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-indigo-500 flex items-center justify-center">
<FolderInput class="w-5 h-5 text-white" />
</div>
<div>
<h3 class="text-xl font-semibold text-white">Edit Skill Content</h3>
<p class="text-sm text-gray-400">Review and edit imported skill content</p>
</div>
</div>
<button @click="closeImportDialog" class="btn-icon">
<X class="w-5 h-5" />
</button>
</div>
<div class="p-5 space-y-4" style="max-height: calc(90vh - 180px); overflow-y: auto;">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Skill Name</label>
<input v-model="importSkillName" type="text" class="input-field">
</div>
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">SKILL.md Content</label>
<textarea v-model="importSkillContent" rows="20" class="input-field resize-none font-mono text-sm" style="min-height: 400px;"></textarea>
</div>
</div>
<div class="flex items-center justify-between gap-3 p-5 border-t border-dark-600 bg-dark-700/50">
<button @click="isImportStep2 = false" class="btn-secondary">Back</button>
<button @click="submitImport" :disabled="isImporting" class="btn-primary disabled:opacity-50">
{{ isImporting ? 'Importing...' : 'Import' }}
</button>
</div>
</template>
</div>
</div>
</Teleport>
</div> </div>
</template> </template>

View File

@@ -0,0 +1,496 @@
import { ref, computed } from 'vue'
import { ElMessageBox, ElMessage } from 'element-plus'
const API_BASE = 'http://localhost:8082'
export interface Skill {
id: string
skill_name: string
skill_type: string
skill_desc: string
path: string
status: string
created_at?: string
updated_at?: string
}
export function useSkills() {
// ============ 导入相关状态 ============
const fileInputRef = ref<HTMLInputElement | null>(null)
const isImporting = ref(false)
const isImportingDialog = ref(false)
const importFile = ref<File | null>(null)
const importFileName = ref('')
const importSkillName = ref('')
const importSkillDesc = ref('')
const importSkillContent = ref('')
const isImportStep2 = ref(false)
// 下拉菜单状态
const showDropdown = ref(false)
const dropdownTimeout = ref<number | null>(null)
// ============ 技能列表状态 ============
const skills = ref<Skill[]>([])
const skillsLoading = ref(false)
// 搜索和筛选
const searchQuery = ref('')
const filterStatus = ref('all')
// 编辑状态
const isEditing = ref(false)
const isCreating = ref(false)
const isEditingContent = ref(false)
const editingSkill = ref<Skill | null>(null)
// 表单
const editForm = ref({
skill_name: '',
skill_desc: '',
skill_type: 'user',
})
const newSkillForm = ref({
skill_name: '',
skill_desc: '',
skill_type: 'user',
})
const newSkillContent = ref('')
// ============ 方法 ============
// 获取技能列表
const fetchSkills = async (type?: string) => {
skillsLoading.value = true
try {
let url = `${API_BASE}/skill/list`
if (type) {
url += `?type=${type}`
}
const response = await fetch(url)
const result = await response.json()
if (result.list) {
skills.value = result.list.map((skill: any) => ({
id: skill.id,
skill_name: skill.skill_name,
skill_type: skill.skill_type,
skill_desc: skill.skill_desc || '',
path: skill.path || '',
status: skill.status || 'active',
created_at: skill.created_at,
updated_at: skill.updated_at,
}))
}
return result.list || []
} catch (error) {
console.error('Failed to fetch skills:', error)
return []
} finally {
skillsLoading.value = false
}
}
// 更新技能
const updateSkill = async (id: string, skill: Partial<Skill>) => {
try {
const response = await fetch(`${API_BASE}/skill/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(skill),
})
const data = await response.json()
await fetchSkills()
return data
} catch (error) {
console.error('Failed to update skill:', error)
throw error
}
}
// 删除技能
const deleteSkill = async (id: string) => {
try {
const response = await fetch(`${API_BASE}/skill/${id}`, {
method: 'DELETE',
})
const data = await response.json()
await fetchSkills()
return data
} catch (error) {
console.error('Failed to delete skill:', error)
throw error
}
}
// 筛选
const filteredSkills = computed(() => {
return skills.value.filter(skill => {
const matchSearch = skill.skill_name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
(skill.skill_desc && skill.skill_desc.toLowerCase().includes(searchQuery.value.toLowerCase()))
const matchStatus = filterStatus.value === 'all' || skill.status === filterStatus.value
return matchSearch && matchStatus
})
})
// 打开创建弹窗
const openCreate = () => {
newSkillForm.value = { skill_name: '', skill_desc: '', skill_type: 'user' }
newSkillContent.value = ''
isCreating.value = true
}
// 关闭创建弹窗
const closeCreate = () => {
isCreating.value = false
newSkillContent.value = ''
}
// 第一步:点击 Next跳转到编辑内容
const goToEditContent = () => {
if (!newSkillForm.value.skill_name || !newSkillForm.value.skill_desc) {
ElMessage.warning('Please fill in skill name and description')
return
}
newSkillContent.value = `---
name: ${newSkillForm.value.skill_name}
description: ${newSkillForm.value.skill_desc}
---
# ${newSkillForm.value.skill_name}
${newSkillForm.value.skill_desc}
`
isCreating.value = false
isEditingContent.value = true
}
// 关闭编辑内容弹窗
const closeEditContent = () => {
isEditingContent.value = false
newSkillContent.value = ''
}
// 第二步:保存技能内容
const saveNewSkill = async () => {
try {
const blob = new Blob([newSkillContent.value], { type: 'text/markdown' })
const file = new File([blob], 'SKILL.md', { type: 'text/markdown' })
const formData = new FormData()
formData.append('skill_name', newSkillForm.value.skill_name)
formData.append('skill_desc', newSkillForm.value.skill_desc)
formData.append('skill_type', newSkillForm.value.skill_type)
formData.append('file', file)
const response = await fetch(`${API_BASE}/skill/add`, {
method: 'POST',
body: formData,
})
const data = await response.json()
if (response.ok) {
ElMessage.success('Skill created successfully')
isEditingContent.value = false
newSkillContent.value = ''
await fetchSkills()
} else {
ElMessage.error(data.error || 'Failed to create skill')
}
} catch (error) {
console.error('Failed to create skill:', error)
ElMessage.error('Failed to create skill')
}
}
// 打开编辑弹窗
const openEdit = (skill: Skill) => {
editingSkill.value = skill
editForm.value = {
skill_name: skill.skill_name,
skill_desc: skill.skill_desc,
skill_type: skill.skill_type,
}
isEditing.value = true
}
// 关闭编辑弹窗
const closeEdit = () => {
isEditing.value = false
editingSkill.value = null
}
// 保存编辑
const saveEdit = async () => {
try {
await updateSkill(editingSkill.value!.id, {
skill_name: editForm.value.skill_name,
skill_desc: editForm.value.skill_desc,
skill_type: editForm.value.skill_type,
})
ElMessage.success('Skill updated successfully')
isEditing.value = false
} catch (error) {
ElMessage.error('Failed to update skill')
}
}
// 切换状态
const toggleStatus = async (skill: Skill) => {
const newStatus = skill.status === 'active' ? 'inactive' : 'active'
try {
await updateSkill(skill.id, { status: newStatus })
skill.status = newStatus
} catch (error) {
ElMessage.error('Failed to update status')
}
}
// 删除技能(带确认)
const handleDeleteSkill = async (id: string) => {
try {
await ElMessageBox.confirm('Are you sure you want to delete this skill?', 'Confirm Delete', {
confirmButtonText: 'Delete',
cancelButtonText: 'Cancel',
type: 'warning',
})
await deleteSkill(id)
ElMessage.success('Skill deleted successfully')
} catch (error: any) {
if (error !== 'cancel') {
console.error('Failed to delete skill:', error)
ElMessage.error('Failed to delete skill')
}
}
}
// ============ 导入相关方法 ============
// 打开导入弹窗
const openImportDialog = () => {
showDropdown.value = false
isImportingDialog.value = true
importFile.value = null
importFileName.value = ''
importSkillName.value = ''
importSkillDesc.value = ''
importSkillContent.value = ''
isImportStep2.value = false
}
// 关闭导入弹窗
const closeImportDialog = () => {
isImportingDialog.value = false
importFile.value = null
importFileName.value = ''
isImportStep2.value = false
}
// 解析 SKILL.md 内容
const parseSkillContent = (content: string, fallbackName: string) => {
let skillName = ''
let skillDesc = ''
const trimmedContent = content.trim()
if (trimmedContent.startsWith('---')) {
const endIndex = trimmedContent.indexOf('---', 3)
if (endIndex > 3) {
const yamlContent = trimmedContent.substring(3, endIndex).trim()
const nameMatch = yamlContent.match(/name:\s*(.+)/)
const descMatch = yamlContent.match(/description:\s*(.+)/)
skillName = nameMatch ? nameMatch[1].trim() : fallbackName
skillDesc = descMatch ? descMatch[1].trim() : ''
const afterYaml = trimmedContent.substring(endIndex + 3).trim()
if (afterYaml && !skillDesc) {
skillDesc = afterYaml
}
}
}
if (!skillName) {
const lines = trimmedContent.split('\n')
skillName = lines[0]?.replace(/^#\s*/, '').trim() || fallbackName
skillDesc = lines.slice(1).join('\n').trim()
}
return { skillName, skillDesc }
}
// 处理文件选择
const handleFileChange = async (event: Event) => {
const input = event.target as HTMLInputElement
if (!input.files || input.files.length === 0) return
const file = input.files[0]
importFile.value = file
importFileName.value = file.name
try {
const content = await file.text()
const { skillName, skillDesc } = parseSkillContent(content, file.name.replace('.md', ''))
importSkillName.value = skillName
importSkillDesc.value = skillDesc
importSkillContent.value = content
isImportStep2.value = true
} catch (error) {
console.error('Failed to read file:', error)
ElMessage.error('读取文件失败')
}
input.value = ''
}
// 提交导入
const submitImport = async () => {
if (!importSkillName.value || !importSkillContent.value) {
ElMessage.warning('请选择有效的技能文件')
return
}
isImporting.value = true
try {
const blob = new Blob([importSkillContent.value], { type: 'text/markdown' })
const file = new File([blob], 'SKILL.md', { type: 'text/markdown' })
const formData = new FormData()
formData.append('skill_name', importSkillName.value)
formData.append('skill_desc', importSkillDesc.value)
formData.append('file', file)
const response = await fetch(`${API_BASE}/skill/add`, {
method: 'POST',
body: formData
})
if (response.ok) {
ElMessage.success(`Skill imported: ${importSkillName.value}`)
await fetchSkills()
closeImportDialog()
} else {
const err = await response.json()
ElMessage.error(err.message || '导入失败')
}
} catch (error) {
console.error('Import failed:', error)
ElMessage.error('导入失败,请检查文件格式是否正确')
} finally {
isImporting.value = false
}
}
// 处理文件夹选择
const handleFolderSelect = async (event: Event) => {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
isImporting.value = true
try {
const folder = files[0].webkitRelativePath?.split('/')[0] || files[0].name
// 查找 SKILL.md 文件
let skillMdFile: File | null = null
for (let i = 0; i < files.length; i++) {
const file = files[i]
if (file.name === 'SKILL.md' || file.webkitRelativePath?.endsWith('/SKILL.md')) {
skillMdFile = file
break
}
}
if (!skillMdFile) {
ElMessage.error('导入失败:所选文件夹中未找到 SKILL.md 文件')
return
}
const content = await skillMdFile.text()
const { skillName, skillDesc } = parseSkillContent(content, folder)
// 使用导入弹窗显示内容
importSkillName.value = skillName
importSkillDesc.value = skillDesc
importSkillContent.value = content
isImportingDialog.value = true
isImportStep2.value = true
} catch (error) {
console.error('Import failed:', error)
ElMessage.error('导入失败,请检查文件夹格式是否正确')
} finally {
isImporting.value = false
input.value = ''
}
}
// 鼠标进入时显示
const onDropdownEnter = () => {
if (dropdownTimeout.value) {
clearTimeout(dropdownTimeout.value)
dropdownTimeout.value = null
}
showDropdown.value = true
}
// 鼠标离开时延迟隐藏
const onDropdownLeave = () => {
dropdownTimeout.value = window.setTimeout(() => {
showDropdown.value = false
}, 300)
}
// ============ 返回 ============
return {
// State
skills,
skillsLoading,
searchQuery,
filterStatus,
isEditing,
isCreating,
isEditingContent,
editingSkill,
editForm,
newSkillForm,
newSkillContent,
// Computed
filteredSkills,
// Methods
fetchSkills,
openCreate,
closeCreate,
goToEditContent,
closeEditContent,
saveNewSkill,
openEdit,
closeEdit,
saveEdit,
toggleStatus,
deleteSkill: handleDeleteSkill,
// 导入相关
fileInputRef,
isImporting,
isImportingDialog,
importFileName,
importSkillName,
importSkillDesc,
importSkillContent,
isImportStep2,
openImportDialog,
closeImportDialog,
handleFileChange,
submitImport,
handleFolderSelect,
// 下拉菜单
showDropdown,
dropdownTimeout,
onDropdownEnter,
onDropdownLeave,
}
}