refactor: 重构文件树组件,优化AgentPanel和SkillsManager组件的文件展示逻辑

This commit is contained in:
Wenjie Zhang 2026-03-02 10:25:33 +08:00
parent bfd22c760f
commit 2020438152
4 changed files with 338 additions and 191 deletions

View File

@ -55,44 +55,29 @@
<div v-if="activeTab === 'files'" class="files-display">
<div v-if="!fileCount" class="empty">暂无文件</div>
<div v-else class="file-tree-container">
<a-tree
<FileTreeComponent
v-model:expandedKeys="expandedKeys"
:tree-data="fileTreeData"
:show-icon="true"
block-node
:show-line="false"
@select="onFileSelect"
>
<template #icon="{ data, expanded }">
<template v-if="data.isLeaf">
<component
:is="getFileIcon(data.key)"
:style="{ color: getFileIconColor(data.key), fontSize: '16px' }"
/>
</template>
<template v-else>
<FolderOpen v-if="expanded" :size="18" class="folder-icon open" />
<Folder v-else :size="18" class="folder-icon" />
</template>
</template>
<template #title="{ data }">
<div class="tree-node-wrapper" @click="toggleFolder(data)">
<div class="tree-node-name" :title="data.title">
<span class="name-start">{{ data.nameStart || data.title }}</span>
<span class="name-end" v-if="data.nameEnd">{{ data.nameEnd }}</span>
</div>
<div v-if="data.isLeaf" class="node-actions" @click.stop>
<button
class="tree-action-btn tree-download-btn"
@click.stop="downloadFile(data.fileData)"
title="下载文件"
>
<Download :size="14" />
</button>
</div>
<template #title="{ node }">
<div class="tree-node-name" :title="node.title">
<span class="name-start">{{ node.nameStart || node.title }}</span>
<span class="name-end" v-if="node.nameEnd">{{ node.nameEnd }}</span>
</div>
</template>
</a-tree>
<template #actions="{ node }">
<div v-if="node.isLeaf" class="node-actions-container">
<button
class="tree-action-btn tree-download-btn"
@click.stop="downloadFile(node.fileData)"
title="下载文件"
>
<Download :size="14" />
</button>
</div>
</template>
</FileTreeComponent>
</div>
</div>
</div>
@ -163,6 +148,7 @@ import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import { useThemeStore } from '@/stores/theme'
import { getFileIcon, getFileIconColor, formatFileSize } from '@/utils/file_utils'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
const props = defineProps({
agentState: {
@ -384,17 +370,6 @@ const truncateFilename = (name) => {
const fileTreeData = computed(() => buildTreeData(normalizedFiles.value))
const toggleFolder = (data) => {
if (data.isLeaf) return
const key = data.key
const index = expandedKeys.value.indexOf(key)
if (index > -1) {
expandedKeys.value = expandedKeys.value.filter((k) => k !== key)
} else {
expandedKeys.value = [...expandedKeys.value, key]
}
}
const onFileSelect = (selectedKeys, { node }) => {
if (node.isLeaf) {
if (node.fileData) {
@ -945,15 +920,6 @@ const stopResize = () => {
margin: 0 -4px;
}
.tree-node-wrapper {
display: flex;
align-items: center;
width: 100%;
height: 28px;
padding-right: 8px;
position: relative;
}
.tree-node-name {
display: flex;
align-items: center;
@ -963,7 +929,6 @@ const stopResize = () => {
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
color: var(--gray-800);
line-height: 28px;
}
.name-start {
@ -977,26 +942,14 @@ const stopResize = () => {
white-space: nowrap;
}
.folder-icon {
color: #dcb67a;
fill: #dcb67a;
fill-opacity: 0.2;
&.open {
color: #dcb67a;
}
}
.node-actions {
.node-actions-container {
display: flex;
align-items: center;
justify-content: flex-end;
padding-left: 4px;
gap: 2px;
gap: 4px;
}
.tree-action-btn {
display: none;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
@ -1012,89 +965,6 @@ const stopResize = () => {
color: var(--main-600);
}
.tree-delete-btn:hover {
color: var(--color-error-500);
}
/* Specific Ant Design Tree Overrides */
.file-tree-container :deep(.ant-tree) {
font-family: inherit;
font-size: 14px;
overflow: hidden;
.ant-tree-treenode {
width: 100%;
padding: 0;
}
.ant-tree-node-content-wrapper {
display: flex;
align-items: center;
transition: none;
border-radius: 0;
padding: 0 8px 0 0;
line-height: 28px;
flex: 1;
position: relative;
padding: 0 6px;
border-radius: 6px;
gap: 6px;
&:hover {
background-color: var(--gray-50);
.tree-action-btn {
display: flex;
}
}
&.ant-tree-node-selected {
background-color: var(--gray-100);
}
}
/* Icon Vertical Alignment */
.ant-tree-iconEle {
line-height: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
}
/* Ensure Title Fills Space */
.ant-tree-title {
flex: 1;
overflow: hidden;
min-width: 0;
}
/* Switcher (Arrow) */
.ant-tree-switcher {
width: 24px;
height: 30px;
line-height: 30px;
display: flex;
align-items: center;
justify-content: center;
.ant-tree-switcher-icon {
font-size: 10px;
color: var(--gray-500);
}
}
.ant-tree-indent-unit {
width: 18px;
}
/* 隐藏文件夹展开/折叠箭头 */
.ant-tree-switcher {
display: none !important;
}
}
/* 附件列表专用样式 */
.attachment-tree :deep(.ant-tree-node-content-wrapper) {
border: 1px solid var(--gray-200);

View File

@ -0,0 +1,240 @@
<template>
<div class="file-tree-wrapper" :class="treeClass">
<a-tree
:selected-keys="selectedKeys"
:expanded-keys="expandedKeys"
:tree-data="treeData"
:show-icon="showIcon"
:block-node="blockNode"
:show-line="showLine"
class="custom-file-tree"
@update:selected-keys="handleSelectedUpdate"
@update:expanded-keys="handleExpandedUpdate"
@select="handleSelect"
>
<!-- Custom Icon Slot -->
<template #icon="{ data, expanded }">
<slot name="icon" :node="data" :expanded="expanded">
<template v-if="data.isLeaf">
<component
v-if="useFileIcons"
:is="getFileIcon(data.key)"
:style="{ color: getFileIconColor(data.key), fontSize: '16px' }"
/>
<FileText v-else :size="16" class="file-icon" />
</template>
<template v-else>
<FolderOpen v-if="expanded" :size="18" class="folder-icon open" />
<Folder v-else :size="18" class="folder-icon" />
</template>
</slot>
</template>
<!-- Custom Title Slot -->
<template #title="{ data }">
<div class="tree-node-wrapper" @click="handleNodeClick(data)">
<div class="tree-node-content">
<slot name="title" :node="data">
<span class="node-title-text" :title="data.title">{{ data.title }}</span>
</slot>
</div>
<div class="node-actions" @click.stop v-if="$slots.actions">
<slot name="actions" :node="data"></slot>
</div>
</div>
</template>
</a-tree>
</div>
</template>
<script setup>
import { Folder, FolderOpen, FileText } from 'lucide-vue-next'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
const props = defineProps({
treeData: {
type: Array,
required: true,
default: () => []
},
selectedKeys: {
type: Array,
default: () => []
},
expandedKeys: {
type: Array,
default: () => []
},
showIcon: {
type: Boolean,
default: true
},
blockNode: {
type: Boolean,
default: true
},
showLine: {
type: Boolean,
default: false
},
treeClass: {
type: String,
default: ''
},
useFileIcons: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:selectedKeys', 'update:expandedKeys', 'select', 'nodeClick', 'toggleFolder'])
const handleSelectedUpdate = (keys) => {
emit('update:selectedKeys', keys)
}
const handleExpandedUpdate = (keys) => {
emit('update:expandedKeys', keys)
}
const handleSelect = (selectedKeys, info) => {
emit('select', selectedKeys, info)
}
const handleNodeClick = (data) => {
emit('nodeClick', data)
const isFolder = data.isLeaf === false || (data.children && Array.isArray(data.children))
if (isFolder) {
const key = data.key
const newExpandedKeys = [...props.expandedKeys]
const index = newExpandedKeys.indexOf(key)
if (index > -1) {
newExpandedKeys.splice(index, 1)
} else {
newExpandedKeys.push(key)
}
emit('update:expandedKeys', newExpandedKeys)
emit('toggleFolder', data, newExpandedKeys.indexOf(key) > -1)
}
}
</script>
<style scoped lang="less">
.file-tree-wrapper {
width: 100%;
/* 统一节点容器 */
:deep(.ant-tree-treenode) {
display: flex;
align-items: center;
width: 100%;
padding: 0 4px;
height: 32px;
/* 隐藏切换器 */
.ant-tree-switcher {
display: none;
}
/* 缩进单元 */
.ant-tree-indent {
display: flex;
align-items: center;
align-self: stretch;
&-unit {
width: 14px;
}
}
/* 内容区域容器 */
.ant-tree-node-content-wrapper {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 32px;
line-height: 32px;
padding: 0 4px;
border-radius: 4px;
transition: background-color 0.2s;
/* 图标容器 */
.ant-tree-iconEle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 100%;
margin-right: 4px;
flex-shrink: 0;
.anticon {
display: flex;
align-items: center;
}
}
/* 标题文字容器 */
.ant-tree-title {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
height: 100%;
}
&:hover {
background-color: var(--gray-50);
}
&.ant-tree-node-selected {
background-color: var(--gray-100);
}
}
}
}
.tree-node-wrapper {
display: flex;
align-items: center;
width: 100%;
height: 100%;
min-width: 0;
}
.tree-node-content {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.node-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--gray-800);
}
.folder-icon {
color: #dcb67a;
fill: #dcb67a;
fill-opacity: 0.2;
}
.node-actions {
display: none;
align-items: center;
padding-left: 8px;
flex-shrink: 0;
}
.ant-tree-node-content-wrapper:hover .node-actions {
display: flex;
}
</style>

View File

@ -104,12 +104,11 @@
</div>
</div>
<div class="tree-content">
<a-tree
<FileTreeComponent
v-model:selectedKeys="selectedTreeKeys"
v-model:expandedKeys="expandedKeys"
:tree-data="treeData"
:selected-keys="selectedTreeKeys"
default-expand-all
@select="handleTreeSelect"
block-node
/>
</div>
</div>
@ -216,6 +215,7 @@ import {
} from 'lucide-vue-next'
import { skillApi } from '@/apis/skill_api'
import HeaderComponent from '@/components/HeaderComponent.vue'
import FileTreeComponent from '@/components/FileTreeComponent.vue'
dayjs.extend(relativeTime)
dayjs.locale('zh-cn')
@ -232,6 +232,7 @@ const skills = ref([])
const currentSkill = ref(null)
const treeData = ref([])
const selectedTreeKeys = ref([])
const expandedKeys = ref([])
const selectedPath = ref('')
const selectedIsDir = ref(false)
const fileContent = ref('')
@ -268,16 +269,34 @@ const resetFileState = () => {
selectedPath.value = ''
selectedIsDir.value = false
selectedTreeKeys.value = []
expandedKeys.value = []
fileContent.value = ''
originalFileContent.value = ''
}
const expandAllKeys = (nodes) => {
let keys = []
nodes.forEach(node => {
if (node.is_dir) {
keys.push(node.key)
if (node.children) {
keys = keys.concat(expandAllKeys(node.children))
}
}
})
return keys
}
const fetchSkills = async () => {
loading.value = true
try {
const result = await skillApi.listSkills()
skills.value = result?.data || []
if (currentSkill.value) {
// SKILL.md
if (!currentSkill.value && skills.value.length > 0) {
await selectSkill(skills.value[0])
} else if (currentSkill.value) {
const latest = skills.value.find(i => i.slug === currentSkill.value.slug)
if (latest) {
currentSkill.value = latest
@ -317,7 +336,9 @@ const reloadTree = async () => {
loading.value = true
try {
const result = await skillApi.getSkillTree(currentSkill.value.slug)
treeData.value = normalizeTree(result?.data || [])
const normalized = normalizeTree(result?.data || [])
treeData.value = normalized
expandedKeys.value = expandAllKeys(normalized)
} catch {
message.error('加载目录树失败')
} finally {
@ -325,11 +346,30 @@ const reloadTree = async () => {
}
}
const loadSkillFile = async (slug, path = 'SKILL.md') => {
try {
const fileResult = await skillApi.getSkillFile(slug, path)
const content = fileResult?.data?.content || ''
fileContent.value = content
originalFileContent.value = content
selectedPath.value = path
selectedIsDir.value = false
selectedTreeKeys.value = [path]
} catch {
//
}
}
const selectSkill = async (record) => {
currentSkill.value = record
syncDependencyFormFromSkill(record)
resetFileState()
await reloadTree()
// SKILL.md
const [treeResult] = await Promise.all([
reloadTree(),
loadSkillFile(record.slug)
])
}
const handleTreeSelect = async (keys, info) => {
@ -541,7 +581,7 @@ onMounted(fetchSkills)
.search-input {
:deep(.ant-input) {
height: 32px;
height: 24px;
}
}
}
@ -617,9 +657,9 @@ onMounted(fetchSkills)
}
.panel-top-bar {
padding: 12px 24px;
padding: 10px 16px 0 16px;
box-sizing: border-box;
border-bottom: 1px solid @border-color;
// border-bottom: 1px solid @border-color;
display: flex;
justify-content: space-between;
align-items: center;
@ -651,7 +691,7 @@ onMounted(fetchSkills)
:deep(.ant-tabs-nav) {
margin: 0;
padding: 0 24px;
padding: 0 16px;
border-bottom: 1px solid @border-color;
flex-shrink: 0;
&::before { border-bottom: none; }
@ -686,11 +726,11 @@ onMounted(fetchSkills)
align-items: center;
border-bottom: 1px solid @border-color;
background-color: var(--gray-50);
.label { font-size: 11px; font-weight: 600; color: var(--gray-400); text-transform: uppercase; letter-spacing: 0.5px; }
.label { font-size: 11px; font-weight: 600; color: var(--gray-500); text-transform: uppercase; letter-spacing: 0.5px; }
.tree-actions {
display: flex; gap: 4px;
button {
background: none; border: none; padding: 2px; cursor: pointer; color: var(--gray-400);
background: none; border: none; padding: 2px; cursor: pointer; color: var(--gray-500);
display: flex; align-items: center;
&:hover { color: var(--gray-900); }
}
@ -699,11 +739,6 @@ onMounted(fetchSkills)
.tree-content {
flex: 1; overflow-y: auto; padding: 8px;
:deep(.ant-tree) {
background: transparent;
.ant-tree-node-content-wrapper { border-radius: 4px; font-size: 13px; padding: 4px 8px; }
.ant-tree-node-selected { background-color: var(--gray-200) !important; font-weight: 500; }
}
}
}
@ -731,24 +766,25 @@ onMounted(fetchSkills)
}
.editor-main {
flex: 1;
min-height: 0;
background-color: var(--gray-0);
display: flex;
flex-direction: column;
}
flex: 1;
min-height: 0;
background-color: var(--gray-0);
display: flex;
flex-direction: column;
}
.editor-main :deep(.ant-empty) {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.editor-main :deep(.ant-empty) {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.editor-main :deep(textarea) {
flex: 1;
min-height: 0;
}
.editor-main :deep(textarea) {
flex: 1;
min-height: 0;
}
.pure-editor {
width: 100%; height: 100%; border: none; resize: none; padding: 20px;
font-family: 'Fira Code', 'Monaco', monospace; font-size: 13px; line-height: 1.6;

View File

@ -107,12 +107,6 @@ const mainList = computed(() => {
path: '/database',
icon: LibraryBig,
activeIcon: LibraryBig
},
{
name: 'Dashboard',
path: '/dashboard',
icon: BarChart3,
activeIcon: BarChart3
}
]
@ -125,6 +119,13 @@ const mainList = computed(() => {
})
}
items.push({
name: 'Dashboard',
path: '/dashboard',
icon: BarChart3,
activeIcon: BarChart3
})
return items
})