feat: 添加资源管理功能和相关接口支持

- 在组件声明中添加了 `WdImgCropper` 组件。
- 更新了环境配置,增加了资源管理根路径。
- 修改了资源 API,支持新的响应格式和查询参数。
- 优化了文件管理页面,增强了搜索和文件预览功能。
This commit is contained in:
Guo Tiantian
2025-09-08 23:36:19 +08:00
parent c15b37eeed
commit 8bb39f4848
10 changed files with 599 additions and 231 deletions
+55 -27
View File
@@ -6,7 +6,7 @@ export const ResourceAPI = {
* @param query 查询参数
*/
getResourceList(query: ResourceListQuery) {
return request<ApiResponse<ResourceItem[]>>({
return request<ApiResponse<ResourceListResponse>>({
url: `/resource/resource/list`,
method: "get",
params: query,
@@ -18,7 +18,7 @@ export const ResourceAPI = {
* @param body 搜索条件
*/
searchResource(body: ResourceSearchQuery) {
return request<ApiResponse<ResourceItem[]>>({
return request<ApiResponse<ResourceListResponse>>({
url: `/resource/resource/search`,
method: "post",
data: body,
@@ -149,6 +149,24 @@ export interface ResourceListQuery {
include_hidden?: boolean;
}
/**
* 资源列表响应
*/
export interface ResourceListResponse {
/** 当前路径 */
path: string;
/** 目录名称 */
name: string;
/** 文件/目录列表 */
items: ResourceItem[];
/** 总文件数 */
total_files: number;
/** 总目录数 */
total_dirs: number;
/** 总大小 */
total_size: number;
}
/**
* 资源搜索查询参数
*/
@@ -183,22 +201,34 @@ export interface ResourceItem {
name: string;
/** 完整路径 */
path: string;
/** 相对路径 */
relative_path?: string;
/** 是否为文件 */
is_file?: boolean;
/** 是否为目录 */
is_directory: boolean;
is_dir?: boolean;
/** 是否为目录(兼容字段) */
is_directory?: boolean;
/** 文件大小(字节) */
size?: number;
/** 文件扩展名 */
extension?: string;
/** 修改时间 */
modified_time: string;
/** 创建时间 */
created_time: string;
/** 是否为隐藏文件 */
is_hidden: boolean;
size?: number | null;
/** 文件类型 */
file_type?: string;
file_type?: string | null;
/** 文件扩展名 */
file_extension?: string | null;
/** 资源类型 */
resource_type?: string;
/** 创建时间 */
created_time: string;
/** 修改时间 */
modified_time: string;
/** 访问时间 */
accessed_time?: string;
/** 父路径 */
parent_path?: string;
/** 深度 */
depth?: number;
/** 是否为隐藏文件 */
is_hidden?: boolean;
/** 文件URL(如果是图片等可预览文件) */
file_url?: string;
/** 缩略图URL */
@@ -253,24 +283,22 @@ export interface ResourceCreateDirQuery {
* 资源统计信息
*/
export interface ResourceStats {
/** 挂载点 */
mount_point: string;
/** 总文件数 */
total_files: number;
/** 总目录数 */
total_directories: number;
total_dirs: number;
/** 总大小(字节) */
total_size: number;
/** 可用空间(字节) */
free_space: number;
/** 已使用空间(字节) */
used_space: number;
/** 总空间(字节) */
total_space: number;
/** 按文件类型统计 */
file_type_stats: Array<{
file_type: string;
count: number;
size: number;
}>;
/** 按资源类型统计 */
resource_type_stats: Array<{
resource_type: string;
count: number;
size: number;
}>;
/** 最近修改的文件 */
recent_files: ResourceItem[];
type_stats: Record<string, number>;
/** 按文件扩展名统计 */
extension_stats: Record<string, number>;
}
+3
View File
@@ -1,4 +1,7 @@
export const ROLE_ROOT = "admin";
// 资源管理根路径
export const RESOURCE_ROOT_PATH = import.meta.env.VITE_RESOURCE_ROOT_PATH || "/home/static";
// 🔗 导出所有存储键常量
export * from "./storage-keys";
+14 -1
View File
@@ -65,7 +65,20 @@ export const constantRoutes: RouteRecordRaw[] = [
meta: { title: "内部应用", icon: "Monitor", hidden: true, keepAlive: false },
component: () => import("@/views/application/myapp/components/InternalApp.vue"),
},
// // 临时构建后面要删除掉
// 资源管理相关页面
{
path: "resource/file",
name: "ResourceFile",
meta: { title: "文件管理", icon: "folder", keepAlive: true },
component: () => import("@/views/resource/file/index.vue"),
},
{
path: "resource/stats",
name: "ResourceStats",
meta: { title: "资源统计", icon: "chart", keepAlive: true },
component: () => import("@/views/resource/stats/index.vue"),
},
// 临时构建后面要删除掉
// {
// path: "form-builder",
// name: "FormBuilder",
+3
View File
@@ -19,6 +19,9 @@ interface ImportMetaEnv {
/** 超时时间 */
VITE_TIMEOUT: number;
/** 资源管理根路径 */
VITE_RESOURCE_ROOT_PATH: string;
}
interface ImportMeta {
+315 -201
View File
@@ -1,49 +1,13 @@
<template>
<div class="resource-management">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-left">
<h2>资源管理</h2>
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="(item, index) in breadcrumbList"
:key="index"
@click="handleBreadcrumbClick(item, index)"
:class="{ 'is-link': index < breadcrumbList.length - 1 }"
>
{{ item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-button type="primary" @click="handleUpload">
<el-icon><Upload /></el-icon>
上传文件
</el-button>
<el-button @click="handleCreateDir">
<el-icon><FolderAdd /></el-icon>
新建文件夹
</el-button>
<el-button @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<!-- 搜索和筛选 -->
<div class="search-section">
<el-form :model="searchForm" inline>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
placeholder="请输入文件名或关键词"
clearable
@keyup.enter="handleSearch"
/>
<div class="app-container">
<!-- 搜索区域 -->
<div class="search-container">
<el-form ref="queryFormRef" :model="queryFormData" :inline="true" label-suffix=":">
<el-form-item prop="keyword" label="关键词">
<el-input v-model="queryFormData.keyword" placeholder="请输入文件名或关键词" clearable />
</el-form-item>
<el-form-item label="文件类型">
<el-select v-model="searchForm.file_type" placeholder="请选择" clearable>
<el-form-item prop="file_type" label="文件类型">
<el-select v-model="queryFormData.file_type" placeholder="请选择" style="width: 167.5px" clearable>
<el-option label="图片" value="image" />
<el-option label="文档" value="document" />
<el-option label="视频" value="video" />
@@ -51,51 +15,54 @@
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="文件大小">
<el-form-item prop="min_size" label="文件大小">
<el-input-number
v-model="searchForm.min_size"
v-model="queryFormData.min_size"
placeholder="最小"
:min="0"
controls-position="right"
style="width: 120px"
/>
<span style="margin: 0 8px">-</span>
<el-input-number
v-model="searchForm.max_size"
v-model="queryFormData.max_size"
placeholder="最大"
:min="0"
controls-position="right"
style="width: 120px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleResetSearch">
<el-icon><RefreshLeft /></el-icon>
重置
</el-button>
<el-form-item class="search-buttons">
<el-button type="primary" icon="search" @click="handleQuery">查询</el-button>
<el-button icon="refresh" @click="handleResetQuery">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-checkbox
v-model="showHiddenFiles"
@change="handleShowHiddenChange"
>
<!-- 内容区域 -->
<el-card shadow="hover" class="data-table">
<template #header>
<div class="card-header">
<span>
<el-tooltip content="资源文件管理系统">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
文件列表
</span>
</div>
</template>
<!-- 功能区域 -->
<div class="data-table__toolbar">
<div class="data-table__toolbar--actions">
<el-button type="success" icon="plus" @click="handleUpload">上传文件</el-button>
<el-button type="primary" icon="folder-add" @click="handleCreateDir">新建文件夹</el-button>
<el-button type="danger" icon="delete" :disabled="selectedItems.length === 0" @click="handleBatchDelete">批量删除</el-button>
</div>
<div class="data-table__toolbar--tools">
<el-checkbox v-model="showHiddenFiles" @change="handleShowHiddenChange">
显示隐藏文件
</el-checkbox>
<el-checkbox
v-model="recursiveMode"
@change="handleRecursiveChange"
>
递归显示
</el-checkbox>
</div>
<div class="toolbar-right">
<el-button-group>
<el-button
:type="viewMode === 'list' ? 'primary' : ''"
@@ -110,21 +77,54 @@
<el-icon><Grid /></el-icon>
</el-button>
</el-button-group>
<el-tooltip content="刷新">
<el-button type="primary" icon="refresh" circle @click="handleRefresh" />
</el-tooltip>
</div>
</div>
<!-- 资源路径 -->
<div class="breadcrumb-section">
<div class="breadcrumb-container">
<div class="breadcrumb-header">
<el-tooltip content="点击路径可以快速返回上级目录">
<el-icon class="breadcrumb-icon"><QuestionFilled /></el-icon>
</el-tooltip>
<span class="breadcrumb-label">当前路径</span>
</div>
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="(item, index) in breadcrumbList"
:key="index"
@click="handleBreadcrumbClick(item, index)"
:class="{ 'is-link': index < breadcrumbList.length - 1 }"
>
{{ item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
<!-- 文件列 -->
<div class="file-list">
<!-- 格区域 -->
<el-table
v-if="viewMode === 'list'"
:data="fileList"
ref="dataTableRef"
v-loading="loading"
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDoubleClick"
:data="fileList"
row-key="path"
class="data-table__content"
height="540"
border
stripe
@selection-change="handleSelectionChange"
@row-click="handleRowClick"
>
<el-table-column type="selection" width="55" />
<el-table-column label="名称" min-width="200">
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column type="index" fixed label="序号" min-width="60" />
<el-table-column label="名称" prop="name" min-width="200">
<template #default="{ row }">
<div class="file-name">
<el-icon class="file-icon">
@@ -135,41 +135,36 @@
</div>
</template>
</el-table-column>
<el-table-column label="大小" width="120">
<el-table-column label="大小" prop="size" min-width="120" align="center">
<template #default="{ row }">
<span v-if="!row.is_directory">{{ formatFileSize(row.size) }}</span>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<el-table-column label="类型" prop="file_type" min-width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.file_type" size="small">{{ row.file_type }}</el-tag>
</template>
</el-table-column>
<el-table-column label="修改时间" width="180">
<template #default="{ row }">
{{ formatDate(row.modified_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="修改时间" prop="modified_time" min-width="180" sortable />
<el-table-column fixed="right" label="操作" align="center" min-width="200">
<template #default="{ row }">
<el-button
v-if="!row.is_directory"
type="primary"
type="success"
size="small"
link
icon="download"
@click="handleDownload(row)"
>
下载
</el-button>
<el-button type="primary" link @click="handleRename(row)">
<el-button type="primary" size="small" link icon="edit" @click="handleRename(row)">
重命名
</el-button>
<el-button type="primary" link @click="handleMove(row)">
移动
</el-button>
<el-button type="primary" link @click="handleCopy(row)">
<el-button type="info" size="small" link icon="copy-document" @click="handleCopy(row)">
复制
</el-button>
<el-button type="danger" link @click="handleDelete(row)">
<el-button type="danger" size="small" link icon="delete" @click="handleDelete(row)">
删除
</el-button>
</template>
@@ -182,7 +177,7 @@
v-for="item in fileList"
:key="item.path"
class="grid-item"
@dblclick="handleItemDoubleClick(item)"
@click="handleItemClick(item)"
>
<div class="item-icon">
<el-icon v-if="item.is_directory" size="48">
@@ -195,23 +190,20 @@
<div class="item-name">{{ item.name }}</div>
<div class="item-size" v-if="!item.is_directory">
{{ formatFileSize(item.size) }}
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page_no"
v-model:page-size="pagination.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 分页区域 -->
<template #footer>
<pagination
v-model:total="total"
v-model:page="pagination.page_no"
v-model:limit="pagination.page_size"
@pagination="handlePagination"
/>
</template>
</el-card>
<!-- 上传对话框 -->
<el-dialog
@@ -303,18 +295,20 @@ import {
Grid,
Folder,
Document,
UploadFilled
UploadFilled,
QuestionFilled
} from '@element-plus/icons-vue'
import { ResourceAPI, type ResourceItem, type ResourceListQuery, type ResourceSearchQuery } from '@/api/resource/resource'
import Pagination from '@/components/Pagination/index.vue'
import { ResourceAPI, type ResourceItem, type ResourceListQuery, type ResourceSearchQuery, type ResourceListResponse } from '@/api/resource/resource'
import { RESOURCE_ROOT_PATH } from '@/constants'
// 响应式数据
const loading = ref(false)
const fileList = ref<ResourceItem[]>([])
const selectedItems = ref<ResourceItem[]>([])
const currentPath = ref('/')
const breadcrumbList = ref([{ name: '根目录', path: '/' }])
const currentPath = ref(RESOURCE_ROOT_PATH)
const breadcrumbList = ref([{ name: '资源根目录', path: RESOURCE_ROOT_PATH }])
const showHiddenFiles = ref(false)
const recursiveMode = ref(false)
const viewMode = ref<'list' | 'grid'>('list')
const total = ref(0)
@@ -325,7 +319,7 @@ const pagination = reactive({
})
// 搜索表单
const searchForm = reactive<ResourceSearchQuery>({
const queryFormData = reactive<ResourceSearchQuery>({
keyword: '',
file_type: '',
min_size: undefined,
@@ -352,10 +346,17 @@ const renameForm = reactive({
old_path: ''
})
// 工具函数:确保路径基于根路径
const ensureRootPath = (path: string) => {
if (path.startsWith(RESOURCE_ROOT_PATH)) {
return path
}
return RESOURCE_ROOT_PATH + '/' + path.replace(/^\/+/, '')
}
// 计算属性
const currentQuery = computed(() => ({
path: currentPath.value,
recursive: recursiveMode.value,
include_hidden: showHiddenFiles.value
}))
@@ -364,11 +365,36 @@ const loadFileList = async () => {
try {
loading.value = true
const response = await ResourceAPI.getResourceList(currentQuery.value)
fileList.value = response.data.data || []
// 根据实际 API 响应结构获取数据
const data = response.data?.data?.items || response.data?.data
if (Array.isArray(data)) {
// 转换数据格式以匹配前端期望的结构
fileList.value = data.map(item => ({
name: item.name,
path: item.path,
is_directory: item.is_dir || item.is_directory,
size: item.size,
file_type: item.file_type,
extension: item.file_extension,
modified_time: item.modified_time,
created_time: item.created_time,
is_hidden: item.name.startsWith('.'),
resource_type: item.resource_type,
file_url: item.file_url,
thumbnail_url: item.thumbnail_url
}))
total.value = fileList.value.length
} else {
fileList.value = []
total.value = 0
}
} catch (error) {
ElMessage.error('加载文件列表失败')
console.error('Load file list error:', error)
fileList.value = []
total.value = 0
} finally {
loading.value = false
}
@@ -376,36 +402,69 @@ const loadFileList = async () => {
const handleBreadcrumbClick = (item: any, index: number) => {
if (index < breadcrumbList.value.length - 1) {
currentPath.value = item.path
currentPath.value = ensureRootPath(item.path)
updateBreadcrumb()
loadFileList()
}
}
const updateBreadcrumb = () => {
const pathParts = currentPath.value.split('/').filter(Boolean)
const rootPath = RESOURCE_ROOT_PATH
const relativePath = currentPath.value.replace(rootPath, '').replace(/^\/+/, '')
const pathParts = relativePath ? relativePath.split('/').filter(Boolean) : []
breadcrumbList.value = [
{ name: '根目录', path: '/' },
{ name: '资源根目录', path: rootPath },
...pathParts.map((part, index) => ({
name: part,
path: '/' + pathParts.slice(0, index + 1).join('/')
path: rootPath + '/' + pathParts.slice(0, index + 1).join('/')
}))
]
}
const handleRowDoubleClick = (row: ResourceItem) => {
const handleRowClick = (row: ResourceItem) => {
if (row.is_directory) {
currentPath.value = row.path
currentPath.value = ensureRootPath(row.path)
updateBreadcrumb()
loadFileList()
} else {
// 文件预览
handleFilePreview(row)
}
}
const handleItemDoubleClick = (item: ResourceItem) => {
const handleItemClick = (item: ResourceItem) => {
if (item.is_directory) {
currentPath.value = item.path
currentPath.value = ensureRootPath(item.path)
updateBreadcrumb()
loadFileList()
} else {
// 文件预览
handleFilePreview(item)
}
}
// 文件预览
const handleFilePreview = (file: ResourceItem) => {
// 构建文件预览URL,移除/home前缀
const relativePath = file.path.replace('/home/static', '')
const previewUrl = `https://service.fastapiadmin.com/api/v1/static${relativePath}`
// 根据文件类型决定预览方式
const fileExtension = file.file_extension?.toLowerCase() || ''
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'].includes(fileExtension)) {
// 图片预览
window.open(previewUrl, '_blank')
} else if (['.pdf'].includes(fileExtension)) {
// PDF预览
window.open(previewUrl, '_blank')
} else if (['.txt', '.md', '.json', '.xml', '.html', '.css', '.js', '.ts', '.vue'].includes(fileExtension)) {
// 文本文件预览
window.open(previewUrl, '_blank')
} else {
// 其他文件直接下载
window.open(previewUrl, '_blank')
}
}
@@ -434,7 +493,7 @@ const handleUploadConfirm = async () => {
uploadFileList.value.forEach((file: any) => {
formData.append('file', file.raw)
})
formData.append('target_path', currentPath.value)
formData.append('target_path', ensureRootPath(currentPath.value))
await ResourceAPI.uploadFile(formData)
ElMessage.success('上传成功')
@@ -466,7 +525,7 @@ const handleCreateDirConfirm = async () => {
try {
await ResourceAPI.createDirectory({
parent_path: currentPath.value,
parent_path: ensureRootPath(currentPath.value),
dir_name: createDirForm.dir_name.trim()
})
ElMessage.success('创建成功')
@@ -482,22 +541,47 @@ const handleRefresh = () => {
loadFileList()
}
const handleSearch = async () => {
const handleQuery = async () => {
try {
loading.value = true
const response = await ResourceAPI.searchResource(searchForm)
fileList.value = response.data.data || []
const response = await ResourceAPI.searchResource(queryFormData)
// 根据实际 API 响应结构获取数据
const data = response.data?.data?.items || response.data?.data
if (Array.isArray(data)) {
// 转换数据格式以匹配前端期望的结构
fileList.value = data.map(item => ({
name: item.name,
path: item.path,
is_directory: item.is_dir || item.is_directory,
size: item.size,
file_type: item.file_type,
extension: item.file_extension,
modified_time: item.modified_time,
created_time: item.created_time,
is_hidden: item.name.startsWith('.'),
resource_type: item.resource_type,
file_url: item.file_url,
thumbnail_url: item.thumbnail_url
}))
total.value = fileList.value.length
} else {
console.warn('搜索 API 返回的数据不是数组类型:', data)
fileList.value = []
total.value = 0
}
} catch (error) {
ElMessage.error('搜索失败')
console.error('Search error:', error)
fileList.value = []
total.value = 0
} finally {
loading.value = false
}
}
const handleResetSearch = () => {
Object.assign(searchForm, {
const handleResetQuery = () => {
Object.assign(queryFormData, {
keyword: '',
file_type: '',
min_size: undefined,
@@ -510,13 +594,10 @@ const handleShowHiddenChange = () => {
loadFileList()
}
const handleRecursiveChange = () => {
loadFileList()
}
const handleDownload = async (item: ResourceItem) => {
try {
const response = await ResourceAPI.downloadFile(item.path)
const filePath = ensureRootPath(item.path)
const response = await ResourceAPI.downloadFile(filePath)
const blob = response.data
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -533,7 +614,7 @@ const handleDownload = async (item: ResourceItem) => {
}
const handleRename = (item: ResourceItem) => {
renameForm.old_path = item.path
renameForm.old_path = ensureRootPath(item.path)
renameForm.new_name = item.name
renameDialogVisible.value = true
}
@@ -580,7 +661,8 @@ const handleDelete = async (item: ResourceItem) => {
}
)
await ResourceAPI.deleteResource([item.path])
const filePath = ensureRootPath(item.path)
await ResourceAPI.deleteResource([filePath])
ElMessage.success('删除成功')
loadFileList()
} catch (error) {
@@ -601,9 +683,44 @@ const handleCurrentChange = (page: number) => {
loadFileList()
}
const handlePagination = (params: { page: number; limit: number }) => {
pagination.page_no = params.page
pagination.page_size = params.limit
loadFileList()
}
const handleBatchDelete = async () => {
if (selectedItems.value.length === 0) {
ElMessage.warning('请选择要删除的文件')
return
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedItems.value.length} 个文件吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
const paths = selectedItems.value.map(item => ensureRootPath(item.path))
await ResourceAPI.deleteResource(paths)
ElMessage.success('删除成功')
loadFileList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
console.error('Batch delete error:', error)
}
}
}
// 工具函数
const formatFileSize = (size?: number) => {
if (!size) return '-'
const formatFileSize = (size?: number | null) => {
if (!size || size === null) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let unitIndex = 0
let fileSize = size
@@ -627,71 +744,76 @@ onMounted(() => {
</script>
<style lang="scss" scoped>
.resource-management {
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.app-container {
.search-container {
margin-bottom: 16px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
// 使用系统主题颜色
.header-left {
h2 {
margin: 0 0 10px 0;
color: #303133;
.search-buttons {
margin-left: 16px;
}
}
.data-table {
.card-header {
display: flex;
align-items: center;
}
.breadcrumb-section {
margin: 16px 0;
padding: 12px 0;
// 使用系统主题颜色
.breadcrumb-container {
display: flex;
align-items: center;
gap: 12px;
.breadcrumb-header {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
.breadcrumb-icon {
font-size: 14px;
}
.breadcrumb-label {
font-size: 14px;
font-weight: 500;
}
}
}
}
.header-right {
display: flex;
gap: 10px;
}
}
.search-section {
margin-bottom: 20px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.toolbar {
.data-table__toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
.toolbar-left {
.data-table__toolbar--actions {
display: flex;
gap: 8px;
}
.data-table__toolbar--tools {
display: flex;
gap: 20px;
align-items: center;
gap: 16px;
}
}
}
.file-list {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
.data-table__content {
.file-name {
display: flex;
align-items: center;
gap: 8px;
.file-icon {
color: #409eff;
}
}
@@ -706,19 +828,13 @@ onMounted(() => {
flex-direction: column;
align-items: center;
padding: 15px;
border: 1px solid #e4e7ed;
// 使用系统主题颜色
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
}
.item-icon {
margin-bottom: 10px;
color: #409eff;
}
.item-name {
@@ -730,26 +846,24 @@ onMounted(() => {
.item-size {
font-size: 12px;
color: #909399;
}
}
}
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
// 表格行悬停效果
:deep(.el-table__row) {
cursor: pointer;
}
}
:deep(.el-breadcrumb__item) {
&.is-link {
cursor: pointer;
color: #409eff;
color: var(--el-color-primary) !important;
&:hover {
color: #66b1ff;
color: var(--el-color-primary-light-3) !important;
}
}
}
+200
View File
@@ -0,0 +1,200 @@
<template>
<div class="app-container">
<!-- 存储空间信息 -->
<el-row :gutter="16" class="mb-4">
<el-col :span="24">
<el-card :loading="statsLoading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Monitor /></el-icon>
<span class="font-medium">存储空间信息</span>
<el-tooltip content="存储空间使用详情">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="挂载点">{{ stats?.mount_point || '-' }}</el-descriptions-item>
<el-descriptions-item label="总空间">{{ formatFileSize(stats?.total_space) }}</el-descriptions-item>
<el-descriptions-item label="已使用">{{ formatFileSize(stats?.used_space) }}</el-descriptions-item>
<el-descriptions-item label="可用空间">{{ formatFileSize(stats?.free_space) }}</el-descriptions-item>
</el-descriptions>
<div class="mt-4">
<div class="mb-2">
<span>使用率</span>
</div>
<el-progress
:percentage="Math.round(((stats?.used_space || 0) / (stats?.total_space || 1)) * 100)"
:status="getStorageStatus(Math.round(((stats?.used_space || 0) / (stats?.total_space || 1)) * 100))"
:text-inside="true"
:stroke-width="16"
/>
</div>
</el-card>
</el-col>
</el-row>
<!-- 基础统计信息表格 -->
<el-row :gutter="16" class="mb-4">
<el-col :span="24">
<el-card :loading="statsLoading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><DataAnalysis /></el-icon>
<span class="font-medium">基础统计信息</span>
<el-tooltip content="文件系统基础统计信息">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-table :data="getBasicStatsData()" border>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column label="文件总数" prop="total_files" align="center" />
<el-table-column label="文件夹数" prop="total_dirs" align="center" />
<el-table-column label="总大小" prop="total_size" align="center" />
<el-table-column label="已使用空间" prop="used_space" align="center" />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 文件类型和扩展名统计 -->
<el-row :gutter="16" class="mb-4">
<!-- 文件类型统计 -->
<el-col :span="12">
<el-card :loading="statsLoading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><DataAnalysis /></el-icon>
<span class="font-medium">文件类型统计</span>
<el-tooltip content="按文件类型统计文件数量">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-table :data="getTypeStatsData()" border>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column label="文件类型" prop="type" />
<el-table-column label="数量" prop="count" align="center" width="100" />
</el-table>
</el-card>
</el-col>
<!-- 文件扩展名统计 -->
<el-col :span="12">
<el-card :loading="statsLoading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><PieChart /></el-icon>
<span class="font-medium">文件扩展名统计</span>
<el-tooltip content="按文件扩展名统计文件数量">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-table :data="getExtensionStatsData()" border>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column label="扩展名" prop="extension" />
<el-table-column label="数量" prop="count" align="center" width="100" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
Document,
Folder,
DataAnalysis,
PieChart,
Monitor,
QuestionFilled
} from '@element-plus/icons-vue'
import { ResourceAPI, type ResourceStats } from '@/api/resource/resource'
// 响应式数据
const stats = ref<ResourceStats | null>(null)
const statsLoading = ref(false)
// 方法
const loadStats = async () => {
try {
statsLoading.value = true
const response = await ResourceAPI.getResourceStats()
stats.value = response.data.data
} catch (error) {
ElMessage.error('获取统计信息失败')
console.error('Load stats error:', error)
} finally {
statsLoading.value = false
}
}
// 数据处理函数
const getBasicStatsData = () => {
if (!stats.value) return []
return [
{
total_files: stats.value.total_files || 0,
total_dirs: stats.value.total_dirs || 0,
total_size: formatFileSize(stats.value.total_size),
used_space: formatFileSize(stats.value.used_space)
}
]
}
const getTypeStatsData = () => {
if (!stats.value?.type_stats) return []
return Object.entries(stats.value.type_stats).map(([type, count]) => ({
type,
count
}))
}
const getExtensionStatsData = () => {
if (!stats.value?.extension_stats) return []
return Object.entries(stats.value.extension_stats).map(([extension, count]) => ({
extension,
count
}))
}
// 工具函数
const formatFileSize = (size?: number | null) => {
if (!size || size === null) return '-'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let unitIndex = 0
let fileSize = size
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024
unitIndex++
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`
}
// 存储空间使用率状态计算
const getStorageStatus = (percentage: number) => {
if (percentage > 80) return 'exception'
if (percentage > 60) return 'warning'
return 'success'
}
// 生命周期
onMounted(() => {
loadStats()
})
</script>
<style lang="scss" scoped></style>