ForcePilot/web/src/components/extensions/SkillCardList.vue

503 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="skill-cards-page extension-page-root">
<PageShoulder search-placeholder="搜索技能..." v-model:search="searchQuery">
<template #actions>
<a-button
@click="handleOpenRemoteInstall"
:disabled="loading || importing"
class="lucide-icon-btn"
>
<Computer :size="14" />
<span>远程安装</span>
</a-button>
<a-upload
accept=".zip,.md"
:show-upload-list="false"
:custom-request="handleImportUpload"
:before-upload="beforeSkillUpload"
:disabled="loading || importing"
>
<a-button type="primary" :loading="importing" class="lucide-icon-btn">
<Upload :size="14" />
<span>上传 Skill</span>
</a-button>
</a-upload>
<a-tooltip title="刷新 Skills">
<a-button class="lucide-icon-btn" :disabled="loading" @click="fetchSkills">
<RefreshCw :size="14" />
</a-button>
</a-tooltip>
</template>
</PageShoulder>
<div
v-if="filteredInstalledSkills.length === 0 && filteredUninstalledBuiltinSkills.length === 0"
class="extension-card-grid-empty-state"
>
<a-empty :image="false" description="无匹配技能" />
</div>
<template v-else>
<div v-if="filteredInstalledSkills.length" class="extension-section-header">
已添加 Skills
</div>
<ExtensionCardGrid>
<InfoCard
v-for="skill in filteredInstalledSkills"
:key="skill.slug"
:title="skill.name"
:description="skill.description || '暂无描述'"
:default-icon="BookMarkedIcon"
:tags="skillTags(skill)"
:status="{ label: '已安装', level: 'success' }"
@click="navigateToDetail(skill)"
>
</InfoCard>
</ExtensionCardGrid>
<div v-if="filteredUninstalledBuiltinSkills.length" class="extension-section-header">
可添加 Skills
</div>
<ExtensionCardGrid v-if="filteredUninstalledBuiltinSkills.length">
<InfoCard
v-for="skill in filteredUninstalledBuiltinSkills"
:key="skill.slug"
:title="skill.name"
:description="skill.description || '暂无描述'"
:default-icon="BookMarkedIcon"
:tags="[{ name: '内置' }]"
action-label="安装"
@action-click="handleInstallBuiltin(skill)"
>
</InfoCard>
</ExtensionCardGrid>
</template>
<a-modal
v-model:open="remoteInstallModalVisible"
title="远程安装 Skill"
:footer="null"
width="560px"
:closable="!installingRemoteSkill"
:mask-closable="!installingRemoteSkill"
:keyboard="!installingRemoteSkill"
>
<div class="remote-install-panel modal-mode">
<div class="panel-header-text">
<span class="title">基于 skills.sh 的能力拉取并导入到当前系统</span>
<span class="desc">
支持 `owner/repo` 或完整 GitHub URL可前往
<a href="https://skills.sh/" target="_blank" rel="noopener noreferrer">skills.sh</a>
查询可用 skills
</span>
</div>
<a-form layout="vertical" class="remote-install-form">
<a-form-item label="来源仓库">
<a-input
v-model:value="remoteInstallForm.source"
placeholder="anthropics/skills 或 GitHub URL"
:disabled="installingRemoteSkill"
/>
</a-form-item>
<a-form-item label="Skill 名称">
<a-select
v-model:value="remoteInstallForm.skills"
mode="tags"
:options="filteredRemoteSkillOptions"
placeholder="frontend-design"
allow-clear
show-search
:disabled="installingRemoteSkill"
:filter-option="filterRemoteSkillOption"
:max-tag-count="6"
/>
</a-form-item>
<div class="remote-install-actions">
<a-button
:loading="listingRemoteSkills"
:disabled="installingRemoteSkill"
@click="handleListRemoteSkills"
>
查看可安装 Skills
</a-button>
<a-button
type="primary"
:loading="installingRemoteSkill"
:disabled="listingRemoteSkills"
@click="handleInstallRemoteSkill"
>
安装
</a-button>
<span v-if="remoteInstallStatusText" class="remote-install-status">{{
remoteInstallStatusText
}}</span>
</div>
<div v-if="remoteSkillOptions.length" class="remote-skill-summary">
共发现 {{ remoteSkillOptions.length }} skills可按输入内容筛选候选项
</div>
</a-form>
</div>
</a-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { RefreshCw, Upload, Computer, BookMarked } from 'lucide-vue-next'
import { skillApi } from '@/apis/skill_api'
import ExtensionCardGrid from './ExtensionCardGrid.vue'
import InfoCard from '@/components/shared/InfoCard.vue'
import PageShoulder from '@/components/shared/PageShoulder.vue'
const BookMarkedIcon = BookMarked
const router = useRouter()
const loading = ref(false)
const importing = ref(false)
const listingRemoteSkills = ref(false)
const installingRemoteSkill = ref(false)
const searchQuery = ref('')
const skills = ref([])
const builtinSkills = ref([])
const remoteInstallModalVisible = ref(false)
const remoteInstallForm = reactive({
source: 'https://github.com/anthropics/skills',
skills: []
})
const remoteSkillOptions = ref([])
const remoteInstallProgress = reactive({
visible: false,
total: 0,
completed: 0,
success: 0,
failed: 0,
currentSkill: ''
})
const remoteInstallResults = reactive({ success: [], failed: [] })
const matchesSearch = (skill) => {
if (!searchQuery.value) return true
const q = searchQuery.value.toLowerCase()
return skill.name.toLowerCase().includes(q) || skill.slug.toLowerCase().includes(q)
}
const installedSkillCards = computed(() => {
const builtinInstalledMap = new Map(
(builtinSkills.value || [])
.filter((skill) => skill.status !== 'not_installed')
.map((skill) => [
skill.slug,
{
...skill,
sourceType: 'builtin',
sourceLabel: '内置',
status:
skill.status === 'update_available'
? { label: '更新可用', level: 'warning' }
: { label: '已安装', level: 'success' }
}
])
)
const importedInstalled = (skills.value || [])
.filter((skill) => !builtinInstalledMap.has(skill.slug))
.map((skill) => ({
...skill,
sourceType: 'imported',
sourceLabel: '导入',
status: { label: '已上传', level: 'success' }
}))
return [...builtinInstalledMap.values(), ...importedInstalled]
})
const filteredInstalledSkills = computed(() => installedSkillCards.value.filter(matchesSearch))
const filteredUninstalledBuiltinSkills = computed(() => {
return (builtinSkills.value || []).filter(
(skill) => skill.status === 'not_installed' && matchesSearch(skill)
)
})
const filteredRemoteSkillOptions = computed(() =>
remoteSkillOptions.value.map((item) => ({
value: item.name,
label: item.description ? `${item.name} - ${item.description}` : item.name
}))
)
const remoteInstallStatusText = computed(() => {
if (!remoteInstallProgress.visible || !remoteInstallProgress.total) return ''
const progressText = `[${remoteInstallProgress.completed}/${remoteInstallProgress.total}]`
const currentSkill = remoteInstallProgress.currentSkill || ''
const failedText =
remoteInstallProgress.failed > 0 ? `, ${remoteInstallProgress.failed} failed` : ''
return `${progressText} ${currentSkill}${failedText}`.trim()
})
const filterRemoteSkillOption = (input, option) => {
const keyword = input.trim().toLowerCase()
if (!keyword) return true
const value = String(option?.value || '').toLowerCase()
const label = String(option?.label || '').toLowerCase()
return value.includes(keyword) || label.includes(keyword)
}
const normalizeRemoteSkillNames = (skills) => {
const seen = new Set()
return (skills || []).reduce((acc, skill) => {
const normalized = String(skill || '').trim()
if (!normalized || seen.has(normalized)) return acc
seen.add(normalized)
acc.push(normalized)
return acc
}, [])
}
const resetRemoteInstallState = () => {
remoteInstallProgress.visible = false
remoteInstallProgress.total = 0
remoteInstallProgress.completed = 0
remoteInstallProgress.success = 0
remoteInstallProgress.failed = 0
remoteInstallProgress.currentSkill = ''
remoteInstallResults.success = []
remoteInstallResults.failed = []
}
const skillTags = (skill) => {
if (skill.sourceType === 'builtin') return [{ name: skill.sourceLabel || '内置' }]
return [{ name: skill.sourceLabel || '外部', color: 'blue' }]
}
const navigateToDetail = (skill) => {
router.push({ path: `/extensions/skill/${encodeURIComponent(skill.slug)}` })
}
const fetchSkills = async () => {
loading.value = true
try {
const [skillResult, builtinResult] = await Promise.all([
skillApi.listSkills(),
skillApi.listBuiltinSkills()
])
skills.value = skillResult?.data || []
builtinSkills.value = (builtinResult?.data || []).map((item) => ({
...item,
...(item.installed_record || {}),
is_builtin_spec: true
}))
} catch {
message.error('加载失败')
} finally {
loading.value = false
}
}
const handleInstallBuiltin = async (record) => {
if (!record?.slug) return
loading.value = true
try {
await skillApi.installBuiltinSkill(record.slug)
await fetchSkills()
message.success('安装成功')
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '安装失败')
} finally {
loading.value = false
}
}
const beforeSkillUpload = (file) => {
const lower = file.name.toLowerCase()
if (!lower.endsWith('.zip') && lower !== 'skill.md') {
message.error('仅支持上传 .zip 文件或 SKILL.md 文件')
return false
}
return true
}
const handleImportUpload = async ({ file, onSuccess, onError }) => {
importing.value = true
try {
const result = await skillApi.importSkillZip(file)
message.success('导入完成')
await fetchSkills()
onSuccess?.(result)
} catch (e) {
message.error('导入失败')
onError?.(e)
} finally {
importing.value = false
}
}
const handleOpenRemoteInstall = () => {
if (!remoteInstallModalVisible.value) {
remoteInstallForm.skills = []
resetRemoteInstallState()
}
remoteInstallModalVisible.value = true
}
const handleListRemoteSkills = async () => {
const source = remoteInstallForm.source.trim()
if (!source) {
message.warning('请输入来源仓库')
return
}
listingRemoteSkills.value = true
try {
const result = await skillApi.listRemoteSkills(source)
remoteSkillOptions.value = result?.data || []
remoteInstallForm.skills = normalizeRemoteSkillNames(remoteInstallForm.skills)
if (!remoteSkillOptions.value.length) {
message.warning('未发现可安装的 Skills')
return
}
message.success(`已发现 ${remoteSkillOptions.value.length} 个 Skills`)
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '获取远程 Skills 失败')
} finally {
listingRemoteSkills.value = false
}
}
const handleInstallRemoteSkill = async () => {
const source = remoteInstallForm.source.trim()
const skillsToInstall = normalizeRemoteSkillNames(remoteInstallForm.skills)
if (!source || !skillsToInstall.length) {
message.warning('请填写来源仓库和 Skill 名称')
return
}
remoteInstallForm.skills = skillsToInstall
resetRemoteInstallState()
installingRemoteSkill.value = true
remoteInstallProgress.visible = true
remoteInstallProgress.total = skillsToInstall.length
try {
for (const skill of skillsToInstall) {
remoteInstallProgress.currentSkill = skill
try {
const result = await skillApi.installRemoteSkill({ source, skill })
const installedSlug = result?.data?.slug || skill
remoteInstallResults.success.push(installedSlug)
remoteInstallProgress.success += 1
} catch (error) {
remoteInstallResults.failed.push({
skill,
error: error?.response?.data?.detail || error.message || '远程 Skill 安装失败'
})
remoteInstallProgress.failed += 1
} finally {
remoteInstallProgress.completed += 1
}
}
remoteInstallProgress.currentSkill = ''
await fetchSkills()
if (remoteInstallResults.failed.length === 0) {
remoteInstallModalVisible.value = false
message.success(`远程 Skills 安装成功,共 ${remoteInstallResults.success.length}`)
resetRemoteInstallState()
remoteInstallForm.skills = []
return
}
message.warning(
`远程 Skills 安装完成,成功 ${remoteInstallResults.success.length} 个,失败 ${remoteInstallResults.failed.length}`
)
} catch (error) {
message.error(error?.response?.data?.detail || error.message || '远程 Skill 安装失败')
} finally {
remoteInstallProgress.currentSkill = ''
installingRemoteSkill.value = false
}
}
watch(remoteInstallModalVisible, (visible) => {
if (!visible && !installingRemoteSkill.value) {
remoteInstallForm.skills = []
resetRemoteInstallState()
}
})
onMounted(() => {
fetchSkills()
})
defineExpose({
fetchSkills,
handleImportUpload,
openRemoteInstallModal: handleOpenRemoteInstall,
loading
})
</script>
<style lang="less" scoped>
@import '@/assets/css/extensions.less';
</style>
<style lang="less" scoped>
.extension-card-grid-empty-state {
background: linear-gradient(180deg, var(--gray-0) 0%, var(--gray-50) 100%);
border: 1px solid var(--gray-150);
border-radius: 12px;
padding: 16px;
&.modal-mode {
border: none;
border-radius: 0;
padding: 0;
background: transparent;
}
.panel-header-text {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
.title {
font-size: 14px;
font-weight: 600;
color: var(--gray-900);
}
.desc {
font-size: 12px;
color: var(--gray-500);
}
}
.remote-install-form {
:deep(.ant-form-item) {
margin-bottom: 12px;
}
}
.remote-install-actions {
display: flex;
align-items: center;
gap: 8px;
}
.remote-install-status {
min-width: 0;
flex: 1;
font-size: 12px;
color: var(--gray-600);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remote-skill-summary {
margin-top: 12px;
font-size: 12px;
color: var(--gray-500);
}
}
</style>