2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 03:07:20 +08:00

feat: 阶段提交

This commit is contained in:
2026-01-12 06:05:08 +08:00
parent fe90dd3dc8
commit 31f39555ab
5 changed files with 575 additions and 91 deletions

View File

@@ -24,7 +24,6 @@ const tabsContainerRef = ref<HTMLDivElement>()
// 标签页滚轮横向滚动
function handleTabsWheel(e: WheelEvent) {
if (tabsContainerRef.value) {
e.preventDefault()
tabsContainerRef.value.scrollLeft += e.deltaY
}
}
@@ -155,11 +154,18 @@ function handleDragOver(e: DragEvent, index: number) {
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move'
}
dragOverIndex.value = index
// 只在值变化时更新,避免高频触发导致闪烁
if (dragOverIndex.value !== index) {
dragOverIndex.value = index
}
}
function handleDragLeave() {
dragOverIndex.value = null
function handleDragLeave(e: DragEvent) {
// 检查是否离开了整个 tabs 容器,而不是在标签页之间移动
const relatedTarget = e.relatedTarget as HTMLElement | null
if (!relatedTarget || !tabsContainerRef.value?.contains(relatedTarget)) {
dragOverIndex.value = null
}
}
function handleDrop(e: DragEvent, toIndex: number) {
@@ -176,6 +182,28 @@ function handleDragEnd() {
dragOverIndex.value = null
}
// 尾部放置区域的拖拽处理
function handleDragOverEnd(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move'
}
const endIndex = editorStore.tabs.length
if (dragOverIndex.value !== endIndex) {
dragOverIndex.value = endIndex
}
}
function handleDropEnd(e: DragEvent) {
e.preventDefault()
if (dragIndex.value !== null && dragIndex.value !== editorStore.tabs.length - 1) {
// 移动到最后
editorStore.reorderTabs(dragIndex.value, editorStore.tabs.length - 1)
}
dragIndex.value = null
dragOverIndex.value = null
}
// 右键菜单
const contextMenuOptions = computed(() => [
{
@@ -318,7 +346,7 @@ defineExpose({
<div class="editor-pane">
<!-- 标签页栏 -->
<div class="tabs-bar" v-if="editorStore.tabs.length > 0">
<div ref="tabsContainerRef" class="tabs-container" @wheel="handleTabsWheel">
<div ref="tabsContainerRef" class="tabs-container" @wheel.prevent="handleTabsWheel">
<div
v-for="(tab, index) in editorStore.tabs"
:key="tab.path"
@@ -333,7 +361,7 @@ defineExpose({
@contextmenu="handleContextMenu($event, tab.path)"
@dragstart="handleDragStart($event, index)"
@dragover="handleDragOver($event, index)"
@dragleave="handleDragLeave"
@dragleave="handleDragLeave($event)"
@drop="handleDrop($event, index)"
@dragend="handleDragEnd"
>
@@ -352,6 +380,15 @@ defineExpose({
</template>
</n-button>
</div>
<!-- 尾部放置区域用于拖拽到最后 -->
<div
v-if="dragIndex !== null"
class="tab-drop-end"
:class="{ 'drag-over': dragOverIndex === editorStore.tabs.length }"
@dragover="handleDragOverEnd($event)"
@dragleave="handleDragLeave($event)"
@drop="handleDropEnd($event)"
/>
</div>
</div>
@@ -361,11 +398,7 @@ defineExpose({
<i-mdi-file-document-outline class="empty-icon" />
<p>{{ $gettext('Select a file to edit') }}</p>
</div>
<div
v-show="editorStore.tabs.length > 0"
ref="containerRef"
class="monaco-container"
/>
<div v-show="editorStore.tabs.length > 0" ref="containerRef" class="monaco-container" />
<div v-if="editorStore.activeTab?.loading" class="loading-overlay">
<n-spin size="medium" />
</div>
@@ -391,12 +424,14 @@ defineExpose({
flex-direction: column;
height: 100%;
overflow: hidden;
min-width: 0; /* 允许在 flex 布局中收缩 */
}
.tabs-bar {
flex-shrink: 0;
border-bottom: 1px solid v-bind('themeVars.borderColor');
background: v-bind('themeVars.cardColor');
overflow: hidden;
}
.tabs-container {
@@ -418,7 +453,9 @@ defineExpose({
cursor: pointer;
border-right: 1px solid v-bind('themeVars.borderColor');
white-space: nowrap;
transition: background-color 0.2s, opacity 0.2s;
transition:
background-color 0.2s,
opacity 0.2s;
position: relative;
user-select: none;
@@ -450,6 +487,15 @@ defineExpose({
}
}
.tab-drop-end {
width: 20px;
flex-shrink: 0;
&.drag-over {
border-left: 2px solid v-bind('themeVars.primaryColor');
}
}
.tab-name {
font-size: 13px;
max-width: 150px;

View File

@@ -83,7 +83,6 @@ function handleIndentChange(value: { tabSize: number; insertSpaces: boolean }) {
<div class="editor-status-bar" v-if="editorStore.activeTab">
<!-- 文件路径 -->
<div class="status-item path">
{{ $gettext('Path') }}:
<n-ellipsis style="max-width: 400px">
{{ editorStore.activeTab.path }}
</n-ellipsis>

View File

@@ -157,6 +157,13 @@ function handleToggleWordWrap() {
const current = editorStore.settings.wordWrap
editorStore.updateSettings({ wordWrap: current === 'on' ? 'off' : 'on' })
}
// 暴露方法供外部调用
defineExpose({
save: handleSave,
saveAll: handleSaveAll,
refresh: handleRefresh
})
</script>
<template>

View File

@@ -24,6 +24,7 @@ const rootPath = ref(props.initialPath || editorStore.rootPath || '/')
// 编辑器面板引用
const editorPaneRef = ref<InstanceType<typeof EditorPane>>()
const fileTreeRef = ref<InstanceType<typeof FileTree>>()
const toolbarRef = ref<InstanceType<typeof EditorToolbar>>()
// 设置弹窗
const showSettings = ref(false)
@@ -63,16 +64,23 @@ watch(rootPath, (newPath) => {
// 键盘快捷键
function handleKeydown(e: KeyboardEvent) {
// Ctrl+S 保存
if (e.ctrlKey && e.key === 's' && !e.shiftKey) {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
const modKey = isMac ? e.metaKey : e.ctrlKey
// Ctrl/Cmd+S 保存
if (modKey && e.key === 's' && !e.shiftKey) {
e.preventDefault()
// 触发保存
const toolbar = document.querySelector('.editor-toolbar button') as HTMLButtonElement
toolbar?.click()
toolbarRef.value?.save()
}
// Ctrl+Shift+S 全部保存
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
// Ctrl/Cmd+Shift+S 全部保存
if (modKey && e.shiftKey && e.key.toLowerCase() === 's') {
e.preventDefault()
toolbarRef.value?.saveAll()
}
// F5 或 Ctrl/Cmd+R 刷新当前文件
if (e.key === 'F5' || (modKey && e.key === 'r')) {
e.preventDefault()
toolbarRef.value?.refresh()
}
}
@@ -95,6 +103,7 @@ defineExpose({
<div class="file-editor-view">
<!-- 顶部工具栏 -->
<EditorToolbar
ref="toolbarRef"
@search="handleSearch"
@replace="handleReplace"
@goto="handleGoto"
@@ -315,13 +324,14 @@ defineExpose({
.editor-content {
height: 100%;
overflow: visible; /* 允许 Monaco tooltip 溢出 */
overflow: hidden;
}
.editor-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: visible; /* 允许 Monaco tooltip 溢出 */
overflow: hidden;
min-width: 0; /* 允许在 flex 布局中收缩 */
}
</style>

View File

@@ -5,7 +5,7 @@ import { useEditorStore } from '@/store'
import { decodeBase64 } from '@/utils'
import { getExt, getIconByExt } from '@/utils/file'
import type { TreeOption } from 'naive-ui'
import { useThemeVars } from 'naive-ui'
import { NInput, useThemeVars } from 'naive-ui'
import { useGettext } from 'vue3-gettext'
const { $gettext } = useGettext()
@@ -26,13 +26,33 @@ const expandedKeys = ref<string[]>([])
const selectedKeys = ref<string[]>([])
const loading = ref(false)
const searchKeyword = ref('')
const searchLoading = ref(false)
const searchResults = ref<TreeOption[]>([])
const isSearchMode = computed(() => searchKeyword.value.trim().length > 0)
// 新建文件/目录弹窗
const showCreateModal = ref(false)
const createType = ref<'file' | 'dir'>('file')
const createName = ref('')
const createParentPath = ref('')
const createLoading = ref(false)
// 内联新建状态
const inlineCreateType = ref<'file' | 'dir' | null>(null)
const inlineCreateName = ref('')
const inlineCreateParentPath = ref('')
const inlineCreateLoading = ref(false)
// 内联新建节点的特殊 key
const INLINE_CREATE_KEY = '__inline_create__'
// 创建内联新建节点
function createInlineNode(): TreeOption {
return {
key: INLINE_CREATE_KEY,
label: '',
isLeaf: true,
prefix: () =>
h(TheIcon, {
icon: inlineCreateType.value === 'dir' ? 'mdi:folder' : 'mdi:file-outline',
size: 18,
color: inlineCreateType.value === 'dir' ? '#f59e0b' : '#6b7280'
})
}
}
// 加载目录内容
async function loadDirectory(path: string): Promise<TreeOption[]> {
@@ -103,7 +123,17 @@ async function handleSelect(keys: string[], option: TreeOption[]) {
selectedKeys.value = keys
const node = option[0]
if (node && node.isLeaf) {
const isDir = (node as any)?.isDir
// 搜索模式下点击文件夹,跳转到该目录
if (isSearchMode.value && isDir) {
const path = node.key as string
searchKeyword.value = ''
emit('update:rootPath', path)
return
}
if (node && node.isLeaf && !isDir) {
// 打开文件
const path = node.key as string
const existingTab = editorStore.tabs.find((t) => t.path === path)
@@ -144,25 +174,39 @@ function handleRefresh() {
initTree()
}
// 显示新建弹窗
// 显示内联新建
function showCreate(type: 'file' | 'dir') {
createType.value = type
createName.value = ''
// 使用选中的目录或根目录作为父目录
// 如果已经在新建中,先取消
if (inlineCreateType.value) {
cancelInlineCreate()
}
inlineCreateType.value = type
inlineCreateName.value = ''
// 确定父目录
if (selectedKeys.value.length > 0) {
const selectedNode = findNode(treeData.value, selectedKeys.value[0])
if (selectedNode && !selectedNode.isLeaf) {
createParentPath.value = selectedKeys.value[0]
// 选中的是目录,在该目录下新建
inlineCreateParentPath.value = selectedKeys.value[0]
// 确保目录已展开
if (!expandedKeys.value.includes(selectedKeys.value[0])) {
expandedKeys.value = [...expandedKeys.value, selectedKeys.value[0]]
}
} else {
// 如果选中的是文件,使用其父目录
// 选中的是文件,其父目录下新建
const parts = selectedKeys.value[0].split('/')
parts.pop()
createParentPath.value = parts.join('/') || props.rootPath
inlineCreateParentPath.value = parts.join('/') || props.rootPath
}
} else {
createParentPath.value = props.rootPath
// 没有选中,在根目录下新建
inlineCreateParentPath.value = props.rootPath
}
showCreateModal.value = true
// 插入内联新建节点
insertInlineNode()
}
// 查找节点
@@ -177,52 +221,235 @@ function findNode(nodes: TreeOption[], key: string): TreeOption | null {
return null
}
// 确认新建
async function handleCreate() {
if (!createName.value.trim()) {
window.$message.warning($gettext('Please enter a name'))
// 插入内联新建节点
function insertInlineNode() {
const inlineNode = createInlineNode()
if (inlineCreateParentPath.value === props.rootPath) {
// 在根目录下新建,插入到 treeData 开头
removeInlineNode()
treeData.value = [inlineNode, ...treeData.value]
} else {
// 在子目录下新建
const parentNode = findNode(treeData.value, inlineCreateParentPath.value)
if (parentNode) {
removeInlineNode()
if (!parentNode.children) {
parentNode.children = []
}
parentNode.children = [inlineNode, ...parentNode.children]
}
}
}
// 移除内联新建节点
function removeInlineNode() {
// 从根目录移除
treeData.value = treeData.value.filter((n) => n.key !== INLINE_CREATE_KEY)
// 从所有子目录移除
function removeFromChildren(nodes: TreeOption[]) {
for (const node of nodes) {
if (node.children) {
node.children = node.children.filter((n) => n.key !== INLINE_CREATE_KEY)
removeFromChildren(node.children)
}
}
}
removeFromChildren(treeData.value)
}
// 取消内联新建
function cancelInlineCreate() {
removeInlineNode()
inlineCreateType.value = null
inlineCreateName.value = ''
inlineCreateParentPath.value = ''
}
// 确认内联新建
async function confirmInlineCreate() {
if (!inlineCreateName.value.trim()) {
cancelInlineCreate()
return
}
const fullPath = `${createParentPath.value}/${createName.value}`.replace(/\/+/g, '/')
const fullPath = `${inlineCreateParentPath.value}/${inlineCreateName.value}`.replace(/\/+/g, '/')
const isDir = inlineCreateType.value === 'dir'
createLoading.value = true
useRequest(file.create(fullPath, createType.value === 'dir'))
inlineCreateLoading.value = true
useRequest(file.create(fullPath, isDir))
.onSuccess(async () => {
window.$message.success($gettext('Created successfully'))
showCreateModal.value = false
// 保存当前新建的类型和路径
const parentPath = inlineCreateParentPath.value
const createdPath = fullPath
// 取消内联状态
cancelInlineCreate()
// 刷新父目录
if (expandedKeys.value.includes(createParentPath.value)) {
const parentNode = findNode(treeData.value, createParentPath.value)
if (parentNode) {
parentNode.children = await loadDirectory(createParentPath.value)
}
} else if (createParentPath.value === props.rootPath) {
// 如果是在根目录创建,刷新整个树
if (parentPath === props.rootPath) {
await initTree()
} else {
// 展开父目录
expandedKeys.value = [...expandedKeys.value, createParentPath.value]
const parentNode = findNode(treeData.value, parentPath)
if (parentNode) {
parentNode.children = await loadDirectory(parentPath)
}
}
// 如果是文件,自动打开
if (createType.value === 'file') {
editorStore.openFile(fullPath, '', 'utf-8')
if (!isDir) {
editorStore.openFile(createdPath, '', 'utf-8')
}
})
.onError(() => {
window.$message.error($gettext('Failed to create'))
})
.onComplete(() => {
createLoading.value = false
inlineCreateLoading.value = false
})
}
// 搜索过滤
function filterTree(pattern: string, option: TreeOption): boolean {
if (!pattern) return true
return (option.label as string).toLowerCase().includes(pattern.toLowerCase())
// 搜索文件
let searchTimer: ReturnType<typeof setTimeout> | null = null
function doSearch(keyword: string) {
if (!keyword.trim()) {
searchResults.value = []
return
}
searchLoading.value = true
useRequest(file.list(props.rootPath, keyword, true, 'name', 1, 100))
.onSuccess(({ data }) => {
const items = data.items || []
// 排序:目录在前,文件在后
const sortedItems = [...items].sort((a: any, b: any) => {
if (a.dir && !b.dir) return -1
if (!a.dir && b.dir) return 1
return a.name.localeCompare(b.name)
})
searchResults.value = sortedItems.map((item: any) => ({
key: item.full,
label: item.name,
fullPath: item.full,
isLeaf: true, // 搜索结果都设为叶子节点,文件夹点击时跳转而不是展开
prefix: () =>
h(TheIcon, {
icon: item.dir ? 'mdi:folder' : getIconByExt(getExt(item.name)),
size: 18,
color: item.dir ? '#f59e0b' : '#6b7280'
}),
isDir: item.dir
}))
})
.onError(() => {
searchResults.value = []
})
.onComplete(() => {
searchLoading.value = false
})
}
// 监听搜索关键词变化(防抖)
watch(searchKeyword, (keyword) => {
if (searchTimer) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
doSearch(keyword)
}, 300)
})
// 自定义渲染 label
function renderLabel({ option }: { option: TreeOption }) {
// 内联新建节点
if (option.key === INLINE_CREATE_KEY) {
return h(NInput, {
value: inlineCreateName.value,
'onUpdate:value': (v: string) => {
inlineCreateName.value = v
},
size: 'tiny',
placeholder:
inlineCreateType.value === 'dir' ? $gettext('Folder name') : $gettext('File name'),
autofocus: true,
disabled: inlineCreateLoading.value,
style: { width: '120px' },
onKeyup: (e: KeyboardEvent) => {
e.stopPropagation()
if (e.key === 'Enter') {
confirmInlineCreate()
} else if (e.key === 'Escape') {
cancelInlineCreate()
}
},
onBlur: () => {
setTimeout(() => {
if (inlineCreateType.value && !inlineCreateLoading.value) {
if (inlineCreateName.value.trim()) {
confirmInlineCreate()
} else {
cancelInlineCreate()
}
}
}, 150)
},
onClick: (e: MouseEvent) => {
e.stopPropagation()
}
})
}
// 内联重命名节点
if (option.key === inlineRenameKey.value) {
return h(NInput, {
value: inlineRenameName.value,
'onUpdate:value': (v: string) => {
inlineRenameName.value = v
},
size: 'tiny',
autofocus: true,
disabled: inlineRenameLoading.value,
style: { width: '120px' },
onKeyup: (e: KeyboardEvent) => {
e.stopPropagation()
if (e.key === 'Enter') {
confirmInlineRename()
} else if (e.key === 'Escape') {
cancelInlineRename()
}
},
onBlur: () => {
setTimeout(() => {
if (inlineRenameKey.value && !inlineRenameLoading.value) {
if (inlineRenameName.value.trim()) {
confirmInlineRename()
} else {
cancelInlineRename()
}
}
}, 150)
},
onClick: (e: MouseEvent) => {
e.stopPropagation()
}
})
}
return option.label as string
}
// 搜索结果渲染 label显示完整路径
function renderSearchLabel({ option }: { option: TreeOption }) {
const fullPath = (option as any).fullPath as string
// 移除 rootPath 前缀,显示相对路径
const relativePath = fullPath.startsWith(props.rootPath)
? fullPath.slice(props.rootPath.length).replace(/^\//, '')
: fullPath
return relativePath || option.label
}
// 路径编辑
@@ -230,6 +457,189 @@ const isEditingPath = ref(false)
const editingPath = ref('')
const pathInputRef = ref<HTMLInputElement | null>(null)
// 右键菜单
const showContextMenu = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const contextMenuNode = ref<TreeOption | null>(null)
// 内联重命名状态
const inlineRenameKey = ref<string | null>(null)
const inlineRenameName = ref('')
const inlineRenameLoading = ref(false)
// 右键菜单选项
const contextMenuOptions = computed(() => {
if (!contextMenuNode.value) return []
const isDir = !contextMenuNode.value.isLeaf
const options = [
{ label: $gettext('Rename'), key: 'rename' },
{ label: $gettext('Delete'), key: 'delete', props: { style: { color: 'red' } } }
]
if (!isDir) {
options.unshift({ label: $gettext('Download'), key: 'download' })
}
return options
})
// 处理右键菜单
function handleContextMenu(e: MouseEvent, option: TreeOption) {
// 忽略内联新建节点
if (option.key === INLINE_CREATE_KEY) return
e.preventDefault()
e.stopPropagation()
showContextMenu.value = false
nextTick(() => {
contextMenuNode.value = option
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
showContextMenu.value = true
})
}
// 关闭右键菜单
function handleContextMenuClose() {
showContextMenu.value = false
contextMenuNode.value = null
}
// 处理右键菜单选择
function handleContextMenuSelect(key: string) {
if (!contextMenuNode.value) return
const nodePath = contextMenuNode.value.key as string
const nodeName = contextMenuNode.value.label as string
const isDir = !contextMenuNode.value.isLeaf
switch (key) {
case 'rename':
startInlineRename(nodePath, nodeName)
break
case 'delete':
handleDelete(nodePath, nodeName, isDir)
break
case 'download':
handleDownload(nodePath)
break
}
handleContextMenuClose()
}
// 开始内联重命名
function startInlineRename(path: string, name: string) {
inlineRenameKey.value = path
inlineRenameName.value = name
}
// 取消内联重命名
function cancelInlineRename() {
inlineRenameKey.value = null
inlineRenameName.value = ''
}
// 确认内联重命名
function confirmInlineRename() {
if (!inlineRenameKey.value || !inlineRenameName.value.trim()) {
cancelInlineRename()
return
}
const oldPath = inlineRenameKey.value
const oldName = oldPath.split('/').pop() || ''
const newName = inlineRenameName.value.trim()
if (oldName === newName) {
cancelInlineRename()
return
}
const parentPath = oldPath.substring(0, oldPath.lastIndexOf('/')) || '/'
const newPath = `${parentPath}/${newName}`.replace(/\/+/g, '/')
inlineRenameLoading.value = true
useRequest(file.move([{ source: oldPath, target: newPath, force: false }]))
.onSuccess(async () => {
window.$message.success($gettext('Renamed successfully'))
// 如果重命名的文件在编辑器中打开,更新标签页
const tab = editorStore.tabs.find((t) => t.path === oldPath)
if (tab) {
editorStore.closeTab(oldPath)
editorStore.openFile(newPath, tab.content, tab.encoding)
if (!tab.modified) {
editorStore.markSaved(newPath)
}
}
cancelInlineRename()
// 刷新父目录
if (parentPath === props.rootPath) {
await initTree()
} else {
const parentNode = findNode(treeData.value, parentPath)
if (parentNode) {
parentNode.children = await loadDirectory(parentPath)
}
}
})
.onError(() => {
window.$message.error($gettext('Failed to rename'))
})
.onComplete(() => {
inlineRenameLoading.value = false
})
}
// 删除文件/目录
function handleDelete(path: string, name: string, isDir: boolean) {
window.$dialog.warning({
title: $gettext('Delete'),
content: $gettext('Are you sure you want to delete %{ name }?', { name }),
positiveText: $gettext('Delete'),
negativeText: $gettext('Cancel'),
onPositiveClick: async () => {
useRequest(file.delete(path))
.onSuccess(async () => {
window.$message.success($gettext('Deleted successfully'))
// 如果删除的文件在编辑器中打开,关闭标签页
if (!isDir) {
editorStore.closeTab(path)
}
// 刷新父目录
const parentPath = path.substring(0, path.lastIndexOf('/')) || '/'
if (parentPath === props.rootPath) {
await initTree()
} else {
const parentNode = findNode(treeData.value, parentPath)
if (parentNode) {
parentNode.children = await loadDirectory(parentPath)
}
}
})
.onError(() => {
window.$message.error($gettext('Failed to delete'))
})
}
})
}
// 下载文件
function handleDownload(path: string) {
window.open('/api/file/download?path=' + encodeURIComponent(path))
}
// 节点属性(用于绑定右键菜单)
function nodeProps({ option }: { option: TreeOption }) {
return {
onContextmenu: (e: MouseEvent) => handleContextMenu(e, option)
}
}
function startEditPath() {
editingPath.value = props.rootPath
isEditingPath.value = true
@@ -339,15 +749,37 @@ defineExpose({
<!-- 文件树 -->
<div class="tree-container">
<n-spin :show="loading" class="tree-spin">
<!-- 搜索模式显示搜索结果 -->
<n-spin v-if="isSearchMode" :show="searchLoading" class="tree-spin">
<n-tree
v-if="searchResults.length > 0"
block-line
:data="searchResults"
:selected-keys="selectedKeys"
:render-label="renderSearchLabel"
:node-props="nodeProps"
@update:selected-keys="handleSelect"
selectable
virtual-scroll
class="file-tree-content"
style="height: 100%"
/>
<n-empty
v-else-if="!searchLoading"
:description="$gettext('No results found')"
class="search-empty"
/>
</n-spin>
<!-- 普通模式显示文件树 -->
<n-spin v-else :show="loading" class="tree-spin">
<n-tree
block-line
:data="treeData"
:expanded-keys="expandedKeys"
:selected-keys="selectedKeys"
:pattern="searchKeyword"
:filter="filterTree"
:on-load="handleLoad"
:render-label="renderLabel"
:node-props="nodeProps"
@update:expanded-keys="handleExpandedKeysUpdate"
@update:selected-keys="handleSelect"
selectable
@@ -359,31 +791,17 @@ defineExpose({
</n-spin>
</div>
<!-- 新建弹窗 -->
<n-modal
v-model:show="showCreateModal"
preset="dialog"
:title="createType === 'file' ? $gettext('New File') : $gettext('New Folder')"
:positive-text="$gettext('Create')"
:negative-text="$gettext('Cancel')"
:loading="createLoading"
@positive-click="handleCreate"
>
<n-form>
<n-form-item :label="$gettext('Parent Directory')">
<n-input :value="createParentPath" disabled />
</n-form-item>
<n-form-item :label="$gettext('Name')">
<n-input
v-model:value="createName"
:placeholder="
createType === 'file' ? $gettext('Enter file name') : $gettext('Enter folder name')
"
@keyup.enter="handleCreate"
/>
</n-form-item>
</n-form>
</n-modal>
<!-- 右键菜单 -->
<n-dropdown
placement="bottom-start"
trigger="manual"
:x="contextMenuX"
:y="contextMenuY"
:options="contextMenuOptions"
:show="showContextMenu"
@select="handleContextMenuSelect"
@clickoutside="handleContextMenuClose"
/>
</div>
</template>
@@ -471,4 +889,8 @@ defineExpose({
height: 100%;
overflow: auto;
}
.search-empty {
padding: 40px 20px;
}
</style>