Files
MoviePilot-Frontend/src/components/filebrowser/FileNavigator.vue

491 lines
14 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.
<script lang="ts" setup>
import type { PropType } from 'vue'
import type { FileItem } from '@/api/types'
import { useDisplay } from 'vuetify'
import type { AxiosRequestConfig } from 'axios'
import type { Axios } from 'axios'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
storage: {
type: String,
default: 'local'
},
currentPath: {
type: String,
default: '/'
},
items: {
type: Array as PropType<FileItem[]>,
default: () => []
},
endpoints: Object,
axios: {
type: Object as PropType<Axios>,
required: true,
}
})
// 对外事件
const emit = defineEmits(['navigate'])
// 树形节点缓存
const treeCache = ref<{[key: string]: FileItem[]}>({})
// 展开的文件夹
const expandedFolders = ref<string[]>([])
// 是否正在加载
const loading = ref<{[key: string]: boolean}>({})
// 点击目录
function handleFolderClick(item: FileItem) {
emit('navigate', item)
}
// 切换文件夹展开状态
async function toggleFolder(path: string) {
const index = expandedFolders.value.indexOf(path)
if (index >= 0) {
// 折叠文件夹
expandedFolders.value.splice(index, 1)
} else {
// 展开文件夹
expandedFolders.value.push(path)
// 如果缓存中没有此目录内容,加载它
if (!treeCache.value[path]) {
await loadSubdirectories(path)
}
}
}
// 判断文件夹是否展开
function isFolderExpanded(path: string) {
return expandedFolders.value.includes(path)
}
// 渲染文件夹图标
function renderFolderIcon(isExpanded: boolean) {
if (isExpanded) {
return 'mdi-folder-open'
}
return 'mdi-folder'
}
// 加载子目录
async function loadSubdirectories(path: string) {
// 如果已经在加载中或已有缓存,跳过
if (loading.value[path] || treeCache.value[path]) return
// 标记为加载中
loading.value[path] = true
try {
// 构建假的文件项以加载目录内容
const fakeItem: FileItem = {
storage: props.storage,
type: 'dir',
name: path.split('/').pop() || '/',
path: path
}
// 调用API加载目录内容
const url = props.endpoints?.list.url.replace(/{sort}/g, 'name')
const config: AxiosRequestConfig<FileItem> = {
url,
method: props.endpoints?.list.method || 'get',
data: fakeItem,
}
const result = await props.axios?.request(config)
if (result && Array.isArray(result)) {
// 过滤出目录项
const dirs = result.filter(item => item.type === 'dir')
// 缓存目录内容
treeCache.value[path] = dirs
}
} catch (error) {
console.error('加载目录失败:', path, error)
} finally {
// 取消加载状态
loading.value[path] = false
}
}
// 初始加载根目录
async function loadRootDirectories() {
await loadSubdirectories('/')
}
// 获取目录层级深度
function getDirectoryDepth(path: string) {
return path.split('/').filter(p => p).length
}
// 检索所有目录节点
function getAllDirectories() {
const allDirs: {dir: FileItem, level: number, parentPath: string}[] = []
// 添加根目录的子目录
if (treeCache.value['/']) {
treeCache.value['/'].forEach(dir => {
allDirs.push({dir, level: 0, parentPath: '/'})
addSubdirectories(dir.path || '', 1, allDirs)
})
}
return allDirs
}
// 递归添加子目录
function addSubdirectories(parentPath: string, level: number, result: {dir: FileItem, level: number, parentPath: string}[]) {
if (treeCache.value[parentPath]) {
treeCache.value[parentPath].forEach(dir => {
result.push({dir, level, parentPath})
if (isFolderExpanded(dir.path || '')) {
addSubdirectories(dir.path || '', level + 1, result)
}
})
}
}
// 监听当前路径变化,自动展开当前路径
watch(() => props.currentPath, async (newPath) => {
if (!newPath) return
// 如果当前路径不是根目录,自动展开父目录
if (newPath !== '/') {
const parts = newPath.split('/').filter(p => p)
let currentPath = ''
// 展开到当前路径的每一层
for (const part of parts) {
currentPath += '/' + part
// 如果该路径未展开,则展开它
if (!expandedFolders.value.includes(currentPath)) {
expandedFolders.value.push(currentPath)
// 确保子目录已加载
if (!treeCache.value[currentPath]) {
await loadSubdirectories(currentPath)
}
}
// 如果有上一级目录,确保它已加载
const parentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/'
if (!treeCache.value[parentPath]) {
await loadSubdirectories(parentPath)
}
}
}
}, { immediate: true })
// 监听目录变化,缓存当前目录的内容
watch(() => props.items, (newItems) => {
if (newItems && newItems.length > 0) {
// 过滤出目录项
const dirs = newItems.filter(item => item.type === 'dir')
// 缓存当前目录内容
treeCache.value[props.currentPath || '/'] = dirs
}
}, { immediate: true })
// 是否为移动端
const isMobile = computed(() => {
return display.smAndDown.value
})
// 可用的根目录列表
const rootDirectories = computed(() => {
return treeCache.value['/'] || []
})
// 扁平化的目录树
const flattenedDirectories = computed(() => {
return getAllDirectories()
})
// 组件挂载时初始加载
onMounted(async () => {
await loadRootDirectories()
})
// 检查路径是否为指定目录的子目录或后代
function isChildOrDescendant(path: string, ancestorPath: string) {
if (!path || !ancestorPath) return false;
if (ancestorPath === '/') return true;
// 确保路径以斜杠结尾,便于比较
const normalizedPath = path.endsWith('/') ? path : path + '/';
const normalizedAncestorPath = ancestorPath.endsWith('/') ? ancestorPath : ancestorPath + '/';
// 检查路径是否以祖先路径开头,但不是祖先路径本身
return normalizedPath.startsWith(normalizedAncestorPath) && normalizedPath !== normalizedAncestorPath;
}
// 计算目录相对于其祖先的缩进级别
function getIndentLevel(path: string, ancestorPath: string) {
if (!path || !ancestorPath) return 0;
// 根目录特殊处理
if (ancestorPath === '/') {
return path.split('/').filter(p => p).length - 1;
}
// 计算路径中斜杠的数量差异
const pathParts = path.split('/').filter(p => p).length;
const ancestorParts = ancestorPath.split('/').filter(p => p).length;
return pathParts - ancestorParts;
}
</script>
<template>
<div class="file-navigator" v-if="!isMobile">
<div class="navigator-header">
<VIcon icon="mdi-folder-home" color="primary" class="me-2" />
<span class="font-weight-medium">文件导航</span>
</div>
<div class="tree-container">
<!-- 根目录项 -->
<div
class="tree-item root-item"
:class="{ 'active': currentPath === '/' }"
@click="handleFolderClick({
storage: storage,
type: 'dir',
name: '/',
path: '/'
})"
>
<div class="folder-content">
<VIcon
size="small"
icon="mdi-home"
:color="currentPath === '/' ? 'primary' : 'grey-darken-1'"
class="me-2"
/>
<span>根目录</span>
</div>
</div>
<!-- 加载根目录 -->
<div v-if="loading['/']" class="tree-loading">
<VProgressCircular indeterminate size="24" color="primary" class="ma-2" />
<span>加载目录结构...</span>
</div>
<!-- 目录树结构 -->
<template v-else>
<!-- 一级目录(根目录下的目录) -->
<div v-for="directory in rootDirectories" :key="directory.path" class="tree-item-container">
<!-- 目录项 -->
<div
class="tree-item"
:class="{ 'active': currentPath === directory.path }"
>
<div class="folder-toggle" @click.stop="toggleFolder(directory.path || '')">
<VProgressCircular
v-if="loading[directory.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VIcon
v-else
size="small"
:icon="isFolderExpanded(directory.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
/>
</div>
<div class="folder-content" @click.stop="handleFolderClick(directory)">
<VIcon
size="small"
:icon="renderFolderIcon(isFolderExpanded(directory.path || ''))"
:color="currentPath === directory.path ? 'primary' : 'amber-darken-1'"
class="me-1"
/>
<VTooltip :disabled="directory.name.length <= 18">
<template #activator="{ props: tooltipProps }">
<span
class="folder-name"
v-bind="tooltipProps"
>
{{ directory.name }}
</span>
</template>
{{ directory.name }}
</VTooltip>
</div>
</div>
<!-- 子目录容器 - 如果该目录被展开显示其所有子目录 -->
<div v-if="isFolderExpanded(directory.path || '')">
<!-- 加载中状态 -->
<div v-if="loading[directory.path || '']" class="tree-loading pl-8">
<VProgressCircular indeterminate size="14" color="primary" class="ma-2" />
<span class="text-caption">加载中...</span>
</div>
<!-- 所有层级的子目录列表 -->
<div v-else>
<!-- 遍历所有扁平化的目录列表查找对应层级的目录 -->
<div
v-for="item in flattenedDirectories"
:key="item.dir.path"
v-show="isChildOrDescendant(item.dir.path || '', directory.path || '')"
class="tree-item"
:class="{ 'active': currentPath === item.dir.path }"
:style="{ paddingLeft: (16 + getIndentLevel(item.dir.path || '', directory.path || '') * 12) + 'px' }"
>
<!-- 展开/折叠按钮 -->
<div class="folder-toggle" @click.stop="toggleFolder(item.dir.path || '')">
<VProgressCircular
v-if="loading[item.dir.path || '']"
indeterminate
size="14"
width="2"
color="primary"
/>
<VIcon
v-else
size="small"
:icon="isFolderExpanded(item.dir.path || '') ? 'mdi-chevron-down' : 'mdi-chevron-right'"
/>
</div>
<!-- 文件夹图标和名称 -->
<div class="folder-content" @click.stop="handleFolderClick(item.dir)">
<VIcon
size="small"
:icon="renderFolderIcon(isFolderExpanded(item.dir.path || ''))"
:color="currentPath === item.dir.path ? 'primary' : 'amber-darken-1'"
class="me-1"
/>
<VTooltip :disabled="item.dir.name.length <= 18">
<template #activator="{ props: tooltipProps }">
<span
class="folder-name"
v-bind="tooltipProps"
>
{{ item.dir.name }}
</span>
</template>
{{ item.dir.name }}
</VTooltip>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>
<style lang="scss" scoped>
.file-navigator {
width: 240px;
border-right: 1px solid rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background-color: var(--v-theme-surface);
flex-shrink: 0;
border-bottom-left-radius: 12px;
}
.navigator-header {
padding: 12px 16px;
display: flex;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background-color: var(--v-theme-surface);
}
.tree-container {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
}
.tree-item-container {
width: 100%;
}
.tree-item {
display: flex;
align-items: center;
padding: 6px 16px 6px 8px;
cursor: pointer;
transition: background-color 0.2s ease;
min-width: 100%;
max-width: 100%;
box-sizing: border-box;
&:hover {
background-color: rgba(var(--v-theme-primary), 0.05);
}
&.active {
background-color: rgba(var(--v-theme-primary), 0.08);
}
}
.folder-toggle {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 4px;
flex-shrink: 0;
}
.folder-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.root-item {
font-weight: 500;
}
.folder-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
display: inline-block;
}
.subdirectory-container {
width: 100%;
}
.tree-loading {
display: flex;
align-items: center;
padding: 4px 16px;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.pl-8 {
padding-left: 20px !important;
}
</style>