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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
496
web/src/views/skill/skill.ts
Normal file
496
web/src/views/skill/skill.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user