@ -164,7 +164,7 @@
<el-table :data=" filteredChildren " border style=" width : 100 % ; margin - bottom : 15 px " max-height=" 300 ">
<el-table-column label=" 子件物料 " min-width=" 250 " v-if=" hasFormFieldPermission ( 'child_id' ) ">
<template #default=" { row , $index } ">
<template #default=" { row } ">
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
<div style=" display : flex ; align - items : center ; gap : 8 px ; ">
<el-select
@ -174,14 +174,14 @@
remote
reserve-keyword
style=" flex : 1 ; "
:remote-method=" ( q : string ) => handleRemoteSearch ( q , 'child' , $index ) "
:remote-method=" ( q : string ) => handleRemoteSearch ( q , 'child' , row . rowKey ) "
:loading=" selectLoading "
:loading-text=" ` 正在加载第 ${ childQueryParams . page } 页... ` "
:popper-class=" ` bom-loadmore-popper child-popper- ${ $index } ` "
@visible-change=" ( visible : boolean ) => handleVisibleChange ( visible , 'child' , $index ) "
:popper-class=" ` bom-loadmore-popper child-popper- ${ row . rowKey } ` "
@visible-change=" ( visible : boolean ) => handleVisibleChange ( visible , 'child' , row . rowKey ) "
>
<el-option
v-for=" item in getChildOptions ( $index ) "
v-for=" item in getChildOptions ( row . rowKey ) "
:key=" item . id "
:label=" ` ${ item . name } ( ${ item . spec } ) ` "
:value=" item . id "
@ -197,7 +197,7 @@
type=" primary "
link
:icon=" EditPen "
@click.stop=" openMaterialInNewTab ( row . child _id , getChildSpec ( $index ) ) "
@click.stop=" openMaterialInNewTab ( row . child _id , getChildSpec ( row . rowKey ) ) "
style=" font - size : 16 px ; padding : 4 px ; "
/>
</el-tooltip>
@ -218,8 +218,8 @@
</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 #default=" { row } ">
<el-button type=" danger " link @click=" removeChild ( row . rowKey ) ">删</el-button>
</template>
</el-table-column>
</el-table>
@ -240,7 +240,7 @@
</template>
<script setup lang=" ts " >
import { ref , reactive , onMounted , computed , nextTick } from 'vue'
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 } from '@element-plus/icons-vue'
@ -266,6 +266,7 @@ interface MaterialBase {
spec : string
}
interface ChildRow {
rowKey : number // 唯一标识,替代 $index 作为 Map key
child _id : number | null
dosage : number
remark : string
@ -288,6 +289,15 @@ const activeCategories = ref([]) // 默认全部展开
const searchKeyword = ref ( '' )
const childSearchKeyword = ref ( '' )
// ★ 自动搜索:输入后 500ms 防抖触发搜索(无需回车)
watch ( searchKeyword , ( val ) => {
// 防抖:延迟 500ms 执行,避免频繁请求
clearTimeout ( ( window as any ) . _bomSearchTimer )
; ( window as any ) . _bomSearchTimer = setTimeout ( ( ) => {
fetchBomList ( )
} , 500 )
} )
// ============================================================
// 【改造】分页 + 远程搜索相关状态
// ============================================================
@ -321,15 +331,15 @@ const getChildOptions = (index: number): MaterialBase[] => {
// ============================================================
const fetchMaterialOptions = async (
type : 'parent' | 'child' ,
index ? : number ,
rowKey ? : number ,
isLoadMore = false
) => {
// 子件行需要 index
if ( type === 'child' && index === undefined ) return
// 子件行需要 rowKey( 唯一标识, 不再依赖数组索引)
if ( type === 'child' && rowKey === undefined ) return
const params = type === 'parent'
? parentQueryParams
: childDropdownStates . value . get ( index ! ) ? . queryParams
: childDropdownStates . value . get ( rowKey ! ) ? . queryParams
if ( ! params ) return
@ -353,12 +363,19 @@ const fetchMaterialOptions = async (
const newItems = list . filter ( m => ! existingIds . has ( m . id ) )
parentOptions . value . push ( ... newItems )
} else {
parentOptions . value = list
// ★ 修复回显丢失:先检查当前选中项是否在列表中,不在则从原 options 保留
const selectedId = form . parent _id
let finalList = [ ... list ]
if ( selectedId && ! list . find ( m => m . id === selectedId ) ) {
const existing = parentOptions . value . find ( m => m . id === selectedId )
if ( existing ) finalList . unshift ( existing )
}
parentOptions . value = finalList
}
// 判断是否还有更多数据
parentHasMore . value = parentOptions . value . length < total
} else {
const state = childDropdownStates . value . get ( index ! )
const state = childDropdownStates . value . get ( rowKey ! )
if ( ! state ) return
if ( isLoadMore ) {
@ -366,7 +383,15 @@ const fetchMaterialOptions = async (
const newItems = list . filter ( m => ! existingIds . has ( m . id ) )
state . options . push ( ... newItems )
} else {
state . options = list
// ★ 修复回显丢失:先通过 rowKey 精准找到当前行,再检查选中项是否在列表中
const currentRow = form . children . find ( c => c . rowKey === rowKey )
const currentSelectedId = currentRow ? . child _id
let finalList = [ ... list ]
if ( currentSelectedId && ! list . find ( m => m . id === currentSelectedId ) ) {
const existing = state . options . find ( m => m . id === currentSelectedId )
if ( existing ) finalList . unshift ( existing )
}
state . options = finalList
}
state . hasMore = state . options . length < total
}
@ -384,27 +409,27 @@ const fetchMaterialOptions = async (
const handleRemoteSearch = (
query : string ,
type : 'parent' | 'child' ,
index ? : number
rowKey ? : 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 )
} else if ( type === 'child' && rowKey !== undefined ) {
const state = childDropdownStates . value . get ( rowKey )
if ( ! state ) return
state . queryParams . keyword = query
state . queryParams . page = 1
state . hasMore = true
fetchMaterialOptions ( 'child' , index )
fetchMaterialOptions ( 'child' , rowKey )
}
}
// ============================================================
// 【改造】下拉框展开/收起处理(重置分页 + 预加载第一页)
// ============================================================
const handleVisibleChange = ( visible : boolean , type : 'parent' | 'child' , index ? : number ) => {
const handleVisibleChange = ( visible : boolean , type : 'parent' | 'child' , rowKey ? : number ) => {
if ( ! visible ) return
if ( type === 'parent' ) {
@ -412,20 +437,20 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
parentQueryParams . keyword = ''
parentHasMore . value = true
fetchMaterialOptions ( 'parent' )
} else if ( type === 'child' && index !== undefined ) {
} else if ( type === 'child' && rowKey !== undefined ) {
// 确保该行下拉状态已初始化
if ( ! childDropdownStates . value . has ( index ) ) {
childDropdownStates . value . set ( index , {
if ( ! childDropdownStates . value . has ( rowKey ) ) {
childDropdownStates . value . set ( rowKey , {
options : [ ] ,
queryParams : { page : 1 , limit : PAGE _SIZE , keyword : '' } ,
hasMore : true
} )
}
const state = childDropdownStates . value . get ( index ) !
const state = childDropdownStates . value . get ( rowKey ) !
state . queryParams . page = 1
state . queryParams . keyword = ''
state . hasMore = true
fetchMaterialOptions ( 'child' , index )
fetchMaterialOptions ( 'child' , rowKey )
}
// 延迟 50ms 等待弹窗 DOM 完全渲染
@ -433,7 +458,7 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
// 动态拼接精确的选择器
const exactSelector = type === 'parent'
? '.parent-popper .el-select-dropdown__wrap'
: ` .child-popper- ${ index } .el-select-dropdown__wrap ` ;
: ` .child-popper- ${ rowKey } .el-select-dropdown__wrap ` ;
const popperWrap = document . querySelector ( exactSelector ) as HTMLElement ;
@ -450,9 +475,9 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', index?:
if ( scrollHeight - scrollTop - clientHeight <= 10 ) {
if ( type === 'parent' ) {
loadMoreParent ( ) ;
} else if ( type === 'child' && index !== undefined ) {
} else if ( type === 'child' && rowKey !== undefined ) {
// 触发子件加载
loadMoreChild ( popperWrap , index ) ;
loadMoreChild ( popperWrap , rowKey ) ;
}
}
} ;
@ -470,20 +495,20 @@ const loadMoreParent = () => {
fetchMaterialOptions ( 'parent' , undefined , true )
}
const loadMoreChild = ( _el : HTMLElement , index : number ) => {
const state = childDropdownStates . value . get ( index )
const loadMoreChild = ( _el : HTMLElement , rowKey : number ) => {
const state = childDropdownStates . value . get ( rowKey )
if ( ! state ) return
if ( selectLoading . value || ! state . hasMore ) return
state . queryParams . page ++
fetchMaterialOptions ( 'child' , index , true )
fetchMaterialOptions ( 'child' , rowKey , true )
}
// ============================================================
// 【改造】初始化子件行下拉状态
// ============================================================
const initChildDropdownState = ( index : number ) => {
if ( ! childDropdownStates . value . has ( index ) ) {
childDropdownStates . value . set ( index , {
const initChildDropdownState = ( rowKey : number ) => {
if ( ! childDropdownStates . value . has ( rowKey ) ) {
childDropdownStates . value . set ( rowKey , {
options : [ ] ,
queryParams : { page : 1 , limit : PAGE _SIZE , keyword : '' } ,
hasMore : true
@ -500,7 +525,7 @@ const filteredChildren = computed(() => {
}
const kw = childSearchKeyword . value . toLowerCase ( )
return form . children . filter ( child => {
const state = childDropdownStates . value . get ( form . children . indexOf ( child ) )
const state = childDropdownStates . value . get ( child . rowKey )
const material = state ? . options . find ( m => m . id === child . child _id )
if ( ! material ) return false
const name = ( material . name || '' ) . toLowerCase ( )
@ -510,10 +535,11 @@ const filteredChildren = computed(() => {
} )
// 获取子件规格(从 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 )
const getChildSpec = ( rowKey : number ) : string => {
const state = childDropdownStates . value . get ( rowKey )
const row = form . children . f ind( c => c . rowKey === rowKey )
if ( ! state || ! row ? . child _id ) return ''
const material = state . options . find ( ( m : MaterialBase ) => m . id === row . child _id )
return material ? . spec || ''
}
@ -677,8 +703,9 @@ const loadDetail = async (bomNo: string, version: string) => {
const res = await getBomDetail ( bomNo , version )
if ( res . code === 200 ) {
const data = res . data
// 1. 映射子件基本数据
form . children = data . children . map ( ( child : any ) => ( {
// 1. 映射子件基本数据(使用 idx 生成唯一 rowKey)
form . children = data . children . map ( ( child : any , idx : number ) => ( {
rowKey : idx , // 用数组索引作为唯一标识(编辑场景下不会增删行)
child _id : child . child _id ,
dosage : child . dosage ,
remark : child . remark || ''
@ -686,7 +713,7 @@ const loadDetail = async (bomNo: string, version: string) => {
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
form . children . forEach ( ( child , idx ) => {
initChildDropdownState ( idx )
initChildDropdownState ( idx ) // rowKey === idx( 编辑场景下唯一)
if ( child . child _id ) {
const state = childDropdownStates . value . get ( idx ) !
@ -755,26 +782,17 @@ const resetForm = () => {
}
const addChild = ( ) => {
const idx = form . children . length
form . children . push ( { child _id : null , dosage : 0 , remark : '' } )
initChildDropdownState ( idx )
const rowKey = Date . now ( ) // 生成唯一标识,不再使用数组长度
form . children . push ( { rowKey , child _id : null , dosage : 0 , remark : '' } )
initChildDropdownState ( rowKey )
}
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 removeChild = ( rowKey : number ) => {
// 通过 rowKey 找到并删除该行(不再依赖数组索引)
const idx = form . children . findIndex ( c => c . rowKey === rowKey )
if ( idx !== - 1 ) form . children . splice ( idx , 1 )
// 直接删除该行的下拉状态(无需重建索引)
childDropdownStates . value . delete ( rowKey )
}
const submitForm = async ( ) => {