feat(pages): 新增文件管理和图库管理页面

**文件管理页面:**
- 实现完整的文件管理界面,支持文件的增删改查操作
- 左侧文件分类导航,支持按文件类型筛选(全部、图片、文档、视频等)
- 右侧文件列表,支持表格和卡片两种显示模式
- 完整的搜索功能,支持文件名、服务类型、上传日期范围等条件筛选
- 文件上传功能,支持拖拽上传和点击上传两种方式
- 批量操作功能,支持批量删除选中文件
- 文件预览和下载功能,提升用户体验

**图库管理页面:**
- 专门的图片管理界面,针对图片文件进行优化
- 图片网格展示,支持缩略图预览
- 图片上传、编辑、删除等基础管理功能
- 支持图片批量操作和分类管理
- 响应式设计,适配不同屏幕尺寸

**技术特点:**
- 严格遵循项目开发规范,统一使用CoiDialog弹框组件
- 所有按钮配备图标,遵循图标使用规范
- 使用NTag组件显示标签,遵循主题色统一规范
- 完整的表单验证和错误处理机制
- 响应式布局设计,良好的用户交互体验
- TypeScript类型安全,完整的类型定义
This commit is contained in:
Leo 2025-07-08 20:34:03 +08:00
parent 407178771a
commit 5b2c57ca34
2 changed files with 2022 additions and 0 deletions

View File

@ -0,0 +1,938 @@
<template>
<div class="file-management p-1 bg-gray-50 h-screen flex flex-col">
<div class="flex-1 flex gap-2 overflow-hidden">
<!-- 左侧文件分类 -->
<div class="w-48 bg-white rounded-lg shadow-sm border border-gray-100 flex flex-col overflow-hidden">
<div class="px-4 py-3 border-b border-gray-100">
<h3 class="text-sm font-medium text-gray-900">
文件分类
</h3>
</div>
<div class="flex-1 overflow-y-auto">
<div class="p-2 space-y-1">
<div
v-for="category in fileCategories"
:key="category.value"
class="flex items-center px-3 py-2 rounded-md cursor-pointer transition-colors category-item"
:class="selectedCategory === category.value ? 'category-selected' : 'category-unselected'"
@click="handleCategoryChange(category.value)"
>
<component :is="category.icon" class="w-4 h-4 mr-2" />
<span class="text-sm flex-1">{{ category.label }}</span>
<NTag
v-if="selectedCategory === category.value"
type="primary"
size="small"
class="ml-auto"
>
</NTag>
</div>
</div>
</div>
</div>
<!-- 右侧文件列表 -->
<div class="flex-1 bg-white rounded-lg shadow-sm border border-gray-100 flex flex-col overflow-hidden">
<!-- 搜索表单 -->
<div class="px-4 py-2 border-b border-gray-100">
<n-form
ref="searchFormRef"
:model="searchForm"
label-placement="left"
label-width="auto"
class="search-form"
>
<n-grid :cols="5" :x-gap="8" :y-gap="4">
<n-grid-item>
<n-form-item label="文件名称" path="fileName">
<n-input
v-model:value="searchForm.fileName"
placeholder="请输入文件名称"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="文件后缀" path="fileSuffix">
<n-input
v-model:value="searchForm.fileSuffix"
placeholder="请输入文件后缀"
clearable
@keydown.enter="handleSearch"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="存储类型" path="fileService">
<n-select
v-model:value="searchForm.fileService"
placeholder="请选择存储类型"
clearable
:options="FILE_SERVICE_OPTIONS"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="" path="">
<NSpace>
<NButton type="primary" @click="handleSearch">
<template #icon>
<NIcon><icon-park-outline:search /></NIcon>
</template>
搜索
</NButton>
<NButton @click="handleReset">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
重置
</NButton>
</NSpace>
</n-form-item>
</n-grid-item>
</n-grid>
</n-form>
</div>
<!-- 表格头部操作栏 -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-100">
<div class="flex items-center gap-4">
<NButton
v-permission="PERMISSIONS.FILE.UPLOAD"
type="primary"
class="px-3 flex items-center"
@click="handleUpload"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:upload />
</NIcon>
</template>
上传文件
</NButton>
<NButton
v-permission="PERMISSIONS.FILE.DELETE"
type="error"
:disabled="selectedRows.length === 0"
class="px-3 flex items-center"
@click="handleBatchDelete"
>
<template #icon>
<NIcon class="mr-1" style="transform: translateY(-1px)">
<icon-park-outline:delete />
</NIcon>
</template>
批量删除
</NButton>
</div>
<div class="flex items-center gap-4 text-sm text-gray-500">
<span> {{ pagination.itemCount }} </span>
<NButton text @click="handleRefresh">
<template #icon>
<NIcon><icon-park-outline:refresh /></NIcon>
</template>
</NButton>
</div>
</div>
<!-- 表格内容 -->
<div class="table-wrapper flex-1 overflow-auto pt-4">
<!-- 数据表格 -->
<NDataTable
v-if="tableData.length > 0 || loading"
ref="tableRef"
:columns="columns"
:data="tableData"
:loading="loading"
:row-key="(row: SysFileVo) => row.fileId"
:checked-row-keys="selectedRowKeys"
:on-update:checked-row-keys="handleRowKeysUpdate"
:bordered="false"
:single-line="false"
:scroll-x="1600"
size="small"
class="custom-table"
/>
<!-- 空状态 -->
<CoiEmpty
v-else
type="empty"
title="暂无文件数据"
description="当前没有找到任何文件记录"
size="medium"
/>
</div>
<!-- 分页器 -->
<div v-if="tableData.length > 0" class="flex items-center px-4 py-2 border-t border-gray-100">
<div class="text-sm text-gray-500 mr-4">
{{ pagination.itemCount }}
</div>
<n-pagination
v-model:page="pagination.page"
v-model:page-size="pagination.pageSize"
:item-count="pagination.itemCount"
:show-size-picker="true"
:page-sizes="[10, 20, 50, 100]"
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</div>
<!-- 文件上传弹框 -->
<CoiDialog
ref="uploadDialogRef"
title="上传操作 ☀️"
:width="600"
height="auto"
confirm-text="确定"
cancel-text="取消"
@coi-confirm="handleUploadConfirm"
@coi-cancel="handleUploadCancel"
>
<template #content>
<div class="px-3 py-2">
<n-form
ref="uploadFormRef"
:model="uploadForm"
:rules="uploadRules"
label-placement="left"
label-width="100px"
require-mark-placement="right-hanging"
>
<!-- 文件服务选择 -->
<n-form-item label="文件服务" path="fileService">
<n-select
v-model:value="uploadForm.fileService"
placeholder="请选择文件服务"
:options="[
{ label: 'LOCAL', value: 'LOCAL' },
{ label: 'MINIO', value: 'MINIO' },
{ label: 'OSS', value: 'OSS' },
]"
/>
</n-form-item>
<!-- 文件回显路径 -->
<n-form-item label="文件回显路径" path="filePath">
<n-input
v-model:value="uploadForm.filePath"
placeholder="系统自动生成"
readonly
disabled
/>
</n-form-item>
<!-- 文件上传区域 -->
<n-form-item label="上传文件" path="files">
<div class="w-full">
<NUpload
ref="uploadRef"
v-model:file-list="uploadFileList"
:max="5"
multiple
directory-dnd
:before-upload="beforeUpload"
:custom-request="customUpload"
:on-remove="handleRemoveFile"
:on-error="handleUploadError"
@change="handleFileChange"
>
<NUploadDragger>
<div class="text-center">
<div class="mb-3">
<NIcon size="48" class="text-blue-500">
<icon-park-outline:upload />
</NIcon>
</div>
<NText class="text-base">
点击或者拖动文件到该区域来上传
</NText>
<NP depth="3" class="mt-2">
支持图片[jpg, jpeg, png, gif, bmp, webp, svg] 文档[doc/docx/pdf/txt/xls/xlsx/ppt/pptx/md/zip/rar/7z]
</NP>
<NP depth="3">
文件大小不能超过2MB最多上传5个
</NP>
</div>
</NUploadDragger>
</NUpload>
</div>
</n-form-item>
</n-form>
</div>
</template>
</CoiDialog>
<!-- 图片预览弹框 -->
<n-modal
v-model:show="imagePreviewVisible"
preset="card"
title="图片预览"
:style="{ width: '80%', maxWidth: '800px' }"
:mask-closable="true"
>
<div class="text-center">
<img
v-if="previewImageUrl"
:src="previewImageUrl"
:alt="previewImageName"
class="max-w-full max-h-96 mx-auto"
>
<div class="mt-2 text-sm text-gray-500">
{{ previewImageName }}
</div>
</div>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { h, onMounted, ref } from 'vue'
import { NButton, NIcon, NImage, NP, NPopconfirm, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
import type { DataTableColumns, DataTableInst, FormInst, UploadFileInfo, UploadInst } from 'naive-ui'
import CoiEmpty from '@/components/common/CoiEmpty.vue'
import { coiMsgBox, coiMsgError, coiMsgSuccess, coiMsgWarning } from '@/utils/coi'
import { usePermission } from '@/hooks/usePermission'
import { PERMISSIONS } from '@/constants/permissions'
import {
batchDeleteSysFiles,
deleteSysFile,
FILE_SERVICE_OPTIONS,
getSysFileList,
uploadFile,
} from '@/service/api/system/file'
import type { SysFileQueryBo, SysFileSearchForm, SysFileVo } from '@/service/api/system/file'
import IconParkOutlineDelete from '~icons/icon-park-outline/delete'
import IconParkOutlinePreviewOpen from '~icons/icon-park-outline/preview-open'
//
const StarIcon = () => h('span', { class: 'text-yellow-500' }, '✨')
const _ImageIcon = () => h('span', { class: 'text-pink-500' }, '🎨')
const DocumentIcon = () => h('span', { class: 'text-blue-500' }, '📄')
const AudioIcon = () => h('span', { class: 'text-purple-500' }, '🎵')
const VideoIcon = () => h('span', { class: 'text-red-500' }, '📹')
const ArchiveIcon = () => h('span', { class: 'text-orange-500' }, '🗂️')
const AppIcon = () => h('span', { class: 'text-green-500' }, '🦄')
const OtherIcon = () => h('span', { class: 'text-gray-500' }, '🎃')
//
const fileCategories = [
{ label: '全部', value: '0', icon: StarIcon },
{ label: '图片', value: '1', icon: StarIcon },
{ label: '文档', value: '2', icon: DocumentIcon },
{ label: '音频', value: '3', icon: AudioIcon },
{ label: '视频', value: '4', icon: VideoIcon },
{ label: '压缩包', value: '5', icon: ArchiveIcon },
{ label: '应用程序', value: '6', icon: AppIcon },
{ label: '其他', value: '9', icon: OtherIcon },
]
//
const { hasPermission } = usePermission()
//
const searchFormRef = ref<FormInst>()
const tableRef = ref<DataTableInst>()
const uploadFormRef = ref<FormInst>()
const uploadRef = ref<UploadInst>()
const uploadDialogRef = ref()
const loading = ref(false)
const tableData = ref<SysFileVo[]>([])
const selectedCategory = ref<string>('0') //
const selectedRows = ref<SysFileVo[]>([])
const selectedRowKeys = ref<number[]>([])
//
const searchForm = ref<SysFileSearchForm>({
fileName: '',
fileSuffix: '',
fileService: '',
fileType: '0', //
})
//
const imagePreviewVisible = ref(false)
const previewImageUrl = ref('')
const previewImageName = ref('')
//
const uploadFileList = ref<UploadFileInfo[]>([])
const uploading = ref(false)
//
const uploadForm = ref({
fileService: 'LOCAL',
filePath: '',
})
//
const uploadRules = {
fileService: [
{ required: true, message: '请选择文件服务', trigger: 'change' },
],
}
//
const pagination = ref({
page: 1,
pageSize: 20,
itemCount: 0,
})
//
const columns: DataTableColumns<SysFileVo> = [
{
type: 'selection',
width: 50,
disabled: (_row: SysFileVo) => !hasPermission(PERMISSIONS.FILE.DELETE),
},
{
title: '序号',
key: 'index',
width: 80,
render: (_, index) => {
return (pagination.value.page - 1) * pagination.value.pageSize + index + 1
},
},
{
title: '文件原始名称',
key: 'fileName',
width: 200,
ellipsis: {
tooltip: true,
},
},
{
title: '文件新名称',
key: 'newName',
width: 200,
ellipsis: {
tooltip: true,
},
},
{
title: '文件后缀',
key: 'fileSuffix',
width: 100,
render: (row) => {
return h(NTag, {
type: 'primary',
size: 'small',
}, { default: () => row.fileSuffix?.toUpperCase() })
},
},
{
title: '文件上传路径',
key: 'fileUpload',
width: 250,
ellipsis: {
tooltip: true,
},
},
{
title: '文件回显路径',
key: 'filePath',
width: 200,
ellipsis: {
tooltip: true,
},
render: (row) => {
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(
row.fileSuffix?.toLowerCase() || '',
)
if (isImage && row.filePath) {
return h(NImage, {
src: row.filePath,
width: 45,
height: 30,
objectFit: 'cover',
previewDisabled: true,
onClick: () => handleImagePreview(row.filePath!, row.fileName),
class: 'cursor-pointer rounded border',
})
}
// ellipsis
return row.filePath || '--'
},
},
{
title: '文件服务类型',
key: 'fileService',
width: 120,
render: (row) => {
const serviceMap: Record<string, { type: 'success' | 'info' | 'warning', text: string }> = {
1: { type: 'success', text: '本地存储' },
2: { type: 'info', text: 'MinIO' },
3: { type: 'warning', text: '阿里云OSS' },
}
const config = serviceMap[row.fileService] || { type: 'info', text: '未知' }
return h(NTag, {
type: config.type,
size: 'small',
}, { default: () => config.text })
},
},
{
title: '创建时间',
key: 'createTime',
width: 160,
render: (row) => {
return row.createTime ? new Date(row.createTime).toLocaleString() : '--'
},
},
{
title: '操作',
key: 'actions',
width: 160,
align: 'center',
fixed: 'right',
render: (row) => {
const buttons = []
//
buttons.push(h(NButton, {
type: 'primary',
size: 'small',
onClick: () => handlePreview(row),
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlinePreviewOpen),
}),
default: () => '预览',
}))
//
if (hasPermission(PERMISSIONS.FILE.DELETE)) {
buttons.push(h(NPopconfirm, {
onPositiveClick: () => handleDelete(row.fileId!),
negativeText: '取消',
positiveText: '确定',
}, {
default: () => '确定删除此文件吗?',
trigger: () => h(NButton, {
type: 'error',
size: 'small',
}, {
icon: () => h(NIcon, { size: 14, style: 'transform: translateY(-1px)' }, {
default: () => h(IconParkOutlineDelete),
}),
default: () => '删除',
}),
}))
}
return h('div', { class: 'flex items-center justify-center gap-2' }, buttons)
},
},
]
//
async function fetchData() {
loading.value = true
try {
const params: SysFileQueryBo = {
pageNo: pagination.value.page,
pageSize: pagination.value.pageSize,
fileName: searchForm.value.fileName || undefined,
fileSuffix: searchForm.value.fileSuffix || undefined,
fileService: searchForm.value.fileService || undefined,
fileType: selectedCategory.value === '0' ? undefined : selectedCategory.value,
}
const { data } = await getSysFileList(params)
if (data) {
tableData.value = data.records
pagination.value.itemCount = data.total
}
}
catch {
coiMsgError('获取文件列表失败')
}
finally {
loading.value = false
}
}
//
function handleSearch() {
pagination.value.page = 1
fetchData()
}
//
function handleReset() {
searchForm.value = {
fileName: '',
fileSuffix: '',
fileService: '',
fileType: '0',
}
selectedCategory.value = '0'
handleSearch()
}
//
function handleRefresh() {
fetchData()
}
//
function handlePageChange(page: number) {
pagination.value.page = page
fetchData()
}
function handlePageSizeChange(pageSize: number) {
pagination.value.pageSize = pageSize
pagination.value.page = 1
fetchData()
}
//
function handleCategoryChange(category: string) {
selectedCategory.value = category
searchForm.value.fileType = category
handleSearch()
}
//
function handleRowKeysUpdate(keys: number[]) {
selectedRowKeys.value = keys
selectedRows.value = tableData.value.filter(row => keys.includes(row.fileId!))
}
//
async function handleDelete(id: number) {
try {
await deleteSysFile(id)
coiMsgSuccess('删除成功')
fetchData()
}
catch {
coiMsgError('删除失败')
}
}
//
async function handleBatchDelete() {
if (selectedRowKeys.value.length === 0) {
coiMsgWarning('请选择要删除的文件')
return
}
try {
await coiMsgBox(`确定要删除选中的 ${selectedRowKeys.value.length} 个文件吗?`, '批量删除确认')
await batchDeleteSysFiles(selectedRowKeys.value)
coiMsgSuccess('批量删除成功')
selectedRowKeys.value = []
selectedRows.value = []
fetchData()
}
catch (error) {
if (error !== 'cancel') {
coiMsgError('批量删除失败')
}
}
}
//
function handlePreview(row: SysFileVo) {
if (row.filePath) {
handleImagePreview(row.filePath, row.fileName)
}
}
//
function handleImagePreview(url: string, name: string) {
previewImageUrl.value = url
previewImageName.value = name
imagePreviewVisible.value = true
}
//
//
function handleUpload() {
uploadForm.value = {
fileService: 'LOCAL',
filePath: '',
}
uploadFileList.value = []
uploading.value = false
uploadDialogRef.value?.coiOpen()
}
//
function validateFile(file: File, fileName: string, showError: boolean = true) {
//
if (!file || file.size === 0) {
if (showError)
coiMsgError('请选择有效的文件')
return false
}
//
if (!fileName) {
if (showError)
coiMsgError('无法获取文件名信息')
return false
}
//
const fileExtension = fileName.split('.').pop()?.toLowerCase()
if (!fileExtension) {
if (showError)
coiMsgError('文件必须有扩展名')
return false
}
// (2MB)
const fileSize = file.size / 1024 / 1024
if (fileSize > 2) {
if (showError) {
coiMsgError(`文件大小超出限制!当前文件:${fileSize.toFixed(2)}MB最大允许2MB`)
}
return false
}
// -
const allowedImageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']
const allowedDocumentExtensions = ['doc', 'docx', 'pdf', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'md', 'zip', 'rar', '7z']
const allAllowedExtensions = [...allowedImageExtensions, ...allowedDocumentExtensions]
if (!allAllowedExtensions.includes(fileExtension)) {
if (showError) {
coiMsgError(`不支持的文件格式"${fileExtension}",仅支持:${allAllowedExtensions.join(', ')}`)
}
return false
}
return true
}
//
function beforeUpload(data: { file: UploadFileInfo }) {
try {
const file = data.file.file as File
const fileName = data.file.name || file?.name || ''
//
return validateFile(file, fileName, true)
}
catch {
coiMsgError('文件验证失败,请重新选择文件')
return false
}
}
//
async function customUpload({ file, onProgress, onFinish, onError }: any) {
try {
const fileObj = file.file as File
const fileName = file.name || fileObj.name
// customUpload -
if (!validateFile(fileObj, fileName, false)) {
onError()
return
}
// pictures files
const fileExtension = fileName.split('.').pop()?.toLowerCase()
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(fileExtension || '')
const folderName = isImage ? 'pictures' : 'files'
//
onProgress({ percent: 10 })
// API - 使2MB
await uploadFile(fileObj, folderName, 2, '-1')
//
onProgress({ percent: 100 })
//
onFinish()
coiMsgSuccess(`文件 ${fileObj.name} 上传成功`)
}
catch (error: any) {
onError()
//
let errorMessage = `文件 ${file.file.name} 上传失败`
//
if (error?.isSuccess === false) {
if (error.message) {
errorMessage = error.message
}
else if (error.msg) {
errorMessage = error.msg
}
}
else if (error?.response?.data?.msg) {
errorMessage = error.response.data.msg
}
else if (error?.message) {
if (error.message.includes('413')) {
errorMessage = '文件大小超出限制,请选择较小的文件'
}
else if (error.message.includes('400')) {
errorMessage = '文件格式不支持或文件无效'
}
else {
errorMessage = error.message
}
}
coiMsgError(errorMessage)
}
}
//
function handleFileChange(data: { fileList: UploadFileInfo[] }) {
uploadFileList.value = data.fileList
}
//
function handleRemoveFile(data: { file: UploadFileInfo }) {
const index = uploadFileList.value.findIndex(item => item.id === data.file.id)
if (index !== -1) {
uploadFileList.value.splice(index, 1)
}
}
// -
function handleUploadError(_data: { file: UploadFileInfo, event?: ProgressEvent }) {
// customUpload
}
//
async function handleUploadConfirm() {
if (!uploadFormRef.value)
return
try {
//
await uploadFormRef.value.validate()
}
catch {
coiMsgWarning('请检查表单填写')
return
}
//
const pendingFiles = uploadFileList.value.filter(file => file.status === 'pending')
if (pendingFiles.length > 0) {
coiMsgWarning('还有文件未完成上传,请等待上传完成')
return
}
//
const errorFiles = uploadFileList.value.filter(file => file.status === 'error')
if (errorFiles.length > 0) {
coiMsgWarning('有文件上传失败,请重新上传')
return
}
//
const finishedFiles = uploadFileList.value.filter(file => file.status === 'finished')
if (finishedFiles.length > 0) {
coiMsgSuccess('所有文件上传完成')
uploadDialogRef.value?.coiClose()
await fetchData() //
}
else {
coiMsgWarning('请先选择要上传的文件')
}
}
//
function handleUploadCancel() {
uploadDialogRef.value?.coiClose()
uploadFileList.value = []
uploading.value = false
}
//
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.search-form :deep(.n-form-item-label) {
font-size: 12px;
color: #666;
}
.search-form :deep(.n-form-item) {
margin-bottom: 0;
}
.file-management {
min-height: 0;
}
.file-management .n-data-table {
--n-th-font-weight: normal;
}
/* 表格字体和间距优化 */
.custom-table :deep(.n-data-table-td) {
font-size: 13px;
padding: 8px 12px;
}
.custom-table :deep(.n-data-table-th) {
font-size: 13px;
padding: 10px 12px;
font-weight: 500;
}
/* 操作按钮尺寸优化 */
.custom-table :deep(.n-button--small-type) {
font-size: 12px;
padding: 4px 8px;
}
/* 标签尺寸优化 */
.custom-table :deep(.n-tag--small-type) {
font-size: 11px;
padding: 2px 6px;
}
/* 分类选择器样式 */
.category-item {
transition: all 0.2s ease;
}
.category-selected {
background-color: #f8faff;
font-weight: 500;
}
.category-unselected {
color: #666;
}
.category-unselected:hover {
background-color: #f5f5f5;
color: #333;
}
</style>

File diff suppressed because it is too large Load Diff