ForcePilot/web/src/components/SkillsManagerComponent.vue
肖泽涛 0f7c0d3017 fix(skills): 增强安全性与交互体验
- 添加 skill slug 格式校验,防止路径遍历攻击
- 优化导出文件清理逻辑,添加异常处理
- 前端依赖管理表单添加禁用状态联动
- 补充相关单元测试

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:52:26 +08:00

686 lines
19 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="skills-manager">
<div class="header-section">
<div class="header-content">
<h3 class="title">Skills 管理</h3>
<p class="description">导入编辑导出和删除技能包技能内容保存在文件系统元数据保存在数据库</p>
</div>
<div class="header-actions">
<a-upload
accept=".zip"
:show-upload-list="false"
:custom-request="handleImportUpload"
:disabled="loading || importing"
>
<a-button type="primary" :loading="importing">导入 ZIP</a-button>
</a-upload>
<a-button @click="fetchSkills" :disabled="loading">刷新</a-button>
</div>
</div>
<a-spin :spinning="loading">
<div class="content-layout">
<div class="skills-list-panel">
<a-table
size="small"
:columns="columns"
:data-source="skills"
:pagination="false"
row-key="slug"
:custom-row="bindSkillRow"
:row-class-name="rowClassName"
:scroll="{ y: 360 }"
/>
</div>
<div class="skill-detail-panel">
<a-empty v-if="!currentSkill" description="请选择一个 Skill" />
<template v-else>
<div class="detail-header">
<div class="detail-title">
<strong>{{ currentSkill.name }}</strong>
<span class="slug">({{ currentSkill.slug }})</span>
<span class="dependency-summary">
工具 {{ (currentSkill.tool_dependencies || []).length }} · MCP
{{ (currentSkill.mcp_dependencies || []).length }} · Skills
{{ (currentSkill.skill_dependencies || []).length }}
</span>
</div>
<div class="detail-actions">
<a-button size="small" @click="reloadTree">刷新目录</a-button>
<a-button size="small" @click="openCreateModal(false)">新建文件</a-button>
<a-button size="small" @click="openCreateModal(true)">新建目录</a-button>
<a-button size="small" @click="handleExport">导出 ZIP</a-button>
<a-button danger size="small" @click="confirmDeleteSkill">删除 Skill</a-button>
</div>
</div>
<div class="dependency-panel">
<div class="dependency-header">
<span class="dependency-title">依赖管理</span>
<a-button
type="primary"
size="small"
:loading="savingDependencies"
:disabled="loading || savingDependencies"
@click="saveDependencies"
>
保存依赖
</a-button>
</div>
<a-form layout="vertical" class="dependency-form">
<a-form-item label="工具依赖">
<a-select
v-model:value="dependencyForm.tool_dependencies"
mode="multiple"
:options="toolDependencyOptions"
placeholder="选择工具依赖"
:disabled="loading || savingDependencies"
allow-clear
/>
</a-form-item>
<a-form-item label="MCP 依赖">
<a-select
v-model:value="dependencyForm.mcp_dependencies"
mode="multiple"
:options="mcpDependencyOptions"
placeholder="选择 MCP 服务依赖"
:disabled="loading || savingDependencies"
allow-clear
/>
</a-form-item>
<a-form-item label="Skill 依赖">
<a-select
v-model:value="dependencyForm.skill_dependencies"
mode="multiple"
:options="skillDependencyOptions"
placeholder="选择 Skill 依赖"
:disabled="loading || savingDependencies"
allow-clear
/>
</a-form-item>
</a-form>
</div>
<div class="detail-body">
<div class="tree-panel">
<div class="tree-toolbar">
<span class="tree-title">目录树</span>
<a-button
size="small"
danger
@click="confirmDeleteNode"
:disabled="!selectedPath || selectedPath === 'SKILL.md'"
>
删除节点
</a-button>
</div>
<a-tree
:tree-data="treeData"
:selected-keys="selectedTreeKeys"
:default-expand-all="true"
@select="handleTreeSelect"
/>
</div>
<div class="editor-panel">
<div class="editor-toolbar">
<span class="editor-path">{{ selectedPath || '请选择文本文件' }}</span>
<a-button
type="primary"
size="small"
@click="saveCurrentFile"
:disabled="!canSave"
:loading="savingFile"
>
保存
</a-button>
</div>
<a-empty v-if="!selectedPath || selectedIsDir" description="请选择文本文件后编辑" />
<a-textarea
v-else
v-model:value="fileContent"
:rows="22"
class="file-editor"
placeholder="文件内容"
/>
</div>
</div>
</template>
</div>
</div>
</a-spin>
<a-modal
v-model:open="createModalVisible"
:title="createForm.isDir ? '新建目录' : '新建文件'"
@ok="handleCreateNode"
:confirm-loading="creatingNode"
:maskClosable="false"
>
<a-form layout="vertical">
<a-form-item label="路径(相对 skill 根目录)" required>
<a-input v-model:value="createForm.path" placeholder="例如 prompts/guide.md" />
</a-form-item>
<a-form-item v-if="!createForm.isDir" label="文件内容">
<a-textarea v-model:value="createForm.content" :rows="8" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { skillApi } from '@/apis/skill_api'
const loading = ref(false)
const importing = ref(false)
const savingFile = ref(false)
const creatingNode = ref(false)
const savingDependencies = ref(false)
const skills = ref([])
const currentSkill = ref(null)
const treeData = ref([])
const selectedTreeKeys = ref([])
const selectedPath = ref('')
const selectedIsDir = ref(false)
const fileContent = ref('')
const originalFileContent = ref('')
const createModalVisible = ref(false)
const createForm = reactive({
path: '',
isDir: false,
content: ''
})
const dependencyOptions = reactive({
tools: [],
mcps: [],
skills: []
})
const dependencyForm = reactive({
tool_dependencies: [],
mcp_dependencies: [],
skill_dependencies: []
})
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 180, ellipsis: true },
{ title: 'Slug', dataIndex: 'slug', key: 'slug', width: 180, ellipsis: true },
{
title: '依赖',
key: 'dependencies',
width: 150,
customRender: ({ record }) =>
`T${(record.tool_dependencies || []).length} / M${(record.mcp_dependencies || []).length} / S${(record.skill_dependencies || []).length}`
},
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180, ellipsis: true }
]
const canSave = computed(() => {
if (!selectedPath.value || selectedIsDir.value) return false
return fileContent.value !== originalFileContent.value
})
const rowClassName = (record) => {
return currentSkill.value?.slug === record.slug ? 'selected-row' : ''
}
const toolDependencyOptions = computed(() =>
(dependencyOptions.tools || []).map((item) => ({ label: item, value: item }))
)
const mcpDependencyOptions = computed(() =>
(dependencyOptions.mcps || []).map((item) => ({ label: item, value: item }))
)
const skillDependencyOptions = computed(() =>
(dependencyOptions.skills || [])
.filter((slug) => slug !== currentSkill.value?.slug)
.map((item) => ({ label: item, value: item }))
)
const bindSkillRow = (record) => ({
onClick: () => selectSkill(record)
})
const normalizeTree = (nodes) => {
return (nodes || []).map((node) => ({
title: node.name,
key: node.path,
isLeaf: !node.is_dir,
path: node.path,
is_dir: node.is_dir,
children: node.is_dir ? normalizeTree(node.children || []) : undefined
}))
}
const resetFileState = () => {
selectedPath.value = ''
selectedIsDir.value = false
selectedTreeKeys.value = []
fileContent.value = ''
originalFileContent.value = ''
}
const fetchSkills = async () => {
loading.value = true
try {
const result = await skillApi.listSkills()
skills.value = result?.data || []
if (currentSkill.value) {
const latest = skills.value.find((item) => item.slug === currentSkill.value.slug)
if (!latest) {
currentSkill.value = null
treeData.value = []
resetFileState()
} else {
currentSkill.value = latest
syncDependencyFormFromSkill(latest)
}
}
await fetchDependencyOptions()
} catch (error) {
message.error(error.message || '获取 Skills 列表失败')
} finally {
loading.value = false
}
}
const fetchDependencyOptions = async () => {
try {
const result = await skillApi.getSkillDependencyOptions()
const data = result?.data || {}
dependencyOptions.tools = data.tools || []
dependencyOptions.mcps = data.mcps || []
dependencyOptions.skills = data.skills || []
} catch (error) {
message.error(error.message || '获取依赖选项失败')
}
}
const syncDependencyFormFromSkill = (skillRecord) => {
dependencyForm.tool_dependencies = [...(skillRecord?.tool_dependencies || [])]
dependencyForm.mcp_dependencies = [...(skillRecord?.mcp_dependencies || [])]
dependencyForm.skill_dependencies = [...(skillRecord?.skill_dependencies || [])]
}
const reloadTree = async () => {
if (!currentSkill.value) return
loading.value = true
try {
const result = await skillApi.getSkillTree(currentSkill.value.slug)
treeData.value = normalizeTree(result?.data || [])
} catch (error) {
message.error(error.message || '加载目录树失败')
} finally {
loading.value = false
}
}
const selectSkill = async (record) => {
currentSkill.value = record
syncDependencyFormFromSkill(record)
resetFileState()
await reloadTree()
}
const handleTreeSelect = async (keys, info) => {
if (!keys?.length) {
resetFileState()
return
}
const node = info?.node || {}
const path = node.path || node.key
const isDir = !!node.is_dir
selectedTreeKeys.value = [path]
selectedPath.value = path
selectedIsDir.value = isDir
if (isDir) {
fileContent.value = ''
originalFileContent.value = ''
return
}
try {
const result = await skillApi.getSkillFile(currentSkill.value.slug, path)
const content = result?.data?.content || ''
fileContent.value = content
originalFileContent.value = content
} catch (error) {
message.error(error.message || '读取文件失败')
}
}
const saveCurrentFile = async () => {
if (!currentSkill.value || !selectedPath.value || selectedIsDir.value) return
savingFile.value = true
try {
await skillApi.updateSkillFile(currentSkill.value.slug, {
path: selectedPath.value,
content: fileContent.value
})
originalFileContent.value = fileContent.value
message.success('保存成功')
if (selectedPath.value === 'SKILL.md') {
await fetchSkills()
}
} catch (error) {
message.error(error.message || '保存失败')
} finally {
savingFile.value = false
}
}
const openCreateModal = (isDir) => {
if (!currentSkill.value) return
createForm.path = ''
createForm.content = ''
createForm.isDir = isDir
createModalVisible.value = true
}
const handleCreateNode = async () => {
if (!currentSkill.value) return
if (!createForm.path.trim()) {
message.warning('请输入路径')
return
}
creatingNode.value = true
try {
await skillApi.createSkillFile(currentSkill.value.slug, {
path: createForm.path.trim(),
is_dir: createForm.isDir,
content: createForm.content
})
createModalVisible.value = false
await reloadTree()
message.success(createForm.isDir ? '目录创建成功' : '文件创建成功')
} catch (error) {
message.error(error.message || '创建失败')
} finally {
creatingNode.value = false
}
}
const confirmDeleteNode = () => {
if (!currentSkill.value || !selectedPath.value || selectedPath.value === 'SKILL.md') return
Modal.confirm({
title: '确认删除节点?',
content: selectedPath.value,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await skillApi.deleteSkillFile(currentSkill.value.slug, selectedPath.value)
resetFileState()
await reloadTree()
message.success('删除成功')
} catch (error) {
message.error(error.message || '删除失败')
}
}
})
}
const confirmDeleteSkill = () => {
if (!currentSkill.value) return
const slug = currentSkill.value.slug
Modal.confirm({
title: `确认删除 Skill「${slug}」?`,
content: '将同时删除技能目录与数据库记录,该操作不可恢复。',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await skillApi.deleteSkill(slug)
message.success('Skill 删除成功')
currentSkill.value = null
treeData.value = []
resetFileState()
await fetchSkills()
} catch (error) {
message.error(error.message || '删除 Skill 失败')
}
}
})
}
const getDownloadFilename = (response, fallback) => {
const header = response.headers.get('content-disposition') || ''
const match = header.match(/filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i)
if (!match) return fallback
return decodeURIComponent(match[1] || match[2] || fallback)
}
const handleExport = async () => {
if (!currentSkill.value) return
try {
const response = await skillApi.exportSkill(currentSkill.value.slug)
const blob = await response.blob()
const filename = getDownloadFilename(response, `${currentSkill.value.slug}.zip`)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
} catch (error) {
message.error(error.message || '导出失败')
}
}
const handleImportUpload = async ({ file, onSuccess, onError }) => {
importing.value = true
try {
const result = await skillApi.importSkillZip(file)
message.success('导入成功')
await fetchSkills()
const imported = result?.data
if (imported?.slug) {
const record = skills.value.find((item) => item.slug === imported.slug)
if (record) {
await selectSkill(record)
}
}
onSuccess?.(result)
} catch (error) {
message.error(error.message || '导入失败')
onError?.(error)
} finally {
importing.value = false
}
}
const saveDependencies = async () => {
if (!currentSkill.value) return
savingDependencies.value = true
try {
const result = await skillApi.updateSkillDependencies(currentSkill.value.slug, {
tool_dependencies: dependencyForm.tool_dependencies,
mcp_dependencies: dependencyForm.mcp_dependencies,
skill_dependencies: dependencyForm.skill_dependencies
})
const updated = result?.data || null
if (updated) {
currentSkill.value = updated
syncDependencyFormFromSkill(updated)
}
await fetchSkills()
message.success('依赖保存成功')
} catch (error) {
message.error(error.message || '依赖保存失败')
} finally {
savingDependencies.value = false
}
}
onMounted(() => {
fetchSkills()
})
</script>
<style scoped lang="less">
.skills-manager {
padding: 16px;
}
.header-section {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.title {
margin-bottom: 4px;
}
.description {
margin: 0;
color: var(--gray-600);
}
.header-actions {
display: flex;
gap: 8px;
align-items: flex-start;
}
.content-layout {
display: grid;
grid-template-columns: minmax(280px, 36%) 1fr;
gap: 12px;
}
.skills-list-panel {
border: 1px solid var(--gray-150);
border-radius: 8px;
overflow: hidden;
}
.skill-detail-panel {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 10px;
min-height: 420px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.detail-title {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.slug {
color: var(--gray-600);
}
.dependency-summary {
color: var(--gray-600);
font-size: 12px;
}
.detail-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dependency-panel {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 8px;
margin-bottom: 10px;
}
.dependency-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.dependency-title {
font-weight: 600;
}
.dependency-form :deep(.ant-form-item) {
margin-bottom: 8px;
}
.detail-body {
display: grid;
grid-template-columns: minmax(240px, 35%) 1fr;
gap: 12px;
}
.tree-panel,
.editor-panel {
border: 1px solid var(--gray-150);
border-radius: 8px;
padding: 8px;
min-height: 360px;
}
.tree-toolbar,
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
gap: 8px;
}
.tree-title {
font-weight: 600;
}
.editor-path {
color: var(--gray-700);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-editor {
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}
:deep(.selected-row td) {
background: var(--gray-100) !important;
}
@media (max-width: 1200px) {
.content-layout {
grid-template-columns: 1fr;
}
.detail-body {
grid-template-columns: 1fr;
}
}
</style>