338 lines
12 KiB
Vue
338 lines
12 KiB
Vue
<template>
|
|
<layout>
|
|
<div class="bg-white box-border w-full h-full flex rounded-[16px] overflow-hidden">
|
|
<div class="w-full flex flex-col h-full p-10 pt-12">
|
|
<!-- Header -->
|
|
<div class="flex flex-col md:flex-row md:items-start justify-between mb-6 shrink-0 gap-4">
|
|
<div>
|
|
<h1 class="text-5xl md:text-6xl font-serif text-[#171717] mb-3 font-normal tracking-tight" style="font-family: Georgia, Cambria, 'Times New Roman', Times, serif">
|
|
{{ t('skills.title') }}
|
|
</h1>
|
|
<p class="text-[17px] text-[#171717]/70 font-medium">
|
|
{{ t('skills.subtitle') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-3 md:mt-2">
|
|
<button
|
|
v-if="hasInstalledSkills"
|
|
@click="handleOpenSkillsFolder"
|
|
class="hover:bg-black/5 transition-colors shrink-0 text-[13px] font-medium px-4 h-8 rounded-full border border-black/10 flex items-center justify-center text-[#171717]/80 hover:text-[#171717]"
|
|
>
|
|
<RiFolderOpenLine class="h-4 w-4 mr-2" />
|
|
{{ t('skills.openFolder') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sub Navigation and Actions -->
|
|
<div class="flex flex-col md:flex-row md:items-center justify-between border-b border-black/10 pb-4 mb-4 shrink-0 gap-4">
|
|
<div class="flex items-center flex-wrap gap-4 text-[14px]">
|
|
<div class="relative group flex items-center bg-black/5 rounded-full px-3 py-1.5 focus-within:bg-black/10 transition-colors border border-transparent focus-within:border-black/10 mr-2">
|
|
<RiSearchLine class="h-4 w-4 shrink-0 text-[#525866]" />
|
|
<input
|
|
:placeholder="t('skills.search')"
|
|
v-model="searchQuery"
|
|
class="ml-2 bg-transparent outline-none w-28 md:w-40 font-normal placeholder:text-[#171717]/50 text-[13px] text-[#171717]"
|
|
/>
|
|
<button
|
|
v-if="searchQuery"
|
|
type="button"
|
|
@click="searchQuery = ''"
|
|
class="text-[#171717]/50 hover:text-[#171717] shrink-0 ml-1"
|
|
>
|
|
<RiCloseLine class="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-6">
|
|
<button
|
|
@click="selectedSource = 'all'"
|
|
:class="['font-medium transition-colors flex items-center gap-1.5', selectedSource === 'all' ? 'text-[#171717]' : 'text-[#525866] hover:text-[#171717]']"
|
|
>
|
|
{{ t('skills.filter.all', { count: sourceStats.all }) }}
|
|
</button>
|
|
<button
|
|
@click="selectedSource = 'built-in'"
|
|
:class="['font-medium transition-colors flex items-center gap-1.5', selectedSource === 'built-in' ? 'text-[#171717]' : 'text-[#525866] hover:text-[#171717]']"
|
|
>
|
|
{{ t('skills.filter.builtIn', { count: sourceStats.builtIn }) }}
|
|
</button>
|
|
<button
|
|
@click="selectedSource = 'marketplace'"
|
|
:class="['font-medium transition-colors flex items-center gap-1.5', selectedSource === 'marketplace' ? 'text-[#171717]' : 'text-[#525866] hover:text-[#171717]']"
|
|
>
|
|
{{ t('skills.filter.marketplace', { count: sourceStats.marketplace }) }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<el-button
|
|
size="small"
|
|
@click="bulkToggleVisible(true)"
|
|
class="!h-8 !text-[13px] !font-medium !rounded-md !px-3 !border-black/10 !bg-transparent hover:!bg-black/5"
|
|
>
|
|
{{ t('skills.actions.enableVisible') }}
|
|
</el-button>
|
|
<el-button
|
|
size="small"
|
|
@click="bulkToggleVisible(false)"
|
|
class="!h-8 !text-[13px] !font-medium !rounded-md !px-3 !border-black/10 !bg-transparent hover:!bg-black/5"
|
|
>
|
|
{{ t('skills.actions.disableVisible') }}
|
|
</el-button>
|
|
<el-button
|
|
size="small"
|
|
@click="openMarketplace"
|
|
class="!h-8 !text-[13px] !font-medium !rounded-md !px-3 !border-black/10 !bg-transparent hover:!bg-black/5"
|
|
>
|
|
{{ t('skills.actions.installSkill') }}
|
|
</el-button>
|
|
<el-button
|
|
size="small"
|
|
@click="store.fetchSkills()"
|
|
class="!h-8 !w-8 !ml-1 !rounded-md !border-black/10 !bg-transparent hover:!bg-black/5 !text-[#525866] hover:!text-[#171717]"
|
|
:title="t('skills.refresh')"
|
|
>
|
|
<RiRefreshLine :class="['h-4 w-4', store.loading && 'animate-spin']" />
|
|
</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Area -->
|
|
<div class="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2">
|
|
<div v-if="store.error" class="mb-4 p-4 rounded-xl border border-red-500/50 bg-red-500/10 text-red-600 text-sm font-medium flex items-center gap-2">
|
|
<RiErrorWarningLine class="h-5 w-5 shrink-0" />
|
|
<span>
|
|
{{ FETCH_ERROR_CODES.has(store.error) ? String(t(`skills.toast.${store.error}`, { path: skillsDirPath })) : store.error }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="store.loading && store.skills.length === 0" class="flex flex-col items-center justify-center h-full text-[#525866]">
|
|
<RiRefreshLine class="h-10 w-10 animate-spin mb-4" />
|
|
<p>{{ t('skills.marketplace.searching') }}</p>
|
|
</div>
|
|
|
|
<div v-else class="flex flex-col gap-1">
|
|
<template v-if="filteredSkills.length === 0">
|
|
<div class="flex flex-col items-center justify-center py-20 text-[#525866]">
|
|
<RiPuzzleLine class="h-10 w-10 mb-4 opacity-50" />
|
|
<p>{{ searchQuery ? t('skills.noSkillsSearch') : t('skills.noSkillsAvailable') }}</p>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<SkillListItem
|
|
v-for="skill in filteredSkills"
|
|
:key="skill.id"
|
|
:skill="skill"
|
|
@click="selectedSkill = skill"
|
|
@toggle="(enabled) => handleToggle(skill.id, enabled)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<MarketplaceDrawer
|
|
v-model="installDrawerOpen"
|
|
:query="installQuery"
|
|
@update:query="installQuery = $event"
|
|
:search-results="store.searchResults"
|
|
:searching="store.searching"
|
|
:search-error="store.searchError"
|
|
:installing="store.installing"
|
|
:installed-skills="store.skills"
|
|
@install="handleInstall"
|
|
@uninstall="handleUninstall"
|
|
/>
|
|
|
|
<SkillDetailDrawer
|
|
:skill="selectedSkill"
|
|
:is-open="!!selectedSkill"
|
|
@close="selectedSkill = null"
|
|
@toggle="(enabled) => {
|
|
if (!selectedSkill) return
|
|
handleToggle(selectedSkill.id, enabled)
|
|
selectedSkill.enabled = enabled
|
|
}"
|
|
@uninstall="(slug) => handleUninstall(slug)"
|
|
/>
|
|
</layout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useSkillsStore, FETCH_ERROR_CODES } from '@src/store/skills'
|
|
import { MOCK_SKILLS, MOCK_MARKETPLACE } from '@src/lib/skills-api'
|
|
import type { Skill, MarketplaceSkill } from '@src/lib/skills-types'
|
|
import { ElMessage } from 'element-plus'
|
|
import {
|
|
RiSearchLine,
|
|
RiPuzzleLine,
|
|
RiCloseLine,
|
|
RiErrorWarningLine,
|
|
RiRefreshLine,
|
|
RiFolderOpenLine,
|
|
} from '@remixicon/vue'
|
|
import SkillListItem from './components/SkillListItem.vue'
|
|
import SkillDetailDrawer from './components/SkillDetailDrawer.vue'
|
|
import MarketplaceDrawer from './components/MarketplaceDrawer.vue'
|
|
|
|
const { t } = useI18n()
|
|
const store = useSkillsStore()
|
|
|
|
const searchQuery = ref('')
|
|
const installQuery = ref('')
|
|
const installDrawerOpen = ref(false)
|
|
const selectedSkill = ref<Skill | null>(null)
|
|
const selectedSource = ref<'all' | 'built-in' | 'marketplace'>('all')
|
|
const skillsDirPath = ref('~/.zn-ai/skills')
|
|
|
|
onMounted(() => {
|
|
// 演示模式:直接填充本地 Mock 数据,避免调用后端接口
|
|
store.skills = MOCK_SKILLS as Skill[]
|
|
store.loading = false
|
|
// store.fetchSkills()
|
|
// skillsDirPath.value = await apiGetSkillsDir()
|
|
})
|
|
|
|
const safeSkills = computed(() => (Array.isArray(store.skills) ? store.skills : []))
|
|
|
|
const filteredSkills = computed(() => {
|
|
const q = searchQuery.value.toLowerCase().trim()
|
|
const result = safeSkills.value.filter((skill: Skill) => {
|
|
const matchesSearch =
|
|
q.length === 0 ||
|
|
skill.name.toLowerCase().includes(q) ||
|
|
skill.description.toLowerCase().includes(q) ||
|
|
skill.id.toLowerCase().includes(q) ||
|
|
(skill.slug || '').toLowerCase().includes(q) ||
|
|
(skill.author || '').toLowerCase().includes(q)
|
|
|
|
let matchesSource = true
|
|
if (selectedSource.value === 'built-in') {
|
|
matchesSource = !!skill.isBundled
|
|
} else if (selectedSource.value === 'marketplace') {
|
|
matchesSource = !skill.isBundled
|
|
}
|
|
|
|
return matchesSearch && matchesSource
|
|
})
|
|
|
|
result.sort((a: Skill, b: Skill) => {
|
|
if (a.enabled && !b.enabled) return -1
|
|
if (!a.enabled && b.enabled) return 1
|
|
if (a.isCore && !b.isCore) return -1
|
|
if (!a.isCore && b.isCore) return 1
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
|
|
return result
|
|
})
|
|
|
|
const sourceStats = computed(() => store.sourceStats)
|
|
|
|
const hasInstalledSkills = computed(() => safeSkills.value.some((s) => !s.isBundled))
|
|
|
|
async function bulkToggleVisible(enable: boolean) {
|
|
const candidates = filteredSkills.value.filter((skill) => !skill.isCore && skill.enabled !== enable)
|
|
if (candidates.length === 0) {
|
|
ElMessage.info(enable ? t('skills.toast.noBatchEnableTargets') : t('skills.toast.noBatchDisableTargets'))
|
|
return
|
|
}
|
|
|
|
let succeeded = 0
|
|
for (const skill of candidates) {
|
|
try {
|
|
// Demo mode: skip store.enableSkill / store.disableSkill API calls
|
|
skill.enabled = enable
|
|
succeeded += 1
|
|
} catch {
|
|
// Continue to next skill
|
|
}
|
|
}
|
|
|
|
if (succeeded === candidates.length) {
|
|
ElMessage.success(enable ? t('skills.toast.batchEnabled', { count: succeeded }) : t('skills.toast.batchDisabled', { count: succeeded }))
|
|
return
|
|
}
|
|
ElMessage.warning(t('skills.toast.batchPartial', { success: succeeded, total: candidates.length }))
|
|
}
|
|
|
|
async function handleToggle(skillId: string, enable: boolean) {
|
|
const skill = safeSkills.value.find((s: Skill) => s.id === skillId)
|
|
if (!skill || skill.isCore) return
|
|
skill.enabled = enable
|
|
ElMessage.success(enable ? t('skills.toast.enabled') : t('skills.toast.disabled'))
|
|
}
|
|
|
|
async function handleOpenSkillsFolder() {
|
|
// Demo mode: skip API call
|
|
ElMessage.info(t('skills.toast.copiedPath') + ' (Demo)')
|
|
}
|
|
|
|
function openMarketplace() {
|
|
installQuery.value = ''
|
|
installDrawerOpen.value = true
|
|
}
|
|
|
|
watch(installDrawerOpen, (open) => {
|
|
if (open) {
|
|
store.searchResults = MOCK_MARKETPLACE as any
|
|
// store.searchSkills('')
|
|
}
|
|
})
|
|
|
|
watch(installQuery, (q) => {
|
|
if (!installDrawerOpen.value) return
|
|
const query = q.trim()
|
|
const all = MOCK_MARKETPLACE
|
|
if (query.length === 0) {
|
|
store.searchResults = all as any
|
|
return
|
|
}
|
|
setTimeout(() => {
|
|
const lower = query.toLowerCase()
|
|
store.searchResults = all.filter(
|
|
(s: MarketplaceSkill) => s.name.toLowerCase().includes(lower) || s.description.toLowerCase().includes(lower)
|
|
) as any
|
|
// store.searchSkills(query)
|
|
}, 300)
|
|
})
|
|
|
|
async function handleInstall(slug: string) {
|
|
// Demo mode: 模拟安装
|
|
const ms = MOCK_MARKETPLACE.find((s: MarketplaceSkill) => s.slug === slug)
|
|
if (ms && !store.skills.some((s: Skill) => s.id === slug || s.name === ms.name)) {
|
|
store.skills.push({
|
|
id: slug,
|
|
slug,
|
|
name: ms.name,
|
|
description: ms.description,
|
|
enabled: true,
|
|
icon: '📦',
|
|
version: ms.version || '1.0.0',
|
|
isCore: false,
|
|
isBundled: false,
|
|
source: 'openclaw-managed',
|
|
author: ms.author,
|
|
} as Skill)
|
|
}
|
|
ElMessage.success(t('skills.toast.installed') + ' (Demo)')
|
|
}
|
|
|
|
async function handleUninstall(slug: string) {
|
|
// Demo mode: 模拟卸载
|
|
const idx = store.skills.findIndex((s: Skill) => s.slug === slug)
|
|
if (idx > -1) store.skills.splice(idx, 1)
|
|
ElMessage.success(t('skills.toast.uninstalled') + ' (Demo)')
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* scrollbar styling is globally handled in tailwind.css */
|
|
</style>
|