Files
KCGL/inventory-web/src/views/bom/BomManage.vue
yueli 1daa60590c feat(bom): BOM 管理页 UI——子件版本下拉、只读启停、状态按钮组、公司筛选、手风琴、详情 loading
- 自制件子件行内版本下拉(必选,默认最新启用,外购件不显示);保存/草稿带引用版本
- 查看/只读态“是否启用”开关解耦,切换走独立启停接口
- 状态筛选改 el-radio-group 按钮组(默认启用)
- 顶部接入 CompanySelector,父件/子件物料搜索联动公司并标注公司
- 分类手风琴(全部展开临时关手风琴)与详情弹窗 v-loading;列表默认只显示启用
2026-09-08 10:14:51 +08:00

1205 lines
47 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">
<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="handleSearch"
@keyup.enter="handleSearch"
>
<template #append>
<el-button :icon="Search" @click="handleSearch" />
</template>
</el-input>
<company-selector v-model="bomCompany" @change="handleSearch" style="margin-right: 10px;" />
<el-radio-group v-model="bomStatusFilter" @change="handleSearch" style="margin-right: 15px;">
<el-radio-button :label="''">全部</el-radio-button>
<el-radio-button label="enabled">启用</el-radio-button>
<el-radio-button label="disabled">停用</el-radio-button>
</el-radio-group>
<el-button @click="expandAllGroups" size="small" style="margin-right: 6px;">全部展开</el-button>
<el-button @click="collapseAllGroups" size="small" style="margin-right: 10px;">全部折叠</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="success" plain :icon="Upload" @click="showImportDialog = true" style="margin-right:10px">导入BOM</el-button>
<el-button v-if="userStore.hasPermission('bom_manage:operation')" type="primary" :icon="Plus" @click="handleCreate">新建 BOM</el-button>
</div>
</div>
</template>
<el-skeleton :rows="8" animated v-if="loading && groupSummary.length === 0" />
<el-empty v-else-if="!loading && groupSummary.length === 0" description="暂无 BOM 数据" />
<el-collapse v-else v-model="activeCategories" :accordion="isAccordion" class="bom-category-collapse" @change="handleCollapseChange">
<el-collapse-item
v-for="group in groupedData"
:key="group.category"
:title="group.category + ' (' + group.count + ')'"
:name="group.category"
>
<el-table v-if="activeCategories.includes(group.category)" :data="group.items" border style="width: 100%">
<el-table-column v-if="hasColumnPermission('bom_no')" prop="bom_no" label="BOM编号" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span style="cursor: pointer; color: #409EFF;" @click="handleView(row)">{{ row.bom_no }}</span>
</template>
</el-table-column>
<el-table-column v-if="hasColumnPermission('parent_name')" prop="parent_name" label="父件名称" min-width="160" show-overflow-tooltip />
<el-table-column v-if="hasColumnPermission('parent_spec')" prop="parent_spec" label="父件规格" min-width="180" 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="160" align="center" fixed="right">
<template #default="{ row }">
<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>
<div v-if="groupLoadingMap.get(group.category)" style="text-align:center;padding:12px;">
<el-icon class="is-loading"><Loading /></el-icon> 加载中...
</div>
</el-collapse-item>
</el-collapse>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules" v-loading="detailLoading">
<el-row :gutter="20">
<el-col :span="16">
<el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')">
<!-- ====== 父件搜索 - el-autocomplete ====== -->
<el-autocomplete
v-model="parentNameInput"
:fetch-suggestions="fetchParentSuggestions"
:value-key="'name'"
:validate-event="false"
clearable
placeholder="请搜索并选择父件"
:loading="searchLoading"
:trigger-on-focus="true"
style="width: 100%"
:disabled="isReadOnlyMode || isEditMode"
@select="onParentSelected"
@clear="onParentClear"
popper-class="bom-parent-popper"
>
<template #default="{ item }">
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
<span v-if="item.company_name" style="font-size:12px;color:#c45656;background:#fef0f0;border-radius:2px;padding:0 4px;margin-left:auto;flex-shrink:0;">{{ item.company_name }}</span>
</div>
</template>
</el-autocomplete>
<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')">
<!-- ★ disabled 仅与操作权限绑定,不再随只读视图全局禁用:
查看/只读态下切换会走 updateBomStatus 单独更新状态(见 onEnabledStatusChange) -->
<el-switch
v-model="form.is_enabled"
active-text="启用"
inactive-text="禁用"
:disabled="!userStore.hasPermission('bom_manage:operation')"
@change="onEnabledStatusChange"
/>
</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="备注信息(可选)" :disabled="isReadOnlyMode" />
</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" :disabled="isReadOnlyMode" @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" :disabled="isReadOnlyMode" />
</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"
:disabled="isReadOnlyMode"
/>
<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 }">
<!-- ====== 子件搜索 - el-autocomplete,行级绑定 ====== -->
<div style="display: flex; align-items: center; gap: 8px;">
<el-autocomplete
v-model="row.material_name"
:fetch-suggestions="(q: string, cb: (results: any[]) => void) => fetchChildSuggestions(row, q, cb)"
:value-key="'name'"
:validate-event="false"
clearable
placeholder="请搜索原料"
:loading="searchLoading"
:trigger-on-focus="true"
style="flex: 1;"
:disabled="isReadOnlyMode"
@select="(item: any) => onChildSelected(row, item)"
@clear="() => onChildClear(row)"
>
<template #default="{ item }">
<div class="option-row">
<span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span>
<span v-if="item.company_name" style="font-size:12px;color:#c45656;background:#fef0f0;border-radius:2px;padding:0 4px;margin-left:auto;flex-shrink:0;">{{ item.company_name }}</span>
</div>
</template>
</el-autocomplete>
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id">
<el-button
type="primary"
link
:icon="EditPen"
@click.stop="openMaterialInNewTab(row.child_id, row.material_spec)"
style="font-size: 16px; padding: 4px;"
/>
</el-tooltip>
</div>
<!-- ★ 自制件子件:编辑态出现"版本"必选下拉;只读态显示其引用的 BOM 版本 -->
<div v-if="childIsSelfMade(row)" style="margin-top: 4px;">
<el-select
v-if="!isReadOnlyMode && row.bom_version_options && row.bom_version_options.length"
v-model="row.child_bom_key"
placeholder="请选择BOM版本"
style="width: 100%;"
size="small"
filterable
@change="() => onChildBomChange(row)"
>
<el-option
v-for="opt in row.bom_version_options"
:key="bomKeyOf(opt.bom_no, opt.version)"
:label="opt.bom_no + ' ' + opt.version"
:value="bomKeyOf(opt.bom_no, opt.version)"
/>
</el-select>
<el-tag v-else-if="row.child_bom_version" size="small" type="warning">
自制 {{ row.child_bom_no ? row.child_bom_no + ' ' : '' }}{{ row.child_bom_version }}
</el-tag>
<div v-else-if="!isReadOnlyMode" style="font-size: 12px; color: #E6A23C;">请选择该自制件的 BOM 版本</div>
</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" :disabled="isReadOnlyMode" />
</template>
</el-table-column>
<el-table-column label="备注" width="150" v-if="hasFormFieldPermission('remark')">
<template #default="{ row }">
<el-input v-model="row.remark" placeholder="备注" :disabled="isReadOnlyMode" />
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center" v-if="userStore.hasPermission('bom_manage:operation')">
<template #default="{ row }">
<el-button v-if="!isReadOnlyMode" type="danger" link @click="removeChild(row.rowKey)">删</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 10px; text-align: center;" v-if="hasFormFieldPermission('child_id') && !isReadOnlyMode">
<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">{{ isReadOnlyMode ? '关闭' : '取消' }}</el-button>
<el-button
v-if="!isReadOnlyMode"
type="warning"
plain
:loading="saving"
@click="saveDraftData"
>
暂存草稿
</el-button>
<el-button
v-if="!isReadOnlyMode"
type="primary"
:loading="saving"
@click="submitForm"
>
保 存
</el-button>
</span>
</template>
</el-dialog>
</div>
<!-- 批量导入弹窗 -->
<ImportDialog v-model="showImportDialog" import-type="bom" @success="fetchBomSummary" />
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed, nextTick, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen, Upload } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { getBomList, getBomSummary, getBomDetail, saveBom, deleteBom, getDraftDetail, saveDraft, publishDraft, getChildBomVersions, updateBomStatus } from '@/api/bom'
import ImportDialog from '@/components/ImportDialog.vue'
import CompanySelector from '@/components/CompanySelector.vue'
import { searchMaterialBase } from '@/api/inbound/buy'
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 {
rowKey: number // 唯一标识,替代 $index 作为 Map key
child_id: number | null
material_name: string
material_spec: string
dosage: number
remark: string
child_bom_no?: string // 子件引用的自制 BOM 编号(必选:该物料作父件的配方)
child_bom_version?: string // 子件引用的自制 BOM 版本(必选,见 bom_version_options)
child_bom_key?: string // 下拉选中项编码 `${bom_no}|${version}`
bom_version_options?: { bom_no: string; version: string }[] // 该物料作父件的启用配方候选
}
// ★ 自制件子件"版本"候选与选中项工具
const BOM_KEY_SEP = '|'
const bomKeyOf = (no?: string, ver?: string) => (no || '') + BOM_KEY_SEP + (ver || '')
const bomKeyTo = (key?: string) => {
const s = String(key || '')
const i = s.indexOf(BOM_KEY_SEP)
if (i < 0) return ['', '']
return [s.slice(0, i), s.slice(i + 1)]
}
const childIsSelfMade = (row: any) =>
!!(row && ((row.bom_version_options && row.bom_version_options.length) || row.child_bom_no || row.child_bom_version))
// 拉取某子件物料作父件的启用配方列表,并维护选中项。
// - 有候选: 若当前已有合法选中(编辑存量行)则保留,否则默认选"最新启用配方"(列表第一项)
// - 无候选(外购件/无现行BOM): 清空版本相关字段
const loadChildBomVersions = async (row: any) => {
if (!row.child_id) { row.bom_version_options = []; return }
let list: { bom_no: string; version: string }[] = []
try {
const res: any = await getChildBomVersions(row.child_id)
if (res?.code === 200 && Array.isArray(res.data)) list = res.data
} catch (e) { list = [] }
row.bom_version_options = list
if (!list.length) {
row.child_bom_no = ''
row.child_bom_version = ''
row.child_bom_key = ''
return
}
const existKey = bomKeyOf(row.child_bom_no, row.child_bom_version)
if (row.child_bom_no && row.child_bom_version && list.some(o => bomKeyOf(o.bom_no, o.version) === existKey)) {
row.child_bom_key = existKey
return
}
const first = list[0]
row.child_bom_no = first.bom_no
row.child_bom_version = first.version
row.child_bom_key = bomKeyOf(first.bom_no, first.version)
}
const onChildBomChange = (row: any) => {
const [no, ver] = bomKeyTo(row.child_bom_key)
row.child_bom_no = no
row.child_bom_version = ver
}
// 给现有 children(从详情/草稿/另存为载入的存量自制件子件)补齐版本候选,
// 使编辑态能渲染下拉;保留其已存储的引用版本。
const hydrateChildBomVersions = async () => {
for (const c of form.children) {
if (c.child_id) await loadChildBomVersions(c)
}
}
const originalDraftHash = ref('')
const getDraftHash = () => {
const children = form.children.map(c => ({
child_id: c.child_id,
dosage: Number(c.dosage) || 0,
remark: c.remark || '',
child_bom_no: c.child_bom_no || '',
child_bom_version: c.child_bom_version || ''
}))
return JSON.stringify({
bom_no: form.bom_no,
version: form.version,
parent_id: form.parent_id,
children
})
}
const userStore = useUserStore()
const showImportDialog = ref(false)
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)
const isReadOnlyMode = ref(false)
let originalVersion = ''
let currentBomNo = ''
let originalChildren: ChildRow[] = []
let pendingDraftBomNo = ''
let pendingDraftVersion = ''
const searchKeyword = ref('')
const childSearchKeyword = ref('')
const bomStatusFilter = ref('enabled') // 默认只看"启用",可手动切全部/停用;enabled=启用 | disabled=停用 | ''=全部
const bomCompany = ref('') // 跨域用户按公司(父件物料公司)过滤;普通用户由后端强制隔离,此控件不显示
// ★ 手风琴开关:默认单开;"全部展开"时临时关闭手风琴以允许多开,"全部折叠"后恢复
const isAccordion = ref(true)
const detailLoading = ref(false) // 弹窗查看详情 loading(防"假死"反馈)
// ★ 懒加载分组架构
interface GroupSummary { category: string; count: number }
const groupSummary = ref<GroupSummary[]>([])
const groupCache = ref<Map<string, any[]>>(new Map())
const groupLoadingMap = ref<Map<string, boolean>>(new Map())
const activeCategories = ref<string[]>([])
const lastKeyword = ref('')
const groupedData = computed(() => {
if (!groupSummary.value.length) return []
return groupSummary.value.map(s => ({
category: s.category,
count: s.count,
items: groupCache.value.get(s.category) ?? [],
loaded: groupCache.value.has(s.category)
}))
})
const filteredChildren = computed(() => {
if (!childSearchKeyword.value) return form.children
const kw = childSearchKeyword.value.toLowerCase()
return form.children.filter(child => {
const name = (child.material_name || '').toLowerCase()
const spec = (child.material_spec || '').toLowerCase()
return name.includes(kw) || spec.includes(kw)
})
})
// 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
watch(searchKeyword, () => {
clearTimeout((window as any)._bomSearchTimer)
;(window as any)._bomSearchTimer = setTimeout(() => {
fetchBomSummary()
}, 500)
})
// ============================================================
// Material Search - el-autocomplete
// ============================================================
const searchLoading = ref(false)
const parentNameInput = ref('')
const fetchParentSuggestions = (query: string, cb: (results: any[]) => void) => {
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery, 1, bomCompany.value || '').then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
cb(formatted)
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
}
const onParentClear = () => {
form.parent_id = null
form.bom_no = ''
}
const onParentSelected = (item: any) => {
form.parent_id = item.id
parentNameInput.value = item.name || item.material_name || ''
if (item.spec) {
form.bom_no = item.spec.split('/')[0].trim()
} else {
form.bom_no = ''
}
}
const fetchChildSuggestions = (row: any, query: string, cb: (results: any[]) => void) => {
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery, 1, bomCompany.value || '').then((res: any) => {
const items = res.data?.items || res.data || []
const formatted = items.map((i: any) => ({ ...i, name: i.name || i.material_name }))
cb(formatted)
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
}
const onChildClear = (row: any) => {
row.child_id = null
row.material_name = ''
row.material_spec = ''
row.dosage = 0
row.child_bom_no = ''
row.child_bom_version = ''
row.child_bom_key = ''
row.bom_version_options = []
}
const onChildSelected = (row: any, item: any) => {
const existingIndex = form.children.findIndex((child, idx) => idx !== form.children.indexOf(row) && child.child_id === item.id)
if (existingIndex !== -1) {
ElMessage.warning(`该物料已在第 ${existingIndex + 1} 行存在,请合并用量或删除重复项`)
row.child_id = null
row.material_name = ''
row.dosage = 0
row.bom_version_options = []
return
}
row.child_id = item.id
row.material_name = item.name
row.material_spec = item.spec
row.dosage = 1
row.child_bom_no = ''
row.child_bom_version = ''
row.child_bom_key = ''
row.bom_version_options = []
// 选中物料后判定:若其为自制件(存在启用BOM),拉取候选版本并默认选最新,否则清空。
loadChildBomVersions(row)
}
const getChildSpec = (rowKey: number): string => {
const row = form.children.find(c => c.rowKey === rowKey)
return row?.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 keyword = parentNameInput.value || ''
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: any[] = []
groupCache.value.forEach((items) => allItems.push(...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>({
parentNameInput: [{ required: true, message: '请选择父件', trigger: 'change' }],
version: [{ required: true, message: '请输入版本号', trigger: 'blur' }]
})
const dialogTitle = ref('新建 BOM')
const handleSearch = () => {
activeCategories.value = []
groupCache.value = new Map()
isAccordion.value = true // 搜索/筛选重置回默认手风琴模式
fetchBomSummary()
}
// ★ 挂载+搜索时调用 — 获取分组摘要
const fetchBomSummary = async () => {
loading.value = true
try {
const params: any = {}
if (searchKeyword.value) params.keyword = searchKeyword.value
if (bomStatusFilter.value) params.status = bomStatusFilter.value
if (bomCompany.value) params.company_name = bomCompany.value
const res: any = await getBomSummary(params)
if (res?.code === 200) {
groupSummary.value = res.data ?? []
if (searchKeyword.value !== lastKeyword.value) {
activeCategories.value = []
lastKeyword.value = searchKeyword.value
}
}
} catch (e) { console.error('获取BOM摘要失败', e) }
finally { loading.value = false }
}
// ★ 全部展开:先临时关闭手风琴(允许多开),再一次性展开+懒加载各分类
const expandAllGroups = () => {
isAccordion.value = false
activeCategories.value = groupSummary.value.map(g => g.category)
groupSummary.value.forEach(g => loadGroupItems(g.category))
}
// ★ 全部折叠:清空面板,并恢复手风琴模式
const collapseAllGroups = () => {
activeCategories.value = []
isAccordion.value = true
}
// ★ 展开分组时懒加载该分类下的 BOM
const loadGroupItems = async (category: string) => {
if (groupCache.value.has(category)) return
if (groupLoadingMap.value.get(category)) return
groupLoadingMap.value.set(category, true)
try {
const params: any = {
category: category,
page: 1,
pageSize: 9999 // 单分类内全量加载(分类内数据量可控)
}
if (searchKeyword.value) params.keyword = searchKeyword.value
if (bomStatusFilter.value) params.status = bomStatusFilter.value
if (bomCompany.value) params.company_name = bomCompany.value
const res: any = await getBomList(params)
if (res?.code === 200) {
groupCache.value.set(category, res.data?.items ?? [])
}
} catch (e) { console.error(`加载BOM分类 [${category}] 失败`, e) }
finally { groupLoadingMap.value.set(category, false) }
}
// ★ collapse @change 事件 → 触发懒加载
const handleCollapseChange = (val: string | string[]) => {
const cats = Array.isArray(val) ? val : (val ? [val] : [])
cats.forEach(c => loadGroupItems(c))
}
const onParentChange = (val: number) => {}
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 getOrGenerateTempBomNo = (): string => {
let storedBomNo = localStorage.getItem('pending_bom_draft_no')
if (storedBomNo) {
return storedBomNo
}
const ts = new Date().toISOString().replace(/[-T:.Z]/g, '').slice(0, 14)
const uid = Math.random().toString(36).slice(2, 10)
const newBomNo = `DRAFT-TEMP-${ts}-${uid}`
localStorage.setItem('pending_bom_draft_no', newBomNo)
return newBomNo
}
const handleCreate = async () => {
// 1. 全局拦截
const isIntercepted = await checkAndInterceptGlobalDraft()
if (isIntercepted) return
// 2. 正常的纯新建逻辑
resetForm()
const tempBomNo = getOrGenerateTempBomNo()
form.bom_no = tempBomNo
originalDraftHash.value = getDraftHash()
dialogTitle.value = '新建 BOM'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = false
dialogVisible.value = true
}
const saveDraftData = async () => {
// 获取当前正在编辑的单号
const draftBomNo = form.bom_no || getOrGenerateTempBomNo()
const currentHash = getDraftHash()
// 场景 A:防呆拦截。如果数据没有任何改动,直接提示并阻断多余的网络请求
if (originalDraftHash.value && currentHash === originalDraftHash.value) {
return ElMessage.warning('草稿数据无变动,无需重复暂存')
}
// 场景 B:执行保存。在"全局唯一草稿"架构下,不再询问,直接实时同步最新状态到数据库
await executeSaveDraftRequest(draftBomNo)
}
const executeSaveDraftRequest = async (targetBomNo: string) => {
const draftVersion = form.version || 'V1.0'
const children = form.children
.filter(child => child.child_id !== null || child.dosage > 0)
.map(child => ({
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
saving.value = true
try {
const res = await saveDraft({
bom_no: targetBomNo,
version: draftVersion,
parent_id: form.parent_id,
children
})
if (res.code === 200) {
ElMessage.success('草稿暂存成功')
form.bom_no = targetBomNo
localStorage.setItem('pending_bom_draft_no', targetBomNo)
localStorage.setItem('pending_bom_draft_version', draftVersion)
originalDraftHash.value = getDraftHash()
} else {
ElMessage.error(res.msg || '暂存失败')
}
} catch (e) {
ElMessage.error('暂存异常')
} finally {
saving.value = false
}
}
const restoreDraftToForm = (draftData: any) => {
form.children = (draftData.children || []).map((child: any, idx: number) => ({
rowKey: Date.now() + idx,
child_id: child.child_id,
material_name: child.child_name || '',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
if (draftData.parent_id) {
form.parent_id = draftData.parent_id
parentNameInput.value = draftData.parent_name || ''
}
form.bom_no = draftData.bom_no || ''
form.version = draftData.version || 'V1.0'
form.remark = draftData.remark || ''
pendingDraftBomNo = draftData.bom_no || pendingDraftBomNo
pendingDraftVersion = draftData.version || pendingDraftVersion
originalDraftHash.value = getDraftHash()
// 草稿中存量自制件子件也要补齐版本候选(保留其已存版本),便于继续编辑
hydrateChildBomVersions()
}
const checkUserDraft = async (bomNo: string, version: string) => {
try {
const res = await getDraftDetail({ bom_no: bomNo, version: version })
if (res.code === 200 && res.data) {
const confirm = await ElMessageBox.confirm(
`检测到未发布的草稿数据,是否恢复继续编辑?`,
'草稿恢复',
{ confirmButtonText: '恢复草稿', cancelButtonText: '放弃草稿', type: 'info' }
).catch(() => 'cancel')
if (confirm === 'confirm') {
return res.data
}
}
} catch (e) {
// 网络异常或查不到均视为无草稿
}
return null
}
const checkAndInterceptGlobalDraft = async (): Promise<boolean> => {
const pendingNo = localStorage.getItem('pending_bom_draft_no')
const pendingVer = localStorage.getItem('pending_bom_draft_version') || 'V1.0'
if (pendingNo) {
try {
const res = await getDraftDetail({ bom_no: pendingNo, version: pendingVer })
if (res.code === 200 && res.data) {
const action = await ElMessageBox.confirm(
`系统检测到您有一个未发布的草稿 [${pendingNo}]。\n\n为防止数据混乱,同一时间只允许保留一份草稿。\n• 点击【恢复草稿】继续编辑\n• 点击【放弃旧草稿】彻底销毁它并开启新任务`,
'全局草稿拦截',
{
confirmButtonText: '恢复草稿',
cancelButtonText: '放弃旧草稿',
distinguishCancelAndClose: true,
type: 'warning'
}
).catch(action => action)
if (action === 'confirm') {
resetForm()
restoreDraftToForm(res.data)
originalDraftHash.value = getDraftHash()
dialogTitle.value = '继续编辑草稿'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = false
dialogVisible.value = true
return true
} else if (action === 'cancel') {
localStorage.removeItem('pending_bom_draft_no')
localStorage.removeItem('pending_bom_draft_version')
return false
} else {
return true
}
}
} catch (e) {
localStorage.removeItem('pending_bom_draft_no')
localStorage.removeItem('pending_bom_draft_version')
}
}
return false
}
const handleView = async (row: BomItem) => {
resetForm() // 清空旧内容,避免加载期间闪现上一条数据
dialogTitle.value = '查看 BOM'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = true
dialogVisible.value = true // 先开窗,让 detailLoading 骨架屏可见
await loadDetail(row.bom_no, row.version)
}
const handleSaveAs = async (row: any) => {
// 1. 全局拦截
const isIntercepted = await checkAndInterceptGlobalDraft()
if (isIntercepted) return
// 2. 正常的纯另存为逻辑
resetForm()
const res = await getBomDetail(row.bom_no, row.version)
if (res.code !== 200) return
const raw = JSON.parse(JSON.stringify(res.data))
if ('id' in raw) delete raw.id
if ('bom_id' in raw) delete raw.bom_id
if (Array.isArray(raw.children)) {
raw.children.forEach((c: any) => {
if ('id' in c) delete c.id
if ('bom_id' in c) delete c.bom_id
})
}
form.children = raw.children.map((child: any, idx: number) => ({
rowKey: idx,
child_id: child.child_id,
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
if (raw.parent_id) {
form.parent_id = raw.parent_id
parentNameInput.value = raw.parent_name || '未知产品'
}
form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim()
form.remark = raw.remark || ''
form.versionUpgradeType = 'minor'
form.version = versionOptions.value?.minor || 'V1.1'
originalVersion = raw.version || ''
currentBomNo = row.bom_no
originalChildren = JSON.parse(JSON.stringify(form.children))
originalDraftHash.value = getDraftHash()
// 另存/升版编辑:存量自制件子件补齐版本候选(保留其已存储的引用版本)
hydrateChildBomVersions()
dialogTitle.value = '新增 BOM (版本升级)'
isEditMode.value = false
isSaveAsMode.value = true
isReadOnlyMode.value = false
dialogVisible.value = true
}
const loadDetail = async (bomNo: string, version: string) => {
detailLoading.value = true
try {
const res = await getBomDetail(bomNo, version)
if (res.code === 200) {
const data = res.data
// 1. 映射子件基本数据(使用 idx 生成唯一 rowKey)
form.children = data.children.map((child: any, idx: number) => ({
rowKey: idx,
child_id: child.child_id,
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage,
remark: child.remark || '',
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
if (data.parent_id) {
form.parent_id = data.parent_id
parentNameInput.value = data.parent_name || '未知产品'
}
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 || ''
// 查看/只读视图的独立启停需要版本与当前状态
form.version = version || data.version || 'V1.0'
form.is_enabled = data.is_enabled !== false
}
} catch (e) {
// 网络/数据异常:仅结束 loading,不弹全局错误,保持弹窗可操作
} finally {
detailLoading.value = false
}
}
// ★ 启用/停用开关:查看/只读态下独立更新状态(不触发整表单保存);
// 新建/编辑/另存模式则仅记录到 form,随 submitForm 一并保存。
const onEnabledStatusChange = async () => {
if (!isReadOnlyMode.value) return
if (!pureBomNo.value || !form.version) {
ElMessage.warning('缺少 BOM 编号/版本,无法更新状态')
return
}
const target = !!form.is_enabled
saving.value = true
try {
const res: any = await updateBomStatus({ bom_no: pureBomNo.value, version: form.version, is_enabled: target })
if (res?.code === 200) {
ElMessage.success(target ? '已启用' : '已停用')
// 刷新左侧列表摘要(计数与状态列)
groupCache.value = new Map()
fetchBomSummary()
} else {
form.is_enabled = !target
ElMessage.error(res?.msg || '状态更新失败')
}
} catch (e) {
form.is_enabled = !target
ElMessage.error('状态更新失败')
} finally {
saving.value = false
}
}
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('删除成功'); groupCache.value = new Map(); fetchBomSummary() }
} 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
isReadOnlyMode.value = false
originalVersion = ''
currentBomNo = ''
childSearchKeyword.value = ''
parentNameInput.value = ''
originalDraftHash.value = ''
pendingDraftBomNo = ''
pendingDraftVersion = ''
if (formRef.value) formRef.value.resetFields()
}
const addChild = () => {
const rowKey = Date.now()
form.children.push({ rowKey, child_id: null, material_name: '', material_spec: '', dosage: 0, remark: '' })
}
const removeChild = (rowKey: number) => {
const idx = form.children.findIndex(c => c.rowKey === rowKey)
if (idx !== -1) form.children.splice(idx, 1)
}
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 childKey = (c: any) => `${c.child_id}-${c.dosage}-${c.child_bom_no || ''}-${c.child_bom_version || ''}`
const currentSet = new Set(form.children.map(childKey))
const originalSet = new Set(originalChildren.map(childKey))
const isIdentical = currentSet.size === originalSet.size && [...currentSet].every(item => originalSet.has(item))
if (isIdentical) {
return ElMessage.warning('您未修改任何子件,与原版本内容一致,请修改后再保存')
}
}
// 前置校验:自制件子件必须已选版本(后端仍会强校验兜底)
const missingVer = form.children.find(
(c: any) => c.child_id !== null &&
(c.child_bom_no || c.child_bom_version || (c.bom_version_options && c.bom_version_options.length)) &&
!(c.child_bom_no && c.child_bom_version)
)
if (missingVer) {
return ElMessage.warning(`子件「${missingVer.material_name || missingVer.child_id}」为自制件,请先选择其 BOM 版本`)
}
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
.filter((c: any) => c.child_id !== null)
.map((c: any) => ({
child_id: c.child_id,
dosage: c.dosage,
remark: c.remark || '',
child_bom_no: c.child_bom_no || '',
child_bom_version: c.child_bom_version || ''
}))
}
saving.value = true
try {
const res = await saveBom(payload)
if (res.code === 200) {
ElMessage.success('保存成功')
localStorage.removeItem('pending_bom_draft_no')
localStorage.removeItem('pending_bom_draft_version')
originalDraftHash.value = ''
dialogVisible.value = false
groupCache.value = new Map()
fetchBomSummary()
} else {
ElMessage.error(res.msg || '保存失败')
}
} finally {
saving.value = false
}
})
}
onMounted(() => {
// 处理外部跳转自动打开 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) || '';
// 1. 把名称填入背景搜索框,并真正触发一次列表搜索,让背景列表也只显示该物料
searchKeyword.value = parentName;
fetchBomSummary();
// 2. 延迟等待基础渲染后进行查重
setTimeout(() => {
getBomList({ keyword: parentName, page: 1, pageSize: 50 }).then((res: any) => {
// ★ 适配新 API 格式:{ items: [...], total, pages }
const flatItems = res.data?.items ?? [];
let existingBom = null;
for (const bom of flatItems) {
if (bom.parent_id === parentId) {
existingBom = bom;
break;
}
}
if (existingBom) {
// ★ 情况 A:已经有BOM了,直接打开只读查看弹窗
ElMessage.success('检测到该物料已有 BOM,已自动为您打开');
handleView(existingBom);
} else {
// ★ 情况 B:还没建过BOM,打开新建并注入父件
handleCreate();
parentNameInput.value = parentName;
// 给表单赋值
form.parent_id = parentId;
// 自动带出版本和生成编号
if (parentSpec) {
form.bom_no = parentSpec.split('/')[0].trim()
}
}
});
}, 300);
} else {
// 如果不是从其他页面跳转过来的,直接正常加载摘要
fetchBomSummary();
}
})
</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>