feat: 新增技能相关功能
This commit is contained in:
250
src/pages/skills/components/SkillDetailDrawer.vue
Normal file
250
src/pages/skills/components/SkillDetailDrawer.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<el-drawer
|
||||
:model-value="isOpen"
|
||||
@update:model-value="(v: boolean) => !v && $emit('close')"
|
||||
size="450px"
|
||||
direction="rtl"
|
||||
:with-header="false"
|
||||
class="skill-detail-drawer"
|
||||
>
|
||||
<div v-if="skill" class="flex flex-col h-full bg-[#f3f1e9]">
|
||||
<div class="flex-1 overflow-y-auto px-8 py-10">
|
||||
<div class="flex flex-col items-center mb-8">
|
||||
<div class="w-16 h-16 flex items-center justify-center rounded-full bg-white border border-black/5 shrink-0 mb-4 relative shadow-sm">
|
||||
<span class="text-3xl">{{ skill.icon || '🔧' }}</span>
|
||||
<div v-if="skill.isCore" class="absolute -bottom-1 -right-1 bg-[#f3f1e9] rounded-full p-1 shadow-sm border border-black/5">
|
||||
<RiLockLine class="h-3 w-3 text-[#525866] shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-[28px] font-serif text-[#171717] font-normal mb-3 text-center tracking-tight">
|
||||
{{ skill.name }}
|
||||
</h2>
|
||||
<div class="flex items-center justify-center gap-2.5 mb-6 opacity-80">
|
||||
<el-tag type="info" effect="plain" class="!rounded-full !bg-black/[0.04] !border-0 !text-[#171717]/70 !px-3 !py-0.5 !text-[11px] !font-mono">
|
||||
v{{ skill.version || '1.0.0' }}
|
||||
</el-tag>
|
||||
<el-tag type="info" effect="plain" class="!rounded-full !bg-black/[0.04] !border-0 !text-[#171717]/70 !px-3 !py-0.5 !text-[11px]">
|
||||
{{ skill.isCore ? t('skills.detail.coreSystem') : skill.isBundled ? t('skills.detail.bundled') : t('skills.detail.userInstalled') }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<p v-if="skill.description" class="text-[14px] text-[#171717]/70 font-medium leading-[1.6] text-center px-4">
|
||||
{{ skill.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-7 px-1">
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-[13px] font-bold text-[#171717]/80">{{ t('skills.detail.source') }}</h3>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<el-tag type="info" effect="plain" class="!rounded-full !bg-black/[0.04] !border-0 !text-[#171717]/70 !px-3 !py-0.5 !text-[11px]">
|
||||
{{ resolvedSource }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-input
|
||||
:model-value="skill.baseDir || t('skills.detail.pathUnavailable')"
|
||||
readonly
|
||||
class="flex-1 !h-[38px] font-mono text-[12px] bg-[#eeece3] border-black/10 rounded-xl text-[#171717]/70"
|
||||
/>
|
||||
<el-button
|
||||
class="!h-[38px] !w-[38px] !px-0"
|
||||
@click="handleCopyPath"
|
||||
:disabled="!skill.baseDir"
|
||||
:title="t('skills.detail.copyPath')"
|
||||
>
|
||||
<RiFileCopyLine class="h-3.5 w-3.5" />
|
||||
</el-button>
|
||||
<el-button
|
||||
class="!h-[38px] !w-[38px] !px-0"
|
||||
@click="handleOpenFolder"
|
||||
:disabled="!skill.baseDir"
|
||||
:title="t('skills.detail.openActualFolder')"
|
||||
>
|
||||
<RiFolderOpenLine class="h-3.5 w-3.5" />
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!skill.isCore" class="space-y-2">
|
||||
<h3 class="text-[13px] font-bold flex items-center gap-2 text-[#171717]/80">
|
||||
<RiKeyLine class="h-3.5 w-3.5 text-blue-500" />
|
||||
{{ t('skills.detail.apiKey') }}
|
||||
</h3>
|
||||
<el-input
|
||||
v-model="apiKey"
|
||||
:placeholder="t('skills.detail.apiKeyPlaceholder', 'Enter API Key (optional)')"
|
||||
type="password"
|
||||
show-password
|
||||
class="!h-[44px]"
|
||||
/>
|
||||
<p class="text-[12px] text-[#171717]/50 mt-2 font-medium">
|
||||
{{ t('skills.detail.apiKeyDesc', 'The primary API key for this skill.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EnvVarManager v-if="!skill.isCore" v-model="envVars" />
|
||||
|
||||
<div v-if="skill.slug && !skill.isBundled && !skill.isCore" class="flex gap-2 justify-center pt-8">
|
||||
<el-button size="small" class="!h-[28px] !text-[11px] !font-medium !px-3 !gap-1.5 !rounded-full !border-black/10 !bg-transparent hover:!bg-black/5" @click="handleOpenClawhub">
|
||||
<RiGlobalLine class="h-3 w-3" />
|
||||
ClawHub
|
||||
</el-button>
|
||||
<el-button size="small" class="!h-[28px] !text-[11px] !font-medium !px-3 !gap-1.5 !rounded-full !border-black/10 !bg-transparent hover:!bg-black/5" @click="handleOpenEditor">
|
||||
<RiFileCodeLine class="h-3 w-3" />
|
||||
{{ t('skills.detail.openManual') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-8 pb-4 flex items-center justify-center gap-4 w-full px-2 max-w-[340px] mx-auto">
|
||||
<el-button
|
||||
v-if="!skill.isCore"
|
||||
type="primary"
|
||||
class="flex-1 !h-[42px] !text-[13px] !rounded-full !font-semibold"
|
||||
:loading="isSaving"
|
||||
@click="handleSaveConfig"
|
||||
>
|
||||
{{ isSaving ? t('skills.detail.saving') : t('skills.detail.saveConfig') }}
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="!skill.isCore"
|
||||
class="flex-1 !h-[42px] !text-[13px] !rounded-full !font-semibold !border-black/20 hover:!bg-black/5"
|
||||
@click="handleSecondaryAction"
|
||||
>
|
||||
{{ secondaryLabel }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { Skill } from '@src/lib/skills-types'
|
||||
import { apiUpdateSkillConfig, apiOpenSkillPath, apiOpenSkillReadme } from '@src/lib/skills-api'
|
||||
import { resolveSkillSourceLabel } from '@src/lib/skills-utils'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { RiLockLine, RiKeyLine, RiFileCopyLine, RiFolderOpenLine, RiGlobalLine, RiFileCodeLine } from '@remixicon/vue'
|
||||
import EnvVarManager from './EnvVarManager.vue'
|
||||
import { useSkillsStore } from '@src/store/skills'
|
||||
|
||||
const props = defineProps<{
|
||||
skill: Skill | null
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
toggle: [enabled: boolean]
|
||||
uninstall: [slug: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useSkillsStore()
|
||||
|
||||
const apiKey = ref('')
|
||||
const envVars = ref<Array<{ key: string; value: string }>>([])
|
||||
const isSaving = ref(false)
|
||||
|
||||
watch(() => props.skill, (s) => {
|
||||
if (!s) return
|
||||
apiKey.value = String(s.config?.apiKey || '')
|
||||
if (s.config?.env && typeof s.config.env === 'object') {
|
||||
envVars.value = Object.entries(s.config.env).map(([k, v]) => ({ key: k, value: String(v) }))
|
||||
} else {
|
||||
envVars.value = []
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const resolvedSource = computed(() => {
|
||||
if (!props.skill) return ''
|
||||
return resolveSkillSourceLabel(props.skill, t)
|
||||
})
|
||||
|
||||
const secondaryLabel = computed(() => {
|
||||
const s = props.skill
|
||||
if (!s) return ''
|
||||
if (!s.isBundled) {
|
||||
return t('skills.detail.uninstall', 'Uninstall')
|
||||
}
|
||||
return s.enabled ? t('skills.detail.disable', 'Disable') : t('skills.detail.enable', 'Enable')
|
||||
})
|
||||
|
||||
function handleOpenClawhub() {
|
||||
if (!props.skill?.slug) return
|
||||
window.api.external.open(`https://clawhub.ai/s/${props.skill.slug}`)
|
||||
}
|
||||
|
||||
async function handleOpenEditor() {
|
||||
if (!props.skill) return
|
||||
try {
|
||||
await apiOpenSkillReadme(props.skill.id, props.skill.slug, props.skill.baseDir)
|
||||
ElMessage.success(t('skills.toast.openedEditor'))
|
||||
} catch (err) {
|
||||
ElMessage.error(t('skills.toast.failedEditor') + ': ' + String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyPath() {
|
||||
if (!props.skill?.baseDir) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.skill.baseDir)
|
||||
ElMessage.success(t('skills.toast.copiedPath'))
|
||||
} catch (err) {
|
||||
ElMessage.error(t('skills.toast.failedCopyPath') + ': ' + String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenFolder() {
|
||||
if (!props.skill) return
|
||||
try {
|
||||
await apiOpenSkillPath(props.skill.id, props.skill.slug, props.skill.baseDir)
|
||||
} catch (err) {
|
||||
ElMessage.error(t('skills.toast.failedOpenActualFolder') + ': ' + String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveConfig() {
|
||||
if (isSaving.value || !props.skill) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const envObj = envVars.value.reduce((acc, curr) => {
|
||||
const k = curr.key.trim()
|
||||
if (k) acc[k] = curr.value.trim()
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
await apiUpdateSkillConfig(props.skill.id, {
|
||||
apiKey: apiKey.value || '',
|
||||
env: envObj,
|
||||
})
|
||||
|
||||
await store.fetchSkills()
|
||||
ElMessage.success(t('skills.detail.configSaved'))
|
||||
} catch (err) {
|
||||
ElMessage.error(t('skills.toast.failedSave') + ': ' + String(err))
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSecondaryAction() {
|
||||
const s = props.skill
|
||||
if (!s) return
|
||||
if (!s.isBundled) {
|
||||
emit('uninstall', s.slug || s.id)
|
||||
emit('close')
|
||||
} else {
|
||||
emit('toggle', !s.enabled)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.skill-detail-drawer .el-drawer__body) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user