mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
feat FileManager
This commit is contained in:
@@ -95,3 +95,17 @@ export function parseDate(dateString: string): Date {
|
|||||||
|
|
||||||
return new Date(year, month - 1, day)
|
return new Date(year, month - 1, day)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文件大小格式化
|
||||||
|
export function formatBytes(bytes: number, decimals = 2) {
|
||||||
|
if (bytes === 0)
|
||||||
|
return '0 bytes'
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -897,3 +897,21 @@ export interface Rss {
|
|||||||
// 状态 0-停用,1-启用
|
// 状态 0-停用,1-启用
|
||||||
state?: number
|
state?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 文件浏览接口
|
||||||
|
export interface EndPoints {
|
||||||
|
list: any
|
||||||
|
mkdir: any
|
||||||
|
delete: any
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件浏览项目
|
||||||
|
export interface FileItem {
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
basename: string
|
||||||
|
path: string
|
||||||
|
extension: string
|
||||||
|
size: number
|
||||||
|
children: []
|
||||||
|
}
|
||||||
|
|||||||
+77
-143
@@ -1,24 +1,39 @@
|
|||||||
<script>
|
<script lang="ts" setup>
|
||||||
|
import type { Axios } from 'axios'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import Toolbar from './filebrowser/Toolbar.vue'
|
import Toolbar from './filebrowser/Toolbar.vue'
|
||||||
import Tree from './filebrowser/Tree.vue'
|
import Tree from './filebrowser/Tree.vue'
|
||||||
import List from './filebrowser/List.vue'
|
import List from './filebrowser/List.vue'
|
||||||
import Upload from './filebrowser/Upload.vue'
|
|
||||||
|
// 输入参数
|
||||||
|
const props = defineProps({
|
||||||
|
storages: String,
|
||||||
|
storage: String,
|
||||||
|
path: String,
|
||||||
|
tree: String,
|
||||||
|
endpoints: Object,
|
||||||
|
axios: Object as PropType<Axios>,
|
||||||
|
axiosConfig: Object,
|
||||||
|
maxUploadFilesCount: Number,
|
||||||
|
maxUploadFileSize: Number,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 事件
|
||||||
|
const emit = defineEmits(['change'])
|
||||||
|
|
||||||
const availableStorages = [
|
const availableStorages = [
|
||||||
{
|
{
|
||||||
name: 'Local',
|
name: '本地',
|
||||||
code: 'local',
|
code: 'local',
|
||||||
icon: 'mdi-folder-multiple-outline',
|
icon: 'mdi-folder-multiple-outline',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const endpoints = {
|
const endpoints = {
|
||||||
list: { url: '/storage/{storage}/list?path={path}', method: 'get' },
|
list: { url: '/filebrowser/list?path={path}', method: 'get' },
|
||||||
upload: { url: '/storage/{storage}/upload?path={path}', method: 'post' },
|
mkdir: { url: '/filebrowser/mkdir?path={path}', method: 'post' },
|
||||||
mkdir: { url: '/storage/{storage}/mkdir?path={path}', method: 'post' },
|
delete: { url: '/filebrowser/delete?path={path}', method: 'post' },
|
||||||
delete: { url: '/storage/{storage}/delete?path={path}', method: 'post' },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileIcons = {
|
const fileIcons = {
|
||||||
@@ -43,171 +58,90 @@ const fileIcons = {
|
|||||||
other: 'mdi-file-outline',
|
other: 'mdi-file-outline',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
// 加载次数
|
||||||
components: {
|
const loading = ref(0)
|
||||||
Toolbar,
|
// 当前路径
|
||||||
Tree,
|
const path = ref(props.path)
|
||||||
List,
|
// 当前存储
|
||||||
Upload,
|
const activeStorage = ref('local')
|
||||||
},
|
// 刷新
|
||||||
model: {
|
const refreshPending = ref(false)
|
||||||
prop: 'path',
|
// axios实例
|
||||||
event: 'change',
|
const axiosInstance = ref<Axios>()
|
||||||
},
|
|
||||||
props: {
|
|
||||||
// comma-separated list of active storage codes
|
|
||||||
storages: {
|
|
||||||
type: String,
|
|
||||||
default: () => availableStorages.map(item => item.code).join(','),
|
|
||||||
},
|
|
||||||
// code of default storage
|
|
||||||
storage: { type: String, default: 'local' },
|
|
||||||
// show tree view
|
|
||||||
tree: { type: Boolean, default: true },
|
|
||||||
// file icons set
|
|
||||||
icons: { type: Object, default: () => fileIcons },
|
|
||||||
// custom backend endpoints
|
|
||||||
endpoints: { type: Object, default: () => endpoints },
|
|
||||||
// custom axios instance
|
|
||||||
axios: { type: Function },
|
|
||||||
// custom configuration for internal axios instance
|
|
||||||
axiosConfig: { type: Object, default: () => {} },
|
|
||||||
// max files count to upload at once. Unlimited by default
|
|
||||||
maxUploadFilesCount: { type: Number, default: 0 },
|
|
||||||
// max file size to upload. Unlimited by default
|
|
||||||
maxUploadFileSize: { type: Number, default: 0 },
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: 0,
|
|
||||||
path: '',
|
|
||||||
activeStorage: null,
|
|
||||||
uploadingFiles: false, // or an Array of files
|
|
||||||
refreshPending: false,
|
|
||||||
axiosInstance: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
storagesArray() {
|
|
||||||
const storageCodes = this.storages.split(',')
|
|
||||||
const result = []
|
|
||||||
storageCodes.forEach((code) => {
|
|
||||||
result.push(availableStorages.find(item => item.code == code))
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.activeStorage = this.storage
|
|
||||||
this.axiosInstance = this.axios || axios.create(this.axiosConfig)
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
if (!this.path && !(this.tree && this.$vuetify.breakpoint.smAndUp))
|
|
||||||
this.pathChanged('/')
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
loadingChanged(loading) {
|
|
||||||
if (loading)
|
|
||||||
this.loading++
|
|
||||||
else if (this.loading > 0)
|
|
||||||
this.loading--
|
|
||||||
},
|
|
||||||
storageChanged(storage) {
|
|
||||||
this.activeStorage = storage
|
|
||||||
},
|
|
||||||
addUploadingFiles(files) {
|
|
||||||
files = Array.from(files)
|
|
||||||
|
|
||||||
if (this.maxUploadFileSize) {
|
// 计算属性
|
||||||
files = files.filter(
|
const storagesArray = computed(() => {
|
||||||
file => file.size <= this.maxUploadFileSize,
|
const storageCodes = props.storages?.split(',')
|
||||||
)
|
return availableStorages.filter(item => storageCodes?.includes(item.code))
|
||||||
}
|
})
|
||||||
|
|
||||||
if (this.uploadingFiles === false)
|
// 方法
|
||||||
this.uploadingFiles = []
|
function loadingChanged(loading: number) {
|
||||||
|
if (loading)
|
||||||
if (this.maxUploadFilesCount && this.uploadingFiles.length + files.length > this.maxUploadFilesCount)
|
loading++
|
||||||
files = files.slice(0, this.maxUploadFilesCount - this.uploadingFiles.length)
|
else if (loading > 0)
|
||||||
|
loading--
|
||||||
this.uploadingFiles.push(...files)
|
|
||||||
},
|
|
||||||
removeUploadingFile(index) {
|
|
||||||
this.uploadingFiles.splice(index, 1)
|
|
||||||
},
|
|
||||||
uploaded() {
|
|
||||||
this.uploadingFiles = false
|
|
||||||
this.refreshPending = true
|
|
||||||
},
|
|
||||||
pathChanged(path) {
|
|
||||||
this.path = path
|
|
||||||
this.$emit('change', path)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function storageChanged(storage: string) {
|
||||||
|
activeStorage.value = storage
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathChanged(_path: string) {
|
||||||
|
path.value = _path
|
||||||
|
emit('change', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(() => {
|
||||||
|
activeStorage.value = props.storage ?? 'local'
|
||||||
|
axiosInstance.value = props.axios ?? axios.create(props.axiosConfig)
|
||||||
|
if (!path.value)
|
||||||
|
pathChanged('/')
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-card class="mx-auto" :loading="loading > 0">
|
<VCard class="mx-auto" :loading="loading > 0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
:path="path"
|
:path="path"
|
||||||
:storages="storagesArray"
|
:storages="storagesArray"
|
||||||
:storage="activeStorage"
|
:storage="activeStorage"
|
||||||
:endpoints="endpoints"
|
:endpoints="endpoints"
|
||||||
:axios="axiosInstance"
|
:axios="axiosInstance"
|
||||||
@storage-changed="storageChanged"
|
@storagechanged="storageChanged"
|
||||||
@path-changed="pathChanged"
|
@pathchanged="pathChanged"
|
||||||
@add-files="addUploadingFiles"
|
@foldercreated="refreshPending = true"
|
||||||
@folder-created="refreshPending = true"
|
|
||||||
/>
|
/>
|
||||||
<v-row no-gutters>
|
<VRow no-gutters>
|
||||||
<v-col v-if="tree && $vuetify.breakpoint.smAndUp" sm="auto">
|
<VCol v-if="tree" sm="auto" class="d-none d-md-block">
|
||||||
<Tree
|
<Tree
|
||||||
:path="path"
|
:path="path"
|
||||||
:storage="activeStorage"
|
:storage="activeStorage"
|
||||||
:icons="icons"
|
:icons="fileIcons"
|
||||||
:endpoints="endpoints"
|
:endpoints="endpoints"
|
||||||
:axios="axiosInstance"
|
:axios="axiosInstance"
|
||||||
:refresh-pending="refreshPending"
|
:refreshpending="refreshPending"
|
||||||
@path-changed="pathChanged"
|
@pathchanged="pathChanged"
|
||||||
@loading="loadingChanged"
|
@loading="loadingChanged"
|
||||||
@refreshed="refreshPending = false"
|
@refreshed="refreshPending = false"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</VCol>
|
||||||
<v-divider v-if="tree" vertical />
|
<VDivider v-if="tree" vertical />
|
||||||
<v-col>
|
<VCol>
|
||||||
<List
|
<List
|
||||||
:path="path"
|
:path="path"
|
||||||
:storage="activeStorage"
|
:storage="activeStorage"
|
||||||
:icons="icons"
|
:icons="fileIcons"
|
||||||
:endpoints="endpoints"
|
:endpoints="endpoints"
|
||||||
:axios="axiosInstance"
|
:axios="axiosInstance"
|
||||||
:refresh-pending="refreshPending"
|
:refreshpending="refreshPending"
|
||||||
@path-changed="pathChanged"
|
@path-changed="pathChanged"
|
||||||
@loading="loadingChanged"
|
@loading="loadingChanged"
|
||||||
@refreshed="refreshPending = false"
|
@refreshed="refreshPending = false"
|
||||||
@file-deleted="refreshPending = true"
|
@file-deleted="refreshPending = true"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</VCol>
|
||||||
</v-row>
|
</VRow>
|
||||||
<Upload
|
</VCard>
|
||||||
v-if="uploadingFiles !== false"
|
|
||||||
:path="path"
|
|
||||||
:storage="activeStorage"
|
|
||||||
:files="uploadingFiles"
|
|
||||||
:icons="icons"
|
|
||||||
:axios="axiosInstance"
|
|
||||||
:endpoint="endpoints.upload"
|
|
||||||
:max-upload-files-count="maxUploadFilesCount"
|
|
||||||
:max-upload-file-size="maxUploadFileSize"
|
|
||||||
@add-files="addUploadingFiles"
|
|
||||||
@remove-file="removeUploadingFile"
|
|
||||||
@clear-files="uploadingFiles = []"
|
|
||||||
@cancel="uploadingFiles = false"
|
|
||||||
@uploaded="uploaded"
|
|
||||||
/>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
<script>
|
|
||||||
/**
|
|
||||||
* Vuetify Confirm Dialog component
|
|
||||||
* https://gist.github.com/eolant/ba0f8a5c9135d1a146e1db575276177d
|
|
||||||
*
|
|
||||||
* Insert component where you want to use it:
|
|
||||||
* <confirm ref="confirm"></confirm>
|
|
||||||
*
|
|
||||||
* Call it:
|
|
||||||
* this.$refs.confirm.open('Delete', 'Are you sure?', { color: 'red' }).then((confirm) => {})
|
|
||||||
* Or use await:
|
|
||||||
* if (await this.$refs.confirm.open('Delete', 'Are you sure?', { color: 'red' })) {
|
|
||||||
* // yes
|
|
||||||
* }
|
|
||||||
* else {
|
|
||||||
* // cancel
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export default {
|
|
||||||
data: () => ({
|
|
||||||
dialog: false,
|
|
||||||
resolve: null,
|
|
||||||
reject: null,
|
|
||||||
message: null,
|
|
||||||
title: null,
|
|
||||||
options: {
|
|
||||||
color: 'error',
|
|
||||||
width: 300,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
methods: {
|
|
||||||
open(title, message, options) {
|
|
||||||
this.dialog = true
|
|
||||||
this.title = title
|
|
||||||
this.message = message
|
|
||||||
this.options = Object.assign(this.options, options)
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.resolve = resolve
|
|
||||||
this.reject = reject
|
|
||||||
})
|
|
||||||
},
|
|
||||||
agree() {
|
|
||||||
this.resolve(true)
|
|
||||||
this.dialog = false
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
this.resolve(false)
|
|
||||||
this.dialog = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-dialog
|
|
||||||
v-model="dialog"
|
|
||||||
:max-width="options.width"
|
|
||||||
@keydown.esc="cancel"
|
|
||||||
>
|
|
||||||
<v-card>
|
|
||||||
<v-toolbar dark :color="options.color" dense flat>
|
|
||||||
<v-toolbar-title class="white--text">
|
|
||||||
{{ title }}
|
|
||||||
</v-toolbar-title>
|
|
||||||
</v-toolbar>
|
|
||||||
<v-card-text v-if="message" class="pa-4 text-center" v-html="message" />
|
|
||||||
<v-card-actions class="pt-0">
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn text @click="cancel">
|
|
||||||
Cancel
|
|
||||||
</v-btn>
|
|
||||||
<v-btn depressed :color="options.color" @click="agree">
|
|
||||||
Yes
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</template>
|
|
||||||
+186
-188
@@ -1,227 +1,225 @@
|
|||||||
<script>
|
<script lang="ts" setup>
|
||||||
import { formatBytes } from './util'
|
import type { Axios } from 'axios'
|
||||||
import Confirm from './Confirm.vue'
|
import type { PropType } from 'vue'
|
||||||
|
import { useConfirm } from 'vuetify-use-dialog'
|
||||||
|
import { formatBytes } from '@core/utils/formatters'
|
||||||
|
import type { EndPoints, FileItem } from '@/api/types'
|
||||||
|
|
||||||
export default {
|
// 输入参数
|
||||||
components: {
|
const props = defineProps({
|
||||||
Confirm,
|
icons: Object,
|
||||||
},
|
storage: String,
|
||||||
props: {
|
path: String,
|
||||||
icons: Object,
|
endpoints: Object as PropType<EndPoints>,
|
||||||
storage: String,
|
axios: Object as PropType<Axios>,
|
||||||
path: String,
|
refreshpending: Boolean,
|
||||||
endpoints: Object,
|
})
|
||||||
axios: Function,
|
|
||||||
refreshPending: Boolean,
|
// 对外事件
|
||||||
},
|
const emit = defineEmits(['loading', 'pathchanged', 'refreshed', 'filedeleted'])
|
||||||
data() {
|
|
||||||
return {
|
// 确认框
|
||||||
items: [],
|
const createConfirm = useConfirm()
|
||||||
filter: '',
|
|
||||||
|
// 内容列表
|
||||||
|
const items = ref<FileItem[]>([])
|
||||||
|
|
||||||
|
// 当前路径
|
||||||
|
const path = ref(props.path ?? '')
|
||||||
|
|
||||||
|
// 过滤条件
|
||||||
|
const filter = ref('')
|
||||||
|
|
||||||
|
// 存储空间类型
|
||||||
|
const storage = ref(props.storage || '')
|
||||||
|
|
||||||
|
// 是否正在加载
|
||||||
|
const refreshPending = ref(props.refreshpending || false)
|
||||||
|
|
||||||
|
// 目录过滤
|
||||||
|
const dirs = computed(() =>
|
||||||
|
items.value.filter(item => item.type === 'dir' && item.basename.includes(filter.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 文件过滤
|
||||||
|
const files = computed(() =>
|
||||||
|
items.value.filter(item => item.type === 'file' && item.basename.includes(filter.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 是否目录
|
||||||
|
const isDir = computed(() => path.value.endsWith('/'))
|
||||||
|
|
||||||
|
// 是否文件
|
||||||
|
const isFile = computed(() => !isDir.value)
|
||||||
|
|
||||||
|
// 调API加载内容
|
||||||
|
async function load() {
|
||||||
|
if (isDir.value) {
|
||||||
|
const url = props.endpoints?.list.url
|
||||||
|
.replace(/{storage}/g, storage.value)
|
||||||
|
.replace(/{path}/g, path.value)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
url,
|
||||||
|
method: props.endpoints?.list.method || 'get',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await props.axios?.request(config)
|
||||||
|
items.value = response?.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除项目
|
||||||
|
async function deleteItem(item: FileItem) {
|
||||||
|
const confirmed = await createConfirm({
|
||||||
|
title: '确认',
|
||||||
|
content: `是否确认删除${
|
||||||
|
item.type === 'dir' ? '目录' : '文件'
|
||||||
|
}?<br><em>${item.basename}</em>?`,
|
||||||
|
confirmationText: '确认',
|
||||||
|
cancellationText: '取消',
|
||||||
|
dialogProps: {
|
||||||
|
maxWidth: 600,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
emit('loading', true)
|
||||||
|
const url = props.endpoints?.delete.url
|
||||||
|
.replace(/{storage}/g, storage.value)
|
||||||
|
.replace(/{path}/g, item.path)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
url,
|
||||||
|
method: props.endpoints?.delete.method || 'post',
|
||||||
|
}
|
||||||
|
|
||||||
|
await props.axios?.request(config)
|
||||||
|
emit('filedeleted')
|
||||||
|
emit('loading', false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换路径
|
||||||
|
function changePath(path: string) {
|
||||||
|
emit('pathchanged', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听path变化
|
||||||
|
watch(
|
||||||
|
() => path.value,
|
||||||
|
async () => {
|
||||||
|
items.value = []
|
||||||
|
await load()
|
||||||
|
if (refreshPending.value) {
|
||||||
|
await load()
|
||||||
|
emit('refreshed')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
)
|
||||||
dirs() {
|
|
||||||
return this.items.filter(
|
|
||||||
item =>
|
|
||||||
item.type === 'dir' && item.basename.includes(this.filter),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
files() {
|
|
||||||
return this.items.filter(
|
|
||||||
item =>
|
|
||||||
item.type === 'file' && item.basename.includes(this.filter),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
isDir() {
|
|
||||||
return this.path[this.path.length - 1] === '/'
|
|
||||||
},
|
|
||||||
isFile() {
|
|
||||||
return !this.isDir
|
|
||||||
},
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
async path() {
|
|
||||||
this.items = []
|
|
||||||
await this.load()
|
|
||||||
},
|
|
||||||
async refreshPending() {
|
|
||||||
if (this.refreshPending) {
|
|
||||||
await this.load()
|
|
||||||
this.$emit('refreshed')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
formatBytes,
|
|
||||||
changePath(path) {
|
|
||||||
this.$emit('path-changed', path)
|
|
||||||
},
|
|
||||||
async load() {
|
|
||||||
this.$emit('loading', true)
|
|
||||||
if (this.isDir) {
|
|
||||||
const url = this.endpoints.list.url
|
|
||||||
.replace(new RegExp('{storage}', 'g'), this.storage)
|
|
||||||
.replace(new RegExp('{path}', 'g'), this.path)
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
url,
|
|
||||||
method: this.endpoints.list.method || 'get',
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.axios.request(config)
|
|
||||||
this.items = response.data
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// TODO: load file
|
|
||||||
}
|
|
||||||
this.$emit('loading', false)
|
|
||||||
},
|
|
||||||
async deleteItem(item) {
|
|
||||||
const confirmed = await this.$refs.confirm.open(
|
|
||||||
'Delete',
|
|
||||||
`Are you sure<br>you want to delete this ${
|
|
||||||
item.type === 'dir' ? 'folder' : 'file'
|
|
||||||
}?<br><em>${item.basename}</em>`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (confirmed) {
|
|
||||||
this.$emit('loading', true)
|
|
||||||
const url = this.endpoints.delete.url
|
|
||||||
.replace(new RegExp('{storage}', 'g'), this.storage)
|
|
||||||
.replace(new RegExp('{path}', 'g'), item.path)
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
url,
|
|
||||||
method: this.endpoints.delete.method || 'post',
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.axios.request(config)
|
|
||||||
this.$emit('file-deleted')
|
|
||||||
this.$emit('loading', false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-card flat tile min-height="380" class="d-flex flex-column">
|
<VCard flat tile min-height="380" class="d-flex flex-column">
|
||||||
<Confirm ref="confirm" />
|
<VCardText
|
||||||
<v-card-text
|
|
||||||
v-if="!path"
|
v-if="!path"
|
||||||
class="grow d-flex justify-center align-center grey--text"
|
class="grow d-flex justify-center align-center grey--text"
|
||||||
>
|
>
|
||||||
Select a folder or a file
|
选择目录或文件
|
||||||
</v-card-text>
|
</VCardText>
|
||||||
<v-card-text
|
<VCardText
|
||||||
v-else-if="isFile"
|
v-else-if="isFile"
|
||||||
class="grow d-flex justify-center align-center"
|
class="grow d-flex justify-center align-center"
|
||||||
>
|
>
|
||||||
File: {{ path }}
|
文件: {{ path }}
|
||||||
</v-card-text>
|
</VCardText>
|
||||||
<v-card-text v-else-if="dirs.length || files.length" class="grow">
|
<VCardText v-else-if="dirs.length || files.length" class="grow">
|
||||||
<v-list v-if="dirs.length" subheader>
|
<VList v-if="dirs.length" subheader>
|
||||||
<v-subheader>Folders</v-subheader>
|
<VListSubheader>目录</VListSubheader>
|
||||||
<v-list-item
|
<VListItem
|
||||||
v-for="item in dirs"
|
v-for="(item, index) in dirs"
|
||||||
:key="item.basename"
|
:key="index"
|
||||||
class="pl-0"
|
class="pl-0"
|
||||||
@click="changePath(item.path)"
|
@click="changePath(item.path)"
|
||||||
>
|
>
|
||||||
<v-list-item-avatar class="ma-0">
|
<template #prepend>
|
||||||
<v-icon>mdi-folder-outline</v-icon>
|
<VIcon icon="mdi-folder-outline" />
|
||||||
</v-list-item-avatar>
|
</template>
|
||||||
<v-list-item-content class="py-2">
|
<VListItemTitle v-text="item.basename" />
|
||||||
<v-list-item-title v-text="item.basename" />
|
<template #append>
|
||||||
</v-list-item-content>
|
<VBtn icon @click.stop="deleteItem(item)">
|
||||||
<v-list-item-action>
|
<VIcon color="grey lighten-1">
|
||||||
<v-btn icon @click.stop="deleteItem(item)">
|
|
||||||
<v-icon color="grey lighten-1">
|
|
||||||
mdi-delete-outline
|
mdi-delete-outline
|
||||||
</v-icon>
|
</VIcon>
|
||||||
</v-btn>
|
</VBtn>
|
||||||
<v-btn v-if="false" icon>
|
</template>
|
||||||
<v-icon color="grey lighten-1">
|
</VListItem>
|
||||||
mdi-information
|
</VList>
|
||||||
</v-icon>
|
<VDivider v-if="dirs.length && files.length" />
|
||||||
</v-btn>
|
<VList v-if="files.length" subheader>
|
||||||
</v-list-item-action>
|
<VListSubheader>文件</VListSubheader>
|
||||||
</v-list-item>
|
<VListItem
|
||||||
</v-list>
|
v-for="(item, index) in files"
|
||||||
<v-divider v-if="dirs.length && files.length" />
|
:key="index"
|
||||||
<v-list v-if="files.length" subheader>
|
|
||||||
<v-subheader>Files</v-subheader>
|
|
||||||
<v-list-item
|
|
||||||
v-for="item in files"
|
|
||||||
:key="item.basename"
|
|
||||||
class="pl-0"
|
class="pl-0"
|
||||||
@click="changePath(item.path)"
|
@click="changePath(item.path)"
|
||||||
>
|
>
|
||||||
<v-list-item-avatar class="ma-0">
|
<template #prepend>
|
||||||
<v-icon>{{ icons[item.extension.toLowerCase()] || icons.other }}</v-icon>
|
<VIcon v-if="props.icons" :icon="props.icons[item.extension.toLowerCase()] || props.icons?.other" />
|
||||||
</v-list-item-avatar>
|
</template>
|
||||||
|
|
||||||
<v-list-item-content class="py-2">
|
<VListItemTitle v-text="item.basename" />
|
||||||
<v-list-item-title v-text="item.basename" />
|
<VListItemSubtitle> {{ formatBytes(item.size) }}</VListItemSubtitle>
|
||||||
<v-list-item-subtitle>{{ formatBytes(item.size) }}</v-list-item-subtitle>
|
|
||||||
</v-list-item-content>
|
|
||||||
|
|
||||||
<v-list-item-action>
|
<template #append>
|
||||||
<v-btn icon @click.stop="deleteItem(item)">
|
<VBtn icon @click.stop="deleteItem(item)">
|
||||||
<v-icon color="grey lighten-1">
|
<VIcon color="grey lighten-1">
|
||||||
mdi-delete-outline
|
mdi-delete-outline
|
||||||
</v-icon>
|
</VIcon>
|
||||||
</v-btn>
|
</VBtn>
|
||||||
<v-btn v-if="false" icon>
|
</template>
|
||||||
<v-icon color="grey lighten-1">
|
</VListItem>
|
||||||
mdi-information
|
</VList>
|
||||||
</v-icon>
|
</VCardText>
|
||||||
</v-btn>
|
<VCardText
|
||||||
</v-list-item-action>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-text
|
|
||||||
v-else-if="filter"
|
v-else-if="filter"
|
||||||
class="grow d-flex justify-center align-center grey--text py-5"
|
class="grow d-flex justify-center align-center grey--text py-5"
|
||||||
>
|
>
|
||||||
No files or folders found
|
没有目录或文件
|
||||||
</v-card-text>
|
</VCardText>
|
||||||
<v-card-text
|
<VCardText
|
||||||
v-else
|
v-else
|
||||||
class="grow d-flex justify-center align-center grey--text py-5"
|
class="grow d-flex justify-center align-center grey--text py-5"
|
||||||
>
|
>
|
||||||
The folder is empty
|
空目录
|
||||||
</v-card-text>
|
</VCardText>
|
||||||
<v-divider v-if="path" />
|
<VDivider v-if="path" />
|
||||||
<v-toolbar v-if="false && path && isFile" dense flat class="shrink">
|
<VToolbar v-if="path && isFile" density="compact" flat color="gray">
|
||||||
<v-btn icon>
|
<VTextField
|
||||||
<v-icon>mdi-download</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-toolbar>
|
|
||||||
<v-toolbar v-if="path && isDir" dense flat class="shrink">
|
|
||||||
<v-text-field
|
|
||||||
v-model="filter"
|
v-model="filter"
|
||||||
solo
|
|
||||||
flat
|
|
||||||
hide-details
|
hide-details
|
||||||
label="Filter"
|
flat
|
||||||
|
density="compact"
|
||||||
|
variant="solo-filled"
|
||||||
|
placeholder="搜索 ..."
|
||||||
prepend-inner-icon="mdi-filter-outline"
|
prepend-inner-icon="mdi-filter-outline"
|
||||||
class="ml-n3"
|
class="me-2"
|
||||||
/>
|
/>
|
||||||
<v-btn v-if="false" icon>
|
<VBtn icon>
|
||||||
<v-icon>mdi-eye-settings-outline</v-icon>
|
<VIcon>mdi-download</VIcon>
|
||||||
</v-btn>
|
</VBtn>
|
||||||
<v-btn icon @click="load">
|
<VBtn icon @click="load">
|
||||||
<v-icon>mdi-refresh</v-icon>
|
<VIcon>mdi-refresh</VIcon>
|
||||||
</v-btn>
|
</VBtn>
|
||||||
</v-toolbar>
|
</VToolbar>
|
||||||
</v-card>
|
</VCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.v-card {
|
.v-card {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.v-toolbar{
|
||||||
|
background: rgb(var(--v-table-header-background));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,194 +1,163 @@
|
|||||||
<script>
|
<script lang="ts" setup>
|
||||||
export default {
|
import type { Axios } from 'axios'
|
||||||
props: {
|
import type { EndPoints } from '@/api/types'
|
||||||
storages: Array,
|
|
||||||
storage: String,
|
// 输入参数
|
||||||
path: String,
|
const inProps = defineProps({
|
||||||
endpoints: Object,
|
storages: Array as PropType<any[]>,
|
||||||
axios: Function,
|
storage: String,
|
||||||
},
|
path: String,
|
||||||
data() {
|
endpoints: Object as PropType<EndPoints>,
|
||||||
|
axios: Object as PropType<Axios>,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对外事件
|
||||||
|
const emit = defineEmits(['storagechanged', 'pathchanged', 'loading', 'foldercreated'])
|
||||||
|
|
||||||
|
// 当前路径
|
||||||
|
const path = ref(inProps.path ?? '')
|
||||||
|
|
||||||
|
// 新建文件夹名称
|
||||||
|
const newFolderPopper = ref(false)
|
||||||
|
|
||||||
|
// 新建文件名称
|
||||||
|
const newFolderName = ref('')
|
||||||
|
|
||||||
|
// 计算PATH面包屑
|
||||||
|
const pathSegments = computed(() => {
|
||||||
|
let path_str = '/'
|
||||||
|
const isFolder = path.value.endsWith('/')
|
||||||
|
const segments = path.value.split('/').filter(item => item)
|
||||||
|
|
||||||
|
return segments.map((item, index) => {
|
||||||
|
path_str += item + ((index < segments.length - 1 || isFolder) ? '/' : '')
|
||||||
return {
|
return {
|
||||||
newFolderPopper: false,
|
name: item,
|
||||||
newFolderName: '',
|
path: path_str,
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
computed: {
|
})
|
||||||
pathSegments() {
|
|
||||||
let path = '/'
|
|
||||||
const isFolder = this.path[this.path.length - 1] === '/'
|
|
||||||
let segments = this.path.split('/').filter(item => item)
|
|
||||||
|
|
||||||
segments = segments.map((item, index) => {
|
const storageObject = computed(() => {
|
||||||
path
|
return inProps.storages?.find(item => item.code === inProps.storage)
|
||||||
+= item + (index < segments.length - 1 || isFolder ? '/' : '')
|
})
|
||||||
return {
|
|
||||||
name: item,
|
|
||||||
path,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return segments
|
// 切换存储
|
||||||
},
|
function changeStorage(code: string) {
|
||||||
storageObject() {
|
if (inProps.storage !== code) {
|
||||||
return this.storages.find(item => item.code == this.storage)
|
emit('storagechanged', code)
|
||||||
},
|
emit('pathchanged', '')
|
||||||
},
|
}
|
||||||
methods: {
|
}
|
||||||
changeStorage(code) {
|
|
||||||
if (this.storage != code) {
|
|
||||||
this.$emit('storage-changed', code)
|
|
||||||
this.$emit('path-changed', '')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
changePath(path) {
|
|
||||||
this.$emit('path-changed', path)
|
|
||||||
},
|
|
||||||
goUp() {
|
|
||||||
const segments = this.pathSegments
|
|
||||||
const path
|
|
||||||
= segments.length === 1
|
|
||||||
? '/'
|
|
||||||
: segments[segments.length - 2].path
|
|
||||||
this.changePath(path)
|
|
||||||
},
|
|
||||||
async addFiles(event) {
|
|
||||||
this.$emit('add-files', event.target.files)
|
|
||||||
this.$refs.inputUpload.value = ''
|
|
||||||
},
|
|
||||||
async mkdir() {
|
|
||||||
this.$emit('loading', true)
|
|
||||||
const url = this.endpoints.mkdir.url
|
|
||||||
.replace(new RegExp('{storage}', 'g'), this.storage)
|
|
||||||
.replace(new RegExp('{path}', 'g'), this.path + this.newFolderName)
|
|
||||||
|
|
||||||
const config = {
|
// 路径变化
|
||||||
url,
|
function changePath(path: string) {
|
||||||
method: this.endpoints.mkdir.method || 'post',
|
emit('pathchanged', path)
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.axios.request(config)
|
// 返回上一级
|
||||||
this.$emit('folder-created', this.newFolderName)
|
function goUp() {
|
||||||
this.newFolderPopper = false
|
const segments = pathSegments.value
|
||||||
this.newFolderName = ''
|
const path = segments.length === 1 ? '/' : segments[segments.length - 2].path
|
||||||
this.$emit('loading', false)
|
changePath(path)
|
||||||
},
|
}
|
||||||
},
|
|
||||||
|
// 创建目录
|
||||||
|
async function mkdir() {
|
||||||
|
emit('loading', true)
|
||||||
|
const url = inProps.endpoints?.mkdir.url
|
||||||
|
.replace(/{storage}/g, inProps.storage)
|
||||||
|
.replace(/{path}/g, inProps.path + newFolderName.value)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
url,
|
||||||
|
method: inProps.endpoints?.mkdir.method || 'post',
|
||||||
|
}
|
||||||
|
|
||||||
|
await inProps.axios?.request(config)
|
||||||
|
emit('foldercreated', newFolderName.value)
|
||||||
|
newFolderPopper.value = false
|
||||||
|
newFolderName.value = ''
|
||||||
|
emit('loading', false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-toolbar flat dense color="blue-grey lighten-5">
|
<VToolbar flat dense>
|
||||||
<v-toolbar-items>
|
<VToolbarItems>
|
||||||
<v-menu v-if="storages.length > 1" offset-y>
|
<VMenu v-if="inProps.storages?.length || 0 > 1" offset-y>
|
||||||
<template #activator="{ on }">
|
<template #activator="{ props }">
|
||||||
<v-btn icon class="storage-select-button mr-3" v-on="on">
|
<VBtn v-bind="props">
|
||||||
<v-icon>mdi-arrow-down-drop-circle-outline</v-icon>
|
<VIcon icon="mdi-arrow-down-drop-circle-outline" />
|
||||||
</v-btn>
|
</VBtn>
|
||||||
</template>
|
</template>
|
||||||
<v-list class="storage-select-list">
|
<VList>
|
||||||
<v-list-item
|
<VListItem
|
||||||
v-for="(item, index) in storages"
|
v-for="(item, index) in storages"
|
||||||
:key="index"
|
:key="index"
|
||||||
:disabled="item.code === storageObject.code"
|
:disabled="item.code === storageObject?.code"
|
||||||
@click="changeStorage(item.code)"
|
@click="changeStorage(item.code)"
|
||||||
>
|
>
|
||||||
<v-list-item-icon>
|
<template #prepend>
|
||||||
<v-icon v-text="item.icon" />
|
<Icon :icon="item.icon" />
|
||||||
</v-list-item-icon>
|
</template>
|
||||||
<v-list-item-title>{{ item.name }}</v-list-item-title>
|
<VListItemTitle>{{ item.name }}</VListItemTitle>
|
||||||
</v-list-item>
|
</VListItem>
|
||||||
</v-list>
|
</VList>
|
||||||
</v-menu>
|
</VMenu>
|
||||||
<v-btn text :input-value="path === '/'" @click="changePath('/')">
|
<VBtn variant="text" :input-value="path === '/'" class="px-1" @click="changePath('/')">
|
||||||
<v-icon class="mr-2">
|
<VIcon :icon="storageObject?.icon" class="mr-2" />
|
||||||
{{ storageObject.icon }}
|
{{ storageObject?.name }}
|
||||||
</v-icon>
|
</VBtn>
|
||||||
{{ storageObject.name }}
|
|
||||||
</v-btn>
|
|
||||||
<template v-for="(segment, index) in pathSegments" :key="index">
|
<template v-for="(segment, index) in pathSegments" :key="index">
|
||||||
<v-icon>
|
<VBtn
|
||||||
mdi-chevron-right
|
variant="text"
|
||||||
</v-icon>
|
|
||||||
<v-btn
|
|
||||||
text
|
|
||||||
:input-value="index === pathSegments.length - 1"
|
:input-value="index === pathSegments.length - 1"
|
||||||
|
class="px-1 d-none d-md-block"
|
||||||
@click="changePath(segment.path)"
|
@click="changePath(segment.path)"
|
||||||
>
|
>
|
||||||
|
<VIcon icon=" mdi-chevron-right" />
|
||||||
{{ segment.name }}
|
{{ segment.name }}
|
||||||
</v-btn>
|
</VBtn>
|
||||||
</template>
|
</template>
|
||||||
</v-toolbar-items>
|
</VToolbarItems>
|
||||||
<div class="flex-grow-1" />
|
<div class="flex-grow-1" />
|
||||||
|
<IconBtn v-if="pathSegments.length > 0" @click="goUp">
|
||||||
<template v-if="$vuetify.breakpoint.smAndUp">
|
<VIcon icon="mdi-arrow-up-bold-outline" />
|
||||||
<v-tooltip v-if="pathSegments.length > 0" bottom>
|
</IconBtn>
|
||||||
<template #activator="{ on }">
|
<VDialog
|
||||||
<v-btn icon @click="goUp" v-on="on">
|
v-model="newFolderPopper"
|
||||||
<v-icon>mdi-arrow-up-bold-outline</v-icon>
|
max-width="600"
|
||||||
</v-btn>
|
>
|
||||||
</template>
|
<template #activator="{ props }">
|
||||||
<span v-if="pathSegments.length === 1">Up to "root"</span>
|
<IconBtn title="新建文件夹" v-bind="props">
|
||||||
<span v-else>Up to "{{ pathSegments[pathSegments.length - 2].name }}"</span>
|
<VIcon icon="mdi-folder-plus-outline" />
|
||||||
</v-tooltip>
|
</IconBtn>
|
||||||
<v-menu
|
</template>
|
||||||
v-model="newFolderPopper"
|
<VCard title="新建文件夹">
|
||||||
:close-on-content-click="false"
|
<VCardText>
|
||||||
:nudge-width="200"
|
<VTextField v-model="newFolderName" label="名称" />
|
||||||
offset-y
|
</VCardText>
|
||||||
>
|
<VCardActions>
|
||||||
<template #activator="{ on }">
|
<div class="flex-grow-1" />
|
||||||
<v-btn v-if="path" icon title="Create Folder" v-on="on">
|
<VBtn depressed @click="newFolderPopper = false">
|
||||||
<v-icon>mdi-folder-plus-outline</v-icon>
|
取消
|
||||||
</v-btn>
|
</VBtn>
|
||||||
</template>
|
<VBtn
|
||||||
<v-card>
|
:disabled="!newFolderName"
|
||||||
<v-card-text>
|
depressed
|
||||||
<v-text-field v-model="newFolderName" label="Name" hide-details />
|
@click="mkdir"
|
||||||
</v-card-text>
|
>
|
||||||
<v-card-actions>
|
新建
|
||||||
<div class="flex-grow-1" />
|
</VBtn>
|
||||||
<v-btn depressed @click="newFolderPopper = false">
|
</VCardActions>
|
||||||
Cancel
|
</VCard>
|
||||||
</v-btn>
|
</VDialog>
|
||||||
<v-btn
|
</VToolbar>
|
||||||
color="success"
|
|
||||||
:disabled="!newFolderName"
|
|
||||||
depressed
|
|
||||||
@click="mkdir"
|
|
||||||
>
|
|
||||||
Create Folder
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-menu>
|
|
||||||
<v-btn v-if="path" icon title="Upload Files" @click="$refs.inputUpload.click()">
|
|
||||||
<v-icon>mdi-plus-circle</v-icon>
|
|
||||||
<input v-show="false" ref="inputUpload" type="file" multiple @change="addFiles">
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
</v-toolbar>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* Storage Menu - alternate appearance
|
.v-toolbar{
|
||||||
.storage-select-button ::v-deep .v-btn__content {
|
background: rgb(var(--v-table-header-background));
|
||||||
flex-wrap: wrap;
|
|
||||||
font-size: 11px;
|
|
||||||
|
|
||||||
.v-icon {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
.storage-select-list .v-list-item--disabled {
|
|
||||||
background-color: rgba(0, 0, 0, 0.25);
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
.v-icon {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+146
-137
@@ -1,116 +1,136 @@
|
|||||||
<script>
|
<script lang="ts" setup>
|
||||||
export default {
|
import type { Axios } from 'axios'
|
||||||
props: {
|
import type { EndPoints, FileItem } from '@/api/types'
|
||||||
icons: Object,
|
|
||||||
storage: String,
|
// 输入参数
|
||||||
path: String,
|
const props = defineProps({
|
||||||
endpoints: Object,
|
icons: Object,
|
||||||
axios: Function,
|
storage: String,
|
||||||
refreshPending: Boolean,
|
path: String,
|
||||||
},
|
endpoints: Object as PropType<EndPoints>,
|
||||||
data() {
|
axios: Object as PropType<Axios>,
|
||||||
return {
|
refreshpending: Boolean,
|
||||||
open: [],
|
})
|
||||||
active: [],
|
|
||||||
items: [],
|
// 对外事件
|
||||||
filter: '',
|
const emit = defineEmits(['pathchanged', 'loading', 'refreshed'])
|
||||||
|
|
||||||
|
// 变量
|
||||||
|
const open = ref<string[]>([])
|
||||||
|
// 活跃的文件夹
|
||||||
|
const active = ref<string[]>([])
|
||||||
|
// 内容
|
||||||
|
const items = ref<FileItem[]>([])
|
||||||
|
// 过滤
|
||||||
|
const filter = ref('')
|
||||||
|
// 刷新状态
|
||||||
|
const refreshPending = ref(props.refreshpending || false)
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
function init() {
|
||||||
|
open.value = []
|
||||||
|
items.value = [{
|
||||||
|
type: 'dir',
|
||||||
|
path: '/',
|
||||||
|
basename: 'root',
|
||||||
|
extension: '',
|
||||||
|
name: 'root',
|
||||||
|
children: [],
|
||||||
|
size: 0,
|
||||||
|
}]
|
||||||
|
if (props.path !== '')
|
||||||
|
emit('pathchanged', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用API读取文件夹
|
||||||
|
async function readFolder(item: FileItem) {
|
||||||
|
emit('loading', true)
|
||||||
|
const url = props.endpoints?.list.url
|
||||||
|
.replace(/{storage}/g, props.storage)
|
||||||
|
.replace(/{path}/g, item.path)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
url,
|
||||||
|
method: props.endpoints?.list.method || 'get',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: any = await props.axios?.request(config)
|
||||||
|
|
||||||
|
item.children = response.data.map((item: FileItem) => {
|
||||||
|
if (item.type === 'dir')
|
||||||
|
item.children = []
|
||||||
|
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('loading', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选中变化
|
||||||
|
function activeChanged(_active: string[]) {
|
||||||
|
active.value = _active
|
||||||
|
let path = ''
|
||||||
|
if (active.value.length)
|
||||||
|
path = active.value[0]
|
||||||
|
|
||||||
|
if (props.path !== path)
|
||||||
|
emit('pathchanged', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找文件
|
||||||
|
function findItem(path: string) {
|
||||||
|
const stack: FileItem[] = []
|
||||||
|
stack.push(items.value[0])
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const node = stack.pop()
|
||||||
|
if (node?.path === path) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
else if (node?.children && node.children.length) {
|
||||||
|
for (const element of node.children)
|
||||||
|
stack.push(element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听存储空间变量
|
||||||
|
watch(() => props.storage, () => {
|
||||||
|
init()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听路径变化
|
||||||
|
watch(() => props.path, () => {
|
||||||
|
if (props.path) {
|
||||||
|
active.value = [props.path]
|
||||||
|
if (!open.value.includes(props.path))
|
||||||
|
open.value.push(props.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 refreshPending
|
||||||
|
watch(
|
||||||
|
() => refreshPending.value,
|
||||||
|
async () => {
|
||||||
|
if (refreshPending.value && props.path) {
|
||||||
|
const item = findItem(props.path)
|
||||||
|
if (item) {
|
||||||
|
await readFolder(item)
|
||||||
|
emit('refreshed')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
)
|
||||||
storage() {
|
|
||||||
this.init()
|
|
||||||
},
|
|
||||||
path() {
|
|
||||||
this.active = [this.path]
|
|
||||||
if (!this.open.includes(this.path))
|
|
||||||
this.open.push(this.path)
|
|
||||||
},
|
|
||||||
async refreshPending() {
|
|
||||||
if (this.refreshPending) {
|
|
||||||
const item = this.findItem(this.path)
|
|
||||||
await this.readFolder(item)
|
|
||||||
this.$emit('refreshed')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.init()
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
init() {
|
|
||||||
this.open = []
|
|
||||||
this.items = []
|
|
||||||
// set default files tree items (root item) in nextTick.
|
|
||||||
// Otherwise this.open isn't cleared properly (due to syncing perhaps)
|
|
||||||
setTimeout(() => {
|
|
||||||
this.items = [
|
|
||||||
{
|
|
||||||
type: 'dir',
|
|
||||||
path: '/',
|
|
||||||
basename: 'root',
|
|
||||||
extension: '',
|
|
||||||
name: 'root',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}, 0)
|
|
||||||
if (this.path !== '')
|
|
||||||
this.$emit('path-changed', '')
|
|
||||||
},
|
|
||||||
async readFolder(item) {
|
|
||||||
this.$emit('loading', true)
|
|
||||||
const url = this.endpoints.list.url
|
|
||||||
.replace(new RegExp('{storage}', 'g'), this.storage)
|
|
||||||
.replace(new RegExp('{path}', 'g'), item.path)
|
|
||||||
|
|
||||||
const config = {
|
onMounted(() => {
|
||||||
url,
|
init()
|
||||||
method: this.endpoints.list.method || 'get',
|
})
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.axios.request(config)
|
|
||||||
|
|
||||||
item.children = response.data.map((item) => {
|
|
||||||
if (item.type === 'dir')
|
|
||||||
item.children = []
|
|
||||||
|
|
||||||
return item
|
|
||||||
})
|
|
||||||
|
|
||||||
this.$emit('loading', false)
|
|
||||||
},
|
|
||||||
activeChanged(active) {
|
|
||||||
this.active = active
|
|
||||||
let path = ''
|
|
||||||
if (active.length)
|
|
||||||
path = active[0]
|
|
||||||
|
|
||||||
if (this.path != path)
|
|
||||||
this.$emit('path-changed', path)
|
|
||||||
},
|
|
||||||
findItem(path) {
|
|
||||||
const stack = []
|
|
||||||
stack.push(this.items[0])
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const node = stack.pop()
|
|
||||||
if (node.path == path) {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
else if (node.children && node.children.length) {
|
|
||||||
for (let i = 0; i < node.children.length; i++)
|
|
||||||
stack.push(node.children[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-card flat tile width="250" min-height="380" class="d-flex flex-column folders-tree-card">
|
<VCard flat width="250" min-height="500" class="d-flex flex-column folders-tree-card">
|
||||||
<div class="grow scroll-x">
|
<div class="grow scroll-x">
|
||||||
<v-treeview
|
<VTreeview
|
||||||
:open="open"
|
:open="open"
|
||||||
:active="active"
|
:active="active"
|
||||||
:items="items"
|
:items="items"
|
||||||
@@ -125,51 +145,37 @@ export default {
|
|||||||
@update:active="activeChanged"
|
@update:active="activeChanged"
|
||||||
>
|
>
|
||||||
<template #prepend="{ item, open }">
|
<template #prepend="{ item, open }">
|
||||||
<v-icon
|
<VIcon
|
||||||
v-if="item.type === 'dir'"
|
v-if="item.type === 'dir'"
|
||||||
>
|
>
|
||||||
{{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
|
{{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
|
||||||
</v-icon>
|
</VIcon>
|
||||||
<v-icon v-else>
|
<VIcon v-else-if="props.icons" :icon="props.icons[item.extension.toLowerCase()] || props.icons.other" />
|
||||||
{{ icons[item.extension.toLowerCase()] || icons.other }}
|
|
||||||
</v-icon>
|
|
||||||
</template>
|
</template>
|
||||||
<template #label="{ item }">
|
<template #label="{ item }">
|
||||||
{{ item.basename }}
|
{{ item.basename }}
|
||||||
<v-btn
|
<VBtn
|
||||||
v-if="item.type === 'dir'"
|
v-if="item.type === 'dir'"
|
||||||
icon
|
icon
|
||||||
class="ml-1"
|
class="ml-1"
|
||||||
@click.stop="readFolder(item)"
|
@click.stop="readFolder(item)"
|
||||||
>
|
>
|
||||||
<v-icon class="pa-0 mdi-18px" color="grey lighten-1">
|
<VIcon class="pa-0 mdi-18px" color="grey lighten-1">
|
||||||
mdi-refresh
|
mdi-refresh
|
||||||
</v-icon>
|
</VIcon>
|
||||||
</v-btn>
|
</VBtn>
|
||||||
</template>
|
</template>
|
||||||
</v-treeview>
|
</VTreeview>
|
||||||
</div>
|
</div>
|
||||||
<v-divider />
|
<VDivider />
|
||||||
<v-toolbar dense flat class="shrink">
|
<VToolbar
|
||||||
<v-text-field
|
density="compact"
|
||||||
v-model="filter"
|
>
|
||||||
solo
|
<VBtn icon @click="init">
|
||||||
flat
|
<VIcon icon="mdi-collapse-all-outline" />
|
||||||
hide-details
|
</VBtn>
|
||||||
label="Filter"
|
</VToolbar>
|
||||||
prepend-inner-icon="mdi-filter-outline"
|
</VCard>
|
||||||
class="ml-n3"
|
|
||||||
/>
|
|
||||||
<v-tooltip top>
|
|
||||||
<template #activator="{ on }">
|
|
||||||
<v-btn icon @click="init" v-on="on">
|
|
||||||
<v-icon>mdi-collapse-all-outline</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<span>Collapse All</span>
|
|
||||||
</v-tooltip>
|
|
||||||
</v-toolbar>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -193,4 +199,7 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.v-toolbar{
|
||||||
|
background: rgb(var(--v-table-header-background));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { formatBytes } from './util'
|
|
||||||
|
|
||||||
const imageMimeTypes = ['image/png', 'image/jpeg']
|
|
||||||
|
|
||||||
export default {
|
|
||||||
props: {
|
|
||||||
path: String,
|
|
||||||
storage: String,
|
|
||||||
endpoint: Object,
|
|
||||||
files: { type: Array, default: () => [] },
|
|
||||||
icons: Object,
|
|
||||||
axios: Function,
|
|
||||||
maxUploadFilesCount: { type: Number, default: 0 },
|
|
||||||
maxUploadFileSize: { type: Number, default: 0 },
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loading: false,
|
|
||||||
uploading: false,
|
|
||||||
progress: 0,
|
|
||||||
listItems: [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
files: {
|
|
||||||
deep: true,
|
|
||||||
immediate: true,
|
|
||||||
async handler() {
|
|
||||||
this.loading = true
|
|
||||||
this.listItems = await this.filesMap(this.files)
|
|
||||||
this.loading = false
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
formatBytes,
|
|
||||||
|
|
||||||
async filesMap(files) {
|
|
||||||
const promises = Array.from(files).map((file) => {
|
|
||||||
const result = {
|
|
||||||
name: file.name,
|
|
||||||
type: file.type,
|
|
||||||
size: file.size,
|
|
||||||
extension: file.name.split('.').pop(),
|
|
||||||
}
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!imageMimeTypes.includes(result.type))
|
|
||||||
return resolve(result)
|
|
||||||
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = function (e) {
|
|
||||||
result.preview = e.target.result
|
|
||||||
resolve(result)
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return await Promise.all(promises)
|
|
||||||
},
|
|
||||||
|
|
||||||
async add(event) {
|
|
||||||
const files = Array.from(event.target.files)
|
|
||||||
this.$emit('add-files', files)
|
|
||||||
this.$refs.inputUpload.value = ''
|
|
||||||
},
|
|
||||||
|
|
||||||
remove(index) {
|
|
||||||
this.$emit('remove-file', index)
|
|
||||||
this.listItems.splice(index, 1)
|
|
||||||
},
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.$emit('clear-files')
|
|
||||||
this.listItems = []
|
|
||||||
},
|
|
||||||
|
|
||||||
cancel() {
|
|
||||||
this.$emit('cancel')
|
|
||||||
},
|
|
||||||
|
|
||||||
async upload() {
|
|
||||||
const formData = new FormData()
|
|
||||||
|
|
||||||
// files
|
|
||||||
for (const file of this.files)
|
|
||||||
formData.append('files', file, file.name)
|
|
||||||
|
|
||||||
const url = this.endpoint.url
|
|
||||||
.replace(new RegExp('{storage}', 'g'), this.storage)
|
|
||||||
.replace(new RegExp('{path}', 'g'), this.path)
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
url,
|
|
||||||
method: this.endpoint.method || 'post',
|
|
||||||
data: formData,
|
|
||||||
onUploadProgress: (progressEvent) => {
|
|
||||||
this.progress
|
|
||||||
= (progressEvent.loaded / progressEvent.total) * 100
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uploading = true
|
|
||||||
const response = await this.axios.request(config)
|
|
||||||
this.uploading = false
|
|
||||||
this.$emit('uploaded')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<v-overlay :absolute="true">
|
|
||||||
<v-card flat light class="mx-auto" :loading="loading">
|
|
||||||
<v-card-text class="py-3 text-center">
|
|
||||||
<div>
|
|
||||||
<span class="grey--text">Upload to:</span>
|
|
||||||
<v-chip color="info" class="mx-1">
|
|
||||||
{{ storage }}
|
|
||||||
</v-chip>
|
|
||||||
<v-chip>{{ path }}</v-chip>
|
|
||||||
</div>
|
|
||||||
<div v-if="maxUploadFilesCount">
|
|
||||||
<span class="grey--text">Max files count: {{ maxUploadFilesCount }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="maxUploadFileSize">
|
|
||||||
<span class="grey--text">Max file size: {{ formatBytes(maxUploadFileSize) }}</span>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
<v-divider />
|
|
||||||
<v-card-text v-if="listItems.length" class="pa-0 files-list-wrapper">
|
|
||||||
<v-list v-if="listItems.length" two-line>
|
|
||||||
<v-list-item v-for="(file, index) in listItems" :key="index" link>
|
|
||||||
<v-list-item-avatar>
|
|
||||||
<v-img v-if="file.preview" :src="file.preview" />
|
|
||||||
<v-icon
|
|
||||||
v-else
|
|
||||||
class="mdi-36px"
|
|
||||||
color="grey lighten-1"
|
|
||||||
v-text="icons[file.extension] || 'mdi-file'"
|
|
||||||
/>
|
|
||||||
</v-list-item-avatar>
|
|
||||||
<v-list-item-content>
|
|
||||||
<v-list-item-title v-text="file.name" />
|
|
||||||
<v-list-item-subtitle>{{ formatBytes(file.size) }} - {{ file.type }}</v-list-item-subtitle>
|
|
||||||
</v-list-item-content>
|
|
||||||
<v-list-item-action>
|
|
||||||
<v-btn icon @click="remove(index)">
|
|
||||||
<v-icon color="grey lighten-1">
|
|
||||||
mdi-close
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-list-item-action>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-text v-else class="py-6 text-center">
|
|
||||||
<v-btn @click="$refs.inputUpload.click()">
|
|
||||||
<v-icon left>
|
|
||||||
mdi-plus-circle
|
|
||||||
</v-icon>Add files
|
|
||||||
</v-btn>
|
|
||||||
</v-card-text>
|
|
||||||
<v-divider />
|
|
||||||
<v-toolbar dense flat>
|
|
||||||
<div class="grow" />
|
|
||||||
<v-btn text class="mx-1" @click="cancel">
|
|
||||||
Cancel
|
|
||||||
</v-btn>
|
|
||||||
<v-btn depressed color="warning" class="mx-1" :disabled="!files" @click="clear">
|
|
||||||
<v-icon>mdi-close</v-icon>Clear
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
:disabled="listItems.length >= maxUploadFilesCount"
|
|
||||||
depressed
|
|
||||||
color="info"
|
|
||||||
class="mx-1"
|
|
||||||
@click="$refs.inputUpload.click()"
|
|
||||||
>
|
|
||||||
<v-icon left>
|
|
||||||
mdi-plus-circle
|
|
||||||
</v-icon>Add Files
|
|
||||||
<input
|
|
||||||
v-show="false"
|
|
||||||
ref="inputUpload"
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
@change="add"
|
|
||||||
>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn depressed color="success" class="ml-1" :disabled="!files" @click="upload">
|
|
||||||
Upload
|
|
||||||
<v-icon right>
|
|
||||||
mdi-upload-outline
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</v-toolbar>
|
|
||||||
<v-overlay :value="uploading" :absolute="true" color="white" opacity="0.9">
|
|
||||||
<v-progress-linear v-model="progress" height="25" striped rounded reactive>
|
|
||||||
<strong>{{ Math.ceil(progress) }}%</strong>
|
|
||||||
</v-progress-linear>
|
|
||||||
</v-overlay>
|
|
||||||
</v-card>
|
|
||||||
</v-overlay>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
::v-deep .v-overlay__content {
|
|
||||||
width: 90%;
|
|
||||||
max-width: 500px;
|
|
||||||
|
|
||||||
.files-list-wrapper {
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export function formatBytes(bytes, decimals = 2) {
|
|
||||||
if (bytes === 0)
|
|
||||||
return '0 bytes'
|
|
||||||
|
|
||||||
const k = 1024
|
|
||||||
const dm = decimals < 0 ? 0 : decimals
|
|
||||||
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
||||||
|
|
||||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
formatBytes,
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FileBrowser from '@/components/FileBrowser.vue'
|
import FileBrowserView from '@/views/reorganize/FileBrowserView.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<FileBrowserView />
|
||||||
<FileBrowser />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import FileBrowser from '@/components/FileBrowser.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<FileBrowser storages="local" tree="true" path="/Users/jxxghp/Downloads/public" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user