This commit is contained in:
jxxghp
2023-08-25 22:05:57 +08:00
parent 235942157e
commit 5db4d97568
10 changed files with 1165 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
<script>
import axios from 'axios'
import Toolbar from './filebrowser/Toolbar.vue'
import Tree from './filebrowser/Tree.vue'
import List from './filebrowser/List.vue'
import Upload from './filebrowser/Upload.vue'
const availableStorages = [
{
name: 'Local',
code: 'local',
icon: 'mdi-folder-multiple-outline',
},
]
const endpoints = {
list: { url: '/storage/{storage}/list?path={path}', method: 'get' },
upload: { url: '/storage/{storage}/upload?path={path}', method: 'post' },
mkdir: { url: '/storage/{storage}/mkdir?path={path}', method: 'post' },
delete: { url: '/storage/{storage}/delete?path={path}', method: 'post' },
}
const fileIcons = {
zip: 'mdi-folder-zip-outline',
rar: 'mdi-folder-zip-outline',
htm: 'mdi-language-html5',
html: 'mdi-language-html5',
js: 'mdi-nodejs',
json: 'mdi-json',
md: 'mdi-markdown',
pdf: 'mdi-file-pdf',
png: 'mdi-file-image',
jpg: 'mdi-file-image',
jpeg: 'mdi-file-image',
mp4: 'mdi-filmstrip',
mkv: 'mdi-filmstrip',
avi: 'mdi-filmstrip',
wmv: 'mdi-filmstrip',
mov: 'mdi-filmstrip',
txt: 'mdi-file-document-outline',
xls: 'mdi-file-excel',
other: 'mdi-file-outline',
}
export default {
components: {
Toolbar,
Tree,
List,
Upload,
},
model: {
prop: 'path',
event: 'change',
},
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(
file => file.size <= this.maxUploadFileSize,
)
}
if (this.uploadingFiles === false)
this.uploadingFiles = []
if (this.maxUploadFilesCount && this.uploadingFiles.length + files.length > this.maxUploadFilesCount)
files = files.slice(0, this.maxUploadFilesCount - this.uploadingFiles.length)
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)
},
},
}
</script>
<template>
<v-card class="mx-auto" :loading="loading > 0">
<Toolbar
:path="path"
:storages="storagesArray"
:storage="activeStorage"
:endpoints="endpoints"
:axios="axiosInstance"
@storage-changed="storageChanged"
@path-changed="pathChanged"
@add-files="addUploadingFiles"
@folder-created="refreshPending = true"
/>
<v-row no-gutters>
<v-col v-if="tree && $vuetify.breakpoint.smAndUp" sm="auto">
<Tree
:path="path"
:storage="activeStorage"
:icons="icons"
:endpoints="endpoints"
:axios="axiosInstance"
:refresh-pending="refreshPending"
@path-changed="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
/>
</v-col>
<v-divider v-if="tree" vertical />
<v-col>
<List
:path="path"
:storage="activeStorage"
:icons="icons"
:endpoints="endpoints"
:axios="axiosInstance"
:refresh-pending="refreshPending"
@path-changed="pathChanged"
@loading="loadingChanged"
@refreshed="refreshPending = false"
@file-deleted="refreshPending = true"
/>
</v-col>
</v-row>
<Upload
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>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,78 @@
<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>

View File

@@ -0,0 +1,227 @@
<script>
import { formatBytes } from './util'
import Confirm from './Confirm.vue'
export default {
components: {
Confirm,
},
props: {
icons: Object,
storage: String,
path: String,
endpoints: Object,
axios: Function,
refreshPending: Boolean,
},
data() {
return {
items: [],
filter: '',
}
},
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>
<template>
<v-card flat tile min-height="380" class="d-flex flex-column">
<Confirm ref="confirm" />
<v-card-text
v-if="!path"
class="grow d-flex justify-center align-center grey--text"
>
Select a folder or a file
</v-card-text>
<v-card-text
v-else-if="isFile"
class="grow d-flex justify-center align-center"
>
File: {{ path }}
</v-card-text>
<v-card-text v-else-if="dirs.length || files.length" class="grow">
<v-list v-if="dirs.length" subheader>
<v-subheader>Folders</v-subheader>
<v-list-item
v-for="item in dirs"
:key="item.basename"
class="pl-0"
@click="changePath(item.path)"
>
<v-list-item-avatar class="ma-0">
<v-icon>mdi-folder-outline</v-icon>
</v-list-item-avatar>
<v-list-item-content class="py-2">
<v-list-item-title v-text="item.basename" />
</v-list-item-content>
<v-list-item-action>
<v-btn icon @click.stop="deleteItem(item)">
<v-icon color="grey lighten-1">
mdi-delete-outline
</v-icon>
</v-btn>
<v-btn v-if="false" icon>
<v-icon color="grey lighten-1">
mdi-information
</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
<v-divider v-if="dirs.length && files.length" />
<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"
@click="changePath(item.path)"
>
<v-list-item-avatar class="ma-0">
<v-icon>{{ icons[item.extension.toLowerCase()] || icons.other }}</v-icon>
</v-list-item-avatar>
<v-list-item-content class="py-2">
<v-list-item-title v-text="item.basename" />
<v-list-item-subtitle>{{ formatBytes(item.size) }}</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn icon @click.stop="deleteItem(item)">
<v-icon color="grey lighten-1">
mdi-delete-outline
</v-icon>
</v-btn>
<v-btn v-if="false" icon>
<v-icon color="grey lighten-1">
mdi-information
</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
</v-card-text>
<v-card-text
v-else-if="filter"
class="grow d-flex justify-center align-center grey--text py-5"
>
No files or folders found
</v-card-text>
<v-card-text
v-else
class="grow d-flex justify-center align-center grey--text py-5"
>
The folder is empty
</v-card-text>
<v-divider v-if="path" />
<v-toolbar v-if="false && path && isFile" dense flat class="shrink">
<v-btn icon>
<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"
solo
flat
hide-details
label="Filter"
prepend-inner-icon="mdi-filter-outline"
class="ml-n3"
/>
<v-btn v-if="false" icon>
<v-icon>mdi-eye-settings-outline</v-icon>
</v-btn>
<v-btn icon @click="load">
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-toolbar>
</v-card>
</template>
<style lang="scss" scoped>
.v-card {
height: 100%;
}
</style>

View File

@@ -0,0 +1,194 @@
<script>
export default {
props: {
storages: Array,
storage: String,
path: String,
endpoints: Object,
axios: Function,
},
data() {
return {
newFolderPopper: false,
newFolderName: '',
}
},
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) => {
path
+= item + (index < segments.length - 1 || isFolder ? '/' : '')
return {
name: item,
path,
}
})
return segments
},
storageObject() {
return this.storages.find(item => item.code == this.storage)
},
},
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,
method: this.endpoints.mkdir.method || 'post',
}
await this.axios.request(config)
this.$emit('folder-created', this.newFolderName)
this.newFolderPopper = false
this.newFolderName = ''
this.$emit('loading', false)
},
},
}
</script>
<template>
<v-toolbar flat dense color="blue-grey lighten-5">
<v-toolbar-items>
<v-menu v-if="storages.length > 1" offset-y>
<template #activator="{ on }">
<v-btn icon class="storage-select-button mr-3" v-on="on">
<v-icon>mdi-arrow-down-drop-circle-outline</v-icon>
</v-btn>
</template>
<v-list class="storage-select-list">
<v-list-item
v-for="(item, index) in storages"
:key="index"
:disabled="item.code === storageObject.code"
@click="changeStorage(item.code)"
>
<v-list-item-icon>
<v-icon v-text="item.icon" />
</v-list-item-icon>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn text :input-value="path === '/'" @click="changePath('/')">
<v-icon class="mr-2">
{{ storageObject.icon }}
</v-icon>
{{ storageObject.name }}
</v-btn>
<template v-for="(segment, index) in pathSegments" :key="index">
<v-icon>
mdi-chevron-right
</v-icon>
<v-btn
text
:input-value="index === pathSegments.length - 1"
@click="changePath(segment.path)"
>
{{ segment.name }}
</v-btn>
</template>
</v-toolbar-items>
<div class="flex-grow-1" />
<template v-if="$vuetify.breakpoint.smAndUp">
<v-tooltip v-if="pathSegments.length > 0" bottom>
<template #activator="{ on }">
<v-btn icon @click="goUp" v-on="on">
<v-icon>mdi-arrow-up-bold-outline</v-icon>
</v-btn>
</template>
<span v-if="pathSegments.length === 1">Up to "root"</span>
<span v-else>Up to "{{ pathSegments[pathSegments.length - 2].name }}"</span>
</v-tooltip>
<v-menu
v-model="newFolderPopper"
:close-on-content-click="false"
:nudge-width="200"
offset-y
>
<template #activator="{ on }">
<v-btn v-if="path" icon title="Create Folder" v-on="on">
<v-icon>mdi-folder-plus-outline</v-icon>
</v-btn>
</template>
<v-card>
<v-card-text>
<v-text-field v-model="newFolderName" label="Name" hide-details />
</v-card-text>
<v-card-actions>
<div class="flex-grow-1" />
<v-btn depressed @click="newFolderPopper = false">
Cancel
</v-btn>
<v-btn
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>
<style lang="scss" scoped>
/* Storage Menu - alternate appearance
.storage-select-button ::v-deep .v-btn__content {
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>

View File

@@ -0,0 +1,196 @@
<script>
export default {
props: {
icons: Object,
storage: String,
path: String,
endpoints: Object,
axios: Function,
refreshPending: Boolean,
},
data() {
return {
open: [],
active: [],
items: [],
filter: '',
}
},
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 = {
url,
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>
<template>
<v-card flat tile width="250" min-height="380" class="d-flex flex-column folders-tree-card">
<div class="grow scroll-x">
<v-treeview
:open="open"
:active="active"
:items="items"
:search="filter"
:load-children="readFolder"
item-key="path"
item-text="basename"
dense
activatable
transition
class="folders-tree"
@update:active="activeChanged"
>
<template #prepend="{ item, open }">
<v-icon
v-if="item.type === 'dir'"
>
{{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
</v-icon>
<v-icon v-else>
{{ icons[item.extension.toLowerCase()] || icons.other }}
</v-icon>
</template>
<template #label="{ item }">
{{ item.basename }}
<v-btn
v-if="item.type === 'dir'"
icon
class="ml-1"
@click.stop="readFolder(item)"
>
<v-icon class="pa-0 mdi-18px" color="grey lighten-1">
mdi-refresh
</v-icon>
</v-btn>
</template>
</v-treeview>
</div>
<v-divider />
<v-toolbar dense flat class="shrink">
<v-text-field
v-model="filter"
solo
flat
hide-details
label="Filter"
prepend-inner-icon="mdi-filter-outline"
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>
<style lang="scss" scoped>
.folders-tree-card {
height: 100%;
.scroll-x {
overflow-x: auto;
}
::v-deep .folders-tree {
width: fit-content;
min-width: 250px;
.v-treeview-node {
cursor: pointer;
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}
}
}
</style>

View File

@@ -0,0 +1,218 @@
<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>

View File

@@ -0,0 +1,16 @@
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,
}

View File

@@ -134,6 +134,13 @@ import UserProfile from '@/layouts/components/UserProfile.vue'
to: '/history',
}"
/>
<VerticalNavLink
:item="{
title: '文件管理',
icon: 'mdi-folder-multiple-outline',
to: '/filemanager',
}"
/>
<!-- 👉 系统 -->
<VerticalNavSectionTitle

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import FileBrowser from '@/components/FileBrowser.vue'
</script>
<template>
<div>
<FileBrowser />
</div>
</template>

View File

@@ -128,6 +128,13 @@ const router = createRouter({
requiresAuth: true,
},
},
{
path: '/filemanager',
component: () => import('../pages/filemanager.vue'),
meta: {
requiresAuth: true,
},
},
],
},
{