feat: 新增技能相关功能
This commit is contained in:
@@ -1,11 +1,345 @@
|
||||
<template>
|
||||
<layout>
|
||||
<div class="bg-white box-border w-full h-full flex rounded-[16px]">
|
||||
|
||||
<layout>
|
||||
<div class="bg-[#f9f7f0] box-border w-full h-full flex rounded-[16px] overflow-hidden">
|
||||
<div class="w-full max-w-5xl mx-auto 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"></script>
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSkillsStore, FETCH_ERROR_CODES, INSTALL_ERROR_CODES } from '@src/store/skills'
|
||||
import { apiGetSkillsDir, apiOpenSkillsDir } from '@src/lib/skills-api'
|
||||
import type { Skill } 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'
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
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(async () => {
|
||||
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 {
|
||||
if (enable) {
|
||||
await store.enableSkill(skill.id)
|
||||
} else {
|
||||
await store.disableSkill(skill.id)
|
||||
}
|
||||
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) {
|
||||
try {
|
||||
if (enable) {
|
||||
await store.enableSkill(skillId)
|
||||
ElMessage.success(t('skills.toast.enabled'))
|
||||
} else {
|
||||
await store.disableSkill(skillId)
|
||||
ElMessage.success(t('skills.toast.disabled'))
|
||||
}
|
||||
} catch (err) {
|
||||
ElMessage.error(String(err))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenSkillsFolder() {
|
||||
try {
|
||||
await apiOpenSkillsDir()
|
||||
ElMessage.success(t('skills.toast.copiedPath'))
|
||||
} catch (err) {
|
||||
const msg = String(err)
|
||||
if (msg.toLowerCase().includes('copied')) {
|
||||
ElMessage.info(msg)
|
||||
} else {
|
||||
ElMessage.error(t('skills.toast.failedOpenFolder') + ': ' + msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openMarketplace() {
|
||||
installQuery.value = ''
|
||||
installDrawerOpen.value = true
|
||||
}
|
||||
|
||||
watch(installDrawerOpen, (open) => {
|
||||
if (open) {
|
||||
store.searchSkills('')
|
||||
}
|
||||
})
|
||||
|
||||
watch(installQuery, (q) => {
|
||||
if (!installDrawerOpen.value) return
|
||||
const query = q.trim()
|
||||
if (query.length === 0) {
|
||||
store.searchSkills('')
|
||||
return
|
||||
}
|
||||
// debounce 300ms handled by watch pattern isn't ideal without clearTimeout,
|
||||
// but for simplicity we call directly. In a real app use useDebounceFn from vueuse.
|
||||
setTimeout(() => {
|
||||
store.searchSkills(query)
|
||||
}, 300)
|
||||
})
|
||||
|
||||
async function handleInstall(slug: string) {
|
||||
try {
|
||||
await store.installSkill(slug)
|
||||
await store.enableSkill(slug)
|
||||
ElMessage.success(t('skills.toast.installed'))
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
if (INSTALL_ERROR_CODES.has(errorMessage)) {
|
||||
ElMessage({ type: 'error', message: String(t(`skills.toast.${errorMessage}`, { path: skillsDirPath.value })), duration: 10000 })
|
||||
} else {
|
||||
ElMessage.error(t('skills.toast.failedInstall') + ': ' + errorMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstall(slug: string) {
|
||||
try {
|
||||
await store.uninstallSkill(slug)
|
||||
ElMessage.success(t('skills.toast.uninstalled'))
|
||||
} catch (err) {
|
||||
ElMessage.error(t('skills.toast.failedUninstall') + ': ' + String(err))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* scrollbar styling is globally handled in tailwind.css */
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user