mirror of
https://github.com/continew-org/continew-admin-ui.git
synced 2025-09-09 20:57:17 +08:00
feat(system/file): 新增文件夹导航、计算文件夹大小功能
This commit is contained in:
@@ -5,6 +5,11 @@ export type * from './type'
|
||||
|
||||
const BASE_URL = '/system/file'
|
||||
|
||||
/** @desc 上传文件 */
|
||||
export function uploadFile(data: FormData) {
|
||||
return http.post(`${BASE_URL}/upload`, data)
|
||||
}
|
||||
|
||||
/** @desc 查询文件列表 */
|
||||
export function listFile(query: T.FilePageQuery) {
|
||||
return http.get<PageRes<T.FileItem[]>>(`${BASE_URL}`, query)
|
||||
@@ -31,6 +36,11 @@ export function checkFile(sha256: string) {
|
||||
}
|
||||
|
||||
/** @desc 创建文件夹 */
|
||||
export function createDir(path: string, name: string) {
|
||||
return http.post<T.FileItem>(`${BASE_URL}/dir`, { path, originalName: name })
|
||||
export function createDir(parentPath: string, name: string) {
|
||||
return http.post<T.FileItem>(`${BASE_URL}/dir`, { parentPath, originalName: name })
|
||||
}
|
||||
|
||||
/** @desc 查询文件夹大小 */
|
||||
export function calcDirSize(id: string) {
|
||||
return http.get<T.FileDirCalcSizeResp>(`${BASE_URL}/dir/${id}/size`)
|
||||
}
|
||||
|
@@ -205,6 +205,7 @@ export interface FileItem {
|
||||
originalName: string
|
||||
size: number
|
||||
url: string
|
||||
parentPath: string
|
||||
path: string
|
||||
sha256: string
|
||||
contentType: string
|
||||
@@ -230,10 +231,14 @@ export interface FileStatisticsResp {
|
||||
unit: string
|
||||
data: Array<FileStatisticsResp>
|
||||
}
|
||||
/** 文件夹计算大小信息 */
|
||||
export interface FileDirCalcSizeResp {
|
||||
size: number
|
||||
}
|
||||
export interface FileQuery {
|
||||
originalName?: string
|
||||
type?: string
|
||||
path?: string
|
||||
parentPath?: string
|
||||
sort: Array<string>
|
||||
}
|
||||
export interface FilePageQuery extends FileQuery, PageQuery {
|
||||
|
@@ -7,13 +7,27 @@
|
||||
<a-row style="margin-top: 15px">
|
||||
<a-descriptions :column="1" layout="inline-vertical">
|
||||
<a-descriptions-item label="名称">
|
||||
<a-typography-paragraph copyable :copy-text="data.url">
|
||||
<a-typography-paragraph :copyable="data.type !== 0" :copy-text="data.url">
|
||||
<template #copy-tooltip>复制链接</template>
|
||||
{{ data.originalName }}
|
||||
</a-typography-paragraph>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="大小">{{ formatFileSize(data.size) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="路径">{{ `${data.path === '/' ? '' : data.path}/${data.name}` }}</a-descriptions-item>
|
||||
<a-descriptions-item label="大小">
|
||||
<span v-if="data.type === 0" v-permission="['system:file:calcDirSize']">
|
||||
<a-link
|
||||
v-if="isCalculating || calculatedSize === null"
|
||||
:disabled="isCalculating"
|
||||
@click="calculateDirSize"
|
||||
>
|
||||
{{ isCalculating ? '计算中...' : '计算' }}
|
||||
</a-link>
|
||||
<span v-else>
|
||||
{{ formatFileSize(calculatedSize) }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>{{ formatFileSize(data.size) }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="路径">{{ `${data.parentPath === '/' ? '' : data.parentPath}/${data.name}` }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="data.sha256" label="SHA256">
|
||||
<a-typography-paragraph copyable :copy-text="data.sha256">
|
||||
<template #copy-tooltip>复制</template>
|
||||
@@ -28,15 +42,31 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import FileImage from '../../main/FileMain/FileImage.vue'
|
||||
import type { FileItem } from '@/apis/system'
|
||||
import { type FileItem, calcDirSize } from '@/apis/system'
|
||||
import { formatFileSize } from '@/utils'
|
||||
|
||||
interface Props {
|
||||
data: FileItem
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {})
|
||||
const props = withDefaults(defineProps<Props>(), {})
|
||||
const isCalculating = ref(false)
|
||||
const calculatedSize = ref<number | null>(null)
|
||||
// 计算文件夹大小
|
||||
const calculateDirSize = async () => {
|
||||
if (isCalculating.value || props.data.type !== 0) return
|
||||
isCalculating.value = true
|
||||
try {
|
||||
const { data } = await calcDirSize(props.data.id)
|
||||
calculatedSize.value = data.size
|
||||
} catch (err) {
|
||||
Message.error('计算失败,请重试')
|
||||
} finally {
|
||||
isCalculating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
|
@@ -10,7 +10,6 @@
|
||||
:selected-keys="selectedFileIds"
|
||||
column-resizable
|
||||
@select="select"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="名称">
|
||||
@@ -24,23 +23,31 @@
|
||||
update-at-scroll
|
||||
scroll-to-close
|
||||
>
|
||||
<section class="file-name">
|
||||
<section class="file-name" @click="handleClick(record)" @dblclick="handleDblclickFile(record)">
|
||||
<div class="file-image">
|
||||
<FileImage :data="record"></FileImage>
|
||||
</div>
|
||||
<a-typography-paragraph copyable :copy-text="record.url">
|
||||
<a-typography-paragraph :copyable="record.type !== 0" :copy-text="record.url">
|
||||
<template #copy-tooltip>复制链接</template>
|
||||
{{ record.originalName }}
|
||||
</a-typography-paragraph>
|
||||
</section>
|
||||
<template #content>
|
||||
<FileRightMenu :data="record" @click="handleRightMenuClick($event, record)" @dblclick="handleDblclickFile(record)"></FileRightMenu>
|
||||
<FileRightMenu :data="record" @click="handleRightMenuClick($event, record)"></FileRightMenu>
|
||||
</template>
|
||||
</a-trigger>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="大小" data-index="size" :width="150">
|
||||
<template #cell="{ record }">{{ formatFileSize(record.size) }}</template>
|
||||
<a-table-column title="大小" data-index="size" :width="160">
|
||||
<template #cell="{ record }">
|
||||
<span v-if="record.type === 0" v-permission="['system:file:calcDirSize']">
|
||||
<a-link v-if="record.size === null" @click="calculateDirSize(record)">计算</a-link>
|
||||
<span v-else>
|
||||
{{ formatFileSize(record.size) }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>{{ formatFileSize(record.size) }}</span>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="存储名称" data-index="storageName" :width="200" />
|
||||
<a-table-column title="修改时间" data-index="updateTime" :width="200" />
|
||||
@@ -64,9 +71,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TableInstance, TableRowSelection } from '@arco-design/web-vue'
|
||||
import { Message, type TableInstance, type TableRowSelection } from '@arco-design/web-vue'
|
||||
import FileRightMenu from './FileRightMenu.vue'
|
||||
import type { FileItem } from '@/apis/system'
|
||||
import { type FileItem, calcDirSize } from '@/apis/system'
|
||||
import { formatFileSize } from '@/utils'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -95,13 +102,24 @@ const rowSelection: TableRowSelection = reactive({
|
||||
showCheckedAll: true,
|
||||
})
|
||||
|
||||
// 计算文件夹大小
|
||||
const calculateDirSize = async (record: FileItem) => {
|
||||
if (record.type !== 0) return
|
||||
try {
|
||||
const { data } = await calcDirSize(record.id)
|
||||
record.size = data.size
|
||||
} catch (err) {
|
||||
Message.error('计算失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 多选
|
||||
const select: TableInstance['onSelect'] = (rowKeys, rowKey, record) => {
|
||||
emit('select', record as unknown as FileItem)
|
||||
}
|
||||
|
||||
// 行点击事件
|
||||
const handleRowClick: TableInstance['onRowClick'] = (record) => {
|
||||
// 单击事件
|
||||
const handleClick = (record) => {
|
||||
emit('click', record as unknown as FileItem)
|
||||
}
|
||||
|
||||
|
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<GiOption :class="{ shadow: props.shadow }">
|
||||
<GiOptionItem label="重命名" @click="onClickItem('rename')"> </GiOptionItem>
|
||||
<GiOptionItem label="详情" @click="onClickItem('detail')"> </GiOptionItem>
|
||||
<GiOptionItem label="下载" @click="onClickItem('download')"></GiOptionItem>
|
||||
<GiOptionItem label="删除" @click="onClickItem('delete')"> </GiOptionItem>
|
||||
<GiOptionItem v-permission="['system:file:rename']" label="重命名" @click="onClickItem('rename')"> </GiOptionItem>
|
||||
<GiOptionItem v-permission="['system:file:detail']" label="详情" @click="onClickItem('detail')"> </GiOptionItem>
|
||||
<GiOptionItem v-permission="['system:file:download']" label="下载" @click="onClickItem('download')"></GiOptionItem>
|
||||
<GiOptionItem v-permission="['system:file:delete']" label="删除" @click="onClickItem('delete')"> </GiOptionItem>
|
||||
</GiOption>
|
||||
</template>
|
||||
|
||||
|
@@ -1,9 +1,18 @@
|
||||
<template>
|
||||
<div class="file-main">
|
||||
<!-- 目录导航面包屑 -->
|
||||
<a-breadcrumb class="file-main__breadcrumb">
|
||||
<a-breadcrumb-item v-if="queryForm.parentPath" @click="handleBreadcrumbClick({ name: '根目录', path: '/' })">根目录</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-else>全部</a-breadcrumb-item>
|
||||
<a-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="index" @click="handleBreadcrumbClick(item)">
|
||||
{{ item.name || '根目录' }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
|
||||
<a-row justify="space-between" class="file-main__search">
|
||||
<!-- 左侧区域 -->
|
||||
<a-space wrap>
|
||||
<a-dropdown>
|
||||
<a-dropdown v-permission="['system:file:upload']">
|
||||
<a-upload :show-file-list="false" :custom-request="handleUpload">
|
||||
<template #upload-button>
|
||||
<a-button type="primary" shape="round">
|
||||
@@ -17,8 +26,7 @@
|
||||
</a-dropdown>
|
||||
|
||||
<a-input-group>
|
||||
<a-select v-model="queryType" placeholder="请选择" :options="queryTypeOption" :style="{ width: '100px' }" @change="reset" />
|
||||
<a-input v-model="queryForm[queryType]" placeholder="请输入" allow-clear style="width: 200px" />
|
||||
<a-input v-model="queryForm.originalName" :placeholder="queryForm.type && queryForm.type !== '0' ? '请输入名称' : '在当前目录下搜索名称'" allow-clear style="width: 200px" />
|
||||
<a-button type="primary" @click="search">
|
||||
<template #icon>
|
||||
<icon-search />
|
||||
@@ -38,13 +46,13 @@
|
||||
<icon-delete />
|
||||
</template>
|
||||
</a-button>
|
||||
<a-button type="primary" :disabled="!queryForm.path" @click="createDirModalVisible = !createDirModalVisible">
|
||||
<a-button v-permission="['system:file:createDir']" type="primary" :disabled="!queryForm.parentPath" @click="createDirModalVisible = !createDirModalVisible">
|
||||
<template #icon>
|
||||
<icon-folder />
|
||||
</template>
|
||||
<template #default>新建文件夹</template>
|
||||
</a-button>
|
||||
<a-button type="primary" @click="isBatchMode = !isBatchMode">
|
||||
<a-button v-permission="['system:file:delete']" type="primary" @click="isBatchMode = !isBatchMode">
|
||||
<template #icon>
|
||||
<icon-select-all />
|
||||
</template>
|
||||
@@ -104,7 +112,7 @@ import {
|
||||
import FileGrid from './FileGrid.vue'
|
||||
import useFileManage from './useFileManage'
|
||||
import { useTable } from '@/hooks'
|
||||
import { type FileItem, type FileQuery, createDir, deleteFile, listFile, uploadFile } from '@/apis'
|
||||
import { type FileItem, type FileQuery, createDir, deleteFile, listFile, uploadFile } from '@/apis/system/file'
|
||||
import { ImageTypes, OfficeTypes } from '@/constant/file'
|
||||
import 'viewerjs/dist/viewer.css'
|
||||
import { downloadByUrl } from '@/utils/downloadFile'
|
||||
@@ -117,32 +125,13 @@ const FileList = defineAsyncComponent(() => import('./FileList.vue'))
|
||||
const route = useRoute()
|
||||
const { mode, selectedFileIds, toggleMode, addSelectedFileItem } = useFileManage()
|
||||
|
||||
const queryTypeOption = [{
|
||||
label: '文件名',
|
||||
value: 'originalName',
|
||||
}, {
|
||||
label: '路径',
|
||||
value: 'path',
|
||||
}]
|
||||
const queryType = ref<string>('originalName')
|
||||
|
||||
// 新建文件夹弹窗显示
|
||||
const createDirModalVisible = ref<boolean>(false)
|
||||
// 新文件名称
|
||||
const newDirName = ref()
|
||||
|
||||
const queryForm = reactive<FileQuery>({
|
||||
originalName: undefined,
|
||||
path: (!route.query.type || route.query.type?.toString() === '0') ? '/' : undefined,
|
||||
parentPath: (!route.query.type || route.query.type?.toString() === '0') ? '/' : undefined,
|
||||
type: route.query.type?.toString() && route.query.type?.toString() !== '0' ? route.query.type?.toString() : undefined,
|
||||
sort: ['type,asc', 'updateTime,desc'],
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
queryForm.originalName = undefined
|
||||
queryForm.path = undefined
|
||||
}
|
||||
|
||||
const paginationOption = reactive({
|
||||
defaultPageSize: 30,
|
||||
defaultSizeOptions: [30, 40, 50, 100, 120],
|
||||
@@ -201,7 +190,7 @@ const handleClickFile = (item: FileItem) => {
|
||||
// 双击文件
|
||||
const handleDblclickFile = (item: FileItem) => {
|
||||
if (item.type === 0) {
|
||||
queryForm.path = `${item.path === '/' ? '' : item.path}/${item.name}`
|
||||
queryForm.parentPath = `${item.parentPath === '/' ? '' : item.parentPath}/${item.name}`
|
||||
search()
|
||||
}
|
||||
}
|
||||
@@ -269,7 +258,7 @@ const handleUpload = (options: RequestOption) => {
|
||||
const { onProgress, onError, onSuccess, fileItem, name = 'file' } = options
|
||||
onProgress(20)
|
||||
const formData = new FormData()
|
||||
formData.append('path', queryForm.path ?? '/')
|
||||
formData.append('parentPath', queryForm.parentPath ?? '/')
|
||||
formData.append(name as string, fileItem.file as Blob)
|
||||
try {
|
||||
const res = await uploadFile(formData)
|
||||
@@ -293,15 +282,19 @@ onBeforeRouteUpdate((to) => {
|
||||
if (!to.query.type) return
|
||||
if (to.query.type === '0' || !to.query.type) {
|
||||
queryForm.type = undefined
|
||||
queryForm.path = '/'
|
||||
queryForm.parentPath = '/'
|
||||
} else {
|
||||
queryForm.type = to.query.type?.toString()
|
||||
queryForm.path = undefined
|
||||
queryForm.parentPath = undefined
|
||||
}
|
||||
|
||||
search()
|
||||
})
|
||||
|
||||
// 新建文件夹弹窗显示
|
||||
const createDirModalVisible = ref<boolean>(false)
|
||||
// 新文件名称
|
||||
const newDirName = ref()
|
||||
// 新建文件夹弹窗窗口取消事件
|
||||
const handleCancel = () => {
|
||||
newDirName.value = undefined
|
||||
@@ -310,12 +303,28 @@ const handleCancel = () => {
|
||||
|
||||
// 新建文件夹弹窗窗口确认事件
|
||||
const handleCreateDir = async () => {
|
||||
await createDir(queryForm.path ?? '/', newDirName.value)
|
||||
await createDir(queryForm.parentPath ?? '/', newDirName.value)
|
||||
newDirName.value = undefined
|
||||
createDirModalVisible.value = false
|
||||
search()
|
||||
}
|
||||
|
||||
// 解析路径生成面包屑列表
|
||||
const breadcrumbList = computed(() => {
|
||||
const path = queryForm.parentPath || '/'
|
||||
const parts = path.split('/').filter((p) => p !== '') // 分割路径并过滤空字符串
|
||||
return parts.map((part, index) => {
|
||||
const fullPath = parts.slice(0, index + 1).join('/')
|
||||
return { name: part || '根目录', path: `/${fullPath}` }
|
||||
})
|
||||
})
|
||||
|
||||
// 处理面包屑点击
|
||||
const handleBreadcrumbClick = (item) => {
|
||||
queryForm.parentPath = item.path
|
||||
search()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
search()
|
||||
})
|
||||
@@ -331,10 +340,30 @@ onMounted(() => {
|
||||
overflow: hidden;
|
||||
|
||||
&__search {
|
||||
border-bottom: 1px dashed var(--color-border-3);
|
||||
margin: 16px $padding 0;
|
||||
}
|
||||
|
||||
&__breadcrumb {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-2);
|
||||
border-bottom: 1px solid var(--color-border-3);
|
||||
|
||||
:deep(.arco-breadcrumb-item) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:deep(.arco-breadcrumb-item-link) {
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
padding: 0 $padding $padding;
|
||||
|
Reference in New Issue
Block a user