半成品/成品入库:物料/BOM 远程搜索粘贴失效 Bug 修复(三层防御)

- 深度净化 query:剔除零宽字符(U+200B-U+200D)/BOM(U+FEFF)/控制字符(U+0000-U+001F,U+007F-U+009F),应对外部复制粘贴混入隐形 Unicode 导致 ilike 匹配失败的场景

- 显式 reserve-keyword="true" / default-first-option="true":物料与 BOM 两个 <el-select> 全部显式标注,防止 Element 框架在选择后清空关键字(BOM 下拉框原缺失)

- handleMaterialDropdownVisible 竞态守卫:粘贴时 remote-method 与 @visible-change 同时触发,后者会 clearTimeout 前者的 debounce 定时器并加载默认列表覆盖结果。新增 !searchKeyword 守卫 + 主动 clearTimeout 互斥
This commit is contained in:
DXC
2026-06-04 16:34:36 +08:00
parent 8a2da1ac1e
commit cdac915a4b
2 changed files with 60 additions and 12 deletions

View File

@ -287,7 +287,7 @@
v-model="form.base_id"
filterable
remote
reserve-keyword
reserve-keyword="true"
clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@ -295,7 +295,7 @@
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
default-first-option="true"
v-loadmore="loadMoreMaterials"
popper-class="product-dropdown"
>
@ -460,12 +460,14 @@
v-model="form.bom_code"
filterable
remote
reserve-keyword="true"
clearable
:disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom"
:loading="bomSearchLoading"
@change="handleBomSelect"
default-first-option="true"
style="width: 100%"
>
<el-option
@ -956,9 +958,15 @@ const form = reactive({
// BOM Search Logic
// ------------------------------------
const handleSearchBom = 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()
bomSearchLoading.value = true
try {
const res: any = await searchBom(query, form.spec_model)
const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false }
}
@ -1057,7 +1065,17 @@ const rules = {
// ------------------------------------
// Material Search & Population Logic (已修改)
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleMaterialDropdownVisible = (visible: boolean) => {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
@ -1067,13 +1085,19 @@ const handleSearchMaterialDebounced = (query: string) => {
}
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()
searchLoading.value = true
searchKeyword.value = query
searchKeyword.value = safeQuery
searchPage.value = 1
materialOptions.value = []
try {
const res: any = await searchMaterialBase(query, 1)
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

View File

@ -322,7 +322,7 @@
v-model="form.base_id"
filterable
remote
reserve-keyword
reserve-keyword="true"
clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterial"
@ -330,7 +330,7 @@
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
default-first-option="true"
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
>
@ -525,12 +525,14 @@
v-model="form.bom_code"
filterable
remote
reserve-keyword="true"
clearable
:disabled="!form.spec_model"
:placeholder="!form.spec_model ? '请先在上方选择入库物料' : '搜规格/编号'"
:remote-method="handleSearchBom"
:loading="bomSearchLoading"
@change="handleBomSelect"
default-first-option="true"
style="width: 100%"
>
<el-option
@ -1003,9 +1005,15 @@ watch(
// BOM Search Logic
// ------------------------------------
const handleSearchBom = 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()
bomSearchLoading.value = true
try {
const res: any = await searchBom(query, form.spec_model)
const res: any = await searchBom(safeQuery, form.spec_model)
bomOptions.value = res.data || []
} finally { bomSearchLoading.value = false }
}
@ -1051,7 +1059,17 @@ const handleManagerSelect = (item: any) => {
// ------------------------------------
// Material Search (Matches Buy.vue)
// ------------------------------------
const handleMaterialDropdownVisible = (visible: boolean) => { if (visible && materialOptions.value.length === 0) handleSearchMaterialDebounced('') }
const handleMaterialDropdownVisible = (visible: boolean) => {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
// 只有当没有搜索关键字、且下拉列表为空时,才加载默认数据。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
const handleSearchMaterialDebounced = (query: string) => {
if (searchTimer) clearTimeout(searchTimer)
@ -1061,13 +1079,19 @@ const handleSearchMaterialDebounced = (query: string) => {
}
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()
searchLoading.value = true
searchKeyword.value = query
searchKeyword.value = safeQuery
searchPage.value = 1
materialOptions.value = []
try {
const res: any = await searchMaterialBase(query, 1)
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