Files
wwjcloud/admin/apps/web-ele/src/views/common/file/index.vue

1248 lines
33 KiB
Vue
Raw Normal View History

<template>
<Page
description="管理系统文件上传、存储和分类"
title="文件管理"
>
<!-- 搜索表单 -->
<el-card>
<el-form
ref="searchFormRef"
:model="searchForm"
:inline="true"
label-width="80px"
>
<el-form-item label="文件名称" prop="filename">
<el-input
v-model="searchForm.filename"
placeholder="请输入文件名称"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="文件类型" prop="type">
<el-select
v-model="searchForm.type"
placeholder="请选择文件类型"
clearable
style="width: 150px"
>
<el-option label="图片" value="image" />
<el-option label="视频" value="video" />
<el-option label="音频" value="audio" />
<el-option label="文档" value="document" />
<el-option label="压缩包" value="archive" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="存储驱动" prop="driver">
<el-select
v-model="searchForm.driver"
placeholder="请选择存储驱动"
clearable
style="width: 150px"
>
<el-option label="本地存储" value="local" />
<el-option label="阿里云OSS" value="oss" />
<el-option label="腾讯云COS" value="cos" />
<el-option label="七牛云" value="qiniu" />
<el-option label="又拍云" value="upyun" />
</el-select>
</el-form-item>
<el-form-item label="分类" prop="categoryId">
<el-tree-select
v-model="searchForm.categoryId"
:data="categoryTree"
placeholder="请选择分类"
clearable
check-strictly
:render-after-expand="false"
style="width: 200px"
/>
</el-form-item>
<el-form-item label="上传时间" prop="dateRange">
<el-date-picker
v-model="searchForm.dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 240px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<Icon icon="ep:search" class="mr-1" />
搜索
</el-button>
<el-button @click="handleResetSearch">
<Icon icon="ep:refresh" class="mr-1" />
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 操作栏 -->
<div class="action-bar">
<div class="action-left">
<el-button type="primary" @click="handleUpload">
<Icon icon="ep:upload" class="mr-1" />
上传文件
</el-button>
<el-button @click="handleCreateCategory">
<Icon icon="ep:folder-add" class="mr-1" />
新建分类
</el-button>
<el-button
type="danger"
:disabled="!selectedFiles.length"
@click="handleBatchDelete"
>
<Icon icon="ep:delete" class="mr-1" />
批量删除
</el-button>
<el-button
:disabled="!selectedFiles.length"
@click="handleBatchMove"
>
<Icon icon="ep:folder" class="mr-1" />
批量移动
</el-button>
</div>
<div class="action-right">
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="grid">
<Icon icon="ep:grid" />
</el-radio-button>
<el-radio-button label="list">
<Icon icon="ep:list" />
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 文件列表 -->
<div class="file-content">
<el-row :gutter="20">
<!-- 分类树 -->
<el-col :span="6">
<el-card class="category-card">
<template #header>
<div class="card-header">
<span>文件分类</span>
<el-button type="primary" text @click="handleCreateCategory">
<Icon icon="ep:plus" />
</el-button>
</div>
</template>
<el-tree
ref="categoryTreeRef"
:data="categoryTree"
:props="{ label: 'name', children: 'children' }"
node-key="id"
:current-node-key="currentCategoryId"
:expand-on-click-node="false"
@node-click="handleCategoryClick"
>
<template #default="{ node, data }">
<div class="category-node">
<Icon icon="ep:folder" class="mr-1" />
<span>{{ data.name }}</span>
<span class="file-count">({{ data.fileCount || 0 }})</span>
<div class="node-actions">
<el-button
type="primary"
text
size="small"
@click.stop="handleEditCategory(data)"
>
<Icon icon="ep:edit" />
</el-button>
<el-button
type="danger"
text
size="small"
@click.stop="handleDeleteCategory(data)"
>
<Icon icon="ep:delete" />
</el-button>
</div>
</div>
</template>
</el-tree>
</el-card>
</el-col>
<!-- 文件区域 -->
<el-col :span="18">
<el-card class="file-card">
<!-- 网格视图 -->
<div v-if="viewMode === 'grid'" class="file-grid" v-loading="loading">
<div
v-for="file in fileList"
:key="file.id"
class="file-item"
:class="{ selected: selectedFiles.includes(file.id) }"
@click="handleFileSelect(file)"
>
<div class="file-checkbox">
<el-checkbox
:model-value="selectedFiles.includes(file.id)"
@change="handleFileCheck(file.id, $event)"
@click.stop
/>
</div>
<div class="file-preview">
<el-image
v-if="isImage(file.type)"
:src="file.url"
:preview-src-list="[file.url]"
fit="cover"
class="file-image"
/>
<div v-else class="file-icon">
<Icon :icon="getFileIcon(file.type)" size="48" />
</div>
</div>
<div class="file-info">
<div class="file-name" :title="file.originalName">
{{ file.originalName }}
</div>
<div class="file-meta">
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<span class="file-time">{{ formatTime(file.createdAt) }}</span>
</div>
</div>
<div class="file-actions">
<el-dropdown trigger="click" @click.stop>
<el-button type="primary" text>
<Icon icon="ep:more" />
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handlePreview(file)">
<Icon icon="ep:view" class="mr-1" />
预览
</el-dropdown-item>
<el-dropdown-item @click="handleDownload(file)">
<Icon icon="ep:download" class="mr-1" />
下载
</el-dropdown-item>
<el-dropdown-item @click="handleCopyUrl(file)">
<Icon icon="ep:copy-document" class="mr-1" />
复制链接
</el-dropdown-item>
<el-dropdown-item @click="handleEditFile(file)">
<Icon icon="ep:edit" class="mr-1" />
编辑
</el-dropdown-item>
<el-dropdown-item @click="handleMoveFile(file)">
<Icon icon="ep:folder" class="mr-1" />
移动
</el-dropdown-item>
<el-dropdown-item divided @click="handleDeleteFile(file)">
<Icon icon="ep:delete" class="mr-1" />
删除
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="file-list">
<el-table
:data="fileList"
v-loading="loading"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="文件名" min-width="200">
<template #default="{ row }">
<div class="file-name-cell">
<div class="file-icon-small">
<Icon :icon="getFileIcon(row.type)" size="20" />
</div>
<div class="file-info-text">
<div class="file-name">{{ row.originalName }}</div>
<div class="file-path">{{ row.path }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag size="small">{{ getFileTypeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">
{{ formatFileSize(row.size) }}
</template>
</el-table-column>
<el-table-column label="存储驱动" width="100">
<template #default="{ row }">
<el-tag type="info" size="small">{{ row.driver }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上传时间" width="180">
<template #default="{ row }">
{{ formatTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" text @click="handlePreview(row)">
预览
</el-button>
<el-button type="primary" text @click="handleDownload(row)">
下载
</el-button>
<el-button type="primary" text @click="handleEditFile(row)">
编辑
</el-button>
<el-button type="danger" text @click="handleDeleteFile(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[20, 50, 100, 200]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 上传对话框 -->
<el-dialog
v-model="uploadDialogVisible"
title="上传文件"
width="600px"
:close-on-click-modal="false"
>
<el-upload
ref="uploadRef"
:action="uploadAction"
:headers="uploadHeaders"
:data="uploadData"
:multiple="true"
:drag="true"
:auto-upload="false"
:on-change="handleFileChange"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
class="upload-area"
>
<Icon icon="ep:upload-filled" size="48" class="upload-icon" />
<div class="upload-text">
<p>将文件拖到此处<em>点击上传</em></p>
<p class="upload-tip">支持多文件上传单个文件不超过 {{ maxFileSize }}MB</p>
</div>
</el-upload>
<div class="upload-options">
<el-form :model="uploadForm" label-width="80px">
<el-form-item label="上传分类">
<el-tree-select
v-model="uploadForm.categoryId"
:data="categoryTree"
placeholder="请选择分类"
clearable
check-strictly
:render-after-expand="false"
/>
</el-form-item>
<el-form-item label="存储驱动">
<el-select v-model="uploadForm.driver" placeholder="请选择存储驱动">
<el-option label="本地存储" value="local" />
<el-option label="阿里云OSS" value="oss" />
<el-option label="腾讯云COS" value="cos" />
<el-option label="七牛云" value="qiniu" />
</el-select>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleStartUpload" :loading="uploading">
开始上传
</el-button>
</template>
</el-dialog>
<!-- 分类对话框 -->
<el-dialog
v-model="categoryDialogVisible"
:title="categoryForm.id ? '编辑分类' : '新建分类'"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="categoryFormRef"
:model="categoryForm"
:rules="categoryRules"
label-width="80px"
>
<el-form-item label="上级分类" prop="parentId">
<el-tree-select
v-model="categoryForm.parentId"
:data="categoryTree"
placeholder="请选择上级分类(不选则为顶级分类)"
clearable
check-strictly
:render-after-expand="false"
/>
</el-form-item>
<el-form-item label="分类名称" prop="name">
<el-input
v-model="categoryForm.name"
placeholder="请输入分类名称"
clearable
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number
v-model="categoryForm.sort"
:min="0"
:max="9999"
placeholder="排序值"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="categoryForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveCategory" :loading="saveLoading">
确定
</el-button>
</template>
</el-dialog>
<!-- 文件编辑对话框 -->
<el-dialog
v-model="editFileDialogVisible"
title="编辑文件"
width="500px"
:close-on-click-modal="false"
>
<el-form
ref="editFileFormRef"
:model="editFileForm"
:rules="editFileRules"
label-width="80px"
>
<el-form-item label="文件名称" prop="originalName">
<el-input
v-model="editFileForm.originalName"
placeholder="请输入文件名称"
clearable
/>
</el-form-item>
<el-form-item label="所属分类" prop="categoryId">
<el-tree-select
v-model="editFileForm.categoryId"
:data="categoryTree"
placeholder="请选择分类"
clearable
check-strictly
:render-after-expand="false"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="editFileForm.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editFileDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveFile" :loading="saveLoading">
确定
</el-button>
</template>
</el-dialog>
</Page>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type UploadInstance } from 'element-plus';
import { Icon } from '@iconify/vue';
import { Page } from '@vben/common-ui';
import {
getFileListApi,
getFileCategoriesApi,
createFileCategoryApi,
updateFileCategoryApi,
deleteFileCategoryApi,
updateFileApi,
deleteFileApi,
batchDeleteFilesApi,
moveFileApi,
batchMoveFilesApi,
type FileItem,
type FileCategory,
type FileListParams,
type CreateFileCategoryParams,
type UpdateFileCategoryParams,
type UpdateFileParams,
} from '#/api/upload';
import { useUserStore } from '#/store/modules/user';
// 响应式数据
const loading = ref(false);
const saveLoading = ref(false);
const uploading = ref(false);
const viewMode = ref<'grid' | 'list'>('grid');
const currentCategoryId = ref<number | null>(null);
const selectedFiles = ref<number[]>([]);
const fileList = ref<FileItem[]>([]);
const categoryTree = ref<FileCategory[]>([]);
const uploadDialogVisible = ref(false);
const categoryDialogVisible = ref(false);
const editFileDialogVisible = ref(false);
const searchFormRef = ref<FormInstance>();
const categoryFormRef = ref<FormInstance>();
const editFileFormRef = ref<FormInstance>();
const uploadRef = ref<UploadInstance>();
const categoryTreeRef = ref();
const userStore = useUserStore();
// 搜索表单
const searchForm = reactive({
filename: '',
type: '',
driver: '',
categoryId: null as number | null,
dateRange: [] as string[],
});
// 分页
const pagination = reactive({
page: 1,
size: 20,
total: 0,
});
// 上传表单
const uploadForm = reactive({
categoryId: null as number | null,
driver: 'local',
});
// 分类表单
const categoryForm = reactive({
id: null as number | null,
parentId: null as number | null,
name: '',
sort: 0,
remark: '',
});
// 文件编辑表单
const editFileForm = reactive({
id: null as number | null,
originalName: '',
categoryId: null as number | null,
remark: '',
});
// 表单验证规则
const categoryRules = {
name: [
{ required: true, message: '请输入分类名称', trigger: 'blur' },
{ min: 1, max: 50, message: '分类名称长度为 1-50 个字符', trigger: 'blur' },
],
};
const editFileRules = {
originalName: [
{ required: true, message: '请输入文件名称', trigger: 'blur' },
{ min: 1, max: 255, message: '文件名称长度为 1-255 个字符', trigger: 'blur' },
],
};
// 计算属性
const uploadAction = computed(() => '/api/upload/file');
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${userStore.token}`,
}));
const uploadData = computed(() => ({
categoryId: uploadForm.categoryId,
driver: uploadForm.driver,
}));
const maxFileSize = computed(() => 100); // MB
// 方法
const loadFileList = async () => {
loading.value = true;
try {
const params: FileListParams = {
page: pagination.page,
size: pagination.size,
filename: searchForm.filename || undefined,
type: searchForm.type || undefined,
driver: searchForm.driver || undefined,
categoryId: searchForm.categoryId || currentCategoryId.value || undefined,
startDate: searchForm.dateRange[0] || undefined,
endDate: searchForm.dateRange[1] || undefined,
};
const result = await getFileListApi(params);
fileList.value = result.list;
pagination.total = result.total;
} catch (error) {
ElMessage.error('加载文件列表失败');
} finally {
loading.value = false;
}
};
const loadCategories = async () => {
try {
categoryTree.value = await getFileCategoriesApi();
} catch (error) {
ElMessage.error('加载分类列表失败');
}
};
const handleSearch = () => {
pagination.page = 1;
loadFileList();
};
const handleResetSearch = () => {
searchFormRef.value?.resetFields();
pagination.page = 1;
loadFileList();
};
const handleSizeChange = (size: number) => {
pagination.size = size;
pagination.page = 1;
loadFileList();
};
const handleCurrentChange = (page: number) => {
pagination.page = page;
loadFileList();
};
const handleCategoryClick = (data: FileCategory) => {
currentCategoryId.value = data.id;
pagination.page = 1;
loadFileList();
};
const handleFileSelect = (file: FileItem) => {
const index = selectedFiles.value.indexOf(file.id);
if (index > -1) {
selectedFiles.value.splice(index, 1);
} else {
selectedFiles.value.push(file.id);
}
};
const handleFileCheck = (fileId: number, checked: boolean) => {
if (checked) {
if (!selectedFiles.value.includes(fileId)) {
selectedFiles.value.push(fileId);
}
} else {
const index = selectedFiles.value.indexOf(fileId);
if (index > -1) {
selectedFiles.value.splice(index, 1);
}
}
};
const handleSelectionChange = (selection: FileItem[]) => {
selectedFiles.value = selection.map(item => item.id);
};
const handleUpload = () => {
uploadDialogVisible.value = true;
};
const handleFileChange = (file: any, fileList: any[]) => {
// 文件选择变化处理
};
const handleBeforeUpload = (file: File) => {
const isValidSize = file.size / 1024 / 1024 < maxFileSize.value;
if (!isValidSize) {
ElMessage.error(`文件大小不能超过 ${maxFileSize.value}MB`);
return false;
}
return true;
};
const handleStartUpload = () => {
uploadRef.value?.submit();
};
const handleUploadSuccess = (response: any, file: any) => {
ElMessage.success('文件上传成功');
loadFileList();
};
const handleUploadError = (error: any) => {
ElMessage.error('文件上传失败');
};
const handleCreateCategory = () => {
Object.assign(categoryForm, {
id: null,
parentId: currentCategoryId.value,
name: '',
sort: 0,
remark: '',
});
categoryDialogVisible.value = true;
};
const handleEditCategory = (category: FileCategory) => {
Object.assign(categoryForm, {
id: category.id,
parentId: category.parentId,
name: category.name,
sort: category.sort,
remark: category.remark,
});
categoryDialogVisible.value = true;
};
const handleSaveCategory = async () => {
if (!categoryFormRef.value) return;
try {
await categoryFormRef.value.validate();
saveLoading.value = true;
if (categoryForm.id) {
const updateData: UpdateFileCategoryParams = {
name: categoryForm.name,
parentId: categoryForm.parentId,
sort: categoryForm.sort,
remark: categoryForm.remark,
};
await updateFileCategoryApi(categoryForm.id, updateData);
ElMessage.success('分类更新成功');
} else {
const createData: CreateFileCategoryParams = {
name: categoryForm.name,
parentId: categoryForm.parentId,
sort: categoryForm.sort,
remark: categoryForm.remark,
};
await createFileCategoryApi(createData);
ElMessage.success('分类创建成功');
}
categoryDialogVisible.value = false;
await loadCategories();
} catch (error) {
ElMessage.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleDeleteCategory = async (category: FileCategory) => {
try {
await ElMessageBox.confirm(
`确定要删除分类 "${category.name}" 吗?删除后该分类下的文件将移动到未分类。`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
await deleteFileCategoryApi(category.id);
ElMessage.success('分类删除成功');
await loadCategories();
if (currentCategoryId.value === category.id) {
currentCategoryId.value = null;
loadFileList();
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handlePreview = (file: FileItem) => {
if (isImage(file.type)) {
// 图片预览
window.open(file.url, '_blank');
} else {
// 其他文件类型预览或下载
handleDownload(file);
}
};
const handleDownload = (file: FileItem) => {
const link = document.createElement('a');
link.href = file.url;
link.download = file.originalName;
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleCopyUrl = async (file: FileItem) => {
try {
await navigator.clipboard.writeText(file.url);
ElMessage.success('链接已复制到剪贴板');
} catch (error) {
ElMessage.error('复制失败');
}
};
const handleEditFile = (file: FileItem) => {
Object.assign(editFileForm, {
id: file.id,
originalName: file.originalName,
categoryId: file.categoryId,
remark: file.remark,
});
editFileDialogVisible.value = true;
};
const handleSaveFile = async () => {
if (!editFileFormRef.value) return;
try {
await editFileFormRef.value.validate();
saveLoading.value = true;
const updateData: UpdateFileParams = {
originalName: editFileForm.originalName,
categoryId: editFileForm.categoryId,
remark: editFileForm.remark,
};
await updateFileApi(editFileForm.id!, updateData);
ElMessage.success('文件更新成功');
editFileDialogVisible.value = false;
loadFileList();
} catch (error) {
ElMessage.error('保存失败');
} finally {
saveLoading.value = false;
}
};
const handleDeleteFile = async (file: FileItem) => {
try {
await ElMessageBox.confirm(
`确定要删除文件 "${file.originalName}" 吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
await deleteFileApi(file.id);
ElMessage.success('文件删除成功');
loadFileList();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败');
}
}
};
const handleMoveFile = async (file: FileItem) => {
// 实现文件移动逻辑
ElMessage.info('移动功能开发中');
};
const handleBatchDelete = async () => {
if (!selectedFiles.value.length) return;
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedFiles.value.length} 个文件吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
await batchDeleteFilesApi(selectedFiles.value);
ElMessage.success('批量删除成功');
selectedFiles.value = [];
loadFileList();
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('批量删除失败');
}
}
};
const handleBatchMove = async () => {
if (!selectedFiles.value.length) return;
ElMessage.info('批量移动功能开发中');
};
// 工具方法
const isImage = (type: string) => {
return type === 'image' || type.startsWith('image/');
};
const getFileIcon = (type: string) => {
const iconMap: Record<string, string> = {
image: 'ep:picture',
video: 'ep:video-play',
audio: 'ep:headphones',
document: 'ep:document',
archive: 'ep:box',
pdf: 'ep:document',
excel: 'ep:document',
word: 'ep:document',
ppt: 'ep:document',
};
return iconMap[type] || 'ep:document';
};
const getFileTypeLabel = (type: string) => {
const labelMap: Record<string, string> = {
image: '图片',
video: '视频',
audio: '音频',
document: '文档',
archive: '压缩包',
pdf: 'PDF',
excel: 'Excel',
word: 'Word',
ppt: 'PPT',
};
return labelMap[type] || '其他';
};
const formatFileSize = (size: number) => {
if (size < 1024) {
return `${size} B`;
} else if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
} else if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)} MB`;
} else {
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
}
};
const formatTime = (time: string) => {
return new Date(time).toLocaleString('zh-CN');
};
// 生命周期
onMounted(() => {
loadFileList();
loadCategories();
});
</script>
<style scoped>
.file-management-page {
padding: 20px;
}
.search-form {
margin-bottom: 20px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 16px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.action-left {
display: flex;
gap: 12px;
}
.action-right {
display: flex;
align-items: center;
}
.category-card {
height: calc(100vh - 300px);
overflow-y: auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.category-node {
display: flex;
align-items: center;
width: 100%;
padding: 4px 0;
}
.file-count {
margin-left: auto;
color: #999;
font-size: 12px;
}
.node-actions {
margin-left: 8px;
opacity: 0;
transition: opacity 0.2s;
}
.category-node:hover .node-actions {
opacity: 1;
}
.file-card {
height: calc(100vh - 300px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
padding: 16px;
flex: 1;
overflow-y: auto;
}
.file-item {
position: relative;
border: 2px solid transparent;
border-radius: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s;
}
.file-item:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.file-item.selected {
border-color: var(--el-color-primary);
}
.file-checkbox {
position: absolute;
top: 8px;
left: 8px;
z-index: 2;
}
.file-preview {
height: 150px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border-radius: 6px 6px 0 0;
}
.file-image {
width: 100%;
height: 100%;
border-radius: 6px 6px 0 0;
}
.file-icon {
color: #999;
}
.file-info {
padding: 12px;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
}
.file-actions {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.2s;
}
.file-item:hover .file-actions {
opacity: 1;
}
.file-list {
flex: 1;
overflow: hidden;
}
.file-name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.file-icon-small {
flex-shrink: 0;
}
.file-info-text {
flex: 1;
min-width: 0;
}
.file-path {
font-size: 12px;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pagination-container {
display: flex;
justify-content: center;
padding: 20px;
border-top: 1px solid #ebeef5;
}
.upload-area {
margin-bottom: 20px;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
color: #c0c4cc;
margin-bottom: 16px;
}
.upload-text {
text-align: center;
}
.upload-text p {
margin: 0;
color: #606266;
}
.upload-text em {
color: var(--el-color-primary);
font-style: normal;
}
.upload-tip {
font-size: 12px;
color: #999;
margin-top: 8px;
}
.upload-options {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ebeef5;
}
</style>