feat(bom): BOM 管理页 UI——子件版本下拉、只读启停、状态按钮组、公司筛选、手风琴、详情 loading

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

View File

@ -17,6 +17,12 @@
<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>
@ -27,7 +33,7 @@
<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" class="bom-category-collapse" @change="handleCollapseChange">
<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"
@ -68,7 +74,7 @@
</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">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules" v-loading="detailLoading">
<el-row :gutter="20">
<el-col :span="16">
@ -93,6 +99,7 @@
<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>
@ -112,7 +119,15 @@
<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="isReadOnlyMode || !userStore.hasPermission('bom_manage:operation')" />
<!-- 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>
@ -184,6 +199,7 @@
<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>
@ -196,10 +212,29 @@
style="font-size: 16px; padding: 4px;"
/>
</el-tooltip>
<!-- ★ 自制件子件:显示其对应 BOM 版本(仅识别用) -->
<el-tag v-if="row.child_bom_version" size="small" type="warning" style="margin-left: 4px;">
自制{{ row.child_bom_version }}
</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>
@ -266,8 +301,9 @@ 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 } from '@/api/bom'
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'
@ -294,7 +330,64 @@ interface ChildRow {
material_spec: string
dosage: number
remark: string
child_bom_version?: string // 自制件子件对应的 BOM 版本(仅展示识别
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('')
@ -303,7 +396,9 @@ const getDraftHash = () => {
const children = form.children.map(c => ({
child_id: c.child_id,
dosage: Number(c.dosage) || 0,
remark: c.remark || ''
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,
@ -331,6 +426,11 @@ 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 }
@ -378,7 +478,7 @@ 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).then((res: any) => {
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)
@ -404,7 +504,7 @@ const fetchChildSuggestions = (row: any, query: string, cb: (results: any[]) =>
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchLoading.value = true
searchMaterialBase(safeQuery).then((res: any) => {
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)
@ -416,6 +516,10 @@ const onChildClear = (row: any) => {
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) => {
@ -425,12 +529,19 @@ const onChildSelected = (row: any, item: any) => {
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)
@ -533,6 +644,7 @@ const dialogTitle = ref('新建 BOM')
const handleSearch = () => {
activeCategories.value = []
groupCache.value = new Map()
isAccordion.value = true // 搜索/筛选重置回默认手风琴模式
fetchBomSummary()
}
@ -542,6 +654,8 @@ const fetchBomSummary = async () => {
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 ?? []
@ -554,12 +668,17 @@ const fetchBomSummary = async () => {
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 = [] }
// ★ 全部折叠:清空面板,并恢复手风琴模式
const collapseAllGroups = () => {
activeCategories.value = []
isAccordion.value = true
}
// ★ 展开分组时懒加载该分类下的 BOM
const loadGroupItems = async (category: string) => {
@ -574,6 +693,8 @@ const loadGroupItems = async (category: string) => {
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 ?? [])
@ -655,7 +776,8 @@ const executeSaveDraftRequest = async (targetBomNo: string) => {
child_id: child.child_id,
dosage: child.dosage,
remark: child.remark || '',
child_bom_version: child.child_bom_version || ''
child_bom_no: child.child_bom_no || '',
child_bom_version: child.child_bom_version || ''
}))
saving.value = true
@ -690,6 +812,7 @@ const restoreDraftToForm = (draftData: any) => {
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 || ''
}))
@ -705,6 +828,8 @@ const restoreDraftToForm = (draftData: any) => {
pendingDraftVersion = draftData.version || pendingDraftVersion
originalDraftHash.value = getDraftHash()
// 草稿中存量自制件子件也要补齐版本候选(保留其已存版本),便于继续编辑
hydrateChildBomVersions()
}
const checkUserDraft = async (bomNo: string, version: string) => {
@ -773,12 +898,13 @@ const checkAndInterceptGlobalDraft = async (): Promise<boolean> => {
}
const handleView = async (row: BomItem) => {
await loadDetail(row.bom_no, row.version)
resetForm() // 清空旧内容,避免加载期间闪现上一条数据
dialogTitle.value = '查看 BOM'
isEditMode.value = false
isSaveAsMode.value = false
isReadOnlyMode.value = true
dialogVisible.value = true
dialogVisible.value = true // 先开窗,让 detailLoading 骨架屏可见
await loadDetail(row.bom_no, row.version)
}
const handleSaveAs = async (row: any) => {
@ -808,6 +934,7 @@ const handleSaveAs = async (row: any) => {
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) {
@ -825,6 +952,9 @@ const handleSaveAs = async (row: any) => {
originalChildren = JSON.parse(JSON.stringify(form.children))
originalDraftHash.value = getDraftHash()
// 另存/升版编辑:存量自制件子件补齐版本候选(保留其已存储的引用版本)
hydrateChildBomVersions()
dialogTitle.value = '新增 BOM (版本升级)'
isEditMode.value = false
isSaveAsMode.value = true
@ -833,6 +963,7 @@ const handleSaveAs = async (row: any) => {
}
const loadDetail = async (bomNo: string, version: string) => {
detailLoading.value = true
try {
const res = await getBomDetail(bomNo, version)
if (res.code === 200) {
@ -845,6 +976,7 @@ const loadDetail = async (bomNo: string, version: string) => {
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 || ''
}))
@ -859,8 +991,45 @@ const loadDetail = async (bomNo: string, version: string) => {
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) {}
} 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) => {
@ -918,14 +1087,25 @@ const submitForm = async () => {
}
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 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,
@ -937,7 +1117,9 @@ const submitForm = async () => {
.map((c: any) => ({
child_id: c.child_id,
dosage: c.dosage,
remark: c.remark || ''
remark: c.remark || '',
child_bom_no: c.child_bom_no || '',
child_bom_version: c.child_bom_version || ''
}))
}