From cdac915a4b7499bc8afbcfc796abcbe476609d8d Mon Sep 17 00:00:00 2001 From: DXC Date: Thu, 4 Jun 2026 16:34:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8D=8A=E6=88=90=E5=93=81/=E6=88=90=E5=93=81?= =?UTF-8?q?=E5=85=A5=E5=BA=93=EF=BC=9A=E7=89=A9=E6=96=99/BOM=20=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E6=90=9C=E7=B4=A2=E7=B2=98=E8=B4=B4=E5=A4=B1=E6=95=88?= =?UTF-8?q?=20Bug=20=E4=BF=AE=E5=A4=8D=EF=BC=88=E4=B8=89=E5=B1=82=E9=98=B2?= =?UTF-8?q?=E5=BE=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 深度净化 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 两个 全部显式标注,防止 Element 框架在选择后清空关键字(BOM 下拉框原缺失) - handleMaterialDropdownVisible 竞态守卫:粘贴时 remote-method 与 @visible-change 同时触发,后者会 clearTimeout 前者的 debounce 定时器并加载默认列表覆盖结果。新增 !searchKeyword 守卫 + 主动 clearTimeout 互斥 --- .../src/views/stock/inbound/product.vue | 36 +++++++++++++++---- .../src/views/stock/inbound/semi.vue | 36 +++++++++++++++---- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/inventory-web/src/views/stock/inbound/product.vue b/inventory-web/src/views/stock/inbound/product.vue index fa6e193..8fe88df 100644 --- a/inventory-web/src/views/stock/inbound/product.vue +++ b/inventory-web/src/views/stock/inbound/product.vue @@ -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%" > { + // 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 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 diff --git a/inventory-web/src/views/stock/inbound/semi.vue b/inventory-web/src/views/stock/inbound/semi.vue index 51a6942..91754c5 100644 --- a/inventory-web/src/views/stock/inbound/semi.vue +++ b/inventory-web/src/views/stock/inbound/semi.vue @@ -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%" > { + // 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 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