Files
GEAR-OA/gear-ui3/src/views/mat/product/index.vue
2026-04-28 16:53:35 +08:00

780 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container" ref="appContainer">
<el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="queryParams.productName" placeholder="请输入产品名称" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="产品规格" prop="spec">
<el-input v-model="queryParams.spec" placeholder="请输入产品规格" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="产品型号" prop="model">
<el-input v-model="queryParams.model" placeholder="请输入产品型号" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="Plus" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate">修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="Download" @click="handleExport">导出</el-button>
</el-col>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
<el-table v-loading="loading" :data="productList" @selection-change="handleSelectionChange"
@row-click="handleRowClick">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="产品ID 主键" align="center" prop="productId" v-if="false" />
<el-table-column label="产品名称" align="center" prop="productName" />
<el-table-column label="产品规格" align="center" prop="spec" />
<el-table-column label="产品型号" align="center" prop="model" />
<el-table-column label="产品单价" align="center" prop="unitPrice">
<template #default="scope">
{{ formatDecimal(scope.row.unitPrice) }}
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="产品图片" align="center" prop="productImages">
<template #default="scope">
<div v-if="scope.row.productImages && scope.row.productImages.trim()" class="image-preview">
<el-image
v-for="(image, index) in scope.row.productImages.split(',')"
:key="index"
:src="image"
:preview-src-list="scope.row.productImages.split(',')"
:z-index="9999"
:preview-teleported="true"
style="width: 40px; height: 40px; margin-right: 8px;"
/>
</div>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="320" fixed="right">
<template #default="scope">
<div class="product-op-actions">
<el-button link type="primary" icon="View" @click="handleDetail(scope.row)">详情</el-button>
<el-button link type="primary" icon="Plus" @click="handleBom(scope.row)">配方</el-button>
<el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)">修改</el-button>
<el-button link type="primary" icon="Setting" @click="handleAddition(scope.row)">附加属性</el-button>
<el-button link type="primary" icon="Money" @click="handleLabor(scope.row)">工价</el-button>
<el-button link type="danger" icon="Delete" @click="handleDelete(scope.row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum"
v-model:limit="queryParams.pageSize" @pagination="getList" />
<!-- 添加或修改产品基础信息对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="productRef" :model="form" :rules="rules" label-width="80px">
<el-form-item label="产品名称" prop="productName">
<el-input v-model="form.productName" placeholder="请输入产品名称" />
</el-form-item>
<el-form-item label="产品规格" prop="spec">
<el-input v-model="form.spec" placeholder="请输入产品规格" />
</el-form-item>
<el-form-item label="产品型号" prop="model">
<el-input v-model="form.model" placeholder="请输入产品型号" />
</el-form-item>
<el-form-item label="产品单价" prop="unitPrice">
<el-input v-model="form.unitPrice" placeholder="请输入产品单价" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="请输入备注" />
</el-form-item>
<el-form-item label="产品图片" prop="productImages">
<el-upload
v-model:file-list="imageFileList"
:action="uploadUrl"
:headers="headers"
:on-success="handleImageUploadSuccess"
:on-remove="handleImageRemove"
multiple
list-type="picture"
:limit="5"
:before-upload="beforeUpload"
>
<el-button type="primary" icon="Upload">上传图片</el-button>
<template #tip>
<div class="el-upload__tip">
最多上传5张图片支持 JPGPNG 格式
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="产品说明书" prop="productPdfs">
<el-upload
v-model:file-list="pdfFileList"
:action="uploadUrl"
:headers="headers"
:on-success="handlePdfUploadSuccess"
:on-remove="handlePdfRemove"
multiple
:limit="5"
:before-upload="beforePdfUpload"
>
<el-button type="primary" icon="Upload">上传说明书</el-button>
<template #tip>
<div class="el-upload__tip">
最多上传5个PDF文件支持 PDF 格式
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button :loading="buttonLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="cancel"> </el-button>
</div>
</template>
</el-dialog>
<el-dialog title="产品配方" v-model="bomOpen" width="800px" append-to-body>
<bom :productId="currentProductId" @close="bomOpen = false" />
</el-dialog>
<!-- 产品附加属性对话框 -->
<el-dialog title="产品附加属性" v-model="additionOpen" width="600px" append-to-body>
<div class="addition-container">
<el-button type="primary" icon="Plus" @click="addAdditionItem" style="margin-bottom: 10px">添加属性</el-button>
<el-table :data="additionList" style="width: 100%" border>
<el-table-column prop="attrName" label="属性名" width="150">
<template #default="scope">
<el-input v-model="scope.row.attrName" placeholder="请输入属性名" />
</template>
</el-table-column>
<el-table-column prop="attrValue" label="属性值">
<template #default="scope">
<el-input v-model="scope.row.attrValue" placeholder="请输入属性值" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button link type="danger" icon="Delete" @click="removeAdditionItem(scope.$index)" />
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="saveAdditions">保存</el-button>
<el-button @click="additionOpen = false">取消</el-button>
</div>
</template>
</el-dialog>
<el-dialog title="产品工价" v-model="laborOpen" width="600px" append-to-body>
<div class="addition-container">
<el-button type="primary" icon="Plus" @click="addLaborItem" style="margin-bottom: 10px">添加工价</el-button>
<el-table :data="laborList" style="width: 100%" border>
<el-table-column prop="laborName" label="工价说明" width="200">
<template #default="scope">
<el-input v-model="scope.row.laborName" placeholder="请输入工价说明" />
</template>
</el-table-column>
<el-table-column prop="laborPrice" label="金额(元)">
<template #default="scope">
<el-input-number style="width: 100%" v-model="scope.row.laborPrice" :controls="false" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="scope">
<el-button link type="danger" icon="Delete" @click="removeLaborItem(scope.$index)" />
</template>
</el-table-column>
</el-table>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="saveLabors">保存</el-button>
<el-button @click="laborOpen = false">取消</el-button>
</div>
</template>
</el-dialog>
<!-- 将这个表格改为始终吸底且外层容器可以拖拽调节高度 -->
<!-- <StickyDragContainer v-if="currentProduct.productId" :parent-ref="appContainer"> -->
<div v-if="currentProduct.productId">
<h3>产品配料</h3>
<!-- 插槽内容原有配料表格无需任何修改 -->
<el-table :data="currentProduct.materials">
<el-table-column label="配料名称" align="center" prop="materialName" />
<el-table-column label="配料规格" align="center" prop="spec" />
<el-table-column label="配料型号" align="center" prop="model" />
<el-table-column label="厂家" align="center" prop="factory" />
<el-table-column label="计量单位" align="center" prop="unit" />
<el-table-column label="现存库存" align="center" prop="currentStock">
<template #default="scope">
{{ formatDecimal(scope.row.currentStock) }}
</template>
</el-table-column>
<el-table-column label="备注" align="center" prop="remark" />
</el-table>
</div>
<el-empty v-else description="选择产品查看配料信息" />
<!-- </StickyDragContainer> -->
</div>
</template>
<script setup name="Product">
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { listProduct, getProduct, delProduct, addProduct, updateProduct } from "@/api/mat/product";
import { listProductMaterialRelation } from "@/api/mat/productMaterialRelation";
import { getMaterial } from "@/api/mat/material";
import { listProductAddition, batchSaveProductAddition } from "@/api/mat/productAddition";
import { listProductLabor, batchSaveProductLabor } from "@/api/mat/productLabor";
import { listByIds, listOss } from "@/api/system/oss";
import bom from "@/views/mat/components/bom.vue";
import StickyDragContainer from "@/components/StickyDragContainer/index.vue";
import Raw from '@/components/Renderer/Raw.vue';
import { formatDecimal } from '@/utils/gear';
import { getToken } from '@/utils/auth';
const router = useRouter();
const bomOpen = ref(false);
const additionOpen = ref(false);
const laborOpen = ref(false);
const { proxy } = getCurrentInstance();
const productList = ref([]);
const open = ref(false);
const buttonLoading = ref(false);
const loading = ref(true);
const showSearch = ref(true);
const ids = ref([]);
const single = ref(true);
const multiple = ref(true);
const total = ref(0);
const title = ref("");
const currentProductId = ref(null);
const currentProduct = ref({});
const appContainer = ref(null);
const imageFileList = ref([]);
const pdfFileList = ref([]);
const pdfUseOssId = ref(true);
const additionList = ref([]);
const laborList = ref([]);
const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + '/system/oss/upload');
const headers = ref({ Authorization: "Bearer " + getToken() });
const isOssIdList = (val) => {
if (val === null || val === undefined) return false;
const str = String(val).trim();
return /^[0-9]+(,[0-9]+)*$/.test(str);
};
const getFileNameFromUrl = (url) => {
if (!url) return '';
const str = String(url);
return str.substring(str.lastIndexOf('/') + 1);
};
const formatterTime = (time) => {
return proxy.parseTime(time, '{y}-{m}-{d}')
}
const data = reactive({
form: {
productId: null,
productName: null,
spec: null,
model: null,
unitPrice: null,
delFlag: null,
createTime: null,
createBy: null,
updateTime: null,
updateBy: null,
remark: null,
productImages: null,
productPdfs: null
},
queryParams: {
pageNum: 1,
pageSize: 10,
productName: undefined,
spec: undefined,
model: undefined,
},
rules: {
}
});
const { queryParams, form, rules } = toRefs(data);
/** 查询产品基础信息列表 */
function getList() {
loading.value = true;
listProduct(queryParams.value).then(response => {
productList.value = response.rows;
total.value = response.total;
loading.value = false;
});
}
// 取消按钮
function cancel() {
open.value = false;
reset();
}
// 表单重置
function reset() {
form.value = {
productId: null,
productName: null,
spec: null,
model: null,
unitPrice: null,
delFlag: null,
createTime: null,
createBy: null,
updateTime: null,
updateBy: null,
remark: null,
productImages: null,
productPdfs: null
};
imageFileList.value = [];
pdfFileList.value = [];
pdfUseOssId.value = true;
proxy.resetForm("productRef");
}
/** 搜索按钮操作 */
function handleQuery() {
queryParams.value.pageNum = 1;
getList();
}
/** 重置按钮操作 */
function resetQuery() {
proxy.resetForm("queryRef");
handleQuery();
}
// 多选框选中数据
function handleSelectionChange(selection) {
ids.value = selection.map(item => item.productId);
single.value = selection.length != 1;
multiple.value = !selection.length;
}
/** 新增按钮操作 */
function handleAdd() {
reset();
open.value = true;
title.value = "添加产品基础信息";
}
/** 修改按钮操作 */
function handleUpdate(row) {
loading.value = true
reset();
const _productId = row.productId || ids.value;
getProduct(_productId).then(response => {
loading.value = false;
form.value = response.data;
// 处理图片文件列表
if (form.value.productImages) {
imageFileList.value = form.value.productImages.split(',').map((url, index) => ({
name: url.substring(url.lastIndexOf('/') + 1),
url: url,
uid: Date.now() + index
}));
} else {
imageFileList.value = [];
}
// 处理PDF文件列表
if (form.value.productPdfs) {
const raw = String(form.value.productPdfs).trim();
if (isOssIdList(raw)) {
pdfUseOssId.value = true;
listByIds(raw).then(res => {
const list = res.data || [];
pdfFileList.value = list.map((oss, index) => ({
name: oss.originalName || oss.fileName || String(oss.ossId),
url: oss.url,
ossId: oss.ossId,
uid: Date.now() + index + 100
}));
}).catch(() => {
pdfFileList.value = [];
});
} else {
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 => {
const mapped = urls.map((url, index) => {
const row = rows[index];
return {
name: (row && (row.originalName || row.fileName)) || getFileNameFromUrl(url),
url: (row && row.url) || url,
ossId: row && row.ossId,
uid: Date.now() + index + 100
};
});
pdfFileList.value = mapped;
const ossIds = mapped.map(f => f.ossId).filter(Boolean);
const allResolved = ossIds.length === mapped.length && mapped.length > 0;
pdfUseOssId.value = allResolved;
if (allResolved) {
form.value.productPdfs = ossIds.join(',');
} else {
form.value.productPdfs = urls.join(',');
}
}).catch(() => {
pdfUseOssId.value = false;
pdfFileList.value = urls.map((url, index) => ({
name: getFileNameFromUrl(url),
url,
uid: Date.now() + index + 100
}));
});
}
} else {
pdfFileList.value = [];
}
open.value = true;
title.value = "修改产品基础信息";
}).catch(err => {
proxy.$modal.msgError("获取产品信息失败");
loading.value = false;
});
}
/** 图片上传成功处理 */
function handleImageUploadSuccess(response, uploadFile, uploadFiles) {
if (response.code === 200) {
const imageUrl = response.data.url;
uploadFile.url = imageUrl;
const urls = uploadFiles
.map(f => f.url)
.filter(url => url && !url.startsWith('blob:'));
form.value.productImages = urls.join(',');
} else {
proxy.$modal.msgError('上传失败:' + response.msg);
const index = imageFileList.value.findIndex(f => f.uid === uploadFile.uid);
if (index > -1) {
imageFileList.value.splice(index, 1);
}
}
}
/** 图片移除处理 */
function handleImageRemove(uploadFile, uploadFiles) {
const urls = uploadFiles
.map(f => f.url)
.filter(url => url && !url.startsWith('blob:'));
form.value.productImages = urls.length > 0 ? urls.join(',') : null;
}
/** PDF上传成功处理 */
function handlePdfUploadSuccess(response, uploadFile, uploadFiles) {
if (response.code === 200) {
uploadFile.url = response.data.url;
uploadFile.name = response.data.fileName || uploadFile.name;
uploadFile.ossId = response.data.ossId;
if (pdfUseOssId.value) {
const ossIds = uploadFiles
.map(f => f.ossId)
.filter(Boolean);
form.value.productPdfs = ossIds.length > 0 ? ossIds.join(',') : null;
} else {
const urls = uploadFiles
.map(f => f.url)
.filter(url => url && !url.startsWith('blob:'));
form.value.productPdfs = urls.length > 0 ? urls.join(',') : null;
}
} else {
proxy.$modal.msgError('上传失败:' + response.msg);
const index = pdfFileList.value.findIndex(f => f.uid === uploadFile.uid);
if (index > -1) {
pdfFileList.value.splice(index, 1);
}
}
}
/** PDF移除处理 */
function handlePdfRemove(uploadFile, uploadFiles) {
if (pdfUseOssId.value) {
const ossIds = uploadFiles
.map(f => f.ossId)
.filter(Boolean);
form.value.productPdfs = ossIds.length > 0 ? ossIds.join(',') : null;
} else {
const urls = uploadFiles
.map(f => f.url)
.filter(url => url && !url.startsWith('blob:'));
form.value.productPdfs = urls.length > 0 ? urls.join(',') : null;
}
}
/** PDF上传前校验 */
function beforePdfUpload(file) {
const isPdf = file.type === 'application/pdf';
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isPdf) {
proxy.$modal.msgError('只能上传 PDF 格式的文件');
return false;
}
if (!isLt10M) {
proxy.$modal.msgError('PDF文件大小不能超过 10MB');
return false;
}
return true;
}
/** 图片上传前校验 */
function beforeUpload(file) {
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
const isLt2M = file.size / 1024 / 1024 < 2;
if (!isJpgOrPng) {
proxy.$modal.msgError('只能上传 JPG/PNG 格式的图片');
return false;
}
if (!isLt2M) {
proxy.$modal.msgError('图片大小不能超过 2MB');
return false;
}
return true;
}
/** 提交按钮 */
function submitForm() {
if (buttonLoading.value) {
return;
}
proxy.$refs["productRef"].validate(valid => {
if (valid) {
const submitData = {
productId: form.value.productId,
productName: form.value.productName,
spec: form.value.spec,
model: form.value.model,
unitPrice: form.value.unitPrice,
remark: form.value.remark,
productImages: form.value.productImages,
productPdfs: form.value.productPdfs
};
buttonLoading.value = true;
if (form.value.productId != null) {
updateProduct(submitData).then(response => {
proxy.$modal.msgSuccess("修改成功");
open.value = false;
getList();
}).catch(err => {
proxy.$modal.msgError("修改失败");
}).finally(() => {
buttonLoading.value = false;
});
} else {
addProduct(submitData).then(response => {
proxy.$modal.msgSuccess("新增成功");
open.value = false;
getList();
}).catch(err => {
proxy.$modal.msgError("新增失败");
}).finally(() => {
buttonLoading.value = false;
});
}
} else {
return;
}
});
}
/** 删除按钮操作 */
function handleDelete(row) {
const _productIds = row.productId || ids.value;
proxy.$modal.confirm('是否确认删除产品基础信息编号为"' + _productIds + '"的数据项?').then(function () {
loading.value = true;
return delProduct(_productIds);
}).then(() => {
loading.value = true;
getList();
proxy.$modal.msgSuccess("删除成功");
}).catch(() => {
}).finally(() => {
loading.value = false;
});
}
function handleBom(row) {
bomOpen.value = true;
currentProductId.value = row.productId;
}
/** 导出按钮操作 */
function handleExport() {
proxy.download('mat/product/export', {
...queryParams.value
}, `product_${new Date().getTime()}.xlsx`)
}
function handleRowClick(row) {
currentProduct.value = row;
}
function handleDetail(row) {
// 跳转到产品详情页
router.push(`/mat/product/detail/${row.productId}`);
}
function handleAddition(row) {
currentProductId.value = row.productId;
// 清空附加属性列表
additionList.value = [];
// 获取已有的附加属性数据
listProductAddition({ productId: row.productId }).then(response => {
if (response.code === 200) {
additionList.value = response.rows || [];
}
});
additionOpen.value = true;
}
function addAdditionItem() {
additionList.value.push({ attrName: '', attrValue: '' });
}
function removeAdditionItem(index) {
additionList.value.splice(index, 1);
}
function saveAdditions() {
const items = (additionList.value || [])
.map(item => ({
addId: item?.addId,
attrName: item?.attrName ? String(item.attrName).trim() : '',
attrValue: item?.attrValue ? String(item.attrValue).trim() : ''
}))
.filter(item => item.attrName);
batchSaveProductAddition({
productId: currentProductId.value,
items
}).then(res => {
if (res.code === 200 && res.data) {
proxy.$modal.msgSuccess('保存成功');
additionOpen.value = false;
} else {
proxy.$modal.msgError('保存失败');
}
}).catch(() => {
proxy.$modal.msgError('保存失败');
});
}
function handleLabor(row) {
currentProductId.value = row.productId;
laborList.value = [];
listProductLabor({ productId: row.productId }).then(response => {
if (response.code === 200) {
laborList.value = response.rows || [];
}
});
laborOpen.value = true;
}
function addLaborItem() {
laborList.value.push({ laborName: '', laborPrice: 0 });
}
function removeLaborItem(index) {
laborList.value.splice(index, 1);
}
async function saveLabors() {
try {
const items = (laborList.value || [])
.map(item => ({
laborId: item?.laborId,
laborName: item?.laborName ? String(item.laborName).trim() : '',
laborPrice: item?.laborPrice ?? 0
}))
.filter(item => item.laborName);
const res = await batchSaveProductLabor({
productId: currentProductId.value,
items
});
if (res.code === 200 && res.data) {
proxy.$modal.msgSuccess('保存成功');
} else {
proxy.$modal.msgError('保存失败');
}
} catch (e) {
proxy.$modal.msgError('保存失败');
} finally {
laborOpen.value = false;
}
}
getList();
</script>
<style scoped>
/* 表格样式 */
:deep(.el-table th) {
background-color: #f5f7fa;
font-weight: bold;
color: #606266;
}
:deep(.el-table tr:hover) {
background-color: #ecf5ff;
}
:deep(.el-table td) {
vertical-align: middle;
}
/* 图片预览层样式 */
:deep(.el-image-viewer__wrapper) {
z-index: 9999 !important;
}
.product-op-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 4px 10px;
}
:deep(.product-op-actions .el-button + .el-button) {
margin-left: 0;
}
</style>