feat(system/file): 新增文件夹导航、计算文件夹大小功能

This commit is contained in:
2025-05-16 23:03:58 +08:00
parent 86fb09efaa
commit abdd773886
6 changed files with 146 additions and 54 deletions

View File

@@ -5,6 +5,11 @@ export type * from './type'
const BASE_URL = '/system/file' const BASE_URL = '/system/file'
/** @desc 上传文件 */
export function uploadFile(data: FormData) {
return http.post(`${BASE_URL}/upload`, data)
}
/** @desc 查询文件列表 */ /** @desc 查询文件列表 */
export function listFile(query: T.FilePageQuery) { export function listFile(query: T.FilePageQuery) {
return http.get<PageRes<T.FileItem[]>>(`${BASE_URL}`, query) return http.get<PageRes<T.FileItem[]>>(`${BASE_URL}`, query)
@@ -31,6 +36,11 @@ export function checkFile(sha256: string) {
} }
/** @desc 创建文件夹 */ /** @desc 创建文件夹 */
export function createDir(path: string, name: string) { export function createDir(parentPath: string, name: string) {
return http.post<T.FileItem>(`${BASE_URL}/dir`, { path, originalName: name }) 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`)
} }

View File

@@ -205,6 +205,7 @@ export interface FileItem {
originalName: string originalName: string
size: number size: number
url: string url: string
parentPath: string
path: string path: string
sha256: string sha256: string
contentType: string contentType: string
@@ -230,10 +231,14 @@ export interface FileStatisticsResp {
unit: string unit: string
data: Array<FileStatisticsResp> data: Array<FileStatisticsResp>
} }
/** 文件夹计算大小信息 */
export interface FileDirCalcSizeResp {
size: number
}
export interface FileQuery { export interface FileQuery {
originalName?: string originalName?: string
type?: string type?: string
path?: string parentPath?: string
sort: Array<string> sort: Array<string>
} }
export interface FilePageQuery extends FileQuery, PageQuery { export interface FilePageQuery extends FileQuery, PageQuery {

View File

@@ -7,13 +7,27 @@
<a-row style="margin-top: 15px"> <a-row style="margin-top: 15px">
<a-descriptions :column="1" layout="inline-vertical"> <a-descriptions :column="1" layout="inline-vertical">
<a-descriptions-item label="名称"> <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> <template #copy-tooltip>复制链接</template>
{{ data.originalName }} {{ data.originalName }}
</a-typography-paragraph> </a-typography-paragraph>
</a-descriptions-item> </a-descriptions-item>
<a-descriptions-item label="大小">{{ formatFileSize(data.size) }}</a-descriptions-item> <a-descriptions-item label="大小">
<a-descriptions-item label="路径">{{ `${data.path === '/' ? '' : data.path}/${data.name}` }}</a-descriptions-item> <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-descriptions-item v-if="data.sha256" label="SHA256">
<a-typography-paragraph copyable :copy-text="data.sha256"> <a-typography-paragraph copyable :copy-text="data.sha256">
<template #copy-tooltip>复制</template> <template #copy-tooltip>复制</template>
@@ -28,15 +42,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Message } from '@arco-design/web-vue'
import FileImage from '../../main/FileMain/FileImage.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' import { formatFileSize } from '@/utils'
interface Props { interface Props {
data: FileItem 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> </script>
<style lang="less" scoped> <style lang="less" scoped>

View File

@@ -10,7 +10,6 @@
:selected-keys="selectedFileIds" :selected-keys="selectedFileIds"
column-resizable column-resizable
@select="select" @select="select"
@row-click="handleRowClick"
> >
<template #columns> <template #columns>
<a-table-column title="名称"> <a-table-column title="名称">
@@ -24,23 +23,31 @@
update-at-scroll update-at-scroll
scroll-to-close scroll-to-close
> >
<section class="file-name"> <section class="file-name" @click="handleClick(record)" @dblclick="handleDblclickFile(record)">
<div class="file-image"> <div class="file-image">
<FileImage :data="record"></FileImage> <FileImage :data="record"></FileImage>
</div> </div>
<a-typography-paragraph copyable :copy-text="record.url"> <a-typography-paragraph :copyable="record.type !== 0" :copy-text="record.url">
<template #copy-tooltip>复制链接</template> <template #copy-tooltip>复制链接</template>
{{ record.originalName }} {{ record.originalName }}
</a-typography-paragraph> </a-typography-paragraph>
</section> </section>
<template #content> <template #content>
<FileRightMenu :data="record" @click="handleRightMenuClick($event, record)" @dblclick="handleDblclickFile(record)"></FileRightMenu> <FileRightMenu :data="record" @click="handleRightMenuClick($event, record)"></FileRightMenu>
</template> </template>
</a-trigger> </a-trigger>
</template> </template>
</a-table-column> </a-table-column>
<a-table-column title="大小" data-index="size" :width="150"> <a-table-column title="大小" data-index="size" :width="160">
<template #cell="{ record }">{{ formatFileSize(record.size) }}</template> <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>
<a-table-column title="存储名称" data-index="storageName" :width="200" /> <a-table-column title="存储名称" data-index="storageName" :width="200" />
<a-table-column title="修改时间" data-index="updateTime" :width="200" /> <a-table-column title="修改时间" data-index="updateTime" :width="200" />
@@ -64,9 +71,9 @@
</template> </template>
<script setup lang="ts"> <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 FileRightMenu from './FileRightMenu.vue'
import type { FileItem } from '@/apis/system' import { type FileItem, calcDirSize } from '@/apis/system'
import { formatFileSize } from '@/utils' import { formatFileSize } from '@/utils'
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -95,13 +102,24 @@ const rowSelection: TableRowSelection = reactive({
showCheckedAll: true, 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) => { const select: TableInstance['onSelect'] = (rowKeys, rowKey, record) => {
emit('select', record as unknown as FileItem) emit('select', record as unknown as FileItem)
} }
// 行点击事件 // 击事件
const handleRowClick: TableInstance['onRowClick'] = (record) => { const handleClick = (record) => {
emit('click', record as unknown as FileItem) emit('click', record as unknown as FileItem)
} }

View File

@@ -1,9 +1,9 @@
<template> <template>
<GiOption :class="{ shadow: props.shadow }"> <GiOption :class="{ shadow: props.shadow }">
<GiOptionItem label="重命名" @click="onClickItem('rename')"> </GiOptionItem> <GiOptionItem v-permission="['system:file:rename']" label="重命名" @click="onClickItem('rename')"> </GiOptionItem>
<GiOptionItem label="详情" @click="onClickItem('detail')"> </GiOptionItem> <GiOptionItem v-permission="['system:file:detail']" label="详情" @click="onClickItem('detail')"> </GiOptionItem>
<GiOptionItem label="下载" @click="onClickItem('download')"></GiOptionItem> <GiOptionItem v-permission="['system:file:download']" label="下载" @click="onClickItem('download')"></GiOptionItem>
<GiOptionItem label="删除" @click="onClickItem('delete')"> </GiOptionItem> <GiOptionItem v-permission="['system:file:delete']" label="删除" @click="onClickItem('delete')"> </GiOptionItem>
</GiOption> </GiOption>
</template> </template>

View File

@@ -1,9 +1,18 @@
<template> <template>
<div class="file-main"> <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-row justify="space-between" class="file-main__search">
<!-- 左侧区域 --> <!-- 左侧区域 -->
<a-space wrap> <a-space wrap>
<a-dropdown> <a-dropdown v-permission="['system:file:upload']">
<a-upload :show-file-list="false" :custom-request="handleUpload"> <a-upload :show-file-list="false" :custom-request="handleUpload">
<template #upload-button> <template #upload-button>
<a-button type="primary" shape="round"> <a-button type="primary" shape="round">
@@ -17,8 +26,7 @@
</a-dropdown> </a-dropdown>
<a-input-group> <a-input-group>
<a-select v-model="queryType" placeholder="请选择" :options="queryTypeOption" :style="{ width: '100px' }" @change="reset" /> <a-input v-model="queryForm.originalName" :placeholder="queryForm.type && queryForm.type !== '0' ? '请输入名称' : '在当前目录下搜索名称'" allow-clear style="width: 200px" />
<a-input v-model="queryForm[queryType]" placeholder="请输入" allow-clear style="width: 200px" />
<a-button type="primary" @click="search"> <a-button type="primary" @click="search">
<template #icon> <template #icon>
<icon-search /> <icon-search />
@@ -38,13 +46,13 @@
<icon-delete /> <icon-delete />
</template> </template>
</a-button> </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> <template #icon>
<icon-folder /> <icon-folder />
</template> </template>
<template #default>新建文件夹</template> <template #default>新建文件夹</template>
</a-button> </a-button>
<a-button type="primary" @click="isBatchMode = !isBatchMode"> <a-button v-permission="['system:file:delete']" type="primary" @click="isBatchMode = !isBatchMode">
<template #icon> <template #icon>
<icon-select-all /> <icon-select-all />
</template> </template>
@@ -104,7 +112,7 @@ import {
import FileGrid from './FileGrid.vue' import FileGrid from './FileGrid.vue'
import useFileManage from './useFileManage' import useFileManage from './useFileManage'
import { useTable } from '@/hooks' 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 { ImageTypes, OfficeTypes } from '@/constant/file'
import 'viewerjs/dist/viewer.css' import 'viewerjs/dist/viewer.css'
import { downloadByUrl } from '@/utils/downloadFile' import { downloadByUrl } from '@/utils/downloadFile'
@@ -117,32 +125,13 @@ const FileList = defineAsyncComponent(() => import('./FileList.vue'))
const route = useRoute() const route = useRoute()
const { mode, selectedFileIds, toggleMode, addSelectedFileItem } = useFileManage() 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>({ const queryForm = reactive<FileQuery>({
originalName: undefined, 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, type: route.query.type?.toString() && route.query.type?.toString() !== '0' ? route.query.type?.toString() : undefined,
sort: ['type,asc', 'updateTime,desc'], sort: ['type,asc', 'updateTime,desc'],
}) })
const reset = () => {
queryForm.originalName = undefined
queryForm.path = undefined
}
const paginationOption = reactive({ const paginationOption = reactive({
defaultPageSize: 30, defaultPageSize: 30,
defaultSizeOptions: [30, 40, 50, 100, 120], defaultSizeOptions: [30, 40, 50, 100, 120],
@@ -201,7 +190,7 @@ const handleClickFile = (item: FileItem) => {
// 双击文件 // 双击文件
const handleDblclickFile = (item: FileItem) => { const handleDblclickFile = (item: FileItem) => {
if (item.type === 0) { if (item.type === 0) {
queryForm.path = `${item.path === '/' ? '' : item.path}/${item.name}` queryForm.parentPath = `${item.parentPath === '/' ? '' : item.parentPath}/${item.name}`
search() search()
} }
} }
@@ -269,7 +258,7 @@ const handleUpload = (options: RequestOption) => {
const { onProgress, onError, onSuccess, fileItem, name = 'file' } = options const { onProgress, onError, onSuccess, fileItem, name = 'file' } = options
onProgress(20) onProgress(20)
const formData = new FormData() const formData = new FormData()
formData.append('path', queryForm.path ?? '/') formData.append('parentPath', queryForm.parentPath ?? '/')
formData.append(name as string, fileItem.file as Blob) formData.append(name as string, fileItem.file as Blob)
try { try {
const res = await uploadFile(formData) const res = await uploadFile(formData)
@@ -293,15 +282,19 @@ onBeforeRouteUpdate((to) => {
if (!to.query.type) return if (!to.query.type) return
if (to.query.type === '0' || !to.query.type) { if (to.query.type === '0' || !to.query.type) {
queryForm.type = undefined queryForm.type = undefined
queryForm.path = '/' queryForm.parentPath = '/'
} else { } else {
queryForm.type = to.query.type?.toString() queryForm.type = to.query.type?.toString()
queryForm.path = undefined queryForm.parentPath = undefined
} }
search() search()
}) })
// 新建文件夹弹窗显示
const createDirModalVisible = ref<boolean>(false)
// 新文件名称
const newDirName = ref()
// 新建文件夹弹窗窗口取消事件 // 新建文件夹弹窗窗口取消事件
const handleCancel = () => { const handleCancel = () => {
newDirName.value = undefined newDirName.value = undefined
@@ -310,12 +303,28 @@ const handleCancel = () => {
// 新建文件夹弹窗窗口确认事件 // 新建文件夹弹窗窗口确认事件
const handleCreateDir = async () => { const handleCreateDir = async () => {
await createDir(queryForm.path ?? '/', newDirName.value) await createDir(queryForm.parentPath ?? '/', newDirName.value)
newDirName.value = undefined newDirName.value = undefined
createDirModalVisible.value = false createDirModalVisible.value = false
search() 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(() => { onMounted(() => {
search() search()
}) })
@@ -331,10 +340,30 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
&__search { &__search {
border-bottom: 1px dashed var(--color-border-3);
margin: 16px $padding 0; 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 { &__list {
flex: 1; flex: 1;
padding: 0 $padding $padding; padding: 0 $padding $padding;