mirror of
https://github.com/tnb-labs/panel.git
synced 2026-09-01 14:55:12 +08:00
feat: 文件管理支持多标签页
This commit is contained in:
@@ -1,40 +1,158 @@
|
||||
export interface File {
|
||||
path: string
|
||||
keyword: string
|
||||
sub: boolean
|
||||
import type { Marked } from '@/views/file/types'
|
||||
|
||||
export interface FileTab {
|
||||
id: string // 标签页唯一标识
|
||||
label: string // 显示名(路径末级目录名)
|
||||
path: string // 当前路径
|
||||
keyword: string // 搜索关键词
|
||||
sub: boolean // 搜索是否包含子目录
|
||||
history: string[] // 浏览历史栈
|
||||
historyCursor: number // 历史指针
|
||||
}
|
||||
|
||||
export interface FileState {
|
||||
tabs: FileTab[]
|
||||
activeTabId: string
|
||||
// 全局偏好(跨标签页共享)
|
||||
showHidden: boolean
|
||||
viewType: 'list' | 'grid'
|
||||
sortKey: string
|
||||
sortOrder: 'asc' | 'desc'
|
||||
// 全局剪贴板(跨标签页共享)
|
||||
clipboard: {
|
||||
marked: Marked[]
|
||||
markedType: 'copy' | 'move'
|
||||
}
|
||||
}
|
||||
|
||||
// 最大标签页数量
|
||||
const MAX_TABS = 10
|
||||
|
||||
// 根据路径生成标签页显示名
|
||||
const getLabelFromPath = (path: string): string => {
|
||||
if (path === '/') return '/'
|
||||
return path.split('/').pop() || '/'
|
||||
}
|
||||
|
||||
// 创建新标签页
|
||||
const createNewTab = (path: string): FileTab => ({
|
||||
id: crypto.randomUUID(),
|
||||
label: getLabelFromPath(path),
|
||||
path,
|
||||
keyword: '',
|
||||
sub: false,
|
||||
history: [path],
|
||||
historyCursor: 0
|
||||
})
|
||||
|
||||
export const useFileStore = defineStore('file', {
|
||||
state: (): File => {
|
||||
state: (): FileState => {
|
||||
const initialTab = createNewTab('/opt')
|
||||
return {
|
||||
path: '/opt',
|
||||
keyword: '',
|
||||
sub: false,
|
||||
tabs: [initialTab],
|
||||
activeTabId: initialTab.id,
|
||||
showHidden: false,
|
||||
viewType: 'list',
|
||||
sortKey: '',
|
||||
sortOrder: 'asc'
|
||||
sortOrder: 'asc',
|
||||
clipboard: {
|
||||
marked: [],
|
||||
markedType: 'copy'
|
||||
}
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
sort(): string {
|
||||
if (!this.sortKey) return ''
|
||||
return this.sortOrder === 'desc' ? `-${this.sortKey}` : this.sortKey
|
||||
},
|
||||
activeTab(): FileTab | undefined {
|
||||
return this.tabs.find((t) => t.id === this.activeTabId)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
set(info: File) {
|
||||
this.path = info.path
|
||||
this.keyword = info.keyword
|
||||
this.sub = info.sub
|
||||
this.showHidden = info.showHidden
|
||||
this.viewType = info.viewType
|
||||
this.sortKey = info.sortKey
|
||||
this.sortOrder = info.sortOrder
|
||||
// 新建标签页
|
||||
createTab(path?: string) {
|
||||
if (this.tabs.length >= MAX_TABS) {
|
||||
window.$message.warning('标签页数量已达上限')
|
||||
return
|
||||
}
|
||||
const tabPath = path ?? this.activeTab?.path ?? '/opt'
|
||||
const tab = createNewTab(tabPath)
|
||||
this.tabs.push(tab)
|
||||
this.activeTabId = tab.id
|
||||
},
|
||||
// 关闭标签页
|
||||
closeTab(tabId: string) {
|
||||
if (this.tabs.length <= 1) return
|
||||
const index = this.tabs.findIndex((t) => t.id === tabId)
|
||||
if (index === -1) return
|
||||
this.tabs.splice(index, 1)
|
||||
// 如果关闭的是当前活跃标签页,切换到相邻标签页
|
||||
if (this.activeTabId === tabId) {
|
||||
const newIndex = Math.min(index, this.tabs.length - 1)
|
||||
this.activeTabId = this.tabs[newIndex]!.id
|
||||
}
|
||||
},
|
||||
// 切换标签页
|
||||
switchTab(tabId: string) {
|
||||
if (this.tabs.some((t) => t.id === tabId)) {
|
||||
this.activeTabId = tabId
|
||||
}
|
||||
},
|
||||
// 更新标签页路径
|
||||
updateTabPath(tabId: string, path: string) {
|
||||
const tab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!tab) return
|
||||
tab.path = path
|
||||
tab.label = getLabelFromPath(path)
|
||||
tab.keyword = ''
|
||||
tab.sub = false
|
||||
this.pushHistory(tabId, path)
|
||||
},
|
||||
// 推入历史记录
|
||||
pushHistory(tabId: string, path: string) {
|
||||
const tab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!tab) return
|
||||
// 如果当前位置就是这个路径,不重复推入
|
||||
if (tab.history[tab.historyCursor] === path) return
|
||||
// 截断 cursor 后的 future
|
||||
tab.history.splice(tab.historyCursor + 1)
|
||||
tab.history.push(path)
|
||||
tab.historyCursor = tab.history.length - 1
|
||||
},
|
||||
// 历史后退
|
||||
historyBack(tabId: string) {
|
||||
const tab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!tab || tab.historyCursor <= 0) return
|
||||
tab.historyCursor--
|
||||
tab.path = tab.history[tab.historyCursor] ?? '/'
|
||||
tab.label = getLabelFromPath(tab.path)
|
||||
tab.keyword = ''
|
||||
tab.sub = false
|
||||
},
|
||||
// 历史前进
|
||||
historyForward(tabId: string) {
|
||||
const tab = this.tabs.find((t) => t.id === tabId)
|
||||
if (!tab || tab.historyCursor >= tab.history.length - 1) return
|
||||
tab.historyCursor++
|
||||
tab.path = tab.history[tab.historyCursor] ?? '/'
|
||||
tab.label = getLabelFromPath(tab.path)
|
||||
tab.keyword = ''
|
||||
tab.sub = false
|
||||
},
|
||||
// 重新排序标签页(拖拽)
|
||||
reorderTabs(tabs: FileTab[]) {
|
||||
this.tabs = tabs
|
||||
},
|
||||
// 设置剪贴板
|
||||
setClipboard(marked: Marked[], markedType: 'copy' | 'move') {
|
||||
this.clipboard.marked = marked
|
||||
this.clipboard.markedType = markedType
|
||||
},
|
||||
// 清空剪贴板
|
||||
clearClipboard() {
|
||||
this.clipboard.marked = []
|
||||
},
|
||||
toggleShowHidden() {
|
||||
this.showHidden = !this.showHidden
|
||||
@@ -44,7 +162,6 @@ export const useFileStore = defineStore('file', {
|
||||
},
|
||||
setSort(key: string) {
|
||||
if (this.sortKey === key) {
|
||||
// 同一列:切换排序方向,或取消排序
|
||||
if (this.sortOrder === 'asc') {
|
||||
this.sortOrder = 'desc'
|
||||
} else {
|
||||
@@ -52,11 +169,24 @@ export const useFileStore = defineStore('file', {
|
||||
this.sortOrder = 'asc'
|
||||
}
|
||||
} else {
|
||||
// 不同列:设置新的排序列
|
||||
this.sortKey = key
|
||||
this.sortOrder = 'asc'
|
||||
}
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
persist: {
|
||||
afterHydrate(ctx: any) {
|
||||
const store = ctx.store as ReturnType<typeof useFileStore>
|
||||
// 恢复后清空剪贴板
|
||||
store.clipboard = { marked: [], markedType: 'copy' }
|
||||
// 确保 activeTabId 有效
|
||||
if (!store.tabs || store.tabs.length === 0) {
|
||||
const tab = createNewTab('/opt')
|
||||
store.tabs = [tab]
|
||||
store.activeTabId = tab.id
|
||||
} else if (!store.tabs.some((t) => t.id === store.activeTabId)) {
|
||||
store.activeTabId = store.tabs[0]!.id
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -72,7 +72,7 @@ const columns: any = [
|
||||
class: 'cursor-pointer hover:opacity-60',
|
||||
type: 'info',
|
||||
onClick: () => {
|
||||
fileStore.path = row.path
|
||||
fileStore.activeTab && fileStore.updateTabPath(fileStore.activeTabId, row.path)
|
||||
router.push({ name: 'file-index' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,20 +3,23 @@ defineOptions({
|
||||
name: 'file-index'
|
||||
})
|
||||
|
||||
import { useThemeVars } from 'naive-ui'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
import { useFileStore } from '@/store'
|
||||
import type { FileTab } from '@/store/modules/file'
|
||||
import CompressModal from '@/views/file/CompressModal.vue'
|
||||
import ListView from '@/views/file/ListView.vue'
|
||||
import PathInput from '@/views/file/PathInput.vue'
|
||||
import PermissionModal from '@/views/file/PermissionModal.vue'
|
||||
import ToolBar from '@/views/file/ToolBar.vue'
|
||||
import UploadModal from '@/views/file/UploadModal.vue'
|
||||
import type { FileInfo, Marked } from '@/views/file/types'
|
||||
import type { FileInfo } from '@/views/file/types'
|
||||
|
||||
const fileStore = useFileStore()
|
||||
const themeVars = useThemeVars()
|
||||
|
||||
const selected = ref<string[]>([])
|
||||
const marked = ref<Marked[]>([])
|
||||
const markedType = ref<string>('copy')
|
||||
// 权限编辑时的文件信息列表
|
||||
const permissionFileInfoList = ref<FileInfo[]>([])
|
||||
|
||||
@@ -28,11 +31,24 @@ const upload = ref(false)
|
||||
const droppedFiles = ref<File[]>([])
|
||||
const isDragging = ref(false)
|
||||
|
||||
// 拖拽排序用的本地副本
|
||||
const localTabs = computed({
|
||||
get: () => fileStore.tabs,
|
||||
set: (val: FileTab[]) => fileStore.reorderTabs(val)
|
||||
})
|
||||
|
||||
// 切换标签页时清空选中
|
||||
watch(
|
||||
() => fileStore.activeTabId,
|
||||
() => {
|
||||
selected.value = []
|
||||
}
|
||||
)
|
||||
|
||||
// 处理拖拽进入
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 检查是否有文件
|
||||
if (e.dataTransfer?.types.includes('Files')) {
|
||||
isDragging.value = true
|
||||
}
|
||||
@@ -42,7 +58,6 @@ const handleDragEnter = (e: DragEvent) => {
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// 只有当离开整个容器时才隐藏
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
if (
|
||||
e.clientX <= rect.left ||
|
||||
@@ -75,7 +90,6 @@ const readDirectoryRecursively = async (
|
||||
}
|
||||
|
||||
let entries: FileSystemEntry[] = []
|
||||
// readEntries 可能需要多次调用才能获取所有条目
|
||||
let batch: FileSystemEntry[]
|
||||
do {
|
||||
batch = await readEntries()
|
||||
@@ -88,7 +102,6 @@ const readDirectoryRecursively = async (
|
||||
const fileEntry = childEntry as FileSystemFileEntry
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
fileEntry.file((f) => {
|
||||
// 创建带有相对路径的新 File 对象
|
||||
const newFile = new File([f], childPath, { type: f.type, lastModified: f.lastModified })
|
||||
resolve(newFile)
|
||||
}, reject)
|
||||
@@ -117,7 +130,6 @@ const handleDrop = async (e: DragEvent) => {
|
||||
|
||||
const files: File[] = []
|
||||
|
||||
// 使用 webkitGetAsEntry 来支持文件夹
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (item?.kind === 'file') {
|
||||
@@ -149,6 +161,25 @@ watch(upload, (val) => {
|
||||
droppedFiles.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// 中键点击标签页关闭
|
||||
const handleTabMouseDown = (e: MouseEvent, tabId: string) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault()
|
||||
fileStore.closeTab(tabId)
|
||||
}
|
||||
}
|
||||
|
||||
// 主题变量映射到 CSS
|
||||
const tabStyles = computed(() => ({
|
||||
'--tab-bg': themeVars.value.cardColor,
|
||||
'--tab-bg-hover': themeVars.value.hoverColor,
|
||||
'--tab-border': themeVars.value.borderColor,
|
||||
'--tab-text': themeVars.value.textColor2,
|
||||
'--tab-text-active': themeVars.value.textColor1,
|
||||
'--tab-text-muted': themeVars.value.textColor3,
|
||||
'--tab-primary': themeVars.value.primaryColor
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -161,34 +192,66 @@ watch(upload, (val) => {
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<n-flex vertical :size="20" class="flex-1 min-h-0">
|
||||
<path-input
|
||||
v-model:path="fileStore.path"
|
||||
v-model:keyword="fileStore.keyword"
|
||||
v-model:sub="fileStore.sub"
|
||||
/>
|
||||
<tool-bar
|
||||
v-model:path="fileStore.path"
|
||||
v-model:selected="selected"
|
||||
v-model:marked="marked"
|
||||
v-model:markedType="markedType"
|
||||
v-model:compress="compress"
|
||||
v-model:permission="permission"
|
||||
v-model:upload="upload"
|
||||
/>
|
||||
<list-view
|
||||
v-model:path="fileStore.path"
|
||||
v-model:keyword="fileStore.keyword"
|
||||
v-model:sub="fileStore.sub"
|
||||
v-model:selected="selected"
|
||||
v-model:marked="marked"
|
||||
v-model:markedType="markedType"
|
||||
v-model:compress="compress"
|
||||
v-model:permission="permission"
|
||||
v-model:permission-file-info-list="permissionFileInfoList"
|
||||
/>
|
||||
<!-- 标签页栏 -->
|
||||
<div class="file-tabs" :style="tabStyles">
|
||||
<draggable
|
||||
v-model="localTabs"
|
||||
item-key="id"
|
||||
class="file-tabs-list"
|
||||
:animation="200"
|
||||
ghost-class="file-tab-ghost"
|
||||
drag-class="file-tab-drag"
|
||||
>
|
||||
<template #item="{ element: tab }">
|
||||
<div
|
||||
class="file-tab"
|
||||
:class="{ active: tab.id === fileStore.activeTabId }"
|
||||
@click="fileStore.switchTab(tab.id)"
|
||||
@mousedown="handleTabMouseDown($event, tab.id)"
|
||||
>
|
||||
<i-mdi-folder-outline class="file-tab-icon" />
|
||||
<span class="file-tab-label" :title="tab.path">{{ tab.label }}</span>
|
||||
<span
|
||||
v-if="fileStore.tabs.length > 1"
|
||||
class="file-tab-close"
|
||||
@click.stop="fileStore.closeTab(tab.id)"
|
||||
>
|
||||
<i-mdi-close :size="14" />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="file-tab-add" @click="fileStore.createTab()">
|
||||
<i-mdi-plus :size="16" />
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
|
||||
<!-- 每个标签页内容(v-if 只渲染活跃的) -->
|
||||
<template v-for="tab in fileStore.tabs" :key="tab.id">
|
||||
<template v-if="tab.id === fileStore.activeTabId">
|
||||
<path-input :tab-id="tab.id" />
|
||||
<tool-bar
|
||||
:tab-id="tab.id"
|
||||
v-model:selected="selected"
|
||||
v-model:compress="compress"
|
||||
v-model:permission="permission"
|
||||
v-model:upload="upload"
|
||||
/>
|
||||
<list-view
|
||||
:tab-id="tab.id"
|
||||
v-model:selected="selected"
|
||||
v-model:compress="compress"
|
||||
v-model:permission="permission"
|
||||
v-model:permission-file-info-list="permissionFileInfoList"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<compress-modal
|
||||
v-model:show="compress"
|
||||
v-model:path="fileStore.path"
|
||||
v-model:path="fileStore.activeTab!.path"
|
||||
v-model:selected="selected"
|
||||
/>
|
||||
<permission-modal
|
||||
@@ -209,7 +272,7 @@ watch(upload, (val) => {
|
||||
<!-- 上传弹窗 -->
|
||||
<upload-modal
|
||||
v-model:show="upload"
|
||||
v-model:path="fileStore.path"
|
||||
v-model:path="fileStore.activeTab!.path"
|
||||
:initial-files="droppedFiles"
|
||||
/>
|
||||
</common-page>
|
||||
@@ -235,4 +298,129 @@ watch(upload, (val) => {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-tabs {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: -8px;
|
||||
border-bottom: 1px solid var(--tab-border);
|
||||
}
|
||||
|
||||
.file-tabs-list {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
|
||||
// 隐藏滚动条
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.file-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
color: var(--tab-text);
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--tab-bg-hover);
|
||||
color: var(--tab-text-active);
|
||||
|
||||
.file-tab-close {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--tab-bg);
|
||||
color: var(--tab-text-active);
|
||||
font-weight: 500;
|
||||
|
||||
// 底部 primary 色指示条
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--tab-primary);
|
||||
}
|
||||
|
||||
.file-tab-close {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-tab-icon {
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.file-tab-label {
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-tab-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
color: var(--tab-text-muted);
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: var(--tab-text-active);
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.file-tab-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: auto 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--tab-text-muted);
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--tab-bg-hover);
|
||||
color: var(--tab-text-active);
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽时的幽灵元素
|
||||
.file-tab-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
// 拖拽中的元素
|
||||
.file-tab-drag {
|
||||
background: var(--tab-bg) !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,25 +26,36 @@ import {
|
||||
isCompress,
|
||||
isImage
|
||||
} from '@/utils/file'
|
||||
import { usePaste } from '@/views/file/composables/usePaste'
|
||||
import EditModal from '@/views/file/EditModal.vue'
|
||||
import PreviewModal from '@/views/file/PreviewModal.vue'
|
||||
import PropertyModal from '@/views/file/PropertyModal.vue'
|
||||
import type { FileInfo, Marked } from '@/views/file/types'
|
||||
import type { FileInfo } from '@/views/file/types'
|
||||
import copy2clipboard from '@vavt/copy2clipboard'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const themeVars = useThemeVars()
|
||||
const fileStore = useFileStore()
|
||||
const { handlePaste: doPaste } = usePaste()
|
||||
|
||||
const props = defineProps<{
|
||||
tabId: string
|
||||
}>()
|
||||
|
||||
// 排序状态
|
||||
const sort = computed(() => fileStore.sort)
|
||||
|
||||
const path = defineModel<string>('path', { type: String, required: true })
|
||||
const keyword = defineModel<string>('keyword', { type: String, default: '' })
|
||||
const sub = defineModel<boolean>('sub', { type: Boolean, default: false })
|
||||
const tab = computed(() => fileStore.tabs.find((t) => t.id === props.tabId)!)
|
||||
const path = computed({
|
||||
get: () => tab.value.path,
|
||||
set: (v: string) => fileStore.updateTabPath(props.tabId, v)
|
||||
})
|
||||
const keyword = computed(() => tab.value.keyword)
|
||||
const sub = computed(() => tab.value.sub)
|
||||
const marked = computed(() => fileStore.clipboard.marked)
|
||||
const markedType = computed(() => fileStore.clipboard.markedType)
|
||||
|
||||
const selected = defineModel<any[]>('selected', { type: Array, default: () => [] })
|
||||
const marked = defineModel<Marked[]>('marked', { type: Array, default: () => [] })
|
||||
const markedType = defineModel<string>('markedType', { type: String, required: true })
|
||||
const compress = defineModel<boolean>('compress', { type: Boolean, required: true })
|
||||
const permission = defineModel<boolean>('permission', { type: Boolean, required: true })
|
||||
const permissionFileInfoList = defineModel<FileInfo[]>('permissionFileInfoList', {
|
||||
@@ -636,12 +647,14 @@ const getSelectedItems = () => {
|
||||
|
||||
// 标记文件(复制/移动)
|
||||
const markFiles = (items: any[], type: 'copy' | 'move') => {
|
||||
marked.value = items.map((item: any) => ({
|
||||
name: item.name,
|
||||
source: item.full,
|
||||
force: false
|
||||
}))
|
||||
markedType.value = type
|
||||
fileStore.setClipboard(
|
||||
items.map((item: any) => ({
|
||||
name: item.name,
|
||||
source: item.full,
|
||||
force: false
|
||||
})),
|
||||
type
|
||||
)
|
||||
window.$message.success(
|
||||
$gettext('Marked successfully, please navigate to the destination path to paste')
|
||||
)
|
||||
@@ -826,77 +839,7 @@ const copyPath = (item: any) => {
|
||||
|
||||
// ==================== 处理粘贴 ====================
|
||||
const handlePaste = () => {
|
||||
if (!marked.value.length) {
|
||||
window.$message.error($gettext('Please mark the files/folders to copy or move first'))
|
||||
return
|
||||
}
|
||||
|
||||
let flag = false
|
||||
const paths = marked.value.map((item) => ({
|
||||
name: item.name,
|
||||
source: item.source,
|
||||
target: path.value + '/' + item.name,
|
||||
force: false
|
||||
}))
|
||||
const sources = paths.map((item: any) => item.target)
|
||||
useRequest(file.exist(sources)).onSuccess(({ data }) => {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i]) {
|
||||
flag = true
|
||||
const pathItem = paths[i]
|
||||
if (pathItem) pathItem.force = true
|
||||
}
|
||||
}
|
||||
if (flag) {
|
||||
window.$dialog.warning({
|
||||
title: $gettext('Warning'),
|
||||
content: $gettext(
|
||||
'There are items with the same name %{ items } Do you want to overwrite?',
|
||||
{
|
||||
items: `${paths
|
||||
.filter((item) => item.force)
|
||||
.map((item) => item.name)
|
||||
.join(', ')}`
|
||||
}
|
||||
),
|
||||
positiveText: $gettext('Overwrite'),
|
||||
negativeText: $gettext('Cancel'),
|
||||
onPositiveClick: () => {
|
||||
if (markedType.value == 'copy') {
|
||||
useRequest(file.copy(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Copied successfully'))
|
||||
})
|
||||
} else {
|
||||
useRequest(file.move(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Moved successfully'))
|
||||
})
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
marked.value = []
|
||||
window.$message.info($gettext('Canceled'))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (markedType.value == 'copy') {
|
||||
useRequest(file.copy(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Copied successfully'))
|
||||
})
|
||||
} else {
|
||||
useRequest(file.move(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Moved successfully'))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
doPaste(path.value)
|
||||
}
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
@@ -1135,6 +1078,16 @@ const handleKeyDown = (event: KeyboardEvent) => {
|
||||
handlePaste()
|
||||
}
|
||||
break
|
||||
case 't':
|
||||
// Ctrl/Cmd + T: 新建标签页
|
||||
event.preventDefault()
|
||||
fileStore.createTab()
|
||||
break
|
||||
case 'w':
|
||||
// Ctrl/Cmd + W: 关闭当前标签页
|
||||
event.preventDefault()
|
||||
fileStore.closeTab(props.tabId)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
const currentIndex = getSelectedIndex()
|
||||
@@ -1277,7 +1230,7 @@ const handleFileSearch = () => {
|
||||
nextTick(() => {
|
||||
refresh()
|
||||
})
|
||||
window.$bus.emit('file:push-history', path.value)
|
||||
fileStore.pushHistory(props.tabId, path.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -1285,14 +1238,11 @@ onMounted(() => {
|
||||
path,
|
||||
() => {
|
||||
selected.value = []
|
||||
keyword.value = ''
|
||||
sub.value = false
|
||||
sizeCache.value.clear()
|
||||
sizeLoading.value.clear()
|
||||
nextTick(() => {
|
||||
refresh()
|
||||
})
|
||||
window.$bus.emit('file:push-history', path.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -8,16 +8,18 @@ import copy2clipboard from '@vavt/copy2clipboard'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const fileStore = useFileStore()
|
||||
const path = defineModel<string>('path', { type: String, required: true }) // 当前路径
|
||||
const keyword = defineModel<string>('keyword', { type: String, default: '' }) // 搜索关键词
|
||||
const sub = defineModel<boolean>('sub', { type: Boolean, default: false }) // 搜索是否包括子目录
|
||||
|
||||
const props = defineProps<{
|
||||
tabId: string
|
||||
}>()
|
||||
|
||||
const tab = computed(() => fileStore.tabs.find((t) => t.id === props.tabId)!)
|
||||
const path = computed(() => tab.value.path)
|
||||
|
||||
const isInput = ref(false)
|
||||
const pathInput = ref<InputInst | null>(null)
|
||||
const input = ref('www')
|
||||
|
||||
const history: string[] = []
|
||||
let current = -1
|
||||
|
||||
const handleInput = () => {
|
||||
isInput.value = true
|
||||
nextTick(() => {
|
||||
@@ -43,8 +45,7 @@ const handleBlur = () => {
|
||||
}
|
||||
|
||||
isInput.value = false
|
||||
path.value = '/' + input.value
|
||||
handlePushHistory(path.value)
|
||||
fileStore.updateTabPath(props.tabId, '/' + input.value)
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
@@ -62,19 +63,11 @@ const handleUp = () => {
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (current > 0) {
|
||||
current--
|
||||
path.value = history[current] ?? '/'
|
||||
input.value = path.value.slice(1)
|
||||
}
|
||||
fileStore.historyBack(props.tabId)
|
||||
}
|
||||
|
||||
const handleForward = () => {
|
||||
if (current < history.length - 1) {
|
||||
current++
|
||||
path.value = history[current] ?? '/'
|
||||
input.value = path.value.slice(1)
|
||||
}
|
||||
fileStore.historyForward(props.tabId)
|
||||
}
|
||||
|
||||
const splitPath = (str: string, delimiter: string) => {
|
||||
@@ -88,20 +81,7 @@ const setPath = (index: number) => {
|
||||
const newPath = splitPath(path.value, '/')
|
||||
.slice(0, index + 1)
|
||||
.join('/')
|
||||
path.value = '/' + newPath
|
||||
input.value = newPath
|
||||
handlePushHistory(path.value)
|
||||
}
|
||||
|
||||
const handlePushHistory = (path: string) => {
|
||||
// 防止在前进后退时重复添加
|
||||
if (current != history.length - 1) {
|
||||
return
|
||||
}
|
||||
|
||||
history.splice(current + 1)
|
||||
history.push(path)
|
||||
current = history.length - 1
|
||||
fileStore.updateTabPath(props.tabId, '/' + newPath)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
@@ -115,14 +95,6 @@ watch(
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.$bus.on('file:push-history', handlePushHistory)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.$bus.off('file:push-history')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -181,9 +153,9 @@ onUnmounted(() => {
|
||||
/>
|
||||
</n-input-group>
|
||||
<n-input-group w-400>
|
||||
<n-input v-model:value="keyword" :placeholder="$gettext('Enter search content')">
|
||||
<n-input v-model:value="tab.keyword" :placeholder="$gettext('Enter search content')">
|
||||
<template #suffix>
|
||||
<n-checkbox v-model:checked="sub">
|
||||
<n-checkbox v-model:checked="tab.sub">
|
||||
{{ $gettext('Include subdirectories') }}
|
||||
</n-checkbox>
|
||||
</template>
|
||||
|
||||
@@ -3,20 +3,26 @@ import file from '@/api/panel/file'
|
||||
import PtyTerminalModal from '@/components/common/PtyTerminalModal.vue'
|
||||
import { useFileStore } from '@/store'
|
||||
import { checkName, lastDirectory } from '@/utils/file'
|
||||
import type { Marked } from '@/views/file/types'
|
||||
import { usePaste } from '@/views/file/composables/usePaste'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
const { $gettext } = useGettext()
|
||||
const fileStore = useFileStore()
|
||||
const { handlePaste: doPaste } = usePaste()
|
||||
|
||||
const props = defineProps<{
|
||||
tabId: string
|
||||
}>()
|
||||
|
||||
const path = defineModel<string>('path', { type: String, required: true })
|
||||
const selected = defineModel<string[]>('selected', { type: Array, default: () => [] })
|
||||
const marked = defineModel<Marked[]>('marked', { type: Array, default: () => [] })
|
||||
const markedType = defineModel<string>('markedType', { type: String, required: true })
|
||||
const compress = defineModel<boolean>('compress', { type: Boolean, required: true })
|
||||
const permission = defineModel<boolean>('permission', { type: Boolean, required: true })
|
||||
const upload = defineModel<boolean>('upload', { type: Boolean, required: true })
|
||||
|
||||
const tab = computed(() => fileStore.tabs.find((t) => t.id === props.tabId)!)
|
||||
const path = computed(() => tab.value.path)
|
||||
const marked = computed(() => fileStore.clipboard.marked)
|
||||
|
||||
// 终端弹窗
|
||||
const terminalModal = ref(false)
|
||||
|
||||
@@ -58,12 +64,14 @@ const handleCopy = () => {
|
||||
window.$message.error($gettext('Please select files/folders to copy'))
|
||||
return
|
||||
}
|
||||
markedType.value = 'copy'
|
||||
marked.value = selected.value.map((path) => ({
|
||||
name: lastDirectory(path),
|
||||
source: path,
|
||||
force: false
|
||||
}))
|
||||
fileStore.setClipboard(
|
||||
selected.value.map((p) => ({
|
||||
name: lastDirectory(p),
|
||||
source: p,
|
||||
force: false
|
||||
})),
|
||||
'copy'
|
||||
)
|
||||
selected.value = []
|
||||
window.$message.success(
|
||||
$gettext('Marked successfully, please navigate to the destination path to paste')
|
||||
@@ -75,12 +83,14 @@ const handleMove = () => {
|
||||
window.$message.error($gettext('Please select files/folders to move'))
|
||||
return
|
||||
}
|
||||
markedType.value = 'move'
|
||||
marked.value = selected.value.map((path) => ({
|
||||
name: lastDirectory(path),
|
||||
source: path,
|
||||
force: false
|
||||
}))
|
||||
fileStore.setClipboard(
|
||||
selected.value.map((p) => ({
|
||||
name: lastDirectory(p),
|
||||
source: p,
|
||||
force: false
|
||||
})),
|
||||
'move'
|
||||
)
|
||||
selected.value = []
|
||||
window.$message.success(
|
||||
$gettext('Marked successfully, please navigate to the destination path to paste')
|
||||
@@ -88,84 +98,11 @@ const handleMove = () => {
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
marked.value = []
|
||||
fileStore.clearClipboard()
|
||||
}
|
||||
|
||||
const handlePaste = () => {
|
||||
if (!marked.value.length) {
|
||||
window.$message.error($gettext('Please mark the files/folders to copy or move first'))
|
||||
return
|
||||
}
|
||||
|
||||
// 查重
|
||||
let flag = false
|
||||
const paths = marked.value.map((item) => {
|
||||
return {
|
||||
name: item.name,
|
||||
source: item.source,
|
||||
target: path.value + '/' + item.name,
|
||||
force: false
|
||||
}
|
||||
})
|
||||
const sources = paths.map((item: any) => item.target)
|
||||
useRequest(file.exist(sources)).onSuccess(({ data }) => {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i]) {
|
||||
flag = true
|
||||
const pathItem = paths[i]
|
||||
if (pathItem) pathItem.force = true
|
||||
}
|
||||
}
|
||||
if (flag) {
|
||||
window.$dialog.warning({
|
||||
title: $gettext('Warning'),
|
||||
content: $gettext(
|
||||
'There are items with the same name %{ items } Do you want to overwrite?',
|
||||
{
|
||||
items: `${paths
|
||||
.filter((item) => item.force)
|
||||
.map((item) => item.name)
|
||||
.join(', ')}`
|
||||
}
|
||||
),
|
||||
positiveText: $gettext('Overwrite'),
|
||||
negativeText: $gettext('Cancel'),
|
||||
onPositiveClick: async () => {
|
||||
if (markedType.value == 'copy') {
|
||||
useRequest(file.copy(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Copied successfully'))
|
||||
})
|
||||
} else {
|
||||
useRequest(file.move(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Moved successfully'))
|
||||
})
|
||||
}
|
||||
},
|
||||
onNegativeClick: () => {
|
||||
marked.value = []
|
||||
window.$message.info($gettext('Canceled'))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
if (markedType.value == 'copy') {
|
||||
useRequest(file.copy(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Copied successfully'))
|
||||
})
|
||||
} else {
|
||||
useRequest(file.move(paths)).onSuccess(() => {
|
||||
marked.value = []
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success($gettext('Moved successfully'))
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
doPaste(path.value)
|
||||
}
|
||||
|
||||
const bulkDelete = async () => {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import file from '@/api/panel/file'
|
||||
import { useFileStore } from '@/store'
|
||||
import { useGettext } from 'vue3-gettext'
|
||||
|
||||
export function usePaste() {
|
||||
const { $gettext } = useGettext()
|
||||
const fileStore = useFileStore()
|
||||
|
||||
const handlePaste = (targetPath: string) => {
|
||||
const { marked, markedType } = fileStore.clipboard
|
||||
if (!marked.length) {
|
||||
window.$message.error($gettext('Please mark the files/folders to copy or move first'))
|
||||
return
|
||||
}
|
||||
|
||||
const paths = marked.map((item) => ({
|
||||
name: item.name,
|
||||
source: item.source,
|
||||
target: targetPath + '/' + item.name,
|
||||
force: false
|
||||
}))
|
||||
const targets = paths.map((item) => item.target)
|
||||
|
||||
useRequest(file.exist(targets)).onSuccess(({ data }) => {
|
||||
let hasConflict = false
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i]) {
|
||||
hasConflict = true
|
||||
const pathItem = paths[i]
|
||||
if (pathItem) pathItem.force = true
|
||||
}
|
||||
}
|
||||
|
||||
const executePaste = () => {
|
||||
const request = markedType === 'copy' ? file.copy(paths) : file.move(paths)
|
||||
const successMsg =
|
||||
markedType === 'copy' ? $gettext('Copied successfully') : $gettext('Moved successfully')
|
||||
useRequest(request).onSuccess(() => {
|
||||
fileStore.clearClipboard()
|
||||
window.$bus.emit('file:refresh')
|
||||
window.$message.success(successMsg)
|
||||
})
|
||||
}
|
||||
|
||||
if (hasConflict) {
|
||||
window.$dialog.warning({
|
||||
title: $gettext('Warning'),
|
||||
content: $gettext(
|
||||
'There are items with the same name %{ items } Do you want to overwrite?',
|
||||
{
|
||||
items: paths
|
||||
.filter((item) => item.force)
|
||||
.map((item) => item.name)
|
||||
.join(', ')
|
||||
}
|
||||
),
|
||||
positiveText: $gettext('Overwrite'),
|
||||
negativeText: $gettext('Cancel'),
|
||||
onPositiveClick: executePaste,
|
||||
onNegativeClick: () => {
|
||||
fileStore.clearClipboard()
|
||||
window.$message.info($gettext('Canceled'))
|
||||
}
|
||||
})
|
||||
} else {
|
||||
executePaste()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { handlePaste }
|
||||
}
|
||||
@@ -104,7 +104,7 @@ const columns: any = [
|
||||
class: 'cursor-pointer hover:opacity-60',
|
||||
type: 'info',
|
||||
onClick: () => {
|
||||
fileStore.path = row.root_dir
|
||||
fileStore.activeTab && fileStore.updateTabPath(fileStore.activeTabId, row.root_dir)
|
||||
router.push({ name: 'file-index' })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -136,7 +136,7 @@ const columns: any = [
|
||||
class: 'cursor-pointer hover:opacity-60',
|
||||
type: 'info',
|
||||
onClick: () => {
|
||||
fileStore.path = row.path
|
||||
fileStore.activeTab && fileStore.updateTabPath(fileStore.activeTabId, row.path)
|
||||
router.push({ name: 'file-index' })
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user