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">
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 数据
const agents = ref([
@@ -16,27 +28,114 @@ const isCreating = ref(false)
const newAgent = ref({
name: '',
description: '',
skills: '',
skillsMode: 'all' as 'all' | 'include' | 'exclude',
selectedSkills: [] as string[],
knowledge: '',
prompt: '',
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 = [
'🤖', '🧠', '💻', '📊', '🔬', '🎧', '✨', '💬', '🔮', '🌙',
'🐉', '☁️', '🎨', '🎯', '🚀', '⚡', '🔥', '💡', '🎭', '🎪'
]
// Skills 选项
const skillsOptions = [
{ value: 'research', label: 'Research' },
{ value: 'coder', label: 'Coder' },
{ value: 'review', label: 'Code Review' },
{ value: 'writer', label: 'Writer' },
{ value: 'analyst', label: 'Analyst' },
{ value: 'assistant', label: 'Assistant' },
]
// Skills 列表
const skillsList = ref<Skill[]>([])
const skillsLoading = ref(false)
// 获取 skills 列表
const fetchSkills = async () => {
skillsLoading.value = true
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 选项
const knowledgeOptions = [
@@ -46,20 +145,110 @@ const knowledgeOptions = [
{ 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 = () => {
newAgent.value = { name: '', description: '', skills: '', knowledge: '', prompt: '', avatar: '🤖' }
newAgent.value = {
name: '',
description: '',
skillsMode: 'all' as const,
selectedSkills: [],
knowledge: '',
prompt: '',
avatar: '🤖'
}
showCreateModal.value = true
}
// 创建智能体
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
}
isCreating.value = true
try {
// 处理 skills 标签
const skillsLabels = newAgent.value.selectedSkills.map((id: string) => getSkillLabel(id)).join(', ')
// 模拟创建
const newId = Math.max(...agents.value.map(a => a.id)) + 1
agents.value.unshift({
@@ -70,7 +259,7 @@ const createAgent = async () => {
accentColor: '#f97316',
gradient: 'from-orange-500/20 to-amber-500/20',
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,
mcpServers: 0,
createdAt: new Date().toISOString().split('T')[0],
@@ -284,9 +473,138 @@ const deleteAgent = (id: number) => {
<div>
<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" />
</el-select>
<!-- 技能模式选择 - 两级下拉框 -->
<div class="skills-selector">
<!-- 已选 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>
@@ -316,7 +634,7 @@ const deleteAgent = (id: number) => {
</button>
<button
@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"
>
<i v-if="isCreating" class="fa-solid fa-circle-notch fa-spin"></i>
@@ -327,3 +645,388 @@ const deleteAgent = (id: number) => {
</div>
</Teleport>
</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>