3 Commits

2 changed files with 97 additions and 64 deletions

View File

@ -140,24 +140,35 @@ class BomService:
)
)
# ★ 调试:打印 SQL 语句
logger.info(f"[BOM List] keyword={keyword!r} → SQL:\n{str(query_base.statement.compile(compile_kwargs={'literal_binds': True}))}")
# 获取符合条件的唯一组合
target_pairs = query_base.distinct().all()
if not target_pairs:
return []
# 2. 聚合查询详情
# 2. 聚合查询详情(★ 修复:使用 string_agg 聚合子件名称解决步骤3过滤遗漏问题
results = []
for bom_no, version in target_pairs:
# ★ 使用子件的别名查询子件信息,聚合所有子件的名称和规格
child_alias = db.aliased(MaterialBase)
summary = db.session.query(
BomTable.parent_id,
MaterialBase.name.label('parent_name'),
MaterialBase.spec_model.label('parent_spec'),
MaterialBase.category.label('parent_category'),
BomTable.is_enabled,
func.count(BomTable.child_id).label('child_count')
func.count(BomTable.child_id).label('child_count'),
# ★ 聚合子件名称为逗号分隔字符串用于步骤3关键词过滤
func.string_agg(child_alias.name, ', ').label('child_names'),
# ★ 同时聚合子件规格(备用)
func.string_agg(child_alias.spec_model, ', ').label('child_specs')
).join(
MaterialBase, BomTable.parent_id == MaterialBase.id
).outerjoin(
child_alias, BomTable.child_id == child_alias.id
).filter(
BomTable.bom_no == bom_no,
BomTable.version == version
@ -174,7 +185,9 @@ class BomService:
'parent_spec': summary.parent_spec or '',
'parent_category': summary.parent_category or '',
'is_enabled': summary.is_enabled,
'child_count': summary.child_count
'child_count': summary.child_count,
'child_names': summary.child_names or '', # ★ 新增:子件名称聚合
'child_specs': summary.child_specs or '' # ★ 新增:子件规格聚合
})
results.sort(key=lambda x: (x['bom_no'], x['version']), reverse=True)
@ -188,6 +201,8 @@ class BomService:
or kw in (r.get('parent_spec') or '').lower()
or kw in (r.get('bom_no') or '').lower()
or kw in (r.get('parent_category') or '').lower()
or kw in (r.get('child_names') or '').lower() # ★ 修复:加入子件名称过滤
or kw in (r.get('child_specs') or '').lower() # ★ 同步加入子件规格过滤
]
# 按 parent_category 分组

View File

@ -164,7 +164,7 @@
<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 }">
<template #default="{ row }">
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== -->
<div style="display: flex; align-items: center; gap: 8px;">
<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: 16px; padding: 4px;"
/>
</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.find(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 () => {