物料搜索:el-select 重构为 el-autocomplete Regression 修复(value-key 缺失 + parentNameInput 未声明 + onChildClear 不完整)

This commit is contained in:
DXC
2026-06-05 11:02:35 +08:00
parent ff5418afa3
commit 355a21e94c
6 changed files with 263 additions and 733 deletions

View File

@ -1,12 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git *)",
"Bash(del *)",
"Bash(rm *)"
]
},
"$version": 3
}

View File

@ -1,8 +0,0 @@
{
"permissions": {
"allow": [
"Bash(git add *)",
"Bash(git commit *)"
]
}
}

View File

@ -69,35 +69,28 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :span="16"> <el-col :span="16">
<el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')"> <el-form-item label="父件 (成品)" prop="parent_id" v-if="hasFormFieldPermission('parent_id')">
<!-- ====== 改造父件下拉 - 远程搜索 + 懒加载 ====== --> <!-- ====== 父件搜索 - el-autocomplete ====== -->
<el-select <el-autocomplete
v-model="form.parent_id" v-model="parentNameInput"
:fetch-suggestions="fetchParentSuggestions"
:value-key="'name'"
clearable
placeholder="请搜索并选择父件" placeholder="请搜索并选择父件"
filterable :loading="searchLoading"
remote :trigger-on-focus="true"
reserve-keyword="true"
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
:loading="selectLoading"
style="width: 100%" style="width: 100%"
:disabled="isReadOnlyMode || isEditMode" :disabled="isReadOnlyMode || isEditMode"
class="beautified-select" @select="onParentSelected"
popper-class="bom-loadmore-popper parent-popper" @clear="onParentClear"
default-first-option="true" popper-class="bom-parent-popper"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
@change="onParentChange"
> >
<el-option <template #default="{ item }">
v-for="item in parentOptions"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
>
<div class="option-row"> <div class="option-row">
<span class="option-name">{{ item.name }}</span> <span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span> <span class="option-spec">{{ item.spec }}</span>
</div> </div>
</el-option> </template>
</el-select> </el-autocomplete>
<el-link <el-link
v-if="form.parent_id && !isReadOnlyMode" v-if="form.parent_id && !isReadOnlyMode"
type="primary" type="primary"
@ -166,41 +159,34 @@
<el-table :data="filteredChildren" border style="width: 100%; margin-bottom: 15px" max-height="300"> <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')"> <el-table-column label="子件物料" min-width="250" v-if="hasFormFieldPermission('child_id')">
<template #default="{ row }"> <template #default="{ row }">
<!-- ====== 改造:子件下拉 - 远程搜索 + 懒加载 ====== --> <!-- ====== 子件搜索 - el-autocomplete行级绑定 ====== -->
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<el-select <el-autocomplete
v-model="row.child_id" v-model="row.material_name"
:fetch-suggestions="(q: string, cb: (results: any[]) => void) => fetchChildSuggestions(row, q, cb)"
:value-key="'name'"
clearable
placeholder="请搜索原料" placeholder="请搜索原料"
filterable :loading="searchLoading"
remote :trigger-on-focus="true"
reserve-keyword="true"
style="flex: 1;" style="flex: 1;"
:remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
:loading="selectLoading"
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
:popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
:disabled="isReadOnlyMode" :disabled="isReadOnlyMode"
default-first-option="true" @select="(item: any) => onChildSelected(row, item)"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)" @clear="() => onChildClear(row)"
> >
<el-option <template #default="{ item }">
v-for="item in getChildOptions(row.rowKey)"
:key="item.id"
:label="`${item.name} (${item.spec})`"
:value="item.id"
>
<div class="option-row"> <div class="option-row">
<span class="option-name">{{ item.name }}</span> <span class="option-name">{{ item.name }}</span>
<span class="option-spec">{{ item.spec }}</span> <span class="option-spec">{{ item.spec }}</span>
</div> </div>
</el-option> </template>
</el-select> </el-autocomplete>
<el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id && !isReadOnlyMode"> <el-tooltip content="前往修改基础信息" placement="top" v-if="row.child_id && !isReadOnlyMode">
<el-button <el-button
type="primary" type="primary"
link link
:icon="EditPen" :icon="EditPen"
@click.stop="openMaterialInNewTab(row.child_id, getChildSpec(row.rowKey))" @click.stop="openMaterialInNewTab(row.child_id, row.material_spec)"
style="font-size: 16px; padding: 4px;" style="font-size: 16px; padding: 4px;"
/> />
</el-tooltip> </el-tooltip>
@ -249,7 +235,7 @@ import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus'
import { Plus, Search, EditPen } from '@element-plus/icons-vue' import { Plus, Search, EditPen } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom' import { getBomList, getBomDetail, saveBom, deleteBom } from '@/api/bom'
import { getMaterialBaseList } from '@/api/inbound/stock' import { searchMaterialBase } from '@/api/inbound/buy'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
// ============================================================ // ============================================================
@ -271,6 +257,8 @@ interface MaterialBase {
interface ChildRow { interface ChildRow {
rowKey: number // 唯一标识,替代 $index 作为 Map key rowKey: number // 唯一标识,替代 $index 作为 Map key
child_id: number | null child_id: number | null
material_name: string
material_spec: string
dosage: number dosage: number
remark: string remark: string
} }
@ -293,7 +281,17 @@ const activeCategories = ref([]) // 默认全部展开
const searchKeyword = ref('') const searchKeyword = ref('')
const childSearchKeyword = ref('') const childSearchKeyword = ref('')
// ★ 自动搜索:输入后 500ms 防抖触发搜索(无需回车) 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, (val) => { watch(searchKeyword, (val) => {
// 防抖:延迟 500ms 执行,避免频繁请求 // 防抖:延迟 500ms 执行,避免频繁请求
clearTimeout((window as any)._bomSearchTimer) clearTimeout((window as any)._bomSearchTimer)
@ -303,257 +301,67 @@ watch(searchKeyword, (val) => {
}) })
// ============================================================ // ============================================================
// 【改造】分页 + 远程搜索相关状态 // Material Search - el-autocomplete
// ============================================================ // ============================================================
const PAGE_SIZE = 20 const searchLoading = ref(false)
const parentNameInput = ref('')
// 父件下拉 const fetchParentSuggestions = (query: string, cb: (results: any[]) => void) => {
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',
rowKey?: number,
isLoadMore = false
) => {
// 子件行需要 rowKey唯一标识不再依赖数组索引
if (type === 'child' && rowKey === undefined) return
const params = type === 'parent'
? parentQueryParams
: childDropdownStates.value.get(rowKey!)?.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 {
// ★ 修复回显丢失:先检查当前选中项是否在列表中,不在则从原 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(rowKey!)
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 {
// ★ 修复回显丢失:先通过 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
}
}
} catch (error) {
// 错误已由全局拦截器统一处理
} finally {
selectLoading.value = false
}
}
// ============================================================
// 【改造】远程搜索处理函数
// ============================================================
const handleRemoteSearch = (
query: string,
type: 'parent' | 'child',
rowKey?: number
) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '') const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim() const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
if (type === 'parent') { searchLoading.value = true
parentQueryParams.keyword = safeQuery searchMaterialBase(safeQuery).then((res: any) => {
parentQueryParams.page = 1 cb(res.data?.items || [])
parentHasMore.value = true }).catch(() => cb([])).finally(() => { searchLoading.value = false })
fetchMaterialOptions('parent') }
} else if (type === 'child' && rowKey !== undefined) {
const state = childDropdownStates.value.get(rowKey) const onParentClear = () => {
if (!state) return form.parent_id = null
state.queryParams.keyword = safeQuery form.bom_no = ''
state.queryParams.page = 1 }
state.hasMore = true
fetchMaterialOptions('child', rowKey) const onParentSelected = (item: any) => {
form.parent_id = item.id
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()
const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?: number) => { searchLoading.value = true
if (!visible) return searchMaterialBase(safeQuery).then((res: any) => {
cb(res.data?.items || [])
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
}
if (type === 'parent') { const onChildClear = (row: any) => {
// 防御性拦截:竞态条件守卫 row.child_id = null
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 keyword row.material_name = ''
// 绝对不要去请求默认列表,否则会清空 keyword、覆盖正确结果。 row.material_spec = ''
if (parentQueryParams.keyword || parentOptions.value.length > 0) return row.dosage = 0
parentQueryParams.page = 1 }
parentQueryParams.keyword = ''
parentHasMore.value = true const onChildSelected = (row: any, item: any) => {
fetchMaterialOptions('parent') const existingIndex = form.children.findIndex((child, idx) => idx !== form.children.indexOf(row) && child.child_id === item.id)
} else if (type === 'child' && rowKey !== undefined) { if (existingIndex !== -1) {
// 确保该行下拉状态已初始化 ElMessage.warning(`该物料已在第 ${existingIndex + 1} 行存在,请合并用量或删除重复项`)
if (!childDropdownStates.value.has(rowKey)) { row.child_id = null
childDropdownStates.value.set(rowKey, { row.material_name = ''
options: [], row.dosage = 0
queryParams: { page: 1, limit: PAGE_SIZE, keyword: '' }, return
hasMore: true
})
}
const state = childDropdownStates.value.get(rowKey)!
// 防御性拦截:竞态条件守卫(同上)
if (state.queryParams.keyword || state.options.length > 0) return
state.queryParams.page = 1
state.queryParams.keyword = ''
state.hasMore = true
fetchMaterialOptions('child', rowKey)
} }
row.child_id = item.id
// 延迟 50ms 等待弹窗 DOM 完全渲染 row.material_name = item.name
setTimeout(() => { row.material_spec = item.spec
// 动态拼接精确的选择器 row.dosage = 1
const exactSelector = type === 'parent'
? '.parent-popper .el-select-dropdown__wrap'
: `.child-popper-${rowKey} .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' && rowKey !== undefined) {
// 触发子件加载
loadMoreChild(popperWrap, rowKey);
}
}
};
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, rowKey: number) => {
const state = childDropdownStates.value.get(rowKey)
if (!state) return
if (selectLoading.value || !state.hasMore) return
state.queryParams.page++
fetchMaterialOptions('child', rowKey, true)
}
// ============================================================
// 【改造】初始化子件行下拉状态
// ============================================================
const initChildDropdownState = (rowKey: number) => {
if (!childDropdownStates.value.has(rowKey)) {
childDropdownStates.value.set(rowKey, {
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(child.rowKey)
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 = (rowKey: number): string => { const getChildSpec = (rowKey: number): string => {
const state = childDropdownStates.value.get(rowKey)
const row = form.children.find(c => c.rowKey === rowKey) const row = form.children.find(c => c.rowKey === rowKey)
if (!state || !row?.child_id) return '' return row?.material_spec || ''
const material = state.options.find((m: MaterialBase) => m.id === row.child_id)
return material?.spec || ''
} }
// 在新标签页打开基础信息编辑 // 在新标签页打开基础信息编辑
@ -568,8 +376,7 @@ const openMaterialInNewTab = (targetId: number | null, keyword: string = '') =>
const openParentMaterial = () => { const openParentMaterial = () => {
if (!form.parent_id) return ElMessage.warning('请先选择父件') if (!form.parent_id) return ElMessage.warning('请先选择父件')
const parent = parentOptions.value.find((p: MaterialBase) => p.id === form.parent_id) const keyword = parentNameInput.value || ''
const keyword = parent?.spec || parent?.name || ''
openMaterialInNewTab(form.parent_id, keyword) openMaterialInNewTab(form.parent_id, keyword)
} }
@ -660,14 +467,7 @@ const fetchBomList = async () => {
} finally { loading.value = false } } finally { loading.value = false }
} }
const onParentChange = (val: number) => { 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) => { const onChildChange = (val: number | null, index: number) => {
if (val !== null) { if (val !== null) {
@ -719,32 +519,18 @@ const handleSaveAs = async (row: BomItem) => {
}) })
} }
// 4. 把"已清除 ID 的纯净数据"写入 form(保留子件下拉回显 + 父件下拉回显) // 4. 把"已清除 ID 的纯净数据"写入 form
form.children = raw.children.map((child: any, idx: number) => ({ form.children = raw.children.map((child: any, idx: number) => ({
rowKey: idx, rowKey: idx,
child_id: child.child_id, child_id: child.child_id,
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage, dosage: child.dosage,
remark: child.remark || '' remark: child.remark || ''
})) }))
form.children.forEach((child, idx) => {
initChildDropdownState(idx)
if (child.child_id) {
const state = childDropdownStates.value.get(idx)!
state.options = [{
id: raw.children[idx].child_id,
name: raw.children[idx].child_name || '未知物料',
spec: raw.children[idx].child_spec || ''
}]
state.hasMore = false
}
})
if (raw.parent_id) { if (raw.parent_id) {
form.parent_id = raw.parent_id form.parent_id = raw.parent_id
parentOptions.value = [{ parentNameInput.value = raw.parent_name || '未知产品'
id: raw.parent_id,
name: raw.parent_name || '未知产品',
spec: raw.parent_spec || ''
}]
} }
form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim() form.bom_no = (raw.parent_spec || row.bom_no).split('/')[0].trim()
form.remark = raw.remark || '' form.remark = raw.remark || ''
@ -771,37 +557,17 @@ const loadDetail = async (bomNo: string, version: string) => {
const data = res.data const data = res.data
// 1. 映射子件基本数据(使用 idx 生成唯一 rowKey // 1. 映射子件基本数据(使用 idx 生成唯一 rowKey
form.children = data.children.map((child: any, idx: number) => ({ form.children = data.children.map((child: any, idx: number) => ({
rowKey: idx, // 用数组索引作为唯一标识(编辑场景下不会增删行) rowKey: idx,
child_id: child.child_id, child_id: child.child_id,
material_name: child.child_name || '未知物料',
material_spec: child.child_spec || '',
dosage: child.dosage, dosage: child.dosage,
remark: child.remark || '' remark: child.remark || ''
})) }))
// 2. 初始化子件下拉状态,并预填充 options 解决回显显示 ID 的问题
form.children.forEach((child, idx) => {
initChildDropdownState(idx) // rowKey === 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) { if (data.parent_id) {
form.parent_id = data.parent_id form.parent_id = data.parent_id
parentOptions.value = [{ parentNameInput.value = data.parent_name || '未知产品'
id: data.parent_id,
name: data.parent_name || '未知产品', // 依赖后端返回 parent_name
spec: data.parent_spec || '' // 依赖后端返回 parent_spec
}]
} }
if (data.parent_spec) { if (data.parent_spec) {
@ -838,28 +604,18 @@ const resetForm = () => {
originalVersion = '' originalVersion = ''
currentBomNo = '' currentBomNo = ''
childSearchKeyword.value = '' childSearchKeyword.value = ''
// 重置子件下拉状态 parentNameInput.value = ''
childDropdownStates.value.clear()
// 重置父件下拉状态
parentOptions.value = []
parentQueryParams.page = 1
parentQueryParams.keyword = ''
parentHasMore.value = true
if (formRef.value) formRef.value.resetFields() if (formRef.value) formRef.value.resetFields()
} }
const addChild = () => { const addChild = () => {
const rowKey = Date.now() // 生成唯一标识,不再使用数组长度 const rowKey = Date.now()
form.children.push({ rowKey, child_id: null, dosage: 0, remark: '' }) form.children.push({ rowKey, child_id: null, material_name: '', material_spec: '', dosage: 0, remark: '' })
initChildDropdownState(rowKey)
} }
const removeChild = (rowKey: number) => { const removeChild = (rowKey: number) => {
// 通过 rowKey 找到并删除该行(不再依赖数组索引)
const idx = form.children.findIndex(c => c.rowKey === rowKey) const idx = form.children.findIndex(c => c.rowKey === rowKey)
if (idx !== -1) form.children.splice(idx, 1) if (idx !== -1) form.children.splice(idx, 1)
// 直接删除该行的下拉状态(无需重建索引)
childDropdownStates.value.delete(rowKey)
} }
const submitForm = async () => { const submitForm = async () => {
@ -938,21 +694,13 @@ onMounted(() => {
// ★ 情况 B还没建过BOM打开新建并注入父件 // ★ 情况 B还没建过BOM打开新建并注入父件
handleCreate(); handleCreate();
// 强行注入父件远程搜索选项 parentNameInput.value = parentName;
parentOptions.value = [{
id: parentId,
name: parentName,
spec: parentSpec
}];
// 给表单赋值 // 给表单赋值
form.parent_id = parentId; form.parent_id = parentId;
// 自动带出版本和生成编号
// 触发联动逻辑(自动带出版本和生成编号) if (parentSpec) {
if (typeof onParentChange === 'function') { form.bom_no = parentSpec.split('/')[0].trim()
setTimeout(() => {
onParentChange(parentId);
}, 100);
} }
} }
}); });

View File

@ -294,31 +294,21 @@
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-autocomplete
v-model="form.base_id" v-model="materialNameInput"
filterable :fetch-suggestions="fetchMaterialSuggestions"
remote :value-key="'name'"
reserve-keyword="true"
clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterialDebounced" :trigger-on-focus="true"
@visible-change="handleMaterialDropdownVisible" clearable
:loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @select="onMaterialSelected"
default-first-option="true" @clear="onMaterialClear"
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
> >
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
</template> </template>
<el-option <template #default="{ item }">
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="option-item"> <div class="option-item">
<div class="opt-main"> <div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -332,11 +322,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div> </div>
</el-option> </template>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;"> </el-autocomplete>
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
@ -846,11 +833,8 @@ const queryParams = reactive({
advancedFilters: [] as any[] advancedFilters: [] as any[]
}) })
const materialNameInput = ref('')
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
const printVisible = ref(false) const printVisible = ref(false)
const printLoading = ref(false) const printLoading = ref(false)
@ -1136,97 +1120,56 @@ const querySearchCurrency = (queryString: string, cb: any) => {
cb(filtered) cb(filtered)
} }
const handleMaterialDropdownVisible = (visible: boolean) => { const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
if (!visible) return const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
// 防御性拦截 1用户已选过物料form.base_id 有值)
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
// 否则会清空 searchKeyword 和 materialOptions破坏用户正在编辑的搜索结果。
if (form.base_id) return
// 防御性拦截 2已经有搜索关键字或已经有下拉数据
// 同样不要重置、不要再请求默认列表
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
// 防御性拦截el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
searchLoading.value = true searchLoading.value = true
searchKeyword.value = safeQuery
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(safeQuery, 1) const res: any = await searchMaterialBase(safeQuery)
if (res.data) { if (res.code === 200 && res.data) {
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false})) cb((res.data || []).map((i: any) => ({ ...i, isHistory: false })))
materialOptions.value = apiResults
hasNextPage.value = res.has_next
}
} finally { searchLoading.value = false }
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.length > 0) {
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.has_next
} else { } else {
hasNextPage.value = false cb([])
} }
} catch (e) { } catch (e) {
searchPage.value -= 1 cb([])
} finally { } finally {
loadingMore.value = false searchLoading.value = false
} }
} }
const onMaterialSelected = async (val: number) => { const onMaterialClear = () => {
const item = materialOptions.value.find(i => i.id === val) form.base_id = undefined
if (item) { form.company_name = ''
form.company_name = item.company_name form.material_name = ''
form.material_name = item.name form.spec_model = ''
form.spec_model = item.spec form.category = ''
form.category = item.category form.unit = ''
form.unit = item.unit form.material_type = ''
form.material_type = item.type isCurrentMaterialInspectionRequired.value = false
// 保存强制质检标记 updateInspectionRules()
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false }
// 更新表单校验规则
updateInspectionRules()
checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口) const onMaterialSelected = async (item: any) => {
try { form.base_id = item.id
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: val } }) form.company_name = item.company_name
if (res.code === 200 && res.data.location) { form.material_name = item.name
form.warehouse_location = res.data.location form.spec_model = item.spec
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) form.category = item.category
} form.unit = item.unit
} catch (e) { form.material_type = item.type
console.error('获取历史库位失败', e) materialNameInput.value = item.name
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
updateInspectionRules()
checkHistoryAndSetMode(item.id)
try {
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
} }
} catch (e) {
console.error('获取历史库位失败', e)
} }
} }
@ -1516,6 +1459,7 @@ const handleUpdate = (row: any) => {
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' } if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' } else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isInspectionRequired: row.isInspectionRequired }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isInspectionRequired: row.isInspectionRequired }]
materialNameInput.value = row.material_name
// 设置强制质检标记 // 设置强制质检标记
isCurrentMaterialInspectionRequired.value = row.isInspectionRequired || false isCurrentMaterialInspectionRequired.value = row.isInspectionRequired || false
updateInspectionRules() updateInspectionRules()
@ -1810,8 +1754,7 @@ const confirmPrint = async () => {
} }
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = '' materialOptions.value = []; materialNameInput.value = ''; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = '';
// 重置强制质检标记 // 重置强制质检标记
isCurrentMaterialInspectionRequired.value = false isCurrentMaterialInspectionRequired.value = false
Object.assign(form, { Object.assign(form, {

View File

@ -283,24 +283,21 @@
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-autocomplete
v-model="form.base_id" v-model="materialNameInput"
filterable :fetch-suggestions="fetchMaterialSuggestions"
remote :value-key="'name'"
reserve-keyword="true"
clearable clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading" :loading="searchLoading"
:trigger-on-focus="true"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @select="onMaterialSelected"
default-first-option="true" @clear="onMaterialClear"
v-loadmore="loadMoreMaterials"
popper-class="product-dropdown" popper-class="product-dropdown"
> >
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id"> <template #default="{ item }">
<div class="option-item"> <div class="option-item">
<div class="opt-main"> <div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -314,11 +311,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div> </div>
</el-option> </template>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;"> </el-autocomplete>
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
@ -625,28 +619,6 @@ const debounce = (fn: Function, delay: number = 500) => {
} }
// ------------------------------------ // ------------------------------------
// v-loadmore
// ------------------------------------
const vLoadmore = {
mounted(el: any, binding: any) {
const checkAndBind = () => {
// 这里的 .product-dropdown 是唯一标识,防止和采购/半成品页面冲突
const dropDownWrap = document.querySelector('.product-dropdown .el-select-dropdown__wrap')
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
dropDownWrap.addEventListener('scroll', function (this: any) {
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
if (condition) {
binding.value()
}
})
}
}
setTimeout(checkAndBind, 500)
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
}
}
const userStore = useUserStore() const userStore = useUserStore()
const router = useRouter() const router = useRouter()
@ -667,7 +639,6 @@ const loading = ref(false)
const submitting = ref(false) const submitting = ref(false)
const visible = ref(false) const visible = ref(false)
const searchLoading = ref(false) const searchLoading = ref(false)
const loadingMore = ref(false)
const dialogStatus = ref<'create' | 'update'>('create') const dialogStatus = ref<'create' | 'update'>('create')
const tableData = ref([]) const tableData = ref([])
const total = ref(0) const total = ref(0)
@ -738,11 +709,7 @@ const operatorOptions = ref([
{ label: '大于等于', value: '>=' }, { label: '大于等于', value: '>=' },
{ label: '小于等于', value: '<=' }, { label: '小于等于', value: '<=' },
]) ])
const materialOptions = ref<any[]>([]) const materialNameInput = ref('')
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// BOM 搜索相关 // BOM 搜索相关
const bomSearchLoading = ref(false) const bomSearchLoading = ref(false)
@ -1062,101 +1029,49 @@ const rules = {
} }
// Material Search & Population Logic
// ------------------------------------ // ------------------------------------
// Material Search & Population Logic (已修改) const fetchMaterialSuggestions = (query: string, cb: (results: any[]) => void) => {
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => {
if (!visible) return
// 防御性拦截 1用户已选过物料form.base_id 有值)
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
// 否则会清空 searchKeyword 和 materialOptions破坏用户正在编辑的搜索结果。
if (form.base_id) return
// 防御性拦截 2已经有搜索关键字或已经有下拉数据
// 同样不要重置、不要再请求默认列表
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '') const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim() const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
// 防御性拦截el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
searchLoading.value = true searchLoading.value = true
searchKeyword.value = safeQuery searchMaterialBase(safeQuery).then((res: any) => {
searchPage.value = 1 cb((res.data?.items || []).map((i: any) => ({ ...i, isHistory: false })))
materialOptions.value = [] }).catch(() => cb([])).finally(() => { searchLoading.value = false })
try {
const res: any = await searchMaterialBase(safeQuery, 1)
const apiResults = (res.data?.items || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
} }
const loadMoreMaterials = async () => { const onMaterialClear = () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return form.base_id = undefined
loadingMore.value = true form.company_name = ''
searchPage.value += 1 form.material_name = ''
try { form.spec_model = ''
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value) form.material_type = ''
if (res.data && res.data.items && res.data.items.length > 0) { form.category = ''
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false})) form.unit = ''
materialOptions.value.push(...newItems) form.bom_code = ''
hasNextPage.value = res.data.has_next form.bom_version = ''
} else { bomOptions.value = []
hasNextPage.value = false
}
} catch (e) {
searchPage.value -= 1
} finally {
loadingMore.value = false
}
} }
const onMaterialSelected = async (val: number) => { const onMaterialSelected = (item: any) => {
const item = materialOptions.value.find(i => i.id === val) form.base_id = item.id
if (item) { form.company_name = item.company_name
form.company_name = item.company_name // [新增] form.material_name = item.name
form.material_name = item.name form.spec_model = item.spec
form.spec_model = item.spec form.material_type = item.type
form.material_type = item.type form.category = item.category
form.category = item.category form.unit = item.unit
form.unit = item.unit // 切换物料时清空已选 BOM防止脏数据
// 切换物料时清空已选 BOM防止脏数据 form.bom_code = ''
form.bom_code = '' form.bom_version = ''
form.bom_version = '' bomOptions.value = []
bomOptions.value = [] // 获取该物料历史入库库位
request.get('/v1/inbound/product/last-location', { params: { base_id: item.id } }).then((res: any) => {
// 获取该物料历史入库库位(新增独立接口) if (res.code === 200 && res.data?.location) {
try { form.warehouse_location = res.data.location
const res = await request.get('/v1/inbound/product/last-location', { params: { base_id: val } }) ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
}
} catch (e) {
console.error('获取历史库位失败', e)
} }
} }).catch(() => {})
} }
// ------------------------------------ // ------------------------------------
@ -1306,7 +1221,6 @@ const handleCreate = () => {
resetForm() resetForm()
form.in_date = dayjs().format('YYYY-MM-DD') form.in_date = dayjs().format('YYYY-MM-DD')
visible.value = true visible.value = true
materialOptions.value = []
} }
const handleUpdate = (row: any) => { const handleUpdate = (row: any) => {
@ -1335,7 +1249,7 @@ const handleUpdate = (row: any) => {
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) })) inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
const iLinks = iReports.filter(r => isExternalLink(r)) const iLinks = iReports.filter(r => isExternalLink(r))
inspection_url.value = iLinks.length > 0 ? iLinks[0] : '' inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }] materialNameInput.value = row.material_name
// 回显BOM // 回显BOM
if (form.bom_code) { if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }] bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -1535,7 +1449,7 @@ const handlePrint = async (row: any) => {
} }
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } } const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = '' materialNameInput.value = ''; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined, sale_price: undefined, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' }) Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined, sale_price: undefined, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
} }
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning') const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')

View File

@ -318,29 +318,19 @@
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;"> <el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="物料搜索" prop="base_id" class="highlight-label"> <el-form-item label="物料搜索" prop="base_id" class="highlight-label">
<el-select <el-autocomplete
v-model="form.base_id" v-model="materialNameInput"
filterable :fetch-suggestions="fetchMaterialSuggestions"
remote :value-key="'name'"
reserve-keyword="true"
clearable
placeholder="请输入名称或规格进行检索..." placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial" :trigger-on-focus="true"
@visible-change="handleMaterialDropdownVisible" clearable
:loading="searchLoading"
style="width: 100%" style="width: 100%"
@change="onMaterialSelected" @select="onMaterialSelected"
default-first-option="true" @clear="onMaterialClear"
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
> >
<template #prefix><el-icon><Search /></el-icon></template> <template #prefix><el-icon><Search /></el-icon></template>
<el-option <template #default="{ item }">
v-for="item in materialOptions"
:key="item.id"
:label="item.name"
:value="item.id"
>
<div class="option-item"> <div class="option-item">
<div class="opt-main"> <div class="opt-main">
<span class="opt-name" :title="item.name">{{ item.name }}</span> <span class="opt-name" :title="item.name">{{ item.name }}</span>
@ -354,11 +344,8 @@
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag> <el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
</div> </div>
</div> </div>
</el-option> </template>
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;"> </el-autocomplete>
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
</div>
</el-select>
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12" style="display: flex; align-items: center;"> <el-col :span="12" style="display: flex; align-items: center;">
@ -791,11 +778,8 @@ const operatorOptions = ref([
{ label: '大于等于', value: '>=' }, { label: '大于等于', value: '>=' },
{ label: '小于等于', value: '<=' }, { label: '小于等于', value: '<=' },
]) ])
const materialNameInput = ref('')
const materialOptions = ref<any[]>([]) const materialOptions = ref<any[]>([])
const searchPage = ref(1)
const searchKeyword = ref('')
const hasNextPage = ref(true)
let searchTimer: any = null
// BOM 搜索相关 // BOM 搜索相关
const bomSearchLoading = ref(false) const bomSearchLoading = ref(false)
@ -1059,98 +1043,58 @@ const handleManagerSelect = (item: any) => {
// ------------------------------------ // ------------------------------------
// Material Search (Matches Buy.vue) // Material Search (Matches Buy.vue)
// ------------------------------------ // ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
if (!visible) return const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
// 防御性拦截 1用户已选过物料form.base_id 有值)
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
// 否则会清空 searchKeyword 和 materialOptions破坏用户正在编辑的搜索结果。
if (form.base_id) return
// 防御性拦截 2已经有搜索关键字或已经有下拉数据
// 同样不要重置、不要再请求默认列表
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
handleSearchMaterial(query)
}, 300)
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
// 1) 强制转字符串,防 ClipboardEvent 对象
// 2) 深度净化剔除所有控制字符、零宽字符、BOM
// 3) 常规 trim
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
// 防御性拦截el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
searchLoading.value = true searchLoading.value = true
searchKeyword.value = safeQuery
searchPage.value = 1
materialOptions.value = []
try { try {
const res: any = await searchMaterialBase(safeQuery, 1) const res: any = await searchMaterialBase(safeQuery)
const apiResults = (res.data?.items || []).map((i: any) => ({...i, isHistory: false})) if (res.code === 200 && res.data) {
materialOptions.value = apiResults cb((res.data?.items || res.data || []).map((i: any) => ({ ...i, isHistory: false })))
hasNextPage.value = res.data?.has_next ?? false
} finally { searchLoading.value = false }
}
const loadMoreMaterials = async () => {
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
loadingMore.value = true
searchPage.value += 1
try {
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
if (res.data && res.data.items && res.data.items.length > 0) {
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
materialOptions.value.push(...newItems)
hasNextPage.value = res.data.has_next
} else { } else {
hasNextPage.value = false cb([])
} }
} catch (e) { } catch (e) {
searchPage.value -= 1 cb([])
} finally { } finally {
loadingMore.value = false searchLoading.value = false
} }
} }
const onMaterialSelected = async (val: number) => { const onMaterialClear = () => {
const item = materialOptions.value.find(i => i.id === val) form.base_id = undefined
if (item) { form.company_name = ''
form.company_name = item.company_name // [新增] form.material_name = ''
form.material_name = item.name form.spec_model = ''
form.spec_model = item.spec form.category = ''
form.category = item.category form.unit = ''
form.unit = item.unit form.material_type = ''
form.material_type = item.type form.bom_code = ''
// 切换物料时清空已选 BOM防止脏数据 form.bom_version = ''
form.bom_code = '' bomOptions.value = []
form.bom_version = '' }
bomOptions.value = []
checkHistoryAndSetMode(item.id)
// 获取该物料历史入库库位(新增独立接口) const onMaterialSelected = async (item: any) => {
try { form.base_id = item.id
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: val } }) form.company_name = item.company_name
if (res.code === 200 && res.data.location) { form.material_name = item.name
form.warehouse_location = res.data.location form.spec_model = item.spec
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`) form.category = item.category
} form.unit = item.unit
} catch (e) { form.material_type = item.type
console.error('获取历史库位失败', e) materialNameInput.value = item.name
form.bom_code = ''
form.bom_version = ''
bomOptions.value = []
checkHistoryAndSetMode(item.id)
try {
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: item.id } })
if (res.code === 200 && res.data.location) {
form.warehouse_location = res.data.location
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
} }
} catch (e) {
console.error('获取历史库位失败', e)
} }
} }
@ -1420,6 +1364,7 @@ const handleUpdate = (row: any) => {
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' } if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' } else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }] materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
materialNameInput.value = row.material_name
// 回显BOM如果存在 // 回显BOM如果存在
if (form.bom_code) { if (form.bom_code) {
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }] bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
@ -1613,7 +1558,7 @@ const handlePrint = async (row: any) => {
} }
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`指令已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } } const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`指令已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
const resetForm = () => { const resetForm = () => {
materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = '' materialOptions.value = []; materialNameInput.value = ''; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
Object.assign(form, { Object.assign(form, {
id: undefined, base_id: undefined, id: undefined, base_id: undefined,
company_name: '', // [新增] company_name: '', // [新增]