530 lines
16 KiB
Vue
530 lines
16 KiB
Vue
<template>
|
||
<div class="app-container product-detail">
|
||
<el-card class="detail-card" v-loading="loading">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<div class="header-title">
|
||
<div class="product-name">{{ productDetail.productName || '-' }}</div>
|
||
<div class="sub-title">产品详情</div>
|
||
</div>
|
||
<el-button type="primary" plain @click="handleBack">返回列表</el-button>
|
||
</div>
|
||
</template>
|
||
|
||
<el-row :gutter="16">
|
||
<el-col :xs="24" :md="14">
|
||
<el-descriptions class="detail-desc" :column="2" border>
|
||
<el-descriptions-item label="产品名称">
|
||
{{ productDetail.productName || '-' }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="产品单价">
|
||
{{ formatDecimal(productDetail.unitPrice) }} 元
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="产品规格">
|
||
{{ productDetail.spec || '-' }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="产品型号">
|
||
{{ productDetail.model || '-' }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="备注" :span="2">
|
||
<span class="remark-text">{{ productDetail.remark || '无' }}</span>
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
|
||
<div class="section" v-loading="additionLoading">
|
||
<div class="section-title">附加属性</div>
|
||
<el-empty v-if="!productAdditionList.length" description="暂无附加属性" />
|
||
<el-descriptions v-else :column="2" border size="small">
|
||
<el-descriptions-item
|
||
v-for="item in productAdditionList"
|
||
:key="item.addId || item.attrName"
|
||
:label="item.attrName"
|
||
>
|
||
{{ item.attrValue || '-' }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
</el-col>
|
||
|
||
<el-col :xs="24" :md="10">
|
||
<el-tabs class="media-tabs" type="border-card">
|
||
<el-tab-pane label="图片">
|
||
<el-empty
|
||
v-if="!(productDetail.productImages && productDetail.productImages.trim())"
|
||
description="暂无图片"
|
||
/>
|
||
<div v-else class="image-grid">
|
||
<div
|
||
v-for="(image, index) in productDetail.productImages.split(',').filter(img => img.trim())"
|
||
:key="index"
|
||
class="image-item"
|
||
>
|
||
<el-image
|
||
:src="image"
|
||
:preview-src-list="productDetail.productImages.split(',').filter(img => img.trim())"
|
||
fit="cover"
|
||
class="image"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
|
||
<el-tab-pane label="说明书">
|
||
<el-empty v-if="!pdfDisplayList.length" description="暂无说明书" />
|
||
<div v-else class="pdf-list">
|
||
<div v-for="(pdf, index) in pdfDisplayList" :key="index" class="pdf-item">
|
||
<div class="pdf-left">
|
||
<el-icon class="pdf-icon"><Document /></el-icon>
|
||
<div class="pdf-meta">
|
||
<div class="pdf-name" :title="pdf.name">{{ pdf.name }}</div>
|
||
<div class="pdf-sub">{{ pdf.ossId ? `ID: ${pdf.ossId}` : '' }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="pdf-actions">
|
||
<el-button type="primary" link @click="previewPdf(pdf)">预览</el-button>
|
||
<el-button type="success" link @click="downloadPdf(pdf)">下载</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</el-col>
|
||
</el-row>
|
||
</el-card>
|
||
|
||
<el-card class="detail-card" v-loading="materialLoading">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<div class="header-title">
|
||
<div class="product-name">成本明细</div>
|
||
<div class="sub-title">主材 / 辅材 / 工价</div>
|
||
</div>
|
||
<div class="total-badge">
|
||
合计:{{ formatDecimal(totalAmount) }} 元
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div class="cost-section">
|
||
<div class="cost-title">主材</div>
|
||
<el-empty v-if="!mainMaterials.length" description="暂无主材" />
|
||
<div v-else>
|
||
<el-table :data="mainMaterials" border stripe>
|
||
<el-table-column prop="materialName" label="配料名称" min-width="140" />
|
||
<el-table-column prop="spec" label="材料规格" min-width="140" />
|
||
<el-table-column prop="quantity" label="数量" width="120" align="center" />
|
||
<el-table-column prop="price" label="单价" width="120" align="center">
|
||
<template #default="scope">{{ formatDecimal(scope.row.price) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="subtotal" label="小计" width="120" align="center">
|
||
<template #default="scope">{{ formatDecimal(scope.row.subtotal) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="section-summary">
|
||
主材小计:{{ formatDecimal(mainMaterials.reduce((sum, item) => sum + item.subtotal, 0)) }} 元
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cost-section">
|
||
<div class="cost-title">辅材</div>
|
||
<el-empty v-if="!auxiliaryMaterials.length" description="暂无辅材" />
|
||
<div v-else>
|
||
<el-table :data="auxiliaryMaterials" border stripe>
|
||
<el-table-column prop="materialName" label="配料名称" min-width="140" />
|
||
<el-table-column prop="spec" label="材料规格" min-width="140" />
|
||
<el-table-column prop="quantity" label="数量" width="120" align="center" />
|
||
<el-table-column prop="price" label="单价" width="120" align="center">
|
||
<template #default="scope">{{ formatDecimal(scope.row.price) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="subtotal" label="小计" width="120" align="center">
|
||
<template #default="scope">{{ formatDecimal(scope.row.subtotal) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="section-summary">
|
||
辅材小计:{{ formatDecimal(auxiliaryMaterials.reduce((sum, item) => sum + item.subtotal, 0)) }} 元
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cost-section">
|
||
<div class="cost-title">工价</div>
|
||
<el-empty v-if="!productLaborList.length" description="暂无工价" />
|
||
<div v-else>
|
||
<el-table :data="productLaborList" border stripe>
|
||
<el-table-column prop="laborName" label="工价说明" min-width="200" />
|
||
<el-table-column prop="laborPrice" label="金额(元)" width="160" align="center">
|
||
<template #default="scope">{{ formatDecimal(scope.row.laborPrice) }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
<div class="section-summary">
|
||
工价小计:{{ formatDecimal(productLaborList.reduce((sum, item) => sum + (Number(item.laborPrice) || 0), 0)) }} 元
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup name="ProductDetail">
|
||
import { ref, computed, onMounted } from 'vue';
|
||
import { useRouter, useRoute } from 'vue-router';
|
||
import { getProduct } from "@/api/mat/product";
|
||
import { listProductMaterialRelation } from "@/api/mat/productMaterialRelation";
|
||
import { getMaterial } from "@/api/mat/material";
|
||
import { listProductAddition } from "@/api/mat/productAddition";
|
||
import { listProductLabor } from "@/api/mat/productLabor";
|
||
import { listByIds, listOss } from "@/api/system/oss";
|
||
import { formatDecimal } from '@/utils/gear';
|
||
import { Document } from '@element-plus/icons-vue';
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
|
||
const productDetail = ref({});
|
||
const loading = ref(true);
|
||
const materialLoading = ref(false);
|
||
const additionLoading = ref(false);
|
||
const pdfFiles = ref([]);
|
||
const pdfUrlFiles = ref([]);
|
||
|
||
// 材料明细数据
|
||
const productMaterialRelationList = ref([]);
|
||
|
||
// 产品附加属性数据
|
||
const productAdditionList = ref([]);
|
||
const productLaborList = ref([]);
|
||
|
||
// 计算主材、辅材和工本
|
||
const mainMaterials = computed(() => {
|
||
// 主材(材料类型为2)
|
||
return productMaterialRelationList.value
|
||
.filter(item => item.material && item.material.materialType === 2)
|
||
.map(item => ({
|
||
materialName: item.material.materialName,
|
||
spec: item.material.spec,
|
||
quantity: item.materialNum + (item.material.unit || ''),
|
||
price: item.material.unitPrice || 0,
|
||
subtotal: (item.materialNum * (item.material.unitPrice || 0)) || 0
|
||
}));
|
||
});
|
||
|
||
const auxiliaryMaterials = computed(() => {
|
||
// 辅材(材料类型为1)
|
||
return productMaterialRelationList.value
|
||
.filter(item => item.material && item.material.materialType === 1)
|
||
.map(item => ({
|
||
materialName: item.material.materialName,
|
||
spec: item.material.spec,
|
||
quantity: item.materialNum + (item.material.unit || ''),
|
||
price: item.material.unitPrice || 0,
|
||
subtotal: (item.materialNum * (item.material.unitPrice || 0)) || 0
|
||
}));
|
||
});
|
||
|
||
// 计算总金额
|
||
const totalAmount = computed(() => {
|
||
const mainTotal = mainMaterials.value.reduce((sum, item) => sum + item.subtotal, 0);
|
||
const auxiliaryTotal = auxiliaryMaterials.value.reduce((sum, item) => sum + item.subtotal, 0);
|
||
const manualLaborTotal = productLaborList.value.reduce((sum, item) => {
|
||
const price = item && item.laborPrice !== undefined && item.laborPrice !== null ? Number(item.laborPrice) : 0;
|
||
return sum + (Number.isFinite(price) ? price : 0);
|
||
}, 0);
|
||
return mainTotal + auxiliaryTotal + manualLaborTotal;
|
||
});
|
||
|
||
const isOssIdList = (val) => {
|
||
if (val === null || val === undefined) return false;
|
||
const str = String(val).trim();
|
||
return /^[0-9]+(,[0-9]+)*$/.test(str);
|
||
};
|
||
|
||
const pdfDisplayList = computed(() => {
|
||
const raw = String(productDetail.value.productPdfs || '').trim();
|
||
if (!raw) return [];
|
||
if (isOssIdList(raw)) return pdfFiles.value;
|
||
return pdfUrlFiles.value;
|
||
});
|
||
|
||
// 获取产品详情
|
||
function getProductDetail() {
|
||
const productId = route.params.id;
|
||
if (productId) {
|
||
loading.value = true;
|
||
getProduct(productId).then(response => {
|
||
productDetail.value = response.data;
|
||
loading.value = false;
|
||
resolvePdfFiles();
|
||
// 获取材料明细
|
||
getMaterialDetail(productId);
|
||
// 获取产品附加属性
|
||
getProductAddition(productId);
|
||
getProductLabor(productId);
|
||
}).catch(error => {
|
||
console.error('获取产品详情失败:', error);
|
||
loading.value = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
// 获取产品附加属性
|
||
function getProductAddition(productId) {
|
||
additionLoading.value = true;
|
||
listProductAddition({ productId }).then(response => {
|
||
productAdditionList.value = response.rows || [];
|
||
additionLoading.value = false;
|
||
}).catch(error => {
|
||
console.error('获取产品附加属性失败:', error);
|
||
additionLoading.value = false;
|
||
});
|
||
}
|
||
|
||
// 获取材料明细
|
||
function getMaterialDetail(productId) {
|
||
materialLoading.value = true;
|
||
listProductMaterialRelation({ productId }).then(response => {
|
||
const relations = response.rows;
|
||
// 为每个配方项获取物料详细信息,包括物料类型
|
||
const promises = relations.map(item => {
|
||
return getMaterial(item.materialId).then(materialResponse => {
|
||
item.material = materialResponse.data;
|
||
return item;
|
||
});
|
||
});
|
||
return Promise.all(promises);
|
||
}).then(relationsWithMaterial => {
|
||
productMaterialRelationList.value = relationsWithMaterial;
|
||
materialLoading.value = false;
|
||
}).catch(error => {
|
||
console.error('获取材料明细失败:', error);
|
||
materialLoading.value = false;
|
||
});
|
||
}
|
||
|
||
function getProductLabor(productId) {
|
||
listProductLabor({ productId }).then(response => {
|
||
productLaborList.value = response.rows || [];
|
||
}).catch(() => {
|
||
productLaborList.value = [];
|
||
});
|
||
}
|
||
|
||
function resolvePdfFiles() {
|
||
const raw = String(productDetail.value.productPdfs || '').trim();
|
||
if (!raw) {
|
||
pdfFiles.value = [];
|
||
pdfUrlFiles.value = [];
|
||
return;
|
||
}
|
||
if (isOssIdList(raw)) {
|
||
pdfUrlFiles.value = [];
|
||
listByIds(raw).then(res => {
|
||
const list = res.data || [];
|
||
pdfFiles.value = list.map(oss => ({
|
||
name: oss.originalName || oss.fileName || String(oss.ossId),
|
||
url: oss.url,
|
||
ossId: oss.ossId
|
||
}));
|
||
}).catch(() => {
|
||
pdfFiles.value = [];
|
||
});
|
||
return;
|
||
}
|
||
|
||
pdfFiles.value = [];
|
||
const urls = raw.split(',').map(s => String(s).trim()).filter(Boolean);
|
||
Promise.all(urls.map((url) =>
|
||
listOss({ url, pageNum: 1, pageSize: 1 })
|
||
.then(r => (r && r.rows && r.rows[0]) ? r.rows[0] : null)
|
||
.catch(() => null)
|
||
)).then(rows => {
|
||
pdfUrlFiles.value = urls.map((url, index) => {
|
||
const row = rows[index];
|
||
return {
|
||
name: (row && (row.originalName || row.fileName)) || getFileName(url),
|
||
url: (row && row.url) || url,
|
||
ossId: row && row.ossId
|
||
};
|
||
});
|
||
}).catch(() => {
|
||
pdfUrlFiles.value = urls.map(url => ({ name: getFileName(url), url }));
|
||
});
|
||
}
|
||
|
||
// 返回列表
|
||
function handleBack() {
|
||
router.back();
|
||
}
|
||
|
||
// 获取文件名
|
||
function getFileName(url) {
|
||
if (!url) return '';
|
||
return url.substring(url.lastIndexOf('/') + 1);
|
||
}
|
||
|
||
// 预览PDF
|
||
function previewPdf(pdf) {
|
||
if (!pdf || !pdf.url) return;
|
||
window.open(pdf.url, '_blank');
|
||
}
|
||
|
||
// 下载PDF
|
||
function downloadPdf(pdf) {
|
||
if (!pdf || !pdf.url) return;
|
||
const link = document.createElement('a');
|
||
link.href = pdf.url;
|
||
link.download = pdf.name || getFileName(pdf.url);
|
||
link.click();
|
||
}
|
||
|
||
onMounted(() => {
|
||
getProductDetail();
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.detail-card {
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.header-title {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.product-name {
|
||
font-weight: 600;
|
||
font-size: 16px;
|
||
line-height: 22px;
|
||
}
|
||
|
||
.sub-title {
|
||
color: var(--el-text-color-secondary);
|
||
font-size: 12px;
|
||
line-height: 18px;
|
||
}
|
||
|
||
.detail-desc :deep(.el-descriptions__label) {
|
||
width: 88px;
|
||
}
|
||
|
||
.remark-text {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.section {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.section-title {
|
||
font-weight: 600;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.media-tabs :deep(.el-tabs__content) {
|
||
padding: 12px;
|
||
}
|
||
|
||
.image-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
}
|
||
|
||
.image-item {
|
||
width: 112px;
|
||
height: 112px;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--el-border-color-light);
|
||
background: var(--el-fill-color-blank);
|
||
}
|
||
|
||
.image {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.pdf-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
|
||
.pdf-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 10px 12px;
|
||
border: 1px solid var(--el-border-color-light);
|
||
border-radius: 8px;
|
||
background: var(--el-fill-color-blank);
|
||
}
|
||
|
||
.pdf-left {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.pdf-icon {
|
||
font-size: 24px;
|
||
color: #409eff;
|
||
}
|
||
|
||
.pdf-meta {
|
||
min-width: 0;
|
||
}
|
||
|
||
.pdf-name {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
max-width: 320px;
|
||
}
|
||
|
||
.pdf-sub {
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
line-height: 18px;
|
||
}
|
||
|
||
.pdf-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.total-badge {
|
||
font-weight: 600;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
.cost-section {
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.cost-title {
|
||
font-weight: 600;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.section-summary {
|
||
text-align: right;
|
||
padding: 10px;
|
||
background-color: var(--el-fill-color-light);
|
||
border-top: 1px solid var(--el-border-color-light);
|
||
margin-top: -1px;
|
||
font-weight: bold;
|
||
}
|
||
</style>
|