mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-23 05:10:57 +00:00
feat: 添加资源管理功能和相关接口支持
- 在组件声明中添加了 `WdImgCropper` 组件。 - 更新了环境配置,增加了资源管理根路径。 - 修改了资源 API,支持新的响应格式和查询参数。 - 优化了文件管理页面,增强了搜索和文件预览功能。
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+3
@@ -19,6 +19,9 @@ interface ImportMetaEnv {
|
||||
|
||||
/** 超时时间 */
|
||||
VITE_TIMEOUT: number;
|
||||
|
||||
/** 资源管理根路径 */
|
||||
VITE_RESOURCE_ROOT_PATH: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user