coder-common-thin-frontend/src/views/system/file/index.vue
Leo 28faeb959d docs(file): 清理文件上传功能注释
- 移除uploadFile调用中的冗余注释"使用2MB限制"
- 保持注释简洁明了,避免重复信息
- 统一代码注释风格
2025-07-08 22:48:58 +08:00

938 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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_DB_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,
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
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>