Files
zn-ai/src/pages/skills/index.vue
2026-04-11 10:55:11 +08:00

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>