Files
ApiServer-Web-admin_dashboa…/src/views/system/SystemFile.vue
T
wlkjyy f7c3be1d30 refactor: extract image form to standalone page and implement tags view store
- Created ImageForm.vue as standalone page for add/edit image functionality
- Removed dialog-based image form from VmImages.vue
- Implemented tagsViewStore for global tab state management
- Added automatic tab closing on form cancel/back
- Fixed data persistence issue when switching between image edits
- Removed quick actions section from ImageForm
- Updated router configuration for new image form route
2025-11-28 14:15:29 +08:00

836 lines
25 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="system-file-container">
<!-- 主容器 -->
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="关键词筛选">
<el-input v-model="queryParams.key" placeholder="请输入关键词" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="筛选用户">
<el-input-number v-model="queryParams.user_id" placeholder="请输入用户ID" :controls="false" clearable style="width: 150px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleUpload">
<el-icon><Upload /></el-icon>上传文件
</el-button>
<el-button type="success" @click="fetchFileList">
<el-icon><Refresh/></el-icon>刷新
</el-button>
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除
</el-button>
</div>
</div>
</div>
<!-- 文件列表 -->
<div class="table-section">
<el-table
v-loading="loading"
:data="fileList"
@selection-change="handleSelectionChange"
style="width: 100%"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="realName" label="真实文件名" min-width="200" />
<el-table-column prop="saveName" label="保存名称" min-width="150" />
<el-table-column prop="savePath" label="保存路径" min-width="250" show-overflow-tooltip />
<el-table-column prop="size" label="文件大小" width="120">
<template #default="{ row }">
{{ formatFileSize(row.size) }}
</template>
</el-table-column>
<el-table-column prop="type" label="文件类型" width="120">
<template #default="{ row }">
<el-tag :type="getFileTypeColor(row.type)">
{{ row.type || '未知' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="userId" label="用户ID" width="100" />
<el-table-column label="是否公开" width="100">
<template #default="{ row }">
<el-tag :type="row.openDow ? 'success' : 'info'">
{{ row.openDow ? '公开' : '私有' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleView(row)">查看</el-button>
<el-button type="success" link @click="handleDownload(row)">下载</el-button>
<el-button type="warning" link @click="handleEdit(row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.count"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:total="total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
class="pagination"
/>
</div>
</el-card>
<!-- 文件详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="文件详情"
width="700px"
destroy-on-close
>
<div v-if="fileDetail" class="file-detail-container">
<!-- 文件预览区域 -->
<div class="file-preview-section">
<div class="preview-label">文件预览</div>
<div class="preview-content">
<el-image
v-if="isImageFile(fileDetail.type) && fileDetail.url"
:src="fileDetail.url"
fit="contain"
style="max-width: 100%; max-height: 400px; border-radius: 8px;"
:preview-src-list="[fileDetail.url]"
:initial-index="0"
>
<template #error>
<div class="image-error">
<el-icon size="40"><Picture /></el-icon>
<div>图片加载失败</div>
</div>
</template>
</el-image>
<div v-else class="file-icon-large">
<el-icon size="80"><Document /></el-icon>
<div class="file-type-text">{{ fileDetail.type || '未知类型' }}</div>
</div>
</div>
</div>
<!-- 文件信息 -->
<el-descriptions :column="2" border class="file-info-descriptions">
<el-descriptions-item label="文件ID" label-align="right">{{ fileDetail.id }}</el-descriptions-item>
<el-descriptions-item label="用户ID" label-align="right">{{ fileDetail.userId }}</el-descriptions-item>
<el-descriptions-item label="真实文件名" label-align="right" :span="2">{{ fileDetail.realName }}</el-descriptions-item>
<el-descriptions-item label="保存名称" label-align="right">{{ fileDetail.saveName }}</el-descriptions-item>
<el-descriptions-item label="文件类型" label-align="right">
<el-tag :type="getFileTypeColor(fileDetail.type)">{{ fileDetail.type || '未知' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="文件大小" label-align="right">{{ formatFileSize(fileDetail.size) }}</el-descriptions-item>
<el-descriptions-item label="是否公开" label-align="right">
<el-tag :type="fileDetail.openDow ? 'success' : 'info'">
{{ fileDetail.openDow ? '公开访问' : '私有' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="保存路径" label-align="right" :span="2">
<span class="file-path">{{ fileDetail.savePath }}</span>
</el-descriptions-item>
<el-descriptions-item label="文件URL" label-align="right" :span="2">
<el-link :href="fileDetail.url" target="_blank" type="primary" v-if="fileDetail.url">
点击查看文件
</el-link>
<span v-else style="color: #909399;">无URL</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间" label-align="right">{{ formatDate(fileDetail.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间" label-align="right">{{ formatDate(fileDetail.UpdatedAt) }}</el-descriptions-item>
<el-descriptions-item label="备注" label-align="right" :span="2">{{ fileDetail.content || '无' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
<!-- 文件编辑对话框 -->
<el-dialog
v-model="editDialogVisible"
title="编辑文件信息"
width="500px"
>
<el-form
ref="fileFormRef"
:model="fileForm"
:rules="fileRules"
label-width="120px"
>
<el-form-item label="文件ID">
<el-input v-model="fileForm.file_id" disabled />
</el-form-item>
<el-form-item label="用户ID" prop="user_id">
<el-input-number v-model="fileForm.user_id" placeholder="请输入用户ID" :controls="false" style="width: 100%" />
</el-form-item>
<el-form-item label="是否允许公开">
<el-switch v-model="fileForm.open_dow" />
<span style="margin-left: 10px; color: #909399; font-size: 12px;">
开启后允许公开访问
</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEditForm">确定</el-button>
</template>
</el-dialog>
<!-- 文件上传对话框 -->
<el-dialog
v-model="uploadDialogVisible"
title="上传文件"
width="600px"
>
<el-form
ref="uploadFormRef"
:model="uploadForm"
label-width="120px"
>
<el-form-item label="上传类型" prop="update_type">
<el-select v-model="uploadForm.update_type" placeholder="请选择上传类型" style="width: 100%">
<el-option label="工单文件" value="work_order" />
<el-option label="封面" value="cover" />
</el-select>
</el-form-item>
<el-form-item label="是否开放下载">
<el-switch v-model="uploadForm.open_down" />
<span style="margin-left: 10px; color: #909399; font-size: 12px;">
开启后允许公开下载
</span>
</el-form-item>
<el-form-item label="上传文件">
<el-upload
ref="uploadRef"
:http-request="handleCustomUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-remove="handleRemoveFile"
:on-change="handleFileChange"
:before-upload="beforeUpload"
:file-list="uploadFileList"
:auto-upload="false"
drag
multiple
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
支持 jpg/png/gif/pdf/doc/docx 文件且不超过 10MB
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleCloseUpload">取消</el-button>
<el-button type="primary" @click="handleSubmitUpload">确定上传</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Upload, Delete, Search, Document, VideoPlay, Folder, UploadFilled, Picture, Refresh } from '@element-plus/icons-vue'
import { getFileList, getFileDetail, updateFile, deleteFile, uploadFile } from '@/api/admin/file'
// 查询参数
const queryParams = reactive({
key: '',
user_id: undefined,
page: 1,
count: 10
})
// 文件表单
const fileForm = reactive({
file_id: undefined,
user_id: undefined,
open_dow: false
})
const fileRules = {
user_id: [
{ required: true, message: '请输入用户ID', trigger: 'blur' }
]
}
// 状态数据
const loading = ref(false)
const fileList = ref([])
const fileDetail = ref(null)
const total = ref(0)
const selectedRows = ref([])
const detailDialogVisible = ref(false)
const editDialogVisible = ref(false)
const uploadDialogVisible = ref(false)
const fileFormRef = ref(null)
const uploadRef = ref(null)
const uploadFormRef = ref(null)
// 上传表单
const uploadForm = reactive({
update_type: 'work_order',
open_down: false
})
// 上传文件列表
const uploadFileList = ref([])
// 判断是否为图片文件
const isImageFile = (type) => {
const imageTypes = ['cover', 'image', 'avatar', 'photo', 'picture']
return imageTypes.includes(type?.toLowerCase())
}
// 获取文件类型颜色
const getFileTypeColor = (type) => {
if (isImageFile(type)) return 'success'
const colorMap = {
'document': 'primary',
'video': 'warning',
'audio': 'info',
'file': ''
}
return colorMap[type?.toLowerCase()] || 'info'
}
// 格式化日期时间
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// 格式化文件大小
const formatFileSize = (size) => {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / (1024 * 1024)).toFixed(2) + ' MB'
return (size / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
}
// 获取文件列表
const fetchFileList = async () => {
loading.value = true
try {
const res = await getFileList(queryParams)
console.log('文件列表数据:', res.data)
if (res.data.code === 200) {
fileList.value = res.data.data.list || []
total.value = res.data.data.all_count || 0
}
} catch (error) {
console.error('获取文件列表失败:', error)
ElMessage.error('获取文件列表失败')
} finally {
loading.value = false
}
}
// 查询
const handleQuery = () => {
queryParams.page = 1
fetchFileList()
}
// 重置查询
const resetQuery = () => {
queryParams.key = ''
queryParams.user_id = undefined
queryParams.page = 1
fetchFileList()
}
// 选择项变化
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// 分页
const handleSizeChange = (size) => {
queryParams.count = size
fetchFileList()
}
const handleCurrentChange = (page) => {
queryParams.page = page
fetchFileList()
}
// 查看文件详情
const handleView = async (row) => {
try {
const res = await getFileDetail({ file_id: row.id })
console.log('文件详情数据:', res.data)
if (res.data.code === 200) {
fileDetail.value = res.data.data.data
fileDetail.value.url = res.data.data.url
detailDialogVisible.value = true
}
} catch (error) {
console.error('获取文件详情失败:', error)
ElMessage.error('获取文件详情失败')
}
}
// 下载文件
const handleDownload = async (row) => {
try {
// 先获取文件详情以获取完整URL
const res = await getFileDetail({ file_id: row.id })
if (res.data.code === 200 && res.data.data.url) {
const link = document.createElement('a')
link.href = res.data.data.url
link.download = row.realName
link.target = '_blank'
link.click()
ElMessage.success('开始下载文件')
} else {
ElMessage.error('获取文件下载链接失败')
}
} catch (error) {
console.error('下载文件失败:', error)
ElMessage.error('下载文件失败')
}
}
// 编辑文件
const handleEdit = (row) => {
Object.assign(fileForm, {
file_id: row.id,
user_id: row.userId || undefined,
open_dow: row.openDow || false
})
editDialogVisible.value = true
}
// 删除文件
const handleDelete = (row) => {
ElMessageBox.confirm(`确认删除文件 ${row.realName} 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteFile({ file_id: row.id })
console.log('删除文件响应:', res.data)
if (res.data.code === 200) {
ElMessage.success('删除成功')
fetchFileList()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
// 批量删除
const handleBatchDelete = () => {
console.log("批量选择的值:",selectedRows.value)
if (selectedRows.value.length === 0) {
ElMessage.warning('请至少选择一条记录')
return
}
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async() => {
try{
const deleteMap = selectedRows.value.map(f => deleteFile({file_id:f.id}))
//等待所有删除完毕
await Promise.all(deleteMap)
ElMessage.success('批量删除成功')
//刷新文件列表
fetchFileList()
}catch(error){
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
// 上传文件
const handleUpload = () => {
uploadForm.update_type = 'work_order'
uploadForm.open_down = false
uploadFileList.value = []
uploadDialogVisible.value = true
}
// 关闭上传对话框
const handleCloseUpload = () => {
uploadDialogVisible.value = false
uploadFileList.value = []
}
// 文件列表变化
const handleFileChange = (file, fileList) => {
console.log('文件列表变化:', file, fileList)
uploadFileList.value = fileList
}
// 移除文件
const handleRemoveFile = (file, fileList) => {
console.log('移除文件:', file, fileList)
uploadFileList.value = fileList
}
// 提交上传
const handleSubmitUpload = () => {
if (uploadFileList.value.length === 0) {
ElMessage.warning('请至少选择一个文件')
return
}
// 触发所有待上传文件的上传
const filesToUpload = uploadFileList.value.filter(file =>
file.status !== 'success' && file.status !== 'uploading'
)
if (filesToUpload.length === 0) {
ElMessage.info('所有文件已上传完成')
return
}
// 逐个提交文件
uploadRef.value?.submit()
}
// 上传前检查(只做提示,不阻止文件添加到列表)
const beforeUpload = (file) => {
const isValidType = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'].includes(file.type)
const isLt10M = file.size / 1024 / 1024 < 10
console.log('beforeUpload', file)
if (!isValidType) {
ElMessage.warning(`文件 ${file.name} 格式不符合要求(仅支持 JPG/PNG/GIF/PDF/DOC/DOCX`)
}
if (!isLt10M) {
ElMessage.warning(`文件 ${file.name} 大小超过 10MB`)
}
// 允许文件添加到列表,在上传时再进行验证
return true
}
// 自定义上传方法
const handleCustomUpload = async (options) => {
const { file, onSuccess, onError } = options
console.log('开始上传文件:', file)
// 在上传前进行验证
const isValidType = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'].includes(file.type)
const isLt10M = file.size / 1024 / 1024 < 10
if (!isValidType) {
const error = new Error(`文件 ${file.name} 格式不符合要求(仅支持 JPG/PNG/GIF/PDF/DOC/DOCX`)
// 标记为校验类错误,on-error 中不再弹 error 提示
error.isValidation = true
onError(error, file)
return
}
if (!isLt10M) {
const error = new Error(`文件 ${file.name} 大小超过 10MB`)
error.isValidation = true
onError(error, file)
return
}
try {
const formData = new FormData()
// 根据 API 文档,字段名应该是 files(复数)
formData.append('files', file)
// 添加文件名列表(虽然 API 文档说是数组,但实际传递时直接传字符串)
formData.append('file_names', file.name)
// 添加上传类型
if (uploadForm.update_type) {
formData.append('update_type', uploadForm.update_type)
}
// 添加是否开放下载
formData.append('open_down', uploadForm.open_down ? '1' : '0')
console.log('上传参数:', {
files: file.name,
file_names: [file.name],
update_type: uploadForm.update_type,
open_down: uploadForm.open_down
})
const res = await uploadFile(formData)
console.log('上传响应:', res)
// 根据返回码严格区分成功和失败
if (res && res.data && res.data.code === 200) {
onSuccess(res.data.data, file)
} else {
const errorMsg = res?.data?.message || res?.data?.msg || '上传失败'
const error = new Error(errorMsg)
onError(error, file)
}
} catch (error) {
console.error('上传文件失败:', error)
const err = new Error(error?.response?.data?.message || error?.message || '上传失败')
onError(err, file)
}
}
// 上传成功
const handleUploadSuccess = (response, file, fileList) => {
console.log('上传成功文件:', file)
console.log('上传成功文件列表:',fileList)
// 成功回调只会在 code === 200 时触发
// ElMessage.success(`文件 ${file.name} 上传成功`)
// 更新文件列表状态
uploadFileList.value = fileList
// 如果所有文件都上传成功,关闭对话框并刷新列表
const allSuccess = fileList.every(f => f.status === 'success')
const uploadList = fileList.some(f => f.status === 'uploading')
if (allSuccess && !uploadList && fileList.length > 0) {
ElMessage.success(`已成功上传${fileList.length}个文件`)
setTimeout(() => {
uploadDialogVisible.value = false
uploadFileList.value = []
fetchFileList()
}, 1000)
}
}
// 上传失败
const handleUploadError = (error, file, fileList) => {
console.error('上传失败:', error, file, fileList)
// 对校验类错误仅在 beforeUpload 中提示过一次 warning,这里不再重复报错
if (error?.isValidation) return
ElMessage.error(error?.message || '上传失败,请检查网络连接或联系管理员')
}
// 提交编辑表单
const submitEditForm = () => {
fileFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
file_id: fileForm.file_id,
user_id: Number(fileForm.user_id),
open_dow: fileForm.open_dow
}
console.log('提交文件信息数据:', submitData)
const res = await updateFile(submitData)
if (res.data.code === 200) {
ElMessage.success('修改成功')
editDialogVisible.value = false
fetchFileList()
}
} catch (error) {
console.error('修改失败:', error)
ElMessage.error(error.response?.data?.message || '修改失败')
}
}
})
}
// 初始化
onMounted(() => {
fetchFileList()
})
</script>
<style scoped>
.system-file-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
}
.filter-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.search-form {
margin: 0;
flex: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 0;
}
.pagination {
margin-top: 20px;
padding: 16px 20px;
border-top: 1px solid #e1e8ed;
background: #fafbfc;
justify-content: flex-end;
}
.file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
background-color: #f5f7fa;
border-radius: 4px;
}
.file-detail-container {
padding: 10px 0;
}
.file-preview-section {
margin-bottom: 24px;
}
.preview-label {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 12px;
}
.preview-content {
display: flex;
justify-content: center;
align-items: center;
background-color: #f5f7fa;
border-radius: 8px;
padding: 20px;
min-height: 200px;
}
.file-icon-large {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
gap: 12px;
}
.file-type-text {
font-size: 14px;
color: #606266;
}
.image-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
gap: 8px;
}
.file-info-descriptions {
margin-top: 16px;
}
.file-path {
font-family: 'Courier New', monospace;
font-size: 12px;
color: #606266;
word-break: break-all;
}
:deep(.el-descriptions__label) {
width: 120px;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.el-card__body) {
padding: 0;
}
</style>