882 lines
32 KiB
Vue
882 lines
32 KiB
Vue
<template>
|
||
<div class="app-container">
|
||
<el-card shadow="always">
|
||
<template #header>
|
||
<div class="card-header">
|
||
<span class="title">BOM 配方管理</span>
|
||
<div class="header-right">
|
||
<el-input
|
||
v-model="searchKeyword"
|
||
placeholder="搜索 编号/名称/规格/子件..."
|
||
style="width: 300px; margin-right: 10px;"
|
||
clearable
|
||
@clear="fetchBomList"
|
||
@keyup.enter="fetchBomList"
|
||
>
|
||
<template #append>
|
||
<el-button :icon="Search" @click="fetchBomList" />
|
||
</template>
|
||
</el-input>
|
||
<el-button @click="activeCategories = bomGroups.map((g: any) => g.category)" size="small" style="margin-right: 6px;">全部展开</el-button>
|
||
<el-button @click="activeCategories = []" size="small" style="margin-right: 10px;">全部折叠</el-button>
|
||
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-loading="loading">
|
||
<el-collapse v-model="activeCategories" class="bom-category-collapse">
|
||
<el-collapse-item
|
||
v-for="group in bomGroups"
|
||
:key="group.category"
|
||
:title="group.category + ' (' + group.count + ')'"
|
||
:name="group.category"
|
||
>
|
||
<el-table :data="group.items" border style="width: 100%">
|
||
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" sortable>
|
||
<template #default="{ row }">
|
||
<span style="cursor: pointer; color: #409EFF;" @click="handleEdit(row)">{{ row.bom_no }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="150" show-overflow-tooltip />
|
||
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="150" show-overflow-tooltip />
|
||
<el-table-column v-if="hasColumnPermission('version')" label="版本" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag>{{ row.version }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column v-if="hasColumnPermission('status')" label="状态" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.is_enabled ? 'success' : 'danger'">{{ row.is_enabled ? '启用' : '禁用' }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column v-if="hasColumnPermission('child_count')" prop="child_count" label="子件数" width="80" align="center" />
|
||
<el-table-column v-if="userStore.hasPermission('bom_manage:operation')" label="操作" width="250" align="center" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||
<el-button type="success" link @click="handleSaveAs(row)">另存为</el-button>
|
||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-collapse-item>
|
||
</el-collapse>
|
||
</div>
|
||
</el-card>
|
||
|
||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
|
||
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
|
||
|
||
<el-row :gutter="20">
|
||
<el-col :span="16">
|
||
<el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')">
|
||
<!-- ====== 改造:父件下拉 - 远程搜索 + 懒加载 ====== -->
|
||
<el-select
|
||
v-model="form.parent_id"
|
||
placeholder="请搜索并选择父件"
|
||
filterable
|
||
remote
|
||
reserve-keyword
|
||
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
|
||
:loading="selectLoading"
|
||
style="width: 100%"
|
||
:disabled="isEditMode"
|
||
class="beautified-select"
|
||
popper-class="bom-loadmore-popper parent-popper"
|
||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
||
@change="onParentChange"
|
||
>
|
||
<el-option
|
||
v-for="item in parentOptions"
|
||
:key="item.id"
|
||
:label="`${item.name} (${item.spec})`"
|
||
:value="item.id"
|
||
>
|
||
<div class="option-row">
|
||
<span class="option-name">{{ item.name }}</span>
|
||
<span class="option-spec">{{ item.spec }}</span>
|
||
</div>
|
||
</el-option>
|
||
</el-select>
|
||
<el-link
|
||
v-if="form.parent_id"
|
||
type="primary"
|
||
:underline="false"
|
||
style="margin-left: 12px; font-size: 13px;"
|
||
@click="openParentMaterial"
|
||
>
|
||
<el-icon style="margin-right: 4px"><EditPen /></el-icon>前往修改基础信息
|
||
</el-link>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="20">
|
||
<el-col :span="8">
|
||
<el-form-item label="是否启用" prop="is_enabled" v-if="hasFormFieldPermission('is_enabled')">
|
||
<el-switch v-model="form.is_enabled" active-text="启用" inactive-text="禁用" :disabled="!userStore.hasPermission('bom_manage:operation')" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="16"></el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="20">
|
||
<el-col :span="10">
|
||
<el-form-item label="BOM 编号" required v-if="hasFormFieldPermission('bom_no')">
|
||
<el-input v-model="form.bom_no" placeholder="选择父件后自动生成" disabled />
|
||
<div style="font-size: 12px; color: #909399; line-height: 1.2; margin-top: 4px;">
|
||
编号预览: <span style="font-weight: bold">{{ form.bom_no || '请选择父件' }}</span>
|
||
</div>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="备注" v-if="hasFormFieldPermission('remark')">
|
||
<el-input v-model="form.remark" placeholder="备注信息(可选)" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-form-item label="版本号" prop="version" v-if="hasFormFieldPermission('version')">
|
||
<template v-if="isSaveAsMode">
|
||
<el-radio-group v-model="form.versionUpgradeType" @change="onVersionUpgradeTypeChange">
|
||
<el-radio-button label="minor">升级次版本 ({{ versionOptions.minor }})</el-radio-button>
|
||
<el-radio-button label="major">升级主版本 ({{ versionOptions.major }})</el-radio-button>
|
||
</el-radio-group>
|
||
</template>
|
||
<template v-else>
|
||
<el-input v-model="form.version" placeholder="如: V1.0" />
|
||
</template>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<div style="font-weight: bold; margin: 15px 0 10px 0; border-left: 4px solid #409EFF; padding-left: 10px;">
|
||
子件列表
|
||
<span style="font-weight: normal; font-size: 12px; color: #909399; margin-left: 10px;">(已选 {{ filteredChildren.length }} 条)</span>
|
||
</div>
|
||
|
||
<el-input
|
||
v-model="childSearchKeyword"
|
||
placeholder="请输入子件名称或规格型号搜索"
|
||
clearable
|
||
style="width: 300px; margin-bottom: 10px;"
|
||
:prefix-icon="Search"
|
||
/>
|
||
|
||
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300">
|
||
<el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
|
||
<template #default="{ row, $index }">
|
||
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<el-select
|
||
v-model="row.child_id"
|
||
placeholder="请搜索原料"
|
||
filterable
|
||
remote
|
||
reserve-keyword
|
||
style="flex: 1;"
|
||
:remote-method="(q: string) => handleRemoteSearch(q, 'child', $index)"
|
||
:loading="selectLoading"
|
||
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
||
:popper-class="`bom-loadmore-popper child-popper-${$index}`"
|
||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', $index)"
|
||
>
|
||
<el-option
|
||
v-for="item in getChildOptions($index)"
|
||
:key="item.id"
|
||
:label="`${item.name} (${item.spec})`"
|
||
:value="item.id"
|
||
>
|
||
<div class="option-row">
|
||
<span class="option-name">{{ item.name }}</span>
|
||
<span class="option-spec">{{ item.spec }}</span>
|
||
</div>
|
||
</el-option>
|
||
</el-select>
|
||
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
|
||
<el-button
|
||
type="primary"
|
||
link
|
||
:icon="EditPen"
|
||
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec($index))"
|
||
style="font-size: 16px; padding: 4px;"
|
||
/>
|
||
</el-tooltip>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="用量" width="140" v-if="hasFormFieldPermission('dosage')">
|
||
<template #default="{ row }">
|
||
<el-input-number v-model="row.dosage" :min="0" :precision="0" style="width: 100%" :controls="false" />
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
|
||
<template #default="{ row }">
|
||
<el-input v-model="row.remark" placeholder="备注" />
|
||
</template>
|
||
</el-table-column>
|
||
|
||
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
|
||
<template #default="{ $index }">
|
||
<el-button type="danger" link @click="removeChild($index)">删</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id')">
|
||
<el-button type="primary" plain :icon="Plus" @click="addChild" style="width: 100%">添加一行子件</el-button>
|
||
</div>
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<span class="dialog-footer">
|
||
<el-button @click="dialogVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
|
||
</span>
|
||
</template>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
|
||
import { Plus, Search, EditPen } from '@element-plus/icons-vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
|
||
import { getMaterialBaseList } from '@/api/inbound/stock'
|
||
import { useUserStore } from '@/stores/user'
|
||
|
||
// ============================================================
|
||
// 类型定义
|
||
// ============================================================
|
||
interface BomItem {
|
||
bom_no: string
|
||
parent_id: number
|
||
parent_name: string
|
||
version: string
|
||
is_enabled: boolean
|
||
child_count: number
|
||
}
|
||
interface MaterialBase {
|
||
id: number
|
||
name: string
|
||
spec: string
|
||
}
|
||
interface ChildRow {
|
||
child_id: number | null
|
||
dosage: number
|
||
remark: string
|
||
}
|
||
|
||
const userStore = useUserStore()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const loading = ref(false)
|
||
const dialogVisible = ref(false)
|
||
const saving = ref(false)
|
||
const isEditMode = ref(false)
|
||
const isSaveAsMode = ref(false)
|
||
let originalVersion = ''
|
||
let currentBomNo = ''
|
||
let originalChildren: ChildRow[] = []
|
||
|
||
const bomGroups = ref([]) // 分组结构: [{category, count, items[]}]
|
||
const activeCategories = ref([]) // 默认全部展开
|
||
const searchKeyword = ref('')
|
||
const childSearchKeyword = ref('')
|
||
|
||
// ============================================================
|
||
// 【改造】分页 + 远程搜索相关状态
|
||
// ============================================================
|
||
const PAGE_SIZE = 20
|
||
|
||
// 父件下拉
|
||
const parentOptions = ref<MaterialBase[]>([])
|
||
const parentQueryParams = reactive({ page: 1, limit: PAGE_SIZE, keyword: '' })
|
||
const parentHasMore = ref(true)
|
||
|
||
// 子件下拉(每行独立状态)
|
||
interface ChildDropdownState {
|
||
options: MaterialBase[]
|
||
queryParams: { page: number; limit: number; keyword: string }
|
||
hasMore: boolean
|
||
}
|
||
const childDropdownStates = ref<Map<number, ChildDropdownState>>(new Map())
|
||
|
||
// 子件下拉专用的查询参数(用于 loading-text 显示)
|
||
const childQueryParams = reactive({ page: 1 })
|
||
|
||
// 加载状态(父件和子件共用一个 loading,避免闪烁)
|
||
const selectLoading = ref(false)
|
||
|
||
const getChildOptions = (index: number): MaterialBase[] => {
|
||
return childDropdownStates.value.get(index)?.options ?? []
|
||
}
|
||
|
||
// ============================================================
|
||
// 【改造】获取物料列表(分页版本)
|
||
// ============================================================
|
||
const fetchMaterialOptions = async (
|
||
type: 'parent' | 'child',
|
||
index?: number,
|
||
isLoadMore = false
|
||
) => {
|
||
// 子件行需要 index
|
||
if (type === 'child' && index === undefined) return
|
||
|
||
const params = type === 'parent'
|
||
? parentQueryParams
|
||
: childDropdownStates.value.get(index!)?.queryParams
|
||
|
||
if (!params) return
|
||
|
||
selectLoading.value = true
|
||
|
||
try {
|
||
const res = await getMaterialBaseList({
|
||
page: params.page,
|
||
limit: params.limit,
|
||
keyword: params.keyword
|
||
})
|
||
|
||
if (res.code === 200) {
|
||
const list: MaterialBase[] = res.data?.list ?? res.data ?? []
|
||
const total = res.data?.total ?? list.length
|
||
|
||
if (type === 'parent') {
|
||
if (isLoadMore) {
|
||
// 去重追加
|
||
const existingIds = new Set(parentOptions.value.map(m => m.id))
|
||
const newItems = list.filter(m => !existingIds.has(m.id))
|
||
parentOptions.value.push(...newItems)
|
||
} else {
|
||
parentOptions.value = list
|
||
}
|
||
// 判断是否还有更多数据
|
||
parentHasMore.value = parentOptions.value.length < total
|
||
} else {
|
||
const state = childDropdownStates.value.get(index!)
|
||
if (!state) return
|
||
|
||
if (isLoadMore) {
|
||
const existingIds = new Set(state.options.map(m => m.id))
|
||
const newItems = list.filter(m => !existingIds.has(m.id))
|
||
state.options.push(...newItems)
|
||
} else {
|
||
state.options = list
|
||
}
|
||
state.hasMore = state.options.length < total
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// 错误已由全局拦截器统一处理
|
||
} finally {
|
||
selectLoading.value = false
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 【改造】远程搜索处理函数
|
||
// ============================================================
|
||
const handleRemoteSearch = (
|
||
query: string,
|
||
type: 'parent' | 'child',
|
||
index?: number
|
||
) => {
|
||
if (type === 'parent') {
|
||
parentQueryParams.keyword = query
|
||
parentQueryParams.page = 1
|
||
parentHasMore.value = true
|
||
fetchMaterialOptions('parent')
|
||
} else if (type === 'child' && index !== undefined) {
|
||
const state = childDropdownStates.value.get(index)
|
||
if (!state) return
|
||
state.queryParams.keyword = query
|
||
state.queryParams.page = 1
|
||
state.hasMore = true
|
||
fetchMaterialOptions('child', index)
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 【改造】下拉框展开/收起处理(重置分页 + 预加载第一页)
|
||
// ============================================================
|
||
const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?: number) => {
|
||
if (!visible) return
|
||
|
||
if (type === 'parent') {
|
||
parentQueryParams.page = 1
|
||
parentQueryParams.keyword = ''
|
||
parentHasMore.value = true
|
||
fetchMaterialOptions('parent')
|
||
} else if (type === 'child' && index !== undefined) {
|
||
// 确保该行下拉状态已初始化
|
||
if (!childDropdownStates.value.has(index)) {
|
||
childDropdownStates.value.set(index, {
|
||
options: [],
|
||
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
|
||
hasMore: true
|
||
})
|
||
}
|
||
const state = childDropdownStates.value.get(index)!
|
||
state.queryParams.page = 1
|
||
state.queryParams.keyword = ''
|
||
state.hasMore = true
|
||
fetchMaterialOptions('child', index)
|
||
}
|
||
|
||
// 延迟 50ms 等待弹窗 DOM 完全渲染
|
||
setTimeout(() => {
|
||
// 动态拼接精确的选择器
|
||
const exactSelector = type === 'parent'
|
||
? '.parent-popper .el-select-dropdown__wrap'
|
||
: `.child-popper-${index} .el-select-dropdown__wrap`;
|
||
|
||
const popperWrap = document.querySelector(exactSelector) as HTMLElement;
|
||
|
||
if (popperWrap) {
|
||
// 解绑旧事件,防止重复触发
|
||
if ((popperWrap as any)._scrollHandler) {
|
||
popperWrap.removeEventListener('scroll', (popperWrap as any)._scrollHandler);
|
||
}
|
||
|
||
// 定义滚动触发逻辑
|
||
(popperWrap as any)._scrollHandler = function() {
|
||
const { scrollTop, scrollHeight, clientHeight } = this;
|
||
// 距离底部 10px 触发
|
||
if (scrollHeight - scrollTop - clientHeight <= 10) {
|
||
if (type === 'parent') {
|
||
loadMoreParent();
|
||
} else if (type === 'child' && index !== undefined) {
|
||
// 触发子件加载
|
||
loadMoreChild(popperWrap, index);
|
||
}
|
||
}
|
||
};
|
||
popperWrap.addEventListener('scroll', (popperWrap as any)._scrollHandler);
|
||
}
|
||
}, 50);
|
||
}
|
||
|
||
// ============================================================
|
||
// 【改造】滚动触底加载更多
|
||
// ============================================================
|
||
const loadMoreParent = () => {
|
||
if (selectLoading.value || !parentHasMore.value) return
|
||
parentQueryParams.page++
|
||
fetchMaterialOptions('parent', undefined, true)
|
||
}
|
||
|
||
const loadMoreChild = (_el: HTMLElement, index: number) => {
|
||
const state = childDropdownStates.value.get(index)
|
||
if (!state) return
|
||
if (selectLoading.value || !state.hasMore) return
|
||
state.queryParams.page++
|
||
fetchMaterialOptions('child', index, true)
|
||
}
|
||
|
||
// ============================================================
|
||
// 【改造】初始化子件行下拉状态
|
||
// ============================================================
|
||
const initChildDropdownState = (index: number) => {
|
||
if (!childDropdownStates.value.has(index)) {
|
||
childDropdownStates.value.set(index, {
|
||
options: [],
|
||
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' },
|
||
hasMore: true
|
||
})
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 原有逻辑保留
|
||
// ============================================================
|
||
const filteredChildren = computed(() => {
|
||
if (!childSearchKeyword.value) {
|
||
return form.children
|
||
}
|
||
const kw = childSearchKeyword.value.toLowerCase()
|
||
return form.children.filter(child => {
|
||
const state = childDropdownStates.value.get(form.children.indexOf(child))
|
||
const material = state?.options.find(m => m.id === child.child_id)
|
||
if (!material) return false
|
||
const name = (material.name || '').toLowerCase()
|
||
const spec = (material.spec || '').toLowerCase()
|
||
return name.includes(kw) || spec.includes(kw)
|
||
})
|
||
})
|
||
|
||
// 获取子件规格(从 childDropdownStates 缓存中查找)
|
||
const getChildSpec = (index: number): string => {
|
||
const state = childDropdownStates.value.get(index)
|
||
if (!state || !form.children[index]?.child_id) return ''
|
||
const material = state.options.find((m: MaterialBase) => m.id === form.children[index].child_id)
|
||
return material?.spec || ''
|
||
}
|
||
|
||
// 在新标签页打开基础信息编辑
|
||
const openMaterialInNewTab = (targetId: number | null, keyword: string = '') => {
|
||
if (!targetId) return ElMessage.warning('请先选择物料')
|
||
const routeUrl = router.resolve({
|
||
path: '/material',
|
||
query: { edit_id: targetId, keyword }
|
||
})
|
||
window.open(routeUrl.href, '_blank')
|
||
}
|
||
|
||
const openParentMaterial = () => {
|
||
if (!form.parent_id) return ElMessage.warning('请先选择父件')
|
||
const parent = parentOptions.value.find((p: MaterialBase) => p.id === form.parent_id)
|
||
const keyword = parent?.spec || parent?.name || ''
|
||
openMaterialInNewTab(form.parent_id, keyword)
|
||
}
|
||
|
||
// 列与权限Code的映射关系(数据库中的code)
|
||
const permissionMap: Record<string, string> = {
|
||
bom_no: 'bom_manage:bom_no',
|
||
parent_name: 'bom_manage:parent_name',
|
||
parent_spec: 'bom_manage:parent_spec',
|
||
version: 'bom_manage:version',
|
||
status: 'bom_manage:status',
|
||
child_count: 'bom_manage:child_count',
|
||
parent_id: 'bom_manage:parent_id',
|
||
is_enabled: 'bom_manage:status',
|
||
child_id: 'bom_manage:child_id',
|
||
dosage: 'bom_manage:dosage',
|
||
remark: 'bom_manage:remark',
|
||
}
|
||
|
||
const hasColumnPermission = (prop: string) => {
|
||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
|
||
const code = permissionMap[prop]
|
||
return code ? userStore.hasPermission(code) : false
|
||
}
|
||
|
||
const hasFormFieldPermission = (fieldName: string) => {
|
||
if (userStore.role === 'SUPER_ADMIN' || userStore.username === 'IRIS') return true
|
||
const code = permissionMap[fieldName]
|
||
return code ? userStore.hasPermission(code) : false
|
||
}
|
||
|
||
const formRef = ref<FormInstance>()
|
||
const form = reactive({
|
||
bom_no: '',
|
||
remark: '',
|
||
parent_id: null as number | null,
|
||
version: 'V1.0',
|
||
versionUpgradeType: 'minor' as 'minor' | 'major',
|
||
is_enabled: true,
|
||
children: [] as ChildRow[]
|
||
})
|
||
|
||
const pureBomNo = computed(() => form.bom_no)
|
||
|
||
const versionOptions = computed(() => {
|
||
const ver = originalVersion || 'V1.0'
|
||
const allItems = bomGroups.value.flatMap((g: any) => g.items)
|
||
const occupiedVersions = new Set(
|
||
allItems.filter((item: any) => item.bom_no === currentBomNo).map((item: any) => item.version)
|
||
)
|
||
const getNextMinor = (baseMajor: number, baseMinor: number): string => {
|
||
let minor = baseMinor + 1
|
||
let candidate = `V${baseMajor}.${minor}`
|
||
while (occupiedVersions.has(candidate)) { minor++; candidate = `V${baseMajor}.${minor}` }
|
||
return candidate
|
||
}
|
||
const getNextMajor = (baseMajor: number): string => {
|
||
let major = baseMajor + 1
|
||
let candidate = `V${major}.0`
|
||
while (occupiedVersions.has(candidate)) { major++; candidate = `V${major}.0` }
|
||
return candidate
|
||
}
|
||
const match = ver.match(/^V(\d+)\.(\d+)$/)
|
||
if (match) {
|
||
return { minor: getNextMinor(parseInt(match[1]), parseInt(match[2])), major: getNextMajor(parseInt(match[1])) }
|
||
}
|
||
return { minor: getNextMinor(1, 0), major: getNextMajor(1) }
|
||
})
|
||
|
||
const onVersionUpgradeTypeChange = (type: 'minor' | 'major') => {
|
||
form.version = versionOptions.value[type]
|
||
}
|
||
|
||
const rules = reactive<FormRules>({
|
||
parent_id: [{ required: true, message: '请选择父件', trigger: 'change' }],
|
||
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
|
||
})
|
||
|
||
const dialogTitle = ref('新建 BOM')
|
||
|
||
const fetchBomList = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res = await getBomList({ keyword: searchKeyword.value })
|
||
if (res.code === 200) {
|
||
bomGroups.value = res.data
|
||
activeCategories.value = []
|
||
}
|
||
} finally { loading.value = false }
|
||
}
|
||
|
||
const onParentChange = (val: number) => {
|
||
const selected = parentOptions.value.find(m => m.id === val)
|
||
if (selected && selected.spec) {
|
||
form.bom_no = selected.spec.split('/')[0].trim()
|
||
} else {
|
||
form.bom_no = ''
|
||
}
|
||
}
|
||
|
||
const onChildChange = (val: number | null, index: number) => {
|
||
if (val !== null) {
|
||
const existingIndex = form.children.findIndex((child, idx) => idx !== index && child.child_id === val)
|
||
if (existingIndex !== -1) {
|
||
ElMessage.warning(`该物料已在第 ${existingIndex + 1} 行存在,请合并用量或删除重复项`)
|
||
form.children[index].child_id = null
|
||
form.children[index].dosage = 0
|
||
return
|
||
}
|
||
form.children[index].dosage = 1
|
||
}
|
||
}
|
||
|
||
const handleCreate = () => {
|
||
resetForm()
|
||
dialogTitle.value = '新建 BOM'
|
||
isEditMode.value = false
|
||
isSaveAsMode.value = false
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const handleEdit = async (row: BomItem) => {
|
||
await loadDetail(row.bom_no, row.version)
|
||
dialogTitle.value = '编辑 BOM'
|
||
isEditMode.value = true
|
||
isSaveAsMode.value = false
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const handleSaveAs = async (row: BomItem) => {
|
||
await loadDetail(row.bom_no, row.version)
|
||
dialogTitle.value = '另存为新版/变体'
|
||
isEditMode.value = true
|
||
isSaveAsMode.value = true
|
||
originalVersion = form.version
|
||
currentBomNo = row.bom_no
|
||
originalChildren = JSON.parse(JSON.stringify(form.children))
|
||
form.versionUpgradeType = 'minor'
|
||
form.version = versionOptions.value.minor
|
||
dialogVisible.value = true
|
||
}
|
||
|
||
const loadDetail = async (bomNo: string, version: string) => {
|
||
try {
|
||
const res = await getBomDetail(bomNo, version)
|
||
if (res.code === 200) {
|
||
const data = res.data
|
||
// 1. 映射子件基本数据
|
||
form.children = data.children.map((child: any) => ({
|
||
child_id: child.child_id,
|
||
dosage: child.dosage,
|
||
remark: child.remark || ''
|
||
}))
|
||
|
||
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
|
||
form.children.forEach((child, idx) => {
|
||
initChildDropdownState(idx)
|
||
|
||
if (child.child_id) {
|
||
const state = childDropdownStates.value.get(idx)!
|
||
// 从原始 data.children 中取对应的名称和规格注入 options
|
||
const rawChildData = data.children[idx]
|
||
state.options = [{
|
||
id: rawChildData.child_id,
|
||
name: rawChildData.child_name || '未知物料', // 依赖后端返回 child_name
|
||
spec: rawChildData.child_spec || '' // 依赖后端返回 child_spec
|
||
}]
|
||
state.hasMore = false
|
||
}
|
||
})
|
||
|
||
// 3. 处理父件回显,预填充 parentOptions
|
||
if (data.parent_id) {
|
||
form.parent_id = data.parent_id
|
||
parentOptions.value = [{
|
||
id: data.parent_id,
|
||
name: data.parent_name || '未知产品', // 依赖后端返回 parent_name
|
||
spec: data.parent_spec || '' // 依赖后端返回 parent_spec
|
||
}]
|
||
}
|
||
|
||
if (data.parent_spec) {
|
||
form.bom_no = (data.parent_spec || '').split('/')[0].trim()
|
||
} else {
|
||
form.bom_no = bomNo.split('/')[0].trim()
|
||
}
|
||
form.remark = data.remark || ''
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
|
||
const handleDelete = (row: BomItem) => {
|
||
ElMessageBox.confirm(`确定删除 ${row.bom_no} (${row.version}) 吗?`, '警告', { type: 'warning' })
|
||
.then(async () => {
|
||
try {
|
||
const res = await deleteBom(row.bom_no, row.version)
|
||
if (res.code === 200) { ElMessage.success('删除成功'); fetchBomList() }
|
||
} catch (e) {}
|
||
})
|
||
.catch(() => {})
|
||
}
|
||
|
||
const resetForm = () => {
|
||
form.bom_no = ''
|
||
form.remark = ''
|
||
form.parent_id = null
|
||
form.version = 'V1.0'
|
||
form.versionUpgradeType = 'minor'
|
||
form.is_enabled = true
|
||
form.children = []
|
||
isSaveAsMode.value = false
|
||
originalVersion = ''
|
||
currentBomNo = ''
|
||
childSearchKeyword.value = ''
|
||
// 重置子件下拉状态
|
||
childDropdownStates.value.clear()
|
||
// 重置父件下拉状态
|
||
parentOptions.value = []
|
||
parentQueryParams.page = 1
|
||
parentQueryParams.keyword = ''
|
||
parentHasMore.value = true
|
||
if (formRef.value) formRef.value.resetFields()
|
||
}
|
||
|
||
const addChild = () => {
|
||
const idx = form.children.length
|
||
form.children.push({ child_id: null, dosage: 0, remark: '' })
|
||
initChildDropdownState(idx)
|
||
}
|
||
|
||
const removeChild = (idx: number) => {
|
||
form.children.splice(idx, 1)
|
||
// 清理该行下拉状态(需要重新索引后续行的状态)
|
||
rebuildChildDropdownStates()
|
||
}
|
||
|
||
// 重建子件下拉状态索引(删除行后需要重新编号)
|
||
const rebuildChildDropdownStates = () => {
|
||
const newMap = new Map<number, ChildDropdownState>()
|
||
form.children.forEach((_, idx) => {
|
||
if (childDropdownStates.value.has(idx)) {
|
||
newMap.set(idx, childDropdownStates.value.get(idx)!)
|
||
}
|
||
})
|
||
childDropdownStates.value = newMap
|
||
}
|
||
|
||
const submitForm = async () => {
|
||
if (!formRef.value) return
|
||
await formRef.value.validate(async (valid) => {
|
||
if (!valid) return
|
||
if (!pureBomNo.value) return ElMessage.warning('BOM编号不能为空')
|
||
if (form.children.length === 0) return ElMessage.warning('请至少添加一个子件')
|
||
|
||
const childIds = form.children.map(c => c.child_id).filter(id => id !== null)
|
||
const uniqueIds = new Set(childIds)
|
||
if (childIds.length !== uniqueIds.size) {
|
||
return ElMessage.warning('子件列表中存在重复物料,请合并用量或删除重复项')
|
||
}
|
||
|
||
if (isSaveAsMode.value && originalChildren.length > 0) {
|
||
const currentSet = new Set(form.children.map(c => `${c.child_id}-${c.dosage}`))
|
||
const originalSet = new Set(originalChildren.map(c => `${c.child_id}-${c.dosage}`))
|
||
const isIdentical = currentSet.size === originalSet.size && [...currentSet].every(item => originalSet.has(item))
|
||
if (isIdentical) {
|
||
return ElMessage.warning('您未修改任何子件,与原版本内容一致,请修改后再保存')
|
||
}
|
||
}
|
||
|
||
const payload = {
|
||
bom_no: pureBomNo.value,
|
||
remark: form.remark,
|
||
version: form.version,
|
||
parent_id: form.parent_id,
|
||
is_enabled: form.is_enabled,
|
||
children: form.children
|
||
}
|
||
|
||
saving.value = true
|
||
try {
|
||
const res = await saveBom(payload)
|
||
if (res.code === 200) { ElMessage.success('保存成功'); dialogVisible.value = false; fetchBomList() }
|
||
else ElMessage.error(res.msg || '保存失败')
|
||
} finally { saving.value = false }
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchBomList()
|
||
|
||
// 【新增】:处理外部跳转自动打开 BOM(带查重保护)
|
||
if (route.query.create_for_id) {
|
||
const parentId = Number(route.query.create_for_id);
|
||
const parentName = (route.query.parent_name as string) || '';
|
||
const parentSpec = (route.query.parent_spec as string) || '';
|
||
|
||
// 把名称填入背景搜索框,让背后的表格也只显示相关的BOM
|
||
searchKeyword.value = parentName;
|
||
|
||
// 延迟等待基础渲染
|
||
setTimeout(() => {
|
||
// 1. 先用 keyword 查询是否已有该父件的 BOM
|
||
getBomList({ keyword: parentName }).then((res: any) => {
|
||
const rows = res.data || [];
|
||
// 严格校验 parent_id
|
||
const existingBom = rows.find((b: any) => b.parent_id === parentId);
|
||
|
||
if (existingBom) {
|
||
// ★ 情况 A:已经有BOM了,直接打开编辑弹窗并拉取历史数据
|
||
ElMessage.success('检测到该物料已有 BOM,已自动为您打开编辑');
|
||
handleEdit(existingBom);
|
||
} else {
|
||
// ★ 情况 B:还没建过BOM,打开新建并注入父件
|
||
handleCreate();
|
||
|
||
// 强行注入父件远程搜索选项
|
||
parentOptions.value = [{
|
||
id: parentId,
|
||
name: parentName,
|
||
spec: parentSpec
|
||
}];
|
||
|
||
// 给表单赋值
|
||
form.parent_id = parentId;
|
||
|
||
// 触发联动逻辑(自动带出版本和生成编号)
|
||
if (typeof onParentChange === 'function') {
|
||
setTimeout(() => {
|
||
onParentChange(parentId);
|
||
}, 100);
|
||
}
|
||
}
|
||
}).catch(err => {
|
||
console.error('BOM 查重失败', err);
|
||
ElMessage.error('获取 BOM 状态失败,请手动操作');
|
||
});
|
||
}, 300);
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||
.header-right { display: flex; align-items: center; }
|
||
.title { font-size: 18px; font-weight: bold; }
|
||
.option-row { display: flex; justify-content: space-between; width: 100%; }
|
||
.option-name { font-weight: bold; color: #303133; }
|
||
.option-spec { font-size: 12px; color: #909399; margin-left: 15px; }
|
||
</style>
|