feat(mes): 新增产品 BOM、SOP、SIP 子表前端组件

- 新增 productBom / productSop / productSip 三组 API 文件
- BOM 组件:表格列表,支持远程搜索物料、编辑用量
- SOP / SIP 组件:卡片式图片列表,支持上传/预览/排序
- MdItemForm.vue 集成三个组件,替换原 el-empty 占位
pull/871/MERGE
YunaiV 2026-02-16 08:58:31 +08:00
parent 403b7e75af
commit 59bd23b3e0
3 changed files with 681 additions and 0 deletions

View File

@ -0,0 +1,223 @@
<!-- MES 产品BOM 列表 -->
<template>
<div>
<el-button type="primary" plain size="small" @click="openAddForm" class="mb-10px">
<Icon icon="ep:plus" class="mr-5px" /> 添加 BOM 物料
</el-button>
<el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true" border>
<el-table-column label="物料编码" align="center" prop="bomItemCode" />
<el-table-column label="物料名称" align="center" prop="bomItemName" />
<el-table-column label="规格型号" align="center" prop="bomItemSpecification" />
<el-table-column label="单位" align="center" prop="unitMeasureName" width="80" />
<el-table-column label="物料/产品" align="center" prop="itemOrProduct" width="100">
<template #default="scope">
{{ getItemOrProductLabel(scope.row.itemOrProduct) }}
</template>
</el-table-column>
<el-table-column label="用量比例" align="center" prop="quantity" width="100" />
<el-table-column label="备注" align="center" prop="remark" />
<el-table-column label="操作" align="center" width="120">
<template #default="scope">
<el-button link type="primary" @click="openEditForm(scope.row)"></el-button>
<el-button link type="danger" @click="handleDelete(scope.row.id)"></el-button>
</template>
</el-table-column>
</el-table>
<!-- 添加 BOM 物料弹窗 -->
<Dialog title="添加 BOM 物料" v-model="addDialogVisible" width="500px">
<el-form ref="addFormRef" :model="addFormData" :rules="addFormRules" label-width="100px">
<el-form-item label="BOM 物料" prop="bomItemId">
<el-select
v-model="addFormData.bomItemId"
filterable
remote
:remote-method="searchItems"
:loading="itemSearchLoading"
placeholder="请搜索物料编码/名称"
class="w-1/1"
>
<el-option
v-for="item in itemOptions"
:key="item.id"
:label="item.code + ' - ' + item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="用量比例" prop="quantity">
<el-input-number
v-model="addFormData.quantity"
:min="0"
:precision="4"
controls-position="right"
class="!w-1/1"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="addFormData.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitAddForm" type="primary"> </el-button>
<el-button @click="addDialogVisible = false"> </el-button>
</template>
</Dialog>
<!-- 编辑 BOM 弹窗 -->
<Dialog title="编辑 BOM 物料" v-model="editDialogVisible" width="500px">
<el-form ref="editFormRef" :model="editFormData" :rules="editFormRules" label-width="100px">
<el-form-item label="用量比例" prop="quantity">
<el-input-number
v-model="editFormData.quantity"
:min="0"
:precision="4"
controls-position="right"
class="!w-1/1"
/>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="editFormData.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitEditForm" type="primary"> </el-button>
<el-button @click="editDialogVisible = false"> </el-button>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
// TODO @AI
import { MdProductBomApi, MdProductBomVO } from '@/api/mes/md/item/productBom'
import { MdItemApi } from '@/api/mes/md/item'
import { getItemOrProductLabel } from '@/views/mes/utils/constants'
defineOptions({ name: 'MdProductBomForm' })
const props = defineProps<{
itemId: number
}>()
const message = useMessage()
const loading = ref(false)
const list = ref<MdProductBomVO[]>([])
/** 加载列表 */
const getList = async () => {
loading.value = true
try {
list.value = await MdProductBomApi.getProductBomListByItemId(props.itemId)
} finally {
loading.value = false
}
}
// ==================== ====================
const itemSearchLoading = ref(false)
const itemOptions = ref<any[]>([])
const searchItems = async (query: string) => {
if (!query) {
itemOptions.value = []
return
}
itemSearchLoading.value = true
try {
const data = await MdItemApi.getItemPage({ pageNo: 1, pageSize: 20, code: query })
itemOptions.value = data.list || []
} finally {
itemSearchLoading.value = false
}
}
// ==================== BOM ====================
const addDialogVisible = ref(false)
const addFormRef = ref()
const addFormData = ref({
itemId: undefined as number | undefined,
bomItemId: undefined as number | undefined,
quantity: 1,
remark: undefined as string | undefined
})
const addFormRules = reactive({
bomItemId: [{ required: true, message: 'BOM 物料不能为空', trigger: 'change' }],
quantity: [{ required: true, message: '用量比例不能为空', trigger: 'blur' }]
})
const openAddForm = () => {
addDialogVisible.value = true
addFormData.value = {
itemId: props.itemId,
bomItemId: undefined,
quantity: 1,
remark: undefined
}
itemOptions.value = []
addFormRef.value?.resetFields()
}
const submitAddForm = async () => {
await addFormRef.value.validate()
await MdProductBomApi.createProductBom(addFormData.value as unknown as MdProductBomVO)
message.success('添加成功')
addDialogVisible.value = false
await getList()
}
// ==================== BOM ====================
const editDialogVisible = ref(false)
const editFormRef = ref()
const editFormData = ref({
id: undefined as number | undefined,
itemId: undefined as number | undefined,
bomItemId: undefined as number | undefined,
quantity: 1,
remark: undefined as string | undefined
})
const editFormRules = reactive({
quantity: [{ required: true, message: '用量比例不能为空', trigger: 'blur' }]
})
const openEditForm = (row: MdProductBomVO) => {
editDialogVisible.value = true
editFormData.value = {
id: row.id,
itemId: row.itemId,
bomItemId: row.bomItemId,
quantity: row.quantity,
remark: row.remark
}
editFormRef.value?.resetFields()
}
const submitEditForm = async () => {
await editFormRef.value.validate()
await MdProductBomApi.updateProductBom(editFormData.value as unknown as MdProductBomVO)
message.success('编辑成功')
editDialogVisible.value = false
await getList()
}
// ==================== ====================
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await MdProductBomApi.deleteProductBom(id)
message.success('删除成功')
await getList()
} catch {}
}
/** 监听 itemId 变化 */
watch(
() => props.itemId,
(val) => {
if (val) {
getList()
}
},
{ immediate: true }
)
</script>

View File

@ -0,0 +1,230 @@
<!-- MES 产品SIP 列表 -->
<template>
<div>
<el-button type="primary" plain size="small" @click="openForm(undefined)" class="mb-10px">
<Icon icon="ep:plus" class="mr-5px" /> 添加 SIP
</el-button>
<!-- SIP 卡片列表 -->
<el-row :gutter="12" v-loading="loading">
<el-col :span="6" v-for="item in list" :key="item.id" class="mb-12px">
<el-card shadow="hover" :body-style="{ padding: '0px' }">
<!-- 图片区域 -->
<div class="sip-image-wrapper" @click="handlePreview(item.url)">
<el-image v-if="item.url" :src="item.url" fit="cover" class="sip-image" />
<div v-else class="sip-image sip-image-empty">
<Icon icon="ep:picture" :size="32" />
</div>
</div>
<!-- 信息区域 -->
<div class="p-10px">
<div class="font-bold text-14px mb-4px truncate">{{ item.title }}</div>
<div class="text-12px color-gray mb-4px">序号{{ item.orderNum }}</div>
<div v-if="item.processName" class="text-12px color-gray mb-4px">
工序{{ item.processName }}
</div>
<div v-if="item.description" class="text-12px color-gray truncate">
{{ item.description }}
</div>
<!-- 操作按钮 -->
<div class="flex justify-end mt-8px">
<el-button link type="primary" size="small" @click="openForm(item)"></el-button>
<el-button link type="danger" size="small" @click="handleDelete(item.id!)"
>删除</el-button
>
</div>
</div>
</el-card>
</el-col>
<!-- 空状态 -->
<el-col :span="24" v-if="!loading && list.length === 0">
<el-empty description="暂无 SIP 数据" />
</el-col>
</el-row>
<!-- 图片预览 -->
<el-image-viewer
v-if="previewVisible"
:url-list="[previewUrl]"
@close="previewVisible = false"
/>
<!-- 新增/编辑弹窗 -->
<Dialog :title="formDialogTitle" v-model="formDialogVisible" width="500px">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="排列顺序" prop="orderNum">
<el-input-number
v-model="formData.orderNum"
:min="0"
controls-position="right"
class="!w-1/1"
/>
</el-form-item>
<!-- TODO @芋艿工序选择等工序pro_process模块实现后对接下拉选择 -->
<el-form-item label="详细描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入详细描述"
/>
</el-form-item>
<el-form-item label="图片" prop="url">
<UploadImg v-model="formData.url" :limit="1" :is-show-tip="false" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary"> </el-button>
<el-button @click="formDialogVisible = false"> </el-button>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
// TODO @AI
import { MdProductSipApi, MdProductSipVO } from '@/api/mes/md/item/productSip'
import { UploadImg } from '@/components/UploadFile'
defineOptions({ name: 'MdProductSipForm' })
const props = defineProps<{
itemId: number
}>()
const message = useMessage()
const loading = ref(false)
const list = ref<MdProductSipVO[]>([])
/** 加载列表 */
const getList = async () => {
loading.value = true
try {
list.value = await MdProductSipApi.getProductSipListByItemId(props.itemId)
} finally {
loading.value = false
}
}
// ==================== ====================
// TODO @AI
const previewVisible = ref(false)
const previewUrl = ref('')
const handlePreview = (url?: string) => {
if (!url) {
return
}
previewUrl.value = url
previewVisible.value = true
}
// ==================== / ====================
const formDialogVisible = ref(false)
const formDialogTitle = ref('')
const formRef = ref()
const formData = ref({
id: undefined as number | undefined,
itemId: undefined as number | undefined,
orderNum: 0,
processId: undefined as number | undefined,
title: undefined as string | undefined,
description: undefined as string | undefined,
url: undefined as string | undefined,
remark: undefined as string | undefined
})
const formRules = reactive({
title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
orderNum: [{ required: true, message: '排列顺序不能为空', trigger: 'blur' }]
})
const openForm = (row?: MdProductSipVO) => {
formDialogVisible.value = true
if (row) {
formDialogTitle.value = '编辑 SIP'
formData.value = {
id: row.id,
itemId: row.itemId,
orderNum: row.orderNum,
processId: row.processId,
title: row.title,
description: row.description,
url: row.url,
remark: row.remark
}
} else {
formDialogTitle.value = '添加 SIP'
formData.value = {
id: undefined,
itemId: props.itemId,
orderNum: 0,
processId: undefined,
title: undefined,
description: undefined,
url: undefined,
remark: undefined
}
}
formRef.value?.resetFields()
}
const submitForm = async () => {
await formRef.value.validate()
if (formData.value.id) {
await MdProductSipApi.updateProductSip(formData.value as unknown as MdProductSipVO)
message.success('编辑成功')
} else {
await MdProductSipApi.createProductSip(formData.value as unknown as MdProductSipVO)
message.success('添加成功')
}
formDialogVisible.value = false
await getList()
}
// ==================== ====================
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await MdProductSipApi.deleteProductSip(id)
message.success('删除成功')
await getList()
} catch {}
}
/** 监听 itemId 变化 */
watch(
() => props.itemId,
(val) => {
if (val) {
getList()
}
},
{ immediate: true }
)
// TODO @AI unocss style
</script>
<style lang="scss" scoped>
.sip-image-wrapper {
cursor: pointer;
}
.sip-image {
width: 100%;
height: 160px;
display: block;
}
.sip-image-empty {
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
color: #c0c4cc;
}
</style>

View File

@ -0,0 +1,228 @@
<!-- MES 产品SOP 列表 -->
<template>
<div>
<el-button type="primary" plain size="small" @click="openForm(undefined)" class="mb-10px">
<Icon icon="ep:plus" class="mr-5px" /> 添加 SOP
</el-button>
<!-- SOP 卡片列表 -->
<el-row :gutter="12" v-loading="loading">
<el-col :span="6" v-for="item in list" :key="item.id" class="mb-12px">
<el-card shadow="hover" :body-style="{ padding: '0px' }">
<!-- 图片区域 -->
<div class="sop-image-wrapper" @click="handlePreview(item.url)">
<el-image v-if="item.url" :src="item.url" fit="cover" class="sop-image" />
<div v-else class="sop-image sop-image-empty">
<Icon icon="ep:picture" :size="32" />
</div>
</div>
<!-- 信息区域 -->
<div class="p-10px">
<div class="font-bold text-14px mb-4px truncate">{{ item.title }}</div>
<div class="text-12px color-gray mb-4px">序号{{ item.orderNum }}</div>
<div v-if="item.processName" class="text-12px color-gray mb-4px">
工序{{ item.processName }}
</div>
<div v-if="item.description" class="text-12px color-gray truncate">
{{ item.description }}
</div>
<!-- 操作按钮 -->
<div class="flex justify-end mt-8px">
<el-button link type="primary" size="small" @click="openForm(item)"></el-button>
<el-button link type="danger" size="small" @click="handleDelete(item.id!)"
>删除</el-button
>
</div>
</div>
</el-card>
</el-col>
<!-- 空状态 -->
<el-col :span="24" v-if="!loading && list.length === 0">
<el-empty description="暂无 SOP 数据" />
</el-col>
</el-row>
<!-- 图片预览 -->
<el-image-viewer
v-if="previewVisible"
:url-list="[previewUrl]"
@close="previewVisible = false"
/>
<!-- 新增/编辑弹窗 -->
<Dialog :title="formDialogTitle" v-model="formDialogVisible" width="500px">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="排列顺序" prop="orderNum">
<el-input-number
v-model="formData.orderNum"
:min="0"
controls-position="right"
class="!w-1/1"
/>
</el-form-item>
<!-- TODO @芋艿工序选择等工序pro_process模块实现后对接下拉选择 -->
<el-form-item label="详细描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入详细描述"
/>
</el-form-item>
<el-form-item label="图片" prop="url">
<UploadImg v-model="formData.url" :limit="1" :is-show-tip="false" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="submitForm" type="primary"> </el-button>
<el-button @click="formDialogVisible = false"> </el-button>
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
// TODO @AI
import { MdProductSopApi, MdProductSopVO } from '@/api/mes/md/item/productSop'
import { UploadImg } from '@/components/UploadFile'
defineOptions({ name: 'MdProductSopForm' })
const props = defineProps<{
itemId: number
}>()
const message = useMessage()
const loading = ref(false)
const list = ref<MdProductSopVO[]>([])
/** 加载列表 */
const getList = async () => {
loading.value = true
try {
list.value = await MdProductSopApi.getProductSopListByItemId(props.itemId)
} finally {
loading.value = false
}
}
// ==================== ====================
// TODO @AI
const previewVisible = ref(false)
const previewUrl = ref('')
const handlePreview = (url?: string) => {
if (!url) return
previewUrl.value = url
previewVisible.value = true
}
// ==================== / ====================
const formDialogVisible = ref(false)
const formDialogTitle = ref('')
const formRef = ref()
const formData = ref({
id: undefined as number | undefined,
itemId: undefined as number | undefined,
orderNum: 0,
processId: undefined as number | undefined,
title: undefined as string | undefined,
description: undefined as string | undefined,
url: undefined as string | undefined,
remark: undefined as string | undefined
})
const formRules = reactive({
title: [{ required: true, message: '标题不能为空', trigger: 'blur' }],
orderNum: [{ required: true, message: '排列顺序不能为空', trigger: 'blur' }]
})
const openForm = (row?: MdProductSopVO) => {
formDialogVisible.value = true
if (row) {
formDialogTitle.value = '编辑 SOP'
formData.value = {
id: row.id,
itemId: row.itemId,
orderNum: row.orderNum,
processId: row.processId,
title: row.title,
description: row.description,
url: row.url,
remark: row.remark
}
} else {
formDialogTitle.value = '添加 SOP'
formData.value = {
id: undefined,
itemId: props.itemId,
orderNum: 0,
processId: undefined,
title: undefined,
description: undefined,
url: undefined,
remark: undefined
}
}
formRef.value?.resetFields()
}
const submitForm = async () => {
await formRef.value.validate()
if (formData.value.id) {
await MdProductSopApi.updateProductSop(formData.value as unknown as MdProductSopVO)
message.success('编辑成功')
} else {
await MdProductSopApi.createProductSop(formData.value as unknown as MdProductSopVO)
message.success('添加成功')
}
formDialogVisible.value = false
await getList()
}
// ==================== ====================
const handleDelete = async (id: number) => {
try {
await message.delConfirm()
await MdProductSopApi.deleteProductSop(id)
message.success('删除成功')
await getList()
} catch {}
}
/** 监听 itemId 变化 */
watch(
() => props.itemId,
(val) => {
if (val) {
getList()
}
},
{ immediate: true }
)
// TODO @AI unocss style
</script>
<style lang="scss" scoped>
.sop-image-wrapper {
cursor: pointer;
}
.sop-image {
width: 100%;
height: 160px;
display: block;
}
.sop-image-empty {
display: flex;
align-items: center;
justify-content: center;
background-color: #f5f7fa;
color: #c0c4cc;
}
</style>