Files
klp-oa/klp-ui/src/views/system/file/index.vue
jhd 89cbe18c72 feat(file): 完善文件管理功能,增加图片管理和权限控制
- 添加管理员权限验证,删除按钮仅对管理员显示
- 实现文件编辑权限控制,仅创建者或管理员可编辑
- 新增多图上传功能,支持文件和图片两种上传模式
- 实现图片预览轮播功能,支持缩略图导航和计数显示
- 添加文件替换功能,支持编辑模式下替换主文件
- 优化图片管理界面,支持添加、删除和替换操作
- 重构上传组件,使用OSS直传替代原有拖拽上传
- 新增图片关联API接口,支持文件与多张图片关联
- 实现批量图片上传处理,按顺序串行上传避免冲突
- 优化表单重置逻辑,清空临时文件和图片数据
- 添加文件类型自动识别和分类展示功能
- 修复可见用户加载异常导致的重复ID问题
- 优化错误处理机制,提升用户体验和系统稳定性
2026-07-13 13:06:58 +08:00

1447 lines
45 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="app-container">
<!-- Tab 切换 -->
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
<el-tab-pane label="我的文件" name="my"></el-tab-pane>
<el-tab-pane label="共享文件" name="share"></el-tab-pane>
<el-tab-pane label="与我相关" name="related"></el-tab-pane>
</el-tabs>
<!-- 左右分栏布局 -->
<div class="file-layout">
<!-- 左侧边栏 -->
<div class="file-sidebar">
<!-- 搜索框 -->
<div class="sidebar-section">
<el-input
v-model="queryParams.fileName"
prefix-icon="el-icon-search"
placeholder="按名称搜索"
clearable
size="mini"
@change="handleQuery"
@clear="handleQuery"
></el-input>
</div>
<!-- 文件类型列表 -->
<div class="sidebar-section sidebar-file-types">
<div class="sidebar-label">文件类型</div>
<div class="file-type-list">
<div
v-for="opt in fileTypeOptions"
:key="opt.value"
class="type-list-item"
:class="{ active: (queryParams.fileType || '') === opt.value }"
@click="handleTypeTabClick(opt.value)"
>{{ opt.label }}</div>
</div>
</div>
</div>
<!-- 右侧主区域 -->
<div class="file-main">
<!-- 工具栏 -->
<div class="main-toolbar">
<el-button icon="el-icon-sort" size="mini" @click="toggleQuery">高级搜索</el-button>
<el-button v-if="activeTab === 'my'" type="primary" icon="el-icon-plus" size="mini" @click="handleAdd">上传文件</el-button>
<el-button v-if="activeTab === 'my'" type="danger" icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport">导出</el-button>
</div>
<!-- 高级搜索 -->
<div v-show="showQuery" class="advanced-search">
<el-form :model="queryParams" inline size="mini" class="advanced-search-form">
<el-form-item label="订单编号">
<el-input v-model="queryParams.orderNo" placeholder="订单编号" clearable @change="handleQuery" style="width:140px;"></el-input>
</el-form-item>
<el-form-item label="所属部门">
<el-input v-model="queryParams.dept" placeholder="所属部门" clearable @change="handleQuery" style="width:140px;"></el-input>
</el-form-item>
<el-form-item label="文件类型">
<el-select v-model="queryParams.fileType" placeholder="文件类型" clearable @change="handleQuery" style="width:120px;">
<el-option v-for="item in dict.type.sys_file_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="可见范围" v-if="activeTab === 'my'">
<el-select v-model="queryParams.scopeType" placeholder="可见范围" clearable @change="handleQuery" style="width:120px;">
<el-option label="公开" :value="1" />
<el-option label="私有" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="上传时间">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="yyyy-MM-dd"
value-format="yyyy-MM-dd"
clearable
style="width:240px;"
@change="handleQuery"
></el-date-picker>
</el-form-item>
</el-form>
</div>
<!-- 文件表格 -->
<div class="file-table-wrapper">
<KLPTable v-loading="loading" :data="fileList" @selection-change="handleSelectionChange" highlight-current-row @row-click="handleRowClick">
<el-table-column type="selection" width="50" align="center" v-if="activeTab === 'my'" />
<el-table-column label="文件名称" align="center" prop="fileName" :show-overflow-tooltip="true" min-width="160">
<template slot-scope="scope">
<el-link type="primary" @click="handleShowInfo(scope.row)">{{ scope.row.fileName }}</el-link>
</template>
</el-table-column>
<el-table-column label="上传人" align="center" prop="createByName" width="120" :show-overflow-tooltip="true" />
<el-table-column label="所属部门" align="center" prop="dept" width="120" :show-overflow-tooltip="true" />
<el-table-column label="订单编号" align="center" prop="orderNo" width="130" :show-overflow-tooltip="true" />
<el-table-column label="文件类型" align="center" width="100">
<template slot-scope="scope">
<dict-tag :options="dict.type.sys_file_type" :value="scope.row.fileType"/>
</template>
</el-table-column>
<el-table-column label="文件大小" align="center" width="90">
<template slot-scope="scope">
<span>{{ formatFileSize(scope.row.fileSize) }}</span>
</template>
</el-table-column>
<el-table-column label="可见范围" align="center" width="80">
<template slot-scope="scope">
<el-tag :type="scope.row.scopeType === 1 ? 'success' : 'warning'" size="small">
{{ scope.row.scopeType === 1 ? '公开' : '私有' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="上传时间" align="center" width="155">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
</template>
</el-table-column>
<el-table-column label="浏览次数" align="center" width="75">
<template slot-scope="scope">
<span>{{ scope.row.viewCount != null ? scope.row.viewCount : 0 }}</span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="140" fixed="right">
<template slot-scope="scope">
<div class="action-cell">
<el-button type="text" size="mini" icon="el-icon-view" @click.stop="handleShowInfo(scope.row)">详情</el-button>
<el-button v-if="canEdit(scope.row)" type="text" size="mini" icon="el-icon-edit" @click.stop="handleUpdate(scope.row)">编辑</el-button>
</div>
</template>
</el-table-column>
</KLPTable>
<pagination
v-show="total>0"
:total="total"
:page.sync="queryParams.pageNum"
:limit.sync="queryParams.pageSize"
@pagination="getList"
/>
</div>
</div>
</div>
<!-- 上传/编辑对话框 -->
<el-dialog :title="dialogTitle" :visible.sync="open" width="650px" append-to-body @close="handleDialogClose">
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="文件名称" prop="fileName">
<el-input v-model="form.fileName" placeholder="请输入文件名称" />
</el-form-item>
<!-- 上传模式 - 选择方式 -->
<el-form-item label="上传方式" v-if="!isEdit">
<el-radio-group v-model="uploadMode">
<el-radio label="file">上传文件</el-radio>
<el-radio label="image">上传图片</el-radio>
</el-radio-group>
</el-form-item>
<!-- 上传文件 -->
<el-form-item v-if="!isEdit && uploadMode === 'file'" label="选择文件" required>
<div class="file-upload-area">
<input type="file" ref="mainFileInput" style="display:none" @change="handleMainFileSelect" />
<el-button type="primary" @click="$refs.mainFileInput.click()">选择文件</el-button>
<span class="upload-hint">支持 pdf / xlsx / docx / 图片 等格式</span>
<div v-if="mainFile" class="main-file-info">
<i class="el-icon-document"></i>
<span class="main-file-name">{{ mainFile.name }}</span>
<span class="main-file-size">({{ formatFileSize(mainFile.size) }})</span>
<el-button type="text" size="mini" icon="el-icon-delete" @click="removeMainFile" style="color:#f56c6c;"></el-button>
</div>
</div>
</el-form-item>
<!-- 上传图片多张 -->
<el-form-item v-if="!isEdit && uploadMode === 'image'" label="选择图片" required>
<div class="image-upload-area">
<input type="file" multiple accept="image/*" ref="fileInput" style="display:none" @change="handleFileSelect" />
<el-button @click="$refs.fileInput.click()">选择图片</el-button>
<span class="upload-hint">支持多选 jpg/png/webp/bmp</span>
<div v-if="stagedImages.length > 0" class="image-grid">
<div v-for="(img, idx) in stagedImages" :key="idx" class="image-item">
<img :src="img.url" />
<div class="image-item-mask">
<el-button type="danger" size="mini" icon="el-icon-delete" circle @click="removeStagedImage(idx)"></el-button>
</div>
<div class="image-item-name">{{ img.name }}</div>
</div>
</div>
</div>
</el-form-item>
<!-- 编辑模式 - 主文件信息 -->
<el-form-item label="当前文件" v-if="isEdit && form.filePath">
<div class="main-file-info">
<i class="el-icon-document"></i>
<span class="main-file-name">{{ form.fileName }}</span>
<span class="main-file-size" v-if="form.fileSize">({{ formatFileSize(form.fileSize) }})</span>
<el-button type="text" size="mini" icon="el-icon-refresh" @click="handleReplaceMainFile" style="margin-left:8px;">替换</el-button>
<el-button type="text" size="mini" icon="el-icon-download" @click="downloadFile(form)" v-if="form.filePath">下载</el-button>
<input type="file" ref="replaceMainFileInput" style="display:none" @change="handleReplaceMainFileSelect" />
</div>
</el-form-item>
<!-- 编辑模式 - 图片管理 -->
<el-form-item label="文件图片" v-if="isEdit">
<div v-if="form.imageList && form.imageList.length > 0" class="image-grid">
<div v-for="(img, idx) in form.imageList" :key="img.imageId || idx" class="image-item">
<img :src="img.imagePath" />
<div class="image-item-mask">
<el-button type="warning" size="mini" icon="el-icon-refresh" circle @click="handleReplaceImage(idx)" title="替换"></el-button>
<el-button type="danger" size="mini" icon="el-icon-delete" circle @click="handleRemoveImage(idx)" title="删除"></el-button>
</div>
<div class="image-item-name">{{ img.imageName }}</div>
</div>
<div class="image-item image-add-btn" @click="handleAddImage">
<i class="el-icon-plus"></i>
<span>添加图片</span>
</div>
<input type="file" multiple accept="image/*" ref="editFileInput" style="display:none" @change="handleEditFileSelect" />
</div>
<div v-else class="no-images-hint">暂无图片<el-button type="text" @click="handleAddImage">添加图片</el-button></div>
</el-form-item>
<el-form-item label="订单编号" prop="orderNo">
<el-input v-model="form.orderNo" placeholder="请输入订单编号" />
</el-form-item>
<el-form-item label="所属部门" prop="dept">
<el-input v-model="form.dept" placeholder="请输入所属部门" />
</el-form-item>
<el-form-item label="文件类型" prop="fileType">
<el-select v-model="form.fileType" placeholder="请选择文件类型" style="width:100%">
<el-option v-for="item in dict.type.sys_file_type" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="可见范围" prop="scopeType">
<el-radio-group v-model="form.scopeType" @change="handleScopeChange">
<el-radio :label="1">公开所有人可见</el-radio>
<el-radio :label="2">私有指定用户可见</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="可见用户" v-if="form.scopeType === 2">
<user-select v-model="form.visibleUsers" :multiple="true" placeholder="请选择可见用户" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" placeholder="请输入备注" :rows="3" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</el-dialog>
<!-- 文件详情弹窗含预览 -->
<el-dialog :title="infoTitle" :visible.sync="infoVisible" width="1100px" append-to-body top="5vh" @close="handleInfoClose">
<div v-if="infoFile" class="info-dialog-body">
<div class="info-preview">
<!-- 多图模式 -->
<template v-if="infoFile.imageList && infoFile.imageList.length > 0">
<div class="image-carousel-wrap">
<el-carousel height="400px" indicator-position="none" arrow="always" ref="previewCarousel" @change="handlePreviewChange">
<el-carousel-item v-for="(img, idx) in infoFile.imageList" :key="idx">
<el-image :src="img.imagePath" fit="contain" :preview-src-list="previewSrcList" :initial-index="idx" class="carousel-image" />
</el-carousel-item>
</el-carousel>
<div class="thumbnail-strip">
<div v-for="(img, idx) in infoFile.imageList" :key="idx" class="thumbnail-item" :class="{ active: activeImageIndex === idx }" @click="handleThumbnailClick(idx)">
<img :src="img.imagePath" />
</div>
</div>
<div class="image-counter">{{ activeImageIndex + 1 }} / {{ infoFile.imageList.length }}</div>
</div>
</template>
<!-- 单文件模式 -->
<template v-else>
<ImagePreview v-if="infoFileCategory === 'image'" :src="infoFile.filePath" />
<PdfPreview v-else-if="infoFileCategory === 'pdf'" :src="infoFile.filePath" />
<DocxPreview v-else-if="infoFileCategory === 'docx'" :src="infoFile.filePath" />
<XlsxPreview v-else-if="infoFileCategory === 'xlsx'" :src="infoFile.filePath" />
<XlsPreview v-else-if="infoFileCategory === 'xls'" :src="infoFile.filePath" />
<div v-else class="info-preview-empty">
<el-empty description="暂不支持预览此文件类型" />
</div>
</template>
</div>
<div class="info-meta">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="文件名称">{{ infoFile.fileName }}</el-descriptions-item>
<el-descriptions-item label="文件类型">
<dict-tag :options="dict.type.sys_file_type" :value="infoFile.fileType"/>
</el-descriptions-item>
<el-descriptions-item label="文件大小">{{ formatFileSize(infoFile.fileSize) }}</el-descriptions-item>
<el-descriptions-item label="文件后缀">{{ infoFile.suffix || '-' }}</el-descriptions-item>
<el-descriptions-item label="浏览次数">{{ infoFile.viewCount != null ? infoFile.viewCount : 0 }} </el-descriptions-item>
<el-descriptions-item label="订单编号">{{ infoFile.orderNo || '-' }}</el-descriptions-item>
<el-descriptions-item label="所属部门">{{ infoFile.dept || '-' }}</el-descriptions-item>
<el-descriptions-item label="可见范围">
<el-tag :type="infoFile.scopeType === 1 ? 'success' : 'warning'" size="small">
{{ infoFile.scopeType === 1 ? '公开' : '私有' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="上传人">{{ infoFile.createByName || infoFile.createBy }}</el-descriptions-item>
<el-descriptions-item label="上传时间">{{ parseTime(infoFile.createTime) }}</el-descriptions-item>
<el-descriptions-item label="备注">{{ infoFile.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 评论区域 -->
<div class="info-comment">
<div class="comment-bar" @click="commentExpanded = !commentExpanded">
<span><i class="el-icon-chat-dot-round"></i> 评论 ({{ comments.length }})</span>
<span class="comment-toggle">{{ commentExpanded ? '收起' : '展开' }} <i :class="commentExpanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i></span>
</div>
<div v-show="commentExpanded" class="comment-body">
<div v-if="commentLoading" class="comment-hint"><i class="el-icon-loading"></i> 加载中...</div>
<div v-else-if="comments.length === 0" class="comment-hint comment-empty">暂无评论</div>
<div v-else class="comment-list">
<div v-for="item in comments" :key="item.commentId" class="comment-item">
<div class="comment-meta">
<span class="comment-dept">{{ item.dept }}</span>
<span class="comment-user">{{ item.createBy }}</span>
<span class="comment-time">{{ parseTime(item.createTime) }}</span>
</div>
<div class="comment-text">{{ item.content }}</div>
</div>
</div>
<div class="comment-input-area">
<el-input v-model="commentInput" type="textarea" :rows="2" placeholder="输入评论..." resize="none" />
<el-button type="primary" size="mini" @click="handleAddComment" :disabled="!commentInput.trim()" style="margin-top:6px;">发送</el-button>
</div>
</div>
</div>
<div style="margin-top:12px;text-align:right;">
<el-button type="warning" plain size="mini" icon="el-icon-download" @click="handleExportInfo">导出</el-button>
<el-button type="primary" size="mini" icon="el-icon-download" @click="downloadFile(infoFile)">下载</el-button>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import { listFile, getFile, addFile, updateFile, delFile, exportFile, listVisibleUser, addVisibleUser, delVisibleUser, listVisibleUserByFileId, listRelatedToMe, incrementView, uploadFile, delFileImage } from '@/api/system/file'
import { listFileComment, addFileComment } from '@/api/system/fileComment'
import UserSelect from '@/components/KLPService/UserSelect/index'
import ImagePreview from '@/components/FilePreview/preview/image/index.vue'
import PdfPreview from '@/components/FilePreview/preview/pdf/index.vue'
import DocxPreview from '@/components/FilePreview/preview/docx/index.vue'
import XlsxPreview from '@/components/FilePreview/preview/xlsx/index.vue'
import XlsPreview from '@/components/FilePreview/preview/xls/index.vue'
export default {
name: 'SysFile',
dicts: ['sys_file_type'],
components: {
UserSelect,
ImagePreview,
PdfPreview,
DocxPreview,
XlsxPreview,
XlsPreview
},
data() {
return {
// 当前激活tab
activeTab: 'my',
// 遮罩层
loading: false,
// 选中数组
ids: [],
// 非单个禁用
single: true,
// 非多个禁用
multiple: true,
// 显示高级搜索
showQuery: false,
// 总条数
total: 0,
// 文件列表
fileList: [],
// 弹出层标题
dialogTitle: '',
// 是否显示弹出层
open: false,
// 是否编辑
isEdit: false,
// 日期范围
dateRange: [],
// 查询参数
queryParams: {
pageNum: 1,
pageSize: 20,
fileName: undefined,
orderNo: undefined,
dept: undefined,
fileType: undefined,
scopeType: undefined
},
// 表单
form: {
fileId: undefined,
fileName: undefined,
filePath: undefined,
fileSize: undefined,
suffix: undefined,
orderNo: undefined,
dept: undefined,
fileType: undefined,
scopeType: 1,
visibleUsers: [],
remark: undefined,
imageList: []
},
// 可见用户原始数据(编辑时回显)
originalVisibleUsers: [],
// 表单校验
rules: {
fileName: [
{ required: true, message: '文件名称不能为空', trigger: 'blur' }
],
fileType: [
{ required: true, message: '请选择文件类型', trigger: 'change' }
]
},
// 上传方式 file | image
uploadMode: 'file',
// 多图上传
mainFile: null,
stagedImages: [],
uploading: false,
// 文件详情弹窗
infoVisible: false,
infoTitle: '',
infoFile: null,
// 评论
comments: [],
commentExpanded: false,
commentInput: '',
commentLoading: false,
// 详情弹窗多图预览
activeImageIndex: 0
}
},
computed: {
/** 文件类型筛选选项 */
fileTypeOptions() {
const dict = this.dict.type.sys_file_type || []
return [{ value: '', label: '全部' }, ...dict]
},
/** 详情弹窗中根据文件后缀决定预览组件 */
infoFileCategory() {
if (!this.infoFile) return null
const raw = this.infoFile.suffix || this.infoFile.fileName || ''
const ext = (raw.includes('.') ? raw.split('.').pop() : raw).toLowerCase()
if (['png', 'jpg', 'jpeg', 'bmp', 'webp'].includes(ext)) return 'image'
if (ext === 'pdf') return 'pdf'
if (ext === 'docx') return 'docx'
if (ext === 'xlsx') return 'xlsx'
if (ext === 'xls') return 'xls'
return 'other'
},
/** 多图预览源列表(用于全屏查看) */
previewSrcList() {
if (this.infoFile && this.infoFile.imageList && this.infoFile.imageList.length > 0) {
return this.infoFile.imageList.map(img => img.imagePath)
}
return []
}
},
created() {
this.getList()
},
watch: {
infoFile(val) {
if (val) {
this.loadComments()
} else {
this.comments = []
this.commentExpanded = false
}
}
},
methods: {
/** 切换tab */
handleTabClick(tab) {
this.resetQuery()
},
/** 查询文件列表 */
getList() {
this.loading = true
const params = this.addDateRange(this.queryParams, this.dateRange)
// 根据tab设置额外参数
if (this.activeTab === 'my') {
params.createBy = this.$store.getters.name
} else if (this.activeTab === 'share') {
params.scopeType = 1
}
// 根据tab选择不同的API
let request
if (this.activeTab === 'related') {
request = listRelatedToMe(params)
} else {
request = listFile(params)
}
request.then(response => {
this.fileList = response.rows
this.total = response.total
this.loading = false
}).catch(() => {
this.loading = false
})
},
/** 格式化文件大小 */
formatFileSize(size) {
if (!size) return '0 B'
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(2) + ' KB'
return (size / 1024 / 1024).toFixed(2) + ' MB'
},
/** 是否可编辑 */
canEdit(row) {
if (this.activeTab === 'related') return false
if (this.activeTab === 'share') return false
return row.createBy === this.$store.getters.name
},
/** 取消按钮 */
cancel() {
this.open = false
this.reset()
},
/** 对话框关闭回调 */
handleDialogClose() {
this.reset()
},
/** 表单重置 */
reset() {
this.form = {
fileId: undefined,
fileName: '',
filePath: '',
fileSize: '',
suffix: '',
orderNo: '',
dept: '',
fileType: '',
scopeType: 1,
visibleUsers: [],
remark: '',
imageList: []
}
this.stagedImages = []
this.mainFile = null
this.originalVisibleUsers = []
this.isEdit = false
this.uploadMode = 'file'
this.resetForm('form')
},
/** 可见范围切换 */
handleScopeChange(val) {
if (val === 1) {
this.form.visibleUsers = []
}
},
/** 搜索 */
handleQuery() {
this.queryParams.pageNum = 1
this.getList()
},
/** 切换高级搜索 */
toggleQuery() {
this.showQuery = !this.showQuery
},
/** 文件类型筛选切换 */
handleTypeTabClick(value) {
this.queryParams.fileType = value || undefined
this.queryParams.pageNum = 1
this.getList()
},
/** 重置按钮 */
resetQuery() {
this.dateRange = []
this.showQuery = false
this.queryParams = {
pageNum: 1,
pageSize: 20,
fileName: undefined,
orderNo: undefined,
dept: undefined,
fileType: undefined,
scopeType: undefined
}
this.getList()
},
/** 多选框 */
handleSelectionChange(selection) {
this.ids = selection.map(item => item.fileId)
this.single = selection.length !== 1
this.multiple = !selection.length
},
/** 新增/上传 */
handleAdd() {
this.reset()
this.isEdit = false
this.open = true
this.dialogTitle = '上传文件'
},
/** 修改 */
handleUpdate(row) {
this.reset()
this.isEdit = true
this.dialogTitle = '编辑文件'
// 先打开对话框,让用户感知操作已触发
this.open = true
const fileId = row.fileId || this.ids[0]
getFile(fileId).then(response => {
const data = response.data
this.form = {
fileId: data.fileId,
fileName: data.fileName,
filePath: data.filePath,
fileSize: data.fileSize,
suffix: data.suffix,
orderNo: data.orderNo,
dept: data.dept,
fileType: data.fileType,
scopeType: data.scopeType,
visibleUsers: [],
remark: data.remark,
imageList: data.imageList || []
}
if (data.scopeType === 2) {
listVisibleUserByFileId(fileId).then(res => {
const rows = res.rows || []
// 去重,防止重复 userId 导致 el-select 渲染异常
const userIds = [...new Set(rows.map(r => r.userId).filter(id => id != null))]
this.form.visibleUsers = userIds
this.originalVisibleUsers = [...userIds]
}).catch(() => {
// 可见用户加载失败不影响对话框使用
})
}
}).catch(() => {
this.$modal.msgError('获取文件信息失败')
this.open = false
})
},
/** 上传模式:选择图片 */
handleFileSelect(event) {
const files = Array.from(event.target.files || [])
files.forEach(file => {
if (!file.type.startsWith('image/')) return
this.stagedImages.push({
file: file,
url: URL.createObjectURL(file),
name: file.name
})
})
event.target.value = ''
},
/** 上传模式:移除已选图片 */
removeStagedImage(idx) {
const img = this.stagedImages[idx]
if (img && img.url) {
URL.revokeObjectURL(img.url)
}
this.stagedImages.splice(idx, 1)
},
/** 编辑模式:替换图片 */
handleReplaceImage(idx) {
this._replaceIndex = idx
this.$refs.editFileInput.click()
},
/** 编辑模式:删除图片 */
handleRemoveImage(idx) {
this.$modal.confirm('是否确认删除此图片?').then(() => {
this.form.imageList.splice(idx, 1)
}).catch(() => {})
},
/** 编辑模式:添加新图片 */
handleAddImage() {
this._replaceIndex = null
this.$refs.editFileInput.click()
},
/** 编辑模式:文件选择后上传到 OSS */
handleEditFileSelect(event) {
const files = Array.from(event.target.files || [])
if (files.length === 0) return
event.target.value = ''
this.uploading = true
const promises = files
.filter(file => file.type.startsWith('image/'))
.map(file => {
return uploadFile(file).then(res => {
if (res.code === 200) {
return {
imageName: file.name,
imagePath: res.data.url,
imageSize: file.size,
suffix: file.name.split('.').pop().toLowerCase()
}
} else {
this.$modal.msgError(res.msg || file.name + ' 上传失败')
return null
}
})
})
Promise.all(promises).then(results => {
const validResults = results.filter(r => r != null)
if (validResults.length === 0) return
if (this._replaceIndex !== null && this._replaceIndex !== undefined) {
// 替换已有图片
validResults.forEach((img, i) => {
const idx = this._replaceIndex + i
if (idx < this.form.imageList.length) {
this.$set(this.form.imageList, idx, img)
} else {
this.form.imageList.push(img)
}
})
this._replaceIndex = null
} else {
// 新增图片
validResults.forEach(img => this.form.imageList.push(img))
}
}).catch(() => {
this.$modal.msgError('图片上传失败,请重试')
}).finally(() => {
this.uploading = false
})
},
/** 上传模式:选择主文件 */
handleMainFileSelect(event) {
const file = event.target.files && event.target.files[0]
if (!file) return
event.target.value = ''
this.mainFile = file
// 自动填充文件名(不含扩展名)
if (!this.form.fileName) {
const name = file.name.lastIndexOf('.') > 0
? file.name.substring(0, file.name.lastIndexOf('.'))
: file.name
this.form.fileName = name
}
},
/** 上传模式:移除已选主文件 */
removeMainFile() {
this.mainFile = null
},
/** 编辑模式:触发替换主文件 */
handleReplaceMainFile() {
this.$refs.replaceMainFileInput.click()
},
/** 编辑模式:选择替换的主文件后直接上传 OSS 并更新表单 */
handleReplaceMainFileSelect(event) {
const file = event.target.files && event.target.files[0]
if (!file) return
event.target.value = ''
this.uploading = true
uploadFile(file).then(res => {
if (res.code === 200) {
this.form.filePath = res.data.url
this.form.fileSize = file.size
const ext = file.name.split('.').pop().toLowerCase()
this.form.suffix = ext
if (!this.form.fileName) {
this.form.fileName = file.name.lastIndexOf('.') > 0
? file.name.substring(0, file.name.lastIndexOf('.'))
: file.name
}
this.$modal.msgSuccess('文件替换成功')
} else {
this.$modal.msgError(res.msg || '文件上传失败')
}
}).catch(() => {
this.$modal.msgError('文件上传失败')
}).finally(() => {
this.uploading = false
})
},
/** 提交表单 */
submitForm() {
this.$refs['form'].validate(valid => {
if (valid) {
if (this.isEdit) {
this.doSubmit()
} else if (this.uploadMode === 'file') {
// 上传文件模式
if (!this.mainFile) {
this.$modal.msgWarning('请选择要上传的文件')
return
}
this.uploading = true
uploadFile(this.mainFile).then(res => {
if (res.code === 200) {
this.form.filePath = res.data.url
this.form.fileSize = this.mainFile.size
this.form.suffix = this.mainFile.name.split('.').pop().toLowerCase()
this.doSubmit()
} else {
this.$modal.msgError(res.msg || '文件上传失败')
}
}).catch(() => {
this.$modal.msgError('文件上传失败,请重试')
}).finally(() => {
this.uploading = false
})
} else if (this.uploadMode === 'image') {
// 上传图片模式(串行避免前端防重复提交拦截)
if (!this.stagedImages || this.stagedImages.length === 0) {
this.$modal.msgWarning('请选择要上传的图片')
return
}
this.uploading = true
const imageList = []
const uploadNext = (idx) => {
if (idx >= this.stagedImages.length) {
this.form.imageList = imageList
this.doSubmit()
return
}
const img = this.stagedImages[idx]
uploadFile(img.file).then(res => {
if (res.code === 200) {
imageList.push({
imageName: img.name,
imagePath: res.data.url,
imageSize: img.file.size,
suffix: img.name.split('.').pop().toLowerCase()
})
}
uploadNext(idx + 1)
}).catch(() => {
this.$modal.msgError('图片上传失败:' + img.name)
this.uploading = false
})
}
uploadNext(0)
}
}
})
},
/** 执行提交 */
doSubmit() {
const data = { ...this.form }
const visibleUsers = data.visibleUsers
delete data.visibleUsers
if (data.fileId) {
updateFile(data).then(response => {
this.saveVisibleUsers(data.fileId, visibleUsers)
this.$modal.msgSuccess('修改成功')
this.open = false
this.getList()
}).catch(() => {})
} else {
addFile(data).then(response => {
const newFileId = response.data
if (newFileId && visibleUsers && visibleUsers.length > 0) {
this.saveVisibleUsers(newFileId, visibleUsers)
}
this.$modal.msgSuccess('新增成功')
this.open = false
this.getList()
}).catch(() => {})
}
},
/** 保存可见用户 */
saveVisibleUsers(fileId, visibleUsers) {
if (!visibleUsers || visibleUsers.length === 0) {
if (this.originalVisibleUsers.length > 0) {
delVisibleUser(this.originalVisibleUsers.join(','))
}
return
}
const toAdd = visibleUsers.filter(u => !this.originalVisibleUsers.includes(u))
const toDel = this.originalVisibleUsers.filter(u => !visibleUsers.includes(u))
if (toDel.length > 0) {
listVisibleUserByFileId(fileId).then(res => {
const rows = res.rows || []
const delIds = rows.filter(r => toDel.includes(r.userId)).map(r => r.visibleId)
if (delIds.length > 0) {
delVisibleUser(delIds.join(','))
}
})
}
toAdd.forEach(userId => {
addVisibleUser({ fileId, userId })
})
},
/** 删除 */
handleDelete(row) {
const fileIds = row && row.fileId ? row.fileId : this.ids.join(',')
this.$modal.confirm('是否确认删除所选文件?').then(() => {
return delFile(fileIds)
}).then(() => {
this.getList()
this.$modal.msgSuccess('删除成功')
}).catch(() => {})
},
/** 导出 */
handleExport() {
this.download('system/file/export', {
...this.queryParams
}, `file_${new Date().getTime()}.xlsx`)
},
/** 详情弹窗关闭 */
handleInfoClose() {
this.infoFile = null
},
/** 打开详情弹窗 */
handleShowInfo(row) {
this.activeImageIndex = 0
this.infoTitle = '文件详情 - ' + row.fileName
this.infoVisible = true
incrementView(row.fileId)
// 加载完整详情(含 imageList
getFile(row.fileId).then(response => {
this.infoFile = response.data
}).catch(() => {
// 详情加载失败时用行数据兜底
this.infoFile = row
})
},
/** 多图预览切换 */
handlePreviewChange(idx) {
this.activeImageIndex = idx
},
/** 缩略图点击跳转 */
handleThumbnailClick(idx) {
this.activeImageIndex = idx
if (this.$refs.previewCarousel) {
this.$refs.previewCarousel.setActiveItem(idx)
}
},
/** 点击行选中 */
handleRowClick(row) {
// 行点击不做跳转,仅高亮
},
/** 加载评论列表 */
loadComments() {
this.commentLoading = true
listFileComment(this.infoFile.fileId).then(res => {
this.comments = res.data || []
this.commentLoading = false
}).catch(() => {
this.commentLoading = false
})
},
/** 发送评论 */
handleAddComment() {
const content = this.commentInput.trim()
if (!content) return
addFileComment({ fileId: this.infoFile.fileId, content }).then(() => {
this.commentInput = ''
this.loadComments()
})
},
/** 导出(详情弹窗中) */
handleExportInfo() {
this.download('system/file/export', {
...this.queryParams
}, `file_${new Date().getTime()}.xlsx`)
},
/** 下载文件 */
downloadFile(row) {
if (row.filePath) {
window.open(row.filePath, '_blank')
}
}
}
}
</script>
<style scoped>
/* 左右分栏 */
.file-layout {
display: flex;
align-items: stretch;
gap: 0;
height: calc(100vh - 140px);
}
/* 左侧边栏 */
.file-sidebar {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
padding-right: 12px;
padding-top: 4px;
overflow-y: auto;
}
.sidebar-section {
margin-bottom: 12px;
}
.sidebar-label {
font-size: 13px;
font-weight: 500;
color: #606266;
margin-bottom: 6px;
padding-left: 2px;
}
/* 文件类型列表(纵向) */
.sidebar-file-types {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.file-type-list {
flex: 1;
overflow-y: auto;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.type-list-item {
padding: 8px 12px;
font-size: 13px;
color: #606266;
cursor: pointer;
user-select: none;
border-bottom: 1px solid #f2f2f2;
transition: all 0.15s;
}
.type-list-item:last-child {
border-bottom: none;
}
.type-list-item:hover {
color: #5F7BA0;
background: #f5f7fa;
}
.type-list-item.active {
color: #fff;
background: #5F7BA0;
font-weight: 500;
}
.file-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
padding-top: 4px;
}
.main-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
/* 高级搜索 */
.advanced-search {
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
padding: 10px 16px;
margin-bottom: 8px;
}
.advanced-search-form {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.advanced-search-form /deep/ .el-form-item {
margin-bottom: 0;
}
/* 表格区域 */
.file-table-wrapper {
flex: 1;
display: flex;
flex-direction: column;
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: hidden;
}
.file-table-wrapper .KLPTable {
flex: 1;
}
/* 操作列按钮对齐 */
.action-cell {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
white-space: nowrap;
}
/* 详情弹窗主体 */
.info-dialog-body {
display: flex;
gap: 16px;
min-height: 400px;
}
.info-preview {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px solid #ebeef5;
border-radius: 4px;
overflow: auto;
padding: 8px;
}
.info-preview-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.info-meta {
width: 320px;
flex-shrink: 0;
overflow-y: auto;
}
/* 详情弹窗评论区 */
.info-comment {
margin-top: 12px;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.comment-bar {
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
font-size: 13px;
color: #606266;
user-select: none;
}
.comment-bar:hover {
background: #f5f7fa;
}
.comment-toggle {
color: #909399;
font-size: 12px;
}
.comment-body {
max-height: 220px;
overflow-y: auto;
border-top: 1px solid #ebeef5;
}
.comment-hint {
padding: 16px;
text-align: center;
color: #909399;
font-size: 13px;
}
.comment-list {
padding: 4px 0;
}
.comment-item {
padding: 8px 12px;
border-bottom: 1px solid #f2f2f2;
}
.comment-item:last-child {
border-bottom: none;
}
.comment-meta {
display: flex;
gap: 8px;
align-items: center;
font-size: 12px;
margin-bottom: 4px;
}
.comment-dept {
color: #409eff;
font-weight: 500;
}
.comment-user {
color: #606266;
}
.comment-time {
color: #c0c4cc;
}
.comment-text {
font-size: 13px;
color: #303133;
line-height: 1.5;
word-break: break-all;
}
.comment-input-area {
padding: 8px 12px;
border-top: 1px solid #f2f2f2;
}
/* 图片上传/编辑区域 */
.image-upload-area {
width: 100%;
}
.upload-hint {
font-size: 12px;
color: #909399;
margin-left: 8px;
}
.image-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.image-item {
position: relative;
width: 100px;
height: 100px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
cursor: default;
background: #fafafa;
}
.image-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-item-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.image-item:hover .image-item-mask {
opacity: 1;
}
.image-item-name {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2px 4px;
font-size: 11px;
color: #fff;
background: rgba(0, 0, 0, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.image-add-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
border: 1px dashed #dcdfe6;
color: #909399;
font-size: 12px;
transition: all 0.2s;
}
.image-add-btn:hover {
border-color: #409eff;
color: #409eff;
}
.image-add-btn i {
font-size: 28px;
margin-bottom: 4px;
}
.image-count {
font-size: 12px;
color: #909399;
margin-top: 6px;
}
/* 主文件上传区域 */
.file-upload-area {
width: 100%;
}
.main-file-info {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
padding: 8px 12px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
font-size: 13px;
}
.main-file-info .el-icon-document {
font-size: 18px;
color: #409eff;
}
.main-file-name {
color: #303133;
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.main-file-size {
color: #909399;
font-size: 12px;
}
.no-images-hint {
font-size: 13px;
color: #909399;
}
/* 详情弹窗多图轮播 */
.image-carousel-wrap {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.image-carousel-wrap .el-carousel {
flex-shrink: 0;
}
.carousel-image {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.carousel-image /deep/ .el-image__inner {
max-width: 100%;
max-height: 100%;
}
.thumbnail-strip {
display: flex;
gap: 6px;
padding: 8px 0;
overflow-x: auto;
flex-shrink: 0;
}
.thumbnail-item {
width: 56px;
height: 56px;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.2s;
}
.thumbnail-item:hover {
border-color: #409eff;
}
.thumbnail-item.active {
border-color: #409eff;
}
.thumbnail-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-counter {
text-align: center;
font-size: 12px;
color: #909399;
flex-shrink: 0;
}
</style>
<!-- 表格选中行高亮 -->
<style>
.file-main .el-table .current-row > td {
background-color: #ecf5ff !important;
}
</style>