- Updated styles in AccountSetting, SystemConfig, Version, and other components to improve dark mode visibility. - Added dark mode classes for text and background colors to ensure better contrast and readability. - Modified CSS variables in dark.css for consistent theming. - Improved accessibility by ensuring all text elements are legible in dark mode.
251 lines
9.4 KiB
Vue
251 lines
9.4 KiB
Vue
<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] dark:bg-[#1b1b1d]">
|
|
<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 dark:bg-[#222225] border border-black/5 dark:border-gray-700 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] dark:bg-[#1b1b1d] rounded-full p-1 shadow-sm border border-black/5 dark:border-gray-700">
|
|
<RiLockLine class="h-3 w-3 text-[#525866] dark:text-gray-400 shrink-0" />
|
|
</div>
|
|
</div>
|
|
<h2 class="text-[28px] font-serif text-[#171717] dark:text-[#f3f4f6] 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] dark:!bg-white/[0.04] !border-0 !text-[#171717]/70 dark:!text-[#f3f4f6]/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] dark:!bg-white/[0.04] !border-0 !text-[#171717]/70 dark:!text-[#f3f4f6]/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 dark:text-gray-400 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 dark:text-gray-300">{{ 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] dark:!bg-white/[0.04] !border-0 !text-[#171717]/70 dark:!text-[#f3f4f6]/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] dark:bg-[#222225] border-black/10 dark:border-gray-700 rounded-xl text-[#171717]/70 dark:text-gray-300"
|
|
/>
|
|
<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 dark:text-gray-300">
|
|
<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 dark:text-gray-500 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 dark:!border-gray-700 !bg-transparent hover:!bg-black/5 dark:hover:!bg-white/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 dark:!border-gray-700 !bg-transparent hover:!bg-black/5 dark:hover:!bg-white/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 dark:!border-gray-700 hover:!bg-black/5 dark:hover:!bg-white/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>
|