<template>
|
<div class="default-main">
|
<!-- 订单信息 + 申请人信息 + 交易内容(合并为同一卡片) -->
|
<el-card shadow="never">
|
<el-descriptions
|
:column="2"
|
border
|
class="mt10 order-desc fixed-label"
|
label-width="180px"
|
:label-style="labelStyle"
|
:content-style="contentStyle"
|
>
|
<el-descriptions-item :span="2" class="section-header">
|
<template #label>
|
<el-icon class="section-icon"><Document /></el-icon>
|
<span>订单信息</span>
|
</template>
|
<template #default></template>
|
</el-descriptions-item>
|
<el-descriptions-item label="订单编号">{{ detail.orderNo }}</el-descriptions-item>
|
<el-descriptions-item label="交易资源类型">{{ detail.resourceTypeName }}</el-descriptions-item>
|
<el-descriptions-item label="申请时间">{{ detail.applyTime }}</el-descriptions-item>
|
<el-descriptions-item label="交易状态">
|
<el-tag :type="getStatusType(detail.status)" size="small">{{ detail.statusName }}</el-tag>
|
</el-descriptions-item>
|
</el-descriptions>
|
|
<!-- 申请人信息(与订单信息同卡片,复用分隔标题样式) -->
|
<el-descriptions
|
:column="2"
|
border
|
class="mt15 order-desc fixed-label"
|
label-width="180px"
|
:label-style="labelStyle"
|
:content-style="contentStyle"
|
>
|
<el-descriptions-item :span="2" class="section-header">
|
<template #label>
|
<el-icon class="section-icon"><User /></el-icon>
|
<span>申请人信息</span>
|
</template>
|
<template #default></template>
|
</el-descriptions-item>
|
<el-descriptions-item label="姓名">{{ detail.userName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="单位">{{ detail.unitName || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="部门">{{ detail.userDept || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="用户名">{{ detail.userAccount || '-' }}</el-descriptions-item>
|
</el-descriptions>
|
|
<!-- 交易内容(紧随申请人信息,同卡片,复用分隔标题样式) -->
|
<el-descriptions
|
:column="2"
|
border
|
class="mt15 order-desc fixed-label"
|
label-width="180px"
|
:label-style="labelStyle"
|
:content-style="contentStyle"
|
>
|
<el-descriptions-item :span="2" class="section-header">
|
<template #label>
|
<el-icon class="section-icon"><Goods /></el-icon>
|
<span>交易内容</span>
|
</template>
|
<template #default></template>
|
</el-descriptions-item>
|
<el-descriptions-item label="产品名称">
|
<el-link type="primary" :underline="false">{{ detail.productName }}</el-link>
|
</el-descriptions-item>
|
<el-descriptions-item label="提供者">{{ detail.supplier }}</el-descriptions-item>
|
<el-descriptions-item label="行业领域">{{ detail.industry }}</el-descriptions-item>
|
<el-descriptions-item label="单位工程">{{ detail.projectUnit }}</el-descriptions-item>
|
<el-descriptions-item label="产品类型">{{ detail.productType || '-' }}</el-descriptions-item>
|
<el-descriptions-item label="产品简介">
|
<div class="desc-wrap">{{ detail.productDesc }}</div>
|
</el-descriptions-item>
|
</el-descriptions>
|
|
<!-- 订单详情(移动到交易内容下面,同一卡片内) -->
|
<div ref="orderTableWrapRef">
|
<el-table
|
:data="tableData"
|
border
|
class="mt10 order-table"
|
:header-cell-style="headerCenterStyle"
|
:cell-style="bodyCellStyle"
|
:row-class-name="getRowClassName"
|
:span-method="arraySpanMethod"
|
>
|
<el-table-column>
|
<template #header>
|
<el-icon class="header-icon"><List /></el-icon>
|
<span>详情</span>
|
</template>
|
<el-table-column label="" :width="colWidths.detail1">
|
<template #default="{ row }">
|
<div v-if="!row.isSummary">{{ row.name }}</div>
|
<div v-else class="summary-merged">
|
<div class="summary-left">
|
共 <span class="count">{{ detail.items.length }}</span> 件
|
</div>
|
<div class="summary-right">
|
总计:<span class="price">{{ detail.pointTotal }}</span> 积分
|
<span class="ml20 price">{{ detail.cashTotal }}</span> 元
|
</div>
|
</div>
|
</template>
|
</el-table-column>
|
<el-table-column label="" :width="colWidths.detail2">
|
<template #default="{ row }">
|
<div v-if="!row.isSummary" class="gray">销售形式:{{ row.saleType || '-' }}</div>
|
<div v-if="!row.isSummary" class="gray">账户数量:{{ row.accountCount ?? '-' }}</div>
|
</template>
|
</el-table-column>
|
<el-table-column label="" :width="colWidths.detail3">
|
<template #default="{ row }">
|
<div v-if="!row.isSummary" class="gray">客户对象:{{ row.customerTarget || '-' }}</div>
|
<div v-if="!row.isSummary" class="gray">并发节点数量:{{ row.concurrentNodes ?? '-' }}</div>
|
</template>
|
</el-table-column>
|
</el-table-column>
|
<el-table-column label="单价">
|
<el-table-column label="" :width="colWidths.price">
|
<template #default="{ row }">
|
<div v-if="!row.isSummary">{{ formatPrice(row) }}</div>
|
</template>
|
</el-table-column>
|
</el-table-column>
|
<el-table-column label="数量">
|
<el-table-column label="" :width="colWidths.quantity">
|
<template #default="{ row }">
|
<div v-if="!row.isSummary">{{ row.quantity }}</div>
|
</template>
|
</el-table-column>
|
</el-table-column>
|
<el-table-column label="期限(年)">
|
<el-table-column label="" :width="colWidths.period">
|
<template #default="{ row }">
|
<div v-if="!row.isSummary">{{ formatPeriod(row) }}</div>
|
</template>
|
</el-table-column>
|
</el-table-column>
|
</el-table>
|
</div>
|
|
<!-- 移除原来的表格底部信息,因为已经移到表格最后一行 -->
|
</el-card>
|
|
<!-- 交易文件 -->
|
<el-card class="mt15" shadow="never">
|
<div class="title">交易文件</div>
|
|
<!-- 文件上传区域 -->
|
<div class="upload-section">
|
<div class="upload-label">
|
<span class="required">*</span>
|
<span>文件上传:</span>
|
</div>
|
<el-upload
|
class="upload-component"
|
:auto-upload="false"
|
v-model:file-list="fileList"
|
:limit="5"
|
:on-exceed="onExceed"
|
:on-remove="handleRemove"
|
:show-file-list="false"
|
:before-upload="beforeUpload"
|
>
|
<el-button type="primary">选择文件</el-button>
|
</el-upload>
|
</div>
|
|
<!-- 文件列表表格 -->
|
<div class="file-table-section" v-if="fileList.length > 0">
|
<el-table
|
:data="fileList"
|
border
|
class="file-table"
|
:header-cell-style="fileTableHeaderStyle"
|
:cell-style="fileTableCellStyle"
|
>
|
<el-table-column label="文件名称" prop="name" min-width="200">
|
<template #default="{ row }">
|
<div class="file-name">
|
<el-icon class="file-icon"><Document /></el-icon>
|
<span>{{ row.name }}</span>
|
</div>
|
</template>
|
</el-table-column>
|
<el-table-column label="文件大小" prop="size" width="120">
|
<template #default="{ row }">
|
{{ formatFileSize(row.size) }}
|
</template>
|
</el-table-column>
|
<el-table-column label="操作" width="280">
|
<template #default="{ row, $index }">
|
<div class="file-actions">
|
<el-button
|
type="text"
|
size="small"
|
class="upload-btn"
|
@click="handleUpload(row)"
|
v-if="!row.status || row.status === 'ready'"
|
:loading="row.uploading"
|
>
|
{{ row.uploading ? '上传中' : '上传' }}
|
</el-button>
|
<el-button
|
type="text"
|
size="small"
|
class="preview-btn"
|
@click="handlePreview(row)"
|
v-if="isPreviewable(row)"
|
:disabled="!isFileUploaded(row)"
|
>
|
预览
|
</el-button>
|
<el-button
|
type="text"
|
size="small"
|
class="download-btn"
|
@click="handleDownload(row)"
|
:disabled="!isFileUploaded(row)"
|
>
|
下载
|
</el-button>
|
<el-button
|
type="text"
|
size="small"
|
class="delete-btn"
|
@click="handleRemove(row, fileList)"
|
>
|
删除
|
</el-button>
|
</div>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
|
<!-- 操作按钮 -->
|
<div class="action-buttons">
|
<el-button @click="goBack">返回</el-button>
|
<el-button type="primary" @click="submit">提交</el-button>
|
</div>
|
</el-card>
|
</div>
|
</template>
|
|
<script setup lang="ts">
|
import { onMounted, reactive, ref, computed, type CSSProperties } from 'vue'
|
import { useRoute, useRouter } from 'vue-router'
|
import { Document, User, Goods, List } from '@element-plus/icons-vue'
|
import { uploadTradeFile } from '@/api/tradeManage'
|
import orderApi from '@/api/orderApi'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { useUserInfo } from '@/stores/modules/userInfo'
|
import createAxios from '@/utils/axios'
|
|
const route = useRoute()
|
const router = useRouter()
|
const userStore = useUserInfo()
|
const detail = reactive<any>({ items: [] })
|
const fileList = ref<any[]>([])
|
const orderTableWrapRef = ref<HTMLElement | null>(null)
|
const labelStyle = { width: '180px', maxWidth: '180px' }
|
const contentStyle = { width: 'calc(50% - 180px)' }
|
|
// 计算表格数据,添加汇总行
|
const tableData = computed(() => {
|
const summaryRow = {
|
id: 'summary',
|
isSummary: true,
|
name: '',
|
saleType: '',
|
accountCount: 0,
|
customerTarget: '',
|
concurrentNodes: 0,
|
pricePoint: 0,
|
priceCash: 0,
|
quantity: 0,
|
period: 0,
|
}
|
return [...detail.items, summaryRow]
|
})
|
|
// 状态映射(后端中文 -> 前端枚举)
|
const statusServerToUi: Record<string, string> = {
|
'待上传文件': 'WAIT_UPLOAD',
|
'待授权': 'WAIT_AUTHORIZE',
|
'待交易确认': 'WAIT_CONFIRM',
|
'已完成': 'COMPLETED',
|
'已评价': 'EVALUATED',
|
}
|
|
const formatDateTime = (val?: string) => (val ? val.replace('T', ' ').slice(0, 19) : '')
|
|
const normalizePriceType = (val?: string): 'points' | 'currency' | 'agreement' | 'free' => {
|
if (!val) return 'currency'
|
const s = String(val)
|
if (/(积分|points)/i.test(s)) return 'points'
|
if (/(协议|agreement)/i.test(s)) return 'agreement'
|
if (/(免费|free)/i.test(s)) return 'free'
|
return 'currency'
|
}
|
|
onMounted(async () => {
|
const orderId = String(route.params.id || '')
|
if (!orderId) {
|
ElMessage.error('订单ID不能为空')
|
return
|
}
|
|
try {
|
const res = (await orderApi.getOrderDetail(orderId)) as any
|
const data = res?.data || {}
|
|
const statusName: string = data.orderStatus || ''
|
const uiStatus = statusServerToUi[statusName] || 'INFO'
|
|
// 映射订单详情头部信息
|
const head = {
|
orderNo: data.orderId,
|
resourceTypeName: '软件产品',
|
status: uiStatus,
|
statusName,
|
applyTime: formatDateTime(data.applyTime),
|
unitName: data.unitName || '-',
|
userName: data.userName || '-',
|
userAccount: data.userAccount || '-',
|
userDept: data.userDept || '-',
|
userPhone: data.userPhone || '-',
|
productName: data.productName || '-',
|
supplier: data.providerName || '-',
|
industry: data.industry || '-',
|
projectUnit: data.projectUnit || '-',
|
productType: data.productType || '-',
|
productDesc: data.productDesc || '-',
|
}
|
|
// 明细项映射
|
const items: any[] = Array.isArray(data.orderDetails)
|
? data.orderDetails.map((d: any, idx: number) => {
|
const pt = normalizePriceType(d.priceType)
|
return {
|
id: String(d.id ?? idx + 1),
|
name: d.suiteName,
|
saleType: d.salesForm,
|
accountCount: d.accountLimit,
|
customerTarget: d.customerType,
|
concurrentNodes: d.concurrentNodes,
|
pricePoint: pt === 'points' ? Number(d.unitPrice || 0) : 0,
|
priceCash: pt === 'currency' ? Number(d.unitPrice || 0) : 0,
|
priceProtocol: pt === 'agreement',
|
quantity: Number(d.quantity || 0),
|
period: Number(d.duration || 0),
|
}
|
})
|
: []
|
|
// 汇总(简单相加:单价*数量)
|
const pointTotalNum = items.reduce((sum, it) => sum + Number(it.pricePoint || 0) * Number(it.quantity || 0), 0)
|
const cashTotalNum = items.reduce((sum, it) => sum + Number(it.priceCash || 0) * Number(it.quantity || 0), 0)
|
|
Object.assign(detail, head, {
|
items,
|
pointTotal: pointTotalNum.toLocaleString(),
|
cashTotal: cashTotalNum.toLocaleString(),
|
})
|
|
// 如果有已上传的文件,显示在文件列表中
|
if (data.attachments && Array.isArray(data.attachments)) {
|
fileList.value = data.attachments.map((file: any) => ({
|
name: file.fileName,
|
size: file.fileSize,
|
uid: file.id,
|
status: 'success',
|
url: file.fileUrl
|
}))
|
}
|
} catch (error) {
|
console.error('获取订单详情失败:', error)
|
ElMessage.error('获取订单详情失败')
|
}
|
})
|
|
const onExceed = () => ElMessage.warning('最多选择5个文件')
|
|
// 文件上传前的验证
|
const beforeUpload = (file: File) => {
|
// 检查文件大小(限制为100MB)
|
const maxSize = 100 * 1024 * 1024
|
if (file.size > maxSize) {
|
ElMessage.error('文件大小不能超过100MB')
|
return false
|
}
|
|
// 检查文件名长度
|
if (file.name.length > 100) {
|
ElMessage.error('文件名称不能超过100字符')
|
return false
|
}
|
|
// 设置文件初始状态
|
const fileObj = {
|
name: file.name,
|
size: file.size,
|
type: file.type,
|
raw: file,
|
status: 'ready', // 初始状态为准备上传
|
uploading: false,
|
uploaded: false
|
}
|
|
// 将文件添加到文件列表
|
fileList.value.push(fileObj)
|
|
return false // 阻止自动上传,改为手动上传
|
}
|
|
// 上传单个文件到服务器
|
const uploadSingleFile = async (file: File) => {
|
const formData = new FormData()
|
formData.append('file', file)
|
formData.append('folder', 'order-attachments')
|
|
try {
|
console.log('开始上传文件:', file.name, '大小:', file.size)
|
|
const response = await createAxios({
|
url: '/admin/file/upload',
|
method: 'POST',
|
headers: {
|
'Content-Type': 'multipart/form-data'
|
},
|
data: formData
|
})
|
|
console.log('文件上传响应:', response)
|
|
// 检查响应格式 - 根据实际返回格式调整
|
const responseData = response as any
|
|
// 根据实际响应格式,直接检查 responseData.code
|
if (responseData && responseData.code === 200) {
|
console.log('文件上传成功,返回数据:', responseData.data)
|
return responseData.data // 返回文件URL
|
} else if (responseData && responseData.data && responseData.data.code === 200) {
|
// 备用检查:如果响应被包装在 data 中
|
console.log('文件上传成功,返回数据:', responseData.data.data)
|
return responseData.data.data // 返回文件URL
|
} else {
|
// 处理错误情况
|
const errorMsg = responseData?.msg || responseData?.data?.msg || responseData?.message || responseData?.data?.message || '文件上传失败'
|
console.error('文件上传失败,错误信息:', errorMsg)
|
throw new Error(errorMsg)
|
}
|
} catch (error) {
|
console.error('文件上传异常:', error)
|
throw error
|
}
|
}
|
|
// 保存文件信息到数据库
|
const saveFileInfo = async (fileData: any) => {
|
try {
|
console.log('开始保存文件信息:', fileData)
|
|
// 使用FormData格式,因为后端使用@RequestParam接收参数
|
const formData = new FormData()
|
formData.append('orderId', fileData.orderId)
|
formData.append('fileName', fileData.fileName)
|
formData.append('originalName', fileData.originalName)
|
formData.append('fileType', fileData.fileType)
|
formData.append('fileSize', fileData.fileSize.toString())
|
formData.append('fileUrl', fileData.fileUrl)
|
formData.append('bucketName', fileData.bucketName)
|
formData.append('objectName', fileData.objectName)
|
formData.append('uploadUserId', fileData.uploadUserId.toString())
|
formData.append('uploadUserName', fileData.uploadUserName)
|
formData.append('attachmentType', fileData.attachmentType)
|
formData.append('description', fileData.description)
|
|
console.log('准备发送的文件信息:', {
|
orderId: fileData.orderId,
|
fileName: fileData.fileName,
|
fileSize: fileData.fileSize,
|
fileUrl: fileData.fileUrl,
|
uploadUserId: fileData.uploadUserId,
|
uploadUserName: fileData.uploadUserName
|
})
|
|
const response = await createAxios({
|
url: '/admin/api/order/attachment/upload',
|
method: 'POST',
|
headers: {
|
'Content-Type': 'multipart/form-data'
|
},
|
data: formData
|
})
|
|
console.log('保存文件信息响应:', response)
|
|
// 使用与文件上传相同的响应判断逻辑
|
const responseData = response as any
|
|
if (responseData && responseData.code === 200) {
|
console.log('文件信息保存成功,返回的附件ID:', responseData.data)
|
const attachmentId = responseData.data
|
|
// 验证attachmentId是否为有效的数字
|
if (typeof attachmentId === 'number' && attachmentId > 0) {
|
return attachmentId
|
} else {
|
console.error('返回的附件ID不是有效数字:', attachmentId, typeof attachmentId)
|
throw new Error(`无效的附件ID: ${attachmentId}`)
|
}
|
} else if (responseData && responseData.data && responseData.data.code === 200) {
|
// 备用检查:如果响应被包装在 data 中
|
console.log('文件信息保存成功(备用检查),返回的附件ID:', responseData.data.data)
|
const attachmentId = responseData.data.data
|
|
// 验证attachmentId是否为有效的数字
|
if (typeof attachmentId === 'number' && attachmentId > 0) {
|
return attachmentId
|
} else {
|
console.error('返回的附件ID不是有效数字(备用检查):', attachmentId, typeof attachmentId)
|
throw new Error(`无效的附件ID: ${attachmentId}`)
|
}
|
} else {
|
// 处理错误情况
|
const errorMsg = responseData?.msg || responseData?.data?.msg || responseData?.message || responseData?.data?.message || '保存文件信息失败'
|
console.error('文件信息保存失败:', errorMsg)
|
throw new Error(errorMsg)
|
}
|
} catch (error) {
|
console.error('保存文件信息异常:', error)
|
throw error
|
}
|
}
|
|
|
|
const goBack = () => router.back()
|
|
// 提交文件并更新订单状态
|
const submit = async () => {
|
if (fileList.value.length === 0) {
|
ElMessage.warning('请至少上传一个文件')
|
return
|
}
|
|
// 检查是否有未上传的文件
|
const unuploadedFiles = fileList.value.filter(file => !file.uploaded && !file.url)
|
if (unuploadedFiles.length > 0) {
|
ElMessage.warning('请先上传所有文件')
|
return
|
}
|
|
try {
|
const orderId = String(route.params.id || '')
|
const userId = userStore.getUserId
|
const userName = userStore.username || userStore.name || '未知用户'
|
|
// 上传所有未上传的文件
|
const uploadPromises = fileList.value
|
.filter(fileItem => fileItem.raw && !fileItem.uploaded)
|
.map(async (fileItem) => {
|
const fileUrl = await uploadSingleFile(fileItem.raw)
|
|
// 保存文件信息到数据库
|
const attachmentData = {
|
orderId: orderId,
|
fileName: fileItem.name,
|
originalName: fileItem.name,
|
fileType: fileItem.type || 'application/octet-stream',
|
fileSize: fileItem.size,
|
fileUrl: fileUrl,
|
bucketName: 'order-attachments',
|
objectName: fileUrl.split('/').pop(),
|
uploadUserId: userId,
|
uploadUserName: userName,
|
attachmentType: 'TRADE_FILE',
|
description: '交易文件'
|
}
|
|
await saveFileInfo(attachmentData)
|
})
|
|
await Promise.all(uploadPromises)
|
|
// 更新订单状态进入下一个状态
|
await orderApi.updateOrderStatusToNext(orderId)
|
|
ElMessage.success('提交成功')
|
router.back()
|
} catch (error) {
|
console.error('提交失败:', error)
|
ElMessage.error(error instanceof Error ? error.message : '提交失败')
|
}
|
}
|
|
// 与列表页保持一致的状态类型映射(UI展示用)
|
const getStatusType = (status: string) => {
|
const statusMap: Record<string, 'warning' | 'danger' | 'success' | 'info'> = {
|
WAIT_APPROVAL: 'warning',
|
WAIT_UPLOAD: 'warning',
|
WAIT_CHECK: 'warning',
|
WAIT_CONFIRM: 'warning',
|
REJECTED: 'danger',
|
FINISH: 'success',
|
}
|
return statusMap[status] || 'info'
|
}
|
|
// 订单详情中"单价"显示:优先显示积分,其次显示货币;格式示例:
|
// "积分:50,000/套" 或 "货币:7,500/套/年" 或 "免费:/年"
|
const formatPrice = (row: any) => {
|
const point = Number(row.pricePoint || 0)
|
const cash = Number(row.priceCash || 0)
|
const protocol = Boolean(row.priceProtocol)
|
|
// 免费
|
if (!point && !cash) {
|
return protocol ? '协议:/年' : '免费:/年'
|
}
|
if (point) {
|
return `积分:${point.toLocaleString()}/套`
|
}
|
// 仅现金
|
return `货币:${cash.toLocaleString()}/套/年`
|
}
|
|
// 期限展示:0 表示"永久",其他显示数字
|
const formatPeriod = (row: any) => {
|
const p = Number(row.period || 0)
|
return p === 0 ? '永久' : `${p}`
|
}
|
|
// 表头文字居中,但第一行的"详情"文字靠左对齐
|
const headerCenterStyle: CSSProperties = {
|
textAlign: 'center',
|
fontSize: '14px',
|
background: '#f3f6fb'
|
}
|
|
// 表体文字大小
|
const bodyCellStyle: CSSProperties = { fontSize: '12px' }
|
|
// 为汇总行添加特殊样式类名
|
const getRowClassName = ({ row }: { row: any }) => {
|
return row.isSummary ? 'summary-row' : ''
|
}
|
|
// 单元格合并方法
|
const arraySpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
|
if (row.isSummary) {
|
// 汇总行:第一列显示合并内容,其他列隐藏
|
if (columnIndex === 0) {
|
return [1, 6] // 合并1行6列
|
} else {
|
return [0, 0] // 隐藏其他列
|
}
|
}
|
return [1, 1] // 普通行正常显示
|
}
|
|
// 症结与修复说明:
|
// 1) Element Plus 的 el-table 子列 width 百分比是相对于表格容器的像素宽度计算,但只有在表格容器有明确宽度时才生效。
|
// 2) 在父列/多级表头下,子列 width 为百分比时,更稳定的做法是将其计算为像素值绑定给子列。
|
// 3) 因此我们读取表格外层容器宽度,按比例计算像素宽度,避免出现百分比与 table-layout 导致的错位与拉伸。
|
const colWidths = computed(() => {
|
const containerWidth = orderTableWrapRef.value?.clientWidth || 0
|
// 百分比分别为:详情 20% x 3,单价 15%,数量 10%,期限 15% => 合计 100%
|
return {
|
detail1: Math.floor(containerWidth * 0.2),
|
detail2: Math.floor(containerWidth * 0.2),
|
detail3: Math.floor(containerWidth * 0.2),
|
price: Math.floor(containerWidth * 0.15),
|
quantity: Math.floor(containerWidth * 0.1),
|
period: Math.floor(containerWidth * 0.15),
|
}
|
})
|
|
// 文件大小格式化
|
const formatFileSize = (size: number) => {
|
if (!size || size === 0) return '0 Bytes';
|
const k = 1024;
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
const i = Math.floor(Math.log(size) / Math.log(k));
|
return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
};
|
|
// 文件列表表格表头文字居中
|
const fileTableHeaderStyle: CSSProperties = { textAlign: 'center' };
|
// 文件列表表格表体文字大小
|
const fileTableCellStyle: CSSProperties = { fontSize: '12px' };
|
|
// 判断文件是否可预览
|
const isPreviewable = (file: any) => {
|
// 首先检查MIME类型
|
const previewableTypes = [
|
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/bmp', 'image/webp',
|
'text/plain', 'text/html', 'text/css', 'text/javascript',
|
'application/pdf',
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
|
]
|
|
// 如果MIME类型匹配,直接返回true
|
if (previewableTypes.includes(file.type || '')) {
|
return true
|
}
|
|
// 如果MIME类型为空或不匹配,根据文件扩展名判断
|
const fileName = file.name || ''
|
const fileExtension = fileName.toLowerCase().split('.').pop()
|
|
const previewableExtensions = [
|
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp',
|
'txt', 'html', 'htm', 'css', 'js',
|
'pdf',
|
'docx', 'xlsx', 'pptx'
|
]
|
|
return previewableExtensions.includes(fileExtension)
|
}
|
|
// 文件预览
|
const handlePreview = async (file: any) => {
|
if (!file.url) {
|
ElMessage.warning('文件链接不存在')
|
return
|
}
|
|
// 获取文件扩展名
|
const fileName = file.name || ''
|
const fileExtension = fileName.toLowerCase().split('.').pop()
|
|
let previewUrl = file.url
|
|
// 如果文件存储在MinIO,优先使用预览URL
|
if (file.url.includes('order-attachments')) {
|
try {
|
// 首先尝试获取预览URL
|
const previewResponse = await createAxios({
|
url: `/admin/file/preview`,
|
method: 'GET',
|
params: {
|
fileName: file.url
|
}
|
})
|
|
console.log('预览URL响应:', previewResponse)
|
|
// 检查响应格式
|
const responseData = previewResponse as any
|
if (responseData && responseData.code === 200 && responseData.data) {
|
previewUrl = responseData.data
|
console.log('使用预览URL:', previewUrl)
|
} else {
|
console.log('预览URL获取失败,使用下载方式')
|
// 如果预览URL获取失败,回退到下载方式
|
const response = await createAxios({
|
url: `/admin/file/download`,
|
method: 'GET',
|
responseType: 'blob',
|
params: {
|
fileName: file.url,
|
originalName: file.name
|
}
|
})
|
|
// 创建预览URL
|
const blob = new Blob([response as any])
|
previewUrl = window.URL.createObjectURL(blob)
|
console.log('使用Blob URL:', previewUrl)
|
}
|
} catch (error) {
|
console.error('获取文件失败:', error)
|
ElMessage.error('获取文件失败,无法预览')
|
return
|
}
|
}
|
|
// 图片文件直接在新窗口打开
|
if ((file.type && file.type.startsWith('image/')) ||
|
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(fileExtension)) {
|
console.log('预览图片文件:', previewUrl)
|
window.open(previewUrl, '_blank')
|
return
|
}
|
|
// PDF文件在新窗口打开
|
if (file.type === 'application/pdf' || fileExtension === 'pdf') {
|
console.log('预览PDF文件:', previewUrl)
|
window.open(previewUrl, '_blank')
|
return
|
}
|
|
// 文本文件在新窗口打开
|
if ((file.type && file.type.startsWith('text/')) ||
|
['txt', 'html', 'htm', 'css', 'js'].includes(fileExtension)) {
|
console.log('预览文本文件:', previewUrl)
|
window.open(previewUrl, '_blank')
|
return
|
}
|
|
// Office文档和其他文件类型,尝试在新窗口打开
|
try {
|
console.log('预览其他文件:', previewUrl)
|
window.open(previewUrl, '_blank')
|
} catch (error) {
|
console.error('预览失败:', error)
|
ElMessage.error('预览失败,请下载后查看')
|
}
|
}
|
|
// 判断文件是否已上传成功
|
const isFileUploaded = (file: any) => {
|
// 文件有url且状态为success表示已上传成功
|
return file.url && (file.status === 'success' || file.uploaded)
|
}
|
|
// 单个文件上传
|
const handleUpload = async (file: any) => {
|
if (!file.raw) {
|
ElMessage.warning('文件数据不存在')
|
return
|
}
|
|
// 设置上传状态
|
file.uploading = true
|
|
try {
|
// 上传文件到服务器
|
const fileUrl = await uploadSingleFile(file.raw)
|
|
// 保存文件信息到数据库
|
const orderId = String(route.params.id || '')
|
const userId = userStore.getUserId
|
const userName = userStore.username || userStore.name || '未知用户'
|
|
const attachmentData = {
|
orderId: orderId,
|
fileName: file.name,
|
originalName: file.name,
|
fileType: file.type || 'application/octet-stream',
|
fileSize: file.size,
|
fileUrl: fileUrl,
|
bucketName: 'order-attachments',
|
objectName: fileUrl.split('/').pop(),
|
uploadUserId: userId,
|
uploadUserName: userName,
|
attachmentType: 'TRADE_FILE',
|
description: '交易文件'
|
}
|
|
const attachmentId = await saveFileInfo(attachmentData)
|
|
// 更新文件状态
|
file.url = fileUrl
|
file.uid = attachmentId // 设置正确的附件ID
|
file.status = 'success'
|
file.uploaded = true
|
file.uploading = false
|
|
ElMessage.success('文件上传成功')
|
} catch (error) {
|
console.error('文件上传失败:', error)
|
file.uploading = false
|
ElMessage.error(error instanceof Error ? error.message : '文件上传失败')
|
}
|
}
|
|
// 文件下载
|
const handleDownload = async (file: any) => {
|
if (!file.url) {
|
ElMessage.warning('文件链接不存在')
|
return
|
}
|
|
console.log('开始下载文件:', file.name, 'URL:', file.url)
|
|
try {
|
// 如果文件存储在MinIO,使用后端直接下载API
|
if (file.url.includes('order-attachments')) {
|
console.log('使用MinIO下载API')
|
|
// 使用axios通过代理访问后端API
|
const response = await createAxios({
|
url: `/admin/file/download`,
|
method: 'GET',
|
responseType: 'blob',
|
params: {
|
fileName: file.url,
|
originalName: file.name
|
}
|
})
|
|
console.log('下载响应:', response)
|
|
// 创建下载链接
|
const blob = new Blob([response as any])
|
const downloadUrl = window.URL.createObjectURL(blob)
|
|
console.log('创建下载链接:', downloadUrl)
|
|
const link = document.createElement('a')
|
link.href = downloadUrl
|
link.download = file.name || 'download'
|
link.target = '_blank'
|
link.rel = 'noopener noreferrer'
|
|
document.body.appendChild(link)
|
link.click()
|
document.body.removeChild(link)
|
|
// 清理URL对象
|
window.URL.revokeObjectURL(downloadUrl)
|
|
ElMessage.success('文件下载成功')
|
} else {
|
console.log('使用直接URL下载')
|
// 其他情况直接使用原URL
|
const link = document.createElement('a')
|
link.href = file.url
|
link.download = file.name || 'download'
|
link.target = '_blank'
|
link.rel = 'noopener noreferrer'
|
|
document.body.appendChild(link)
|
link.click()
|
document.body.removeChild(link)
|
|
ElMessage.success('开始下载文件')
|
}
|
} catch (error) {
|
console.error('下载失败:', error)
|
ElMessage.error('下载失败,请重试')
|
}
|
}
|
|
// 删除MinIO文件
|
const deleteMinioFile = async (fileName: string) => {
|
try {
|
console.log('开始删除MinIO文件:', fileName)
|
|
const response = await createAxios({
|
url: `/admin/file/delete`,
|
method: 'DELETE',
|
params: {
|
fileName: fileName
|
}
|
})
|
|
console.log('删除MinIO文件响应:', response)
|
|
const responseData = response as any
|
if (responseData && responseData.code === 200) {
|
console.log('MinIO文件删除成功')
|
return true
|
} else {
|
const errorMsg = responseData?.msg || responseData?.data?.msg || responseData?.message || responseData?.data?.message || '删除MinIO文件失败'
|
console.error('删除MinIO文件失败:', errorMsg)
|
throw new Error(errorMsg)
|
}
|
} catch (error) {
|
console.error('删除MinIO文件异常:', error)
|
throw error
|
}
|
}
|
|
// 删除数据库附件记录
|
const deleteAttachmentRecord = async (attachmentId: number) => {
|
try {
|
console.log('开始删除数据库附件记录:', attachmentId)
|
|
const response = await createAxios({
|
url: `/admin/api/order/attachment/delete/${attachmentId}`,
|
method: 'DELETE'
|
})
|
|
console.log('删除数据库附件记录响应:', response)
|
|
const responseData = response as any
|
if (responseData && responseData.code === 200) {
|
console.log('数据库附件记录删除成功')
|
return true
|
} else {
|
const errorMsg = responseData?.msg || responseData?.data?.msg || responseData?.message || responseData?.data?.message || '删除数据库附件记录失败'
|
console.error('删除数据库附件记录失败:', errorMsg)
|
throw new Error(errorMsg)
|
}
|
} catch (error) {
|
console.error('删除数据库附件记录异常:', error)
|
throw error
|
}
|
}
|
|
// 文件列表移除文件
|
const handleRemove = async (file: any, uploadFiles: any) => {
|
try {
|
// 显示确认对话框
|
await ElMessageBox.confirm(
|
`确定要删除文件 "${file.name}" 吗?`,
|
'删除确认',
|
{
|
confirmButtonText: '确定',
|
cancelButtonText: '取消',
|
type: 'warning',
|
center: true
|
}
|
)
|
|
// 用户确认后执行删除操作
|
// 如果是已上传的文件(有uid且为数字,表示数据库记录ID)
|
if (file.uid && !isNaN(file.uid) && file.url) {
|
console.log('删除已上传的文件:', file.name, '附件ID:', file.uid)
|
|
// 1. 删除MinIO中的文件
|
if (file.url.includes('order-attachments')) {
|
await deleteMinioFile(file.url)
|
}
|
|
// 2. 删除数据库中的附件记录
|
await deleteAttachmentRecord(file.uid)
|
|
// 3. 从文件列表中移除
|
const index = fileList.value.findIndex(item => item.uid === file.uid)
|
if (index > -1) {
|
fileList.value.splice(index, 1)
|
}
|
|
ElMessage.success('文件删除成功')
|
} else {
|
// 如果是未上传的文件(只有raw文件对象)
|
console.log('删除未上传的文件:', file.name)
|
|
// 直接从文件列表中移除
|
const index = fileList.value.findIndex(item => item.name === file.name && item.size === file.size)
|
if (index > -1) {
|
fileList.value.splice(index, 1)
|
}
|
|
ElMessage.success('文件已删除')
|
}
|
} catch (error) {
|
// 如果用户取消删除,error为'cancel',不需要显示错误消息
|
if (error === 'cancel') {
|
console.log('用户取消删除操作')
|
return
|
}
|
|
console.error('删除文件失败:', error)
|
ElMessage.error(error instanceof Error ? error.message : '删除文件失败')
|
}
|
}
|
</script>
|
|
<style scoped lang="scss">
|
.title { font-weight: 600; }
|
.mt10 { margin-top: 10px; }
|
.mt15 { margin-top: 15px; }
|
.gray { color: #909399; font-size: 12px; }
|
.total { text-align: right; margin-top: 10px; }
|
.price { color: #f56c6c; font-weight: 600; }
|
.ml20 { margin-left: 20px; }
|
/* 统一表格内容文字大小 */
|
.order-table :deep(.el-table__body),
|
.order-table :deep(.el-table__header) {
|
font-size: 12px;
|
}
|
/* 表头第一行背景色(与交易内容标题行保持一致) */
|
.order-table :deep(.el-table__header-wrapper thead tr:first-child > th) {
|
background: #f3f6fb !important;
|
font-size: 14px !important;
|
}
|
/* 表头第二行高度与内边距 */
|
.order-table :deep(.el-table__header-wrapper thead tr:nth-child(2) > th) {
|
height: 5px !important;
|
padding-top: 0 !important;
|
padding-bottom: 0 !important;
|
padding-left: 0 !important;
|
padding-right: 0 !important;
|
}
|
/* 强制表格列固定布局,避免内容影响列宽 */
|
.order-table :deep(table) {
|
table-layout: fixed;
|
width: 100% !important;
|
}
|
|
/* 汇总行样式 */
|
.summary-row {
|
font-weight: 600;
|
color: #f56c6c;
|
}
|
.summary-total {
|
text-align: right;
|
font-weight: 600;
|
}
|
.summary-merged {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
width: 100%;
|
}
|
.summary-left {
|
text-align: left;
|
}
|
.summary-left .count {
|
color: #f56c6c;
|
font-weight: 600;
|
}
|
.summary-right {
|
text-align: right;
|
}
|
.order-table :deep(.summary-row) {
|
background-color: #fafafa;
|
}
|
.order-table :deep(.summary-row td) {
|
border-top: 2px solid #e4e7ed;
|
}
|
|
/* 表头第一行"详情"文字靠左对齐 */
|
.order-table :deep(.el-table__header-wrapper thead tr:first-child th:first-child) {
|
text-align: left !important;
|
}
|
|
/* 表头图标样式 */
|
.header-icon {
|
margin-right: 6px;
|
color: #409eff;
|
vertical-align: middle;
|
}
|
|
/* 分隔标题风格 */
|
.section-header {
|
background: #f3f6fb;
|
font-weight: 600;
|
--el-border-color: #e4e7ed;
|
}
|
.section-header :deep(.el-descriptions__label) {
|
background: #f3f6fb;
|
border-right: none !important;
|
width: 100%;
|
}
|
.section-header :deep(.el-descriptions__cell) {
|
background: #f3f6fb;
|
}
|
.section-header :deep(.el-descriptions__content) {
|
display: none !important;
|
padding: 0 !important;
|
border: 0 !important;
|
}
|
.section-icon {
|
margin-right: 6px;
|
color: #409eff;
|
}
|
|
/* 调整描述组件边框以便标题行颜色覆盖中间分隔线 */
|
:deep(.el-descriptions--border .el-descriptions__body .el-descriptions__table .el-descriptions__cell) {
|
border-right: 1px solid var(--el-border-color);
|
}
|
.section-header :deep(.el-descriptions__cell) {
|
border-right-color: transparent !important;
|
}
|
|
/* 强制 Element Plus 描述项的 label 宽度遵循 label-width(避免内容撑开) */
|
/* 兜底:即使内联样式被覆盖,也用 important 强制固定 */
|
.fixed-label :deep(.el-descriptions__label) {
|
width: 180px !important;
|
max-width: 180px !important;
|
}
|
.fixed-label :deep(.el-descriptions__content) {
|
width: calc(50% - 180px) !important;
|
}
|
|
/* 强化第一行分隔标题的背景与边框覆盖 */
|
.order-desc :deep(.el-descriptions__table tr:first-child .el-descriptions__cell),
|
.order-desc :deep(.el-descriptions__table tr:first-child .el-descriptions__label),
|
.order-desc :deep(.el-descriptions__table tr:first-child .el-descriptions__content) {
|
background: #eef3fb !important;
|
border-top-color: transparent !important;
|
border-bottom-color: #dcdfe6 !important;
|
}
|
.order-desc :deep(.el-descriptions__table tr:first-child .el-descriptions__label) {
|
border-right-color: transparent !important;
|
}
|
|
/* 统一两个描述表格的列对齐(标签列固定宽度,内容列等分剩余宽度) */
|
.order-desc :deep(.el-descriptions__table) {
|
table-layout: fixed;
|
width: 100%;
|
}
|
/* 使用类选择器而非 nth-child,提升稳定性,确保每行两列严格对齐 */
|
.order-desc :deep(.el-descriptions__table tr:not(:first-child) .el-descriptions__label) {
|
width: 180px !important;
|
max-width: 180px !important;
|
box-sizing: border-box;
|
}
|
.order-desc :deep(.el-descriptions__table tr:not(:first-child) .el-descriptions__content) {
|
width: calc(50% - 180px) !important;
|
}
|
|
.desc-wrap {
|
white-space: pre-wrap;
|
line-height: 22px;
|
}
|
|
/* 文件上传区域样式 */
|
.upload-section {
|
display: flex;
|
align-items: center;
|
margin-bottom: 15px;
|
.upload-label {
|
display: flex;
|
align-items: center;
|
margin-right: 10px;
|
.required {
|
color: #f56c6c;
|
margin-right: 4px;
|
}
|
}
|
.upload-component {
|
flex: 1;
|
}
|
}
|
|
/* 文件列表表格样式 */
|
.file-table-section {
|
margin-top: 15px;
|
.file-table {
|
width: 100%;
|
.file-name {
|
display: flex;
|
align-items: center;
|
.file-icon {
|
margin-right: 8px;
|
color: #409eff;
|
}
|
}
|
.file-actions {
|
display: flex;
|
gap: 8px;
|
align-items: center;
|
justify-content: center;
|
|
.upload-btn {
|
color: #e6a23c;
|
&:hover {
|
text-decoration: underline;
|
}
|
&:disabled {
|
color: #c0c4cc;
|
cursor: not-allowed;
|
}
|
}
|
|
.preview-btn {
|
color: #409eff;
|
&:hover {
|
text-decoration: underline;
|
}
|
&:disabled {
|
color: #c0c4cc;
|
cursor: not-allowed;
|
}
|
}
|
|
.download-btn {
|
color: #67c23a;
|
&:hover {
|
text-decoration: underline;
|
}
|
&:disabled {
|
color: #c0c4cc;
|
cursor: not-allowed;
|
}
|
}
|
|
.delete-btn {
|
color: #f56c6c;
|
&:hover {
|
text-decoration: underline;
|
}
|
}
|
}
|
}
|
}
|
|
/* 操作按钮样式 */
|
.action-buttons {
|
display: flex;
|
justify-content: flex-end;
|
margin-top: 15px;
|
.el-button {
|
margin-left: 10px;
|
}
|
}
|
</style>
|