feat: 附件上传

This commit is contained in:
2025-11-19 16:45:26 +08:00
parent dd3fd18132
commit 5af4d27532
17 changed files with 576 additions and 21 deletions

View File

@@ -29,6 +29,10 @@ export default {
requestDetail(data) {
return request('requestDetail', data, 'get')
},
// 获取项目附件信息
getAttachment(data) {
return request('getAttachment', data, 'get')
},
// 获取用户信息详情
devUsers(data) {
return request('dev-all-users', data, 'get')

View File

@@ -24,5 +24,9 @@ export default {
// 获取项目附件表详情
projectFileDetail(data) {
return request('detail', data, 'get')
},
// 获取项目附件表详情
bindProjectFile(data) {
return request('bindProjectFile', data, 'post')
}
}

View File

@@ -1,6 +1,8 @@
<template>
<a-card :bordered="false">
<a-spin :spinning="loading">
<div class="description-wrapper">
<div v-if="!previewVisible">
<a-card :bordered="false">
<a-spin :spinning="loading">
<!-- 项目名称标题 -->
<div class="project-title">
<h2>{{ parsedQuery?.projectName || '项目名称' }}</h2>
@@ -140,17 +142,56 @@
</a-card>
</div>
<a-empty v-else description="暂无数据" />
</a-spin>
</a-card>
<stage-add-dialog ref="formRef" @successful="fetchDetail"/>
<!-- 附件区域 -->
<div class="attachments-section">
<div class="attachments-header">
<div class="attachments-title">附件</div>
<a-button type="primary" @click="uploadFormRef.openUpload(projectInfo.projectId)">上传附件</a-button>
</div>
<div v-if="attachmentList.length" class="attachment-list">
<div
v-for="file in attachmentList"
:key="file.id"
class="attachment-item"
@click="handlePreview(file)"
>
<div class="attachment-thumb">
<img :src="getAttachmentThumbnail(file)" :alt="file.name" />
</div>
<div class="attachment-name" :title="file.name">
{{ file.name }}
</div>
<a-tag v-if="!canPreview(file)" color="default" class="attachment-tag">暂不支持预览</a-tag>
</div>
</div>
<a-empty v-else description="暂无附件" />
</div>
</a-spin>
</a-card>
</div>
<preview v-else ref="previewRef" @goBack="handlePreviewClose" />
<stage-add-dialog ref="formRef" @successful="fetchDetail"/>
<fj-upload-form ref="uploadFormRef" @successful="fetchAttachments"/>
</div>
</template>
<script setup lang="ts" name="Description">
import { computed, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import projectApi from '@/api/pmm/projectApi'
import { message } from 'ant-design-vue'
import StageAddDialog from "@/views/pmm/project/request/stageAddDialog.vue";
import Preview from '@/views/dev/file/preview.vue'
import FjUploadForm from "@/views/pmm/project/request/fjUploadForm.vue";
interface AttachmentItem {
id: number | string
name: string
suffix: string
downloadPath: string
thumbnail?: string
}
const route = useRoute()
@@ -158,6 +199,11 @@
const stageList = ref<any[]>([])
const plannedHours = ref(0)
const formRef = ref()
const previewRef = ref()
const uploadFormRef = ref()
const previewVisible = ref(false)
const attachmentList = ref<AttachmentItem[]>([])
const parsedQuery = computed(() => {
const payload = route.query.item as string | undefined
@@ -316,9 +362,156 @@
})
}
watch(projectId, () => {
fetchDetail()
}, { immediate: true })
const resolveAttachmentSource = (payload: unknown) => {
if (Array.isArray(payload)) {
return payload
}
if (payload && typeof payload === 'object') {
const obj = payload as Record<string, any>
const candidateKeys = ['data', 'records', 'rows', 'list']
for (const key of candidateKeys) {
if (Array.isArray(obj[key])) {
return obj[key]
}
}
}
return []
}
const getSuffixFromName = (name?: string) => {
if (!name || typeof name !== 'string') {
return ''
}
const lastDotIndex = name.lastIndexOf('.')
if (lastDotIndex === -1) {
return ''
}
return name.slice(lastDotIndex + 1)
}
const normalizeAttachmentRecord = (raw: Record<string, any>): AttachmentItem | null => {
if (!raw) {
return null
}
const name =
raw.name ||
raw.fileName ||
raw.originName ||
raw.originalName ||
raw.fileOriginalName ||
raw.attachmentName ||
raw.title
const downloadPath =
raw.downloadPath ||
raw.fileUrl ||
raw.url ||
raw.downloadUrl ||
raw.path ||
raw.filePath ||
raw.storagePath
if (!downloadPath) {
return null
}
const suffixSource =
raw.suffix ||
raw.fileSuffix ||
raw.fileType ||
getSuffixFromName(name) ||
getSuffixFromName(downloadPath)
const suffix = typeof suffixSource === 'string' ? suffixSource.toLowerCase() : ''
const thumbnail = raw.thumbnail || raw.thumbUrl || raw.previewUrl || raw.cover
const id = raw.id || raw.fileId || raw.attachmentId || raw.uid || downloadPath || name || Date.now()
return {
id,
name: name || '未命名文件',
suffix,
downloadPath,
thumbnail
}
}
const fetchAttachments = () => {
if (!projectId.value) {
attachmentList.value = []
return
}
projectApi
.getAttachment({ projectId: projectId.value })
.then((response) => {
const source = resolveAttachmentSource(response)
if (!source.length) {
attachmentList.value = []
return
}
const parsed = source
.map((item) => normalizeAttachmentRecord(item))
.filter((item): item is AttachmentItem => Boolean(item))
if (!parsed.length) {
message.warning('附件数据格式错误')
}
attachmentList.value = parsed
})
.catch((error) => {
console.warn('获取项目附件失败', error)
message.error('获取项目附件失败')
attachmentList.value = []
})
}
watch(projectId, () => {
fetchDetail()
fetchAttachments()
}, { immediate: true })
const previewableSuffix = ['doc', 'docx', 'xls', 'xlsx', 'pdf', 'jpg', 'png', 'gif', 'svg', 'ico', 'tmp', 'jpeg']
const getFileIcon = (suffix: string) => {
const lower = suffix?.toLowerCase()
const iconMap: Record<string, string> = {
doc: '/src/assets/images/fileImg/docx.png',
docx: '/src/assets/images/fileImg/docx.png',
xls: '/src/assets/images/fileImg/xlsx.png',
xlsx: '/src/assets/images/fileImg/xlsx.png',
ppt: '/src/assets/images/fileImg/ppt.png',
pptx: '/src/assets/images/fileImg/ppt.png',
pdf: '/src/assets/images/fileImg/pdf.png',
txt: '/src/assets/images/fileImg/txt.png',
zip: '/src/assets/images/fileImg/zip.png',
rar: '/src/assets/images/fileImg/rar.png',
html: '/src/assets/images/fileImg/html.png'
}
return iconMap[lower] || '/src/assets/images/fileImg/file.png'
}
const getAttachmentThumbnail = (file: AttachmentItem) => {
if (file.thumbnail) {
return file.thumbnail
}
const imgSuffix = ['png', 'jpg', 'jpeg', 'gif', 'bmp']
if (imgSuffix.indexOf(file.suffix?.toLowerCase()) > -1) {
return file.downloadPath
}
return getFileIcon(file.suffix)
}
const canPreview = (file: AttachmentItem) => {
return previewableSuffix.indexOf(file.suffix?.toLowerCase()) > -1
}
const handlePreview = (file: AttachmentItem) => {
if (!canPreview(file)) {
message.warning('当前文件暂不支持在线预览')
return
}
previewVisible.value = true
nextTick(() => {
previewRef.value?.onOpen(file)
})
}
const handlePreviewClose = () => {
previewVisible.value = false
}
</script>
<style scoped>
@@ -440,4 +633,69 @@
.action-btn {
padding: 0 8px;
}
.attachments-section {
margin-top: 32px;
border-top: 1px solid #f0f0f0;
padding-top: 24px;
}
.attachments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.attachments-title {
font-size: 18px;
font-weight: 600;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.attachment-item {
width: 120px;
text-align: center;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.attachment-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.attachment-thumb {
width: 80px;
height: 80px;
margin: 0 auto 8px;
display: flex;
align-items: center;
justify-content: center;
}
.attachment-thumb img {
max-width: 80px;
max-height: 80px;
object-fit: contain;
}
.attachment-name {
font-size: 13px;
color: #333;
word-break: break-all;
}
.attachment-tag {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,156 @@
<template>
<xn-form-container
title="文件上传"
:width="550"
:visible="visible"
:destroy-on-close="true"
:bodyStyle="{ 'padding-top': '0px' }"
@close="onClose"
>
<a-tabs v-model:activeKey="activeKey">
<a-tab-pane key="Local" tab="本地">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :show-upload-list="false" :custom-request="customRequestLocal">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
<a-tab-pane key="Aliyun" tab="阿里云">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :custom-request="customRequestAliyun" :show-upload-list="false">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
<a-tab-pane key="Tencent" tab="腾讯云">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :custom-request="customRequestTencent" :show-upload-list="false">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
<a-tab-pane key="Minio" tab="MINIO">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :custom-request="customRequestMinio" :show-upload-list="false">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
</a-tabs>
</xn-form-container>
</template>
<script setup name="uploadForm">
import fileApi from '@/api/dev/fileApi'
import projectFileApi from '@/api/pmm/projectFileApi'
// 定义emit事件
const emit = defineEmits({ successful: null })
// 默认是关闭状态
const visible = ref(false)
const activeKey = ref('Local')
const uploadLoading = ref(false)
const projectId = ref('')
// 打开抽屉
const openUpload = (record) => {
projectId.value = record
visible.value = true
}
// 关闭抽屉
const onClose = () => {
visible.value = false
projectId.value = ''
emit('successful')
}
// 上传本地文件
const customRequestLocal = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadLocalReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 上传阿里云文件
const customRequestAliyun = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadAliyunReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 上传腾讯文件
const customRequestTencent = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadTencentReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 上传Minio文件
const customRequestMinio = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadMinioReturnUrl(fileData)
.then((res) => {
bindProjectFile(res).then(() => {
emit('successful')
})
})
.finally(() => {
uploadLoading.value = false
})
}
// 绑定项目与文件
const bindProjectFile = (fileUrl) => {
const param = {
projectId: projectId.value,
fileAddress: fileUrl
}
return projectFileApi.bindProjectFile(param)
}
// 调用这个函数将子组件的一些数据和方法暴露出去
defineExpose({
openUpload
})
</script>