Files
MoviePilot-Frontend/src/components/FileBrowser.vue
2025-12-30 01:47:45 +08:00

366 lines
9.3 KiB
Vue

<script lang="ts" setup>
import FileList from './filebrowser/FileList.vue'
import FileToolbar from './filebrowser/FileToolbar.vue'
import FileNavigator from './filebrowser/FileNavigator.vue'
import type { EndPoints, FileItem, StorageConf } from '@/api/types'
import { storageIconDict } from '@/api/constants'
import type { AxiosInstance } from 'axios'
// LocalStorage keys
const SORT_KEY = 'fileBrowser.sort'
const SHOW_TREE_KEY = 'fileBrowser.showDirTree'
const NAV_WIDTH_KEY = 'fileBrowser.navigatorWidth'
// 输入参数
const props = defineProps({
storages: Array as PropType<StorageConf[]>,
tree: Boolean,
endpoints: Object as PropType<EndPoints>,
axios: {
type: Object as PropType<AxiosInstance>,
required: true,
},
axiosconfig: Object,
item: {
type: Object as PropType<FileItem>,
required: true,
},
itemstack: {
type: Array as PropType<FileItem[]>,
default: () => [],
},
})
// 对外事件
const emit = defineEmits(['pathchanged'])
const fileIcons = {
// 压缩包
zip: 'mdi-folder-zip-outline',
rar: 'mdi-folder-zip-outline',
bak: 'mdi-folder-zip-outline',
tar: 'mdi-folder-zip-outline',
gz: 'mdi-folder-zip-outline',
bz2: 'mdi-folder-zip-outline',
// 开发
htm: 'mdi-language-html5',
html: 'mdi-language-html5',
vue: 'mdi-vuejs',
js: 'mdi-nodejs',
ts: 'mdi-language-typescript',
json: 'mdi-file-document-outline',
css: 'mdi-language-css3',
scss: 'mdi-language-css3',
less: 'mdi-language-css3',
php: 'mdi-language-php',
py: 'mdi-language-python',
java: 'mdi-language-java',
go: 'mdi-language-go',
c: 'mdi-language-c',
cpp: 'mdi-language-cpp',
h: 'mdi-language-c',
cs: 'mdi-language-csharp',
sql: 'mdi-database',
sh: 'mdi-language-bash',
bat: 'mdi-language-bash',
ps1: 'mdi-language-powershell',
// markdown
md: 'mdi-language-markdown-outline',
markdown: 'mdi-language-markdown-outline',
// 图片
png: 'mdi-file-png-box',
jpg: 'mdi-file-jpg-box',
jpeg: 'mdi-file-jpg-box',
gif: 'mdi-file-gif-box',
bmp: 'mdi-file-image-box',
webp: 'mdi-file-image-box',
ico: 'mdi-file-image-box',
svg: 'mdi-file-image-box',
// 视频
mp4: 'mdi-filmstrip',
mkv: 'mdi-filmstrip',
avi: 'mdi-filmstrip',
wmv: 'mdi-filmstrip',
mov: 'mdi-filmstrip',
flv: 'mdi-filmstrip',
rmvb: 'mdi-filmstrip',
// 文档
txt: 'mdi-file-document-outline',
env: 'mdi-file-cog-outline',
yml: 'mdi-file-cog-outline',
yaml: 'mdi-file-cog-outline',
conf: 'mdi-file-cog-outline',
log: 'mdi-file-document-outline',
csv: 'mdi-file-delimited',
// office
xls: 'mdi-file-excel',
xlsx: 'mdi-file-excel',
doc: 'mdi-file-word',
docx: 'mdi-file-word',
ppt: 'mdi-file-powerpoint',
pptx: 'mdi-file-powerpoint',
pdf: 'mdi-file-pdf',
// 音频
mp2: 'mdi-music',
mp3: 'mdi-music',
m4a: 'mdi-music',
wma: 'mdi-music',
aac: 'mdi-music',
ogg: 'mdi-music',
flac: 'mdi-music',
wav: 'mdi-music',
// 字体
ttf: 'mdi-format-font',
otf: 'mdi-format-font',
woff: 'mdi-format-font',
woff2: 'mdi-format-font',
eot: 'mdi-format-font',
// 字幕
srt: 'mdi-subtitles-outline',
ass: 'mdi-subtitles-outline',
sub: 'mdi-subtitles-outline',
// 其他
other: 'mdi-file-outline',
}
// 加载次数
const loading = ref(0)
// 刷新
const refreshPending = ref(false)
// 排序 - 从localStorage恢复
const sort = ref(localStorage.getItem(SORT_KEY) || 'name')
// 是否显示目录树 - 从localStorage恢复
const showDirTree = ref(localStorage.getItem(SHOW_TREE_KEY) === 'true')
// 拖动分隔条相关 - 从localStorage恢复宽度
const navigatorWidth = ref(parseInt(localStorage.getItem(NAV_WIDTH_KEY) || '280'))
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartWidth = ref(0)
watch(sort, (val) => {
localStorage.setItem(SORT_KEY, val)
})
watch(showDirTree, (val) => {
localStorage.setItem(SHOW_TREE_KEY, String(val))
})
watch(navigatorWidth, (val) => {
localStorage.setItem(NAV_WIDTH_KEY, String(val))
})
// 计算属性
const storagesArray = computed(() => {
return props.storages?.map(item => ({
title: item.name,
value: item.type,
icon: storageIconDict[item.type] ?? 'mdi-server-network-outline',
}))
})
// 方法
function loadingChanged(isLoading: number) {
if (isLoading) loading.value++
else if (loading.value > 0) loading.value--
}
// 存储切换
async function storageChanged(storage: string) {
emit('pathchanged', { storage: storage, path: '/', fileid: 'root' })
}
// 路径变化
function pathChanged(item: FileItem) {
emit('pathchanged', item)
}
// 排序变化
function sortChanged(s: string) {
sort.value = s
refreshPending.value = true
}
// 切换目录树
function switchDirTree(state: boolean) {
showDirTree.value = state
}
// 文件列表
const fileListItems = ref<FileItem[]>([])
// 文件列表数据更新
function fileListUpdated(items: FileItem[]) {
fileListItems.value = items
}
// 阻止选择事件
function preventSelect(event: Event) {
event.preventDefault()
return false
}
// 拖动分隔条相关方法
function startDrag(event: MouseEvent) {
event.preventDefault() // 阻止默认行为
event.stopPropagation() // 阻止事件冒泡
isDragging.value = true
dragStartX.value = event.clientX
dragStartWidth.value = navigatorWidth.value
document.addEventListener('mousemove', handleDrag, { passive: false })
document.addEventListener('mouseup', stopDrag, { passive: false })
document.addEventListener('selectstart', preventSelect) // 阻止选择开始
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
;(document.body.style as any).webkitUserSelect = 'none' // Safari兼容
;(document.body.style as any).mozUserSelect = 'none' // Firefox兼容
}
function handleDrag(event: MouseEvent) {
if (!isDragging.value) return
event.preventDefault() // 阻止默认行为
const deltaX = event.clientX - dragStartX.value
const newWidth = dragStartWidth.value + deltaX
// 设置最小和最大宽度限制
const minWidth = 200
const maxWidth = window.innerWidth * 0.6
navigatorWidth.value = Math.max(minWidth, Math.min(maxWidth, newWidth))
}
function stopDrag() {
isDragging.value = false
document.removeEventListener('mousemove', handleDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('selectstart', preventSelect)
document.body.style.cursor = ''
document.body.style.userSelect = ''
;(document.body.style as any).webkitUserSelect = ''
;(document.body.style as any).mozUserSelect = ''
}
</script>
<template>
<div class="mx-auto" :loading="loading > 0">
<div v-if="item">
<FileToolbar
:sort="sort"
:item="item"
:itemstack="itemstack"
:storages="storagesArray"
:endpoints="endpoints"
:axios="axios"
@storagechanged="storageChanged"
@pathchanged="pathChanged"
@foldercreated="refreshPending = true"
@sortchanged="sortChanged"
/>
<div class="flex">
<FileNavigator
v-if="showDirTree"
:storage="item.storage"
:currentPath="item.path"
:items="fileListItems"
:endpoints="endpoints"
:axios="axios"
:style="{ width: `${navigatorWidth}px`, minWidth: `${navigatorWidth}px` }"
@navigate="pathChanged"
/>
<!-- 拖动分隔条 -->
<div v-if="showDirTree" class="divider" :class="{ 'divider-dragging': isDragging }" @mousedown="startDrag">
<div class="divider-line"></div>
<VIcon class="divider-icon" size="small">mdi-drag-vertical</VIcon>
</div>
<FileList
:item="item"
:icons="fileIcons"
:endpoints="endpoints"
:axios="axios"
:refreshpending="refreshPending"
:sort="sort"
:showTree="showDirTree"
:style="{ flex: 1 }"
@pathchanged="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@filedeleted="refreshPending = true"
@renamed="refreshPending = true"
@items-updated="fileListUpdated"
@switch-tree="switchDirTree"
/>
</div>
</div>
</div>
</template>
<style scoped>
.divider {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
cursor: col-resize;
inline-size: 4px;
transition: background-color 0.2s ease;
user-select: none;
}
.divider:hover {
background-color: rgba(var(--v-theme-on-surface), 0.08);
}
.divider-dragging {
background-color: rgba(var(--v-theme-primary), 0.12) !important;
}
.divider-line {
background-color: rgba(var(--v-theme-outline), 0.3);
block-size: 100%;
inline-size: 1px;
transition: background-color 0.2s ease;
user-select: none;
}
.divider-dragging .divider-line {
background-color: rgb(var(--v-theme-primary)) !important;
}
.divider:hover .divider-line {
background-color: rgba(var(--v-theme-primary), 0.8);
}
.divider-icon {
position: absolute;
z-index: 1;
padding: 2px;
border-radius: 2px;
background-color: rgba(var(--v-theme-surface), 0.9);
color: rgba(var(--v-theme-on-surface-variant), 0.6);
opacity: 0;
pointer-events: none;
transition: all 0.2s ease;
}
.divider-dragging .divider-icon {
background-color: rgba(var(--v-theme-surface), 0.95);
color: rgb(var(--v-theme-primary));
opacity: 1;
}
.divider:hover .divider-icon {
color: rgba(var(--v-theme-primary), 0.9);
opacity: 1;
}
</style>