前端全局:<el-select remote> 三道防线扩展到 BOM 配方/采购/采购入库/售后入库

- 第一道防线:<el-select> 模板显式补充 reserve-keyword="true" / default-first-option="true",覆盖 4 文件 5 实例

- 第二道防线:handleRemoteSearch / handleSearchMaterial 首行深度净化 query(零宽字符/控制字符/BOM/不可见 Unicode)

- 第三道防线:handleVisibleChange / handleMaterialDropdownVisible 加竞态守卫,已有 searchKeyword 或 options 非空时跳过默认列表加载;带 debounce 的场景主动 clearTimeout 互斥

- service.vue 原本缺少 searchKeyword 状态,本轮新增 ref('') 专供 el-select 守卫使用

- BomManage.vue 父件/子件共用 handleVisibleChange,两套守卫分别按 parentQueryParams.keyword 和 state.queryParams.keyword 隔离判断
This commit is contained in:
DXC
2026-06-04 16:44:59 +08:00
parent cdac915a4b
commit 8bb3e58b44
4 changed files with 70 additions and 29 deletions

View File

@ -63,7 +63,7 @@
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false">
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="850px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="form" label-width="120px" ref="formRef" :rules="rules">
<el-row :gutter="20">
@ -75,13 +75,14 @@
placeholder="请搜索并选择父件"
filterable
remote
reserve-keyword
reserve-keyword="true"
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
:loading="selectLoading"
style="width: 100%"
:disabled="isReadOnlyMode || isEditMode"
class="beautified-select"
popper-class="bom-loadmore-popper parent-popper"
default-first-option="true"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
@change="onParentChange"
>
@ -172,13 +173,14 @@
placeholder="请搜索原料"
filterable
remote
reserve-keyword
reserve-keyword="true"
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"
default-first-option="true"
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
>
<el-option
@ -413,15 +415,18 @@ const handleRemoteSearch = (
type: 'parent' | 'child',
rowKey?: number
) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
if (type === 'parent') {
parentQueryParams.keyword = query
parentQueryParams.keyword = safeQuery
parentQueryParams.page = 1
parentHasMore.value = true
fetchMaterialOptions('parent')
} else if (type === 'child' && rowKey !== undefined) {
const state = childDropdownStates.value.get(rowKey)
if (!state) return
state.queryParams.keyword = query
state.queryParams.keyword = safeQuery
state.queryParams.page = 1
state.hasMore = true
fetchMaterialOptions('child', rowKey)
@ -435,6 +440,10 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
if (!visible) return
if (type === 'parent') {
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 keyword
// 绝对不要去请求默认列表,否则会清空 keyword、覆盖正确结果。
if (parentQueryParams.keyword || parentOptions.value.length > 0) return
parentQueryParams.page = 1
parentQueryParams.keyword = ''
parentHasMore.value = true
@ -449,6 +458,8 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
})
}
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

View File

@ -64,7 +64,7 @@
/>
<!-- ========== 新建/编辑弹窗 ========== -->
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false">
<el-dialog v-model="formDialogVisible" :title="dialogTitle" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form ref="formRef" :model="form" label-width="110px">
<el-row :gutter="20">
@ -74,14 +74,14 @@
v-model="materialBaseId"
filterable
remote
reserve-keyword
reserve-keyword="true"
clearable
placeholder="输入名称或规格搜索..."
:remote-method="handleSearchMaterialDebounced"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
default-first-option="true"
popper-class="long-dropdown"
v-loadmore="handleLoadMoreMaterials"
@visible-change="onMaterialDropdownVisibleChange"
@ -171,7 +171,7 @@
</el-dialog>
<!-- ========== 详情弹窗 ========== -->
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close>
<el-dialog v-model="detailDialogVisible" title="采购申请详情" width="700px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-descriptions :column="2" border>
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
<el-descriptions-item label="状态">
@ -215,7 +215,7 @@
</el-dialog>
<!-- ========== 驳回原因弹窗 ========== -->
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close>
<el-dialog v-model="rejectDialogVisible" title="驳回申请" width="480px" destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<el-form label-width="80px">
<el-form-item label="申请单号">
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
@ -396,14 +396,17 @@ const handleSearchMaterialDebounced = (query: string) => {
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
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 = []
hasNextPage.value = true
try {
const res: any = await searchMaterialPurchase(query, 1)
const res: any = await searchMaterialPurchase(safeQuery, 1)
materialOptions.value = res.data || []
hasNextPage.value = res.has_next !== false
} finally {
@ -431,9 +434,14 @@ const handleLoadMoreMaterials = async () => {
}
const onMaterialDropdownVisibleChange = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
handleSearchMaterial('')
}
}
const onMaterialSelected = (id: number | null) => {

View File

@ -298,7 +298,7 @@
v-model="form.base_id"
filterable
remote
reserve-keyword
reserve-keyword="true"
clearable
placeholder="请输入名称或规格进行检索..."
:remote-method="handleSearchMaterialDebounced"
@ -306,7 +306,7 @@
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
default-first-option="true"
v-loadmore="loadMoreMaterials"
popper-class="long-dropdown"
>
@ -651,8 +651,8 @@
</template>
</el-dialog>
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false">
<el-dialog v-model="dialogVisibleImage" append-to-body width="50%" :close-on-click-modal="false" :close-on-press-escape="false"><img style="width: 100%" :src="dialogImageUrl" alt="Preview Image" /></el-dialog>
<el-dialog v-model="cameraDialogVisible" title="拍照上传" width="500px" append-to-body destroy-on-close :close-on-click-modal="false" :close-on-press-escape="false">
<WebRtcCamera
ref="cameraRef"
@photo-submit="handleCameraConfirm"
@ -660,7 +660,7 @@
/>
</el-dialog>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body>
<el-dialog v-model="printVisible" title="标签打印预览" width="400px" destroy-on-close append-to-body :close-on-click-modal="false" :close-on-press-escape="false">
<div style="text-align: center;">
<div v-loading="printLoading" class="preview-box">
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
@ -1136,7 +1136,16 @@ const querySearchCurrency = (queryString: string, cb: any) => {
cb(filtered)
}
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)
@ -1146,13 +1155,16 @@ const handleSearchMaterialDebounced = (query: string) => {
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
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)
if (res.data) {
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
materialOptions.value = apiResults

View File

@ -85,6 +85,8 @@
:title="dialogTitle"
width="700px"
destroy-on-close
:close-on-click-modal="false"
:close-on-press-escape="false"
@close="resetDialog"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
@ -103,14 +105,14 @@
v-model="form.base_id"
filterable
remote
reserve-keyword
reserve-keyword="true"
placeholder="输入名称或规格..."
:remote-method="handleSearchMaterial"
@visible-change="handleMaterialDropdownVisible"
:loading="searchLoading"
style="width: 100%"
@change="onMaterialSelected"
default-first-option
default-first-option="true"
>
<el-option
v-for="item in materialOptions"
@ -269,6 +271,7 @@ const perPage = ref(20)
const total = ref(0)
const materialOptions = ref<any[]>([])
const searchKeyword = ref('')
const searchLoading = ref(false)
const searchForm = reactive({
@ -329,15 +332,22 @@ const handlePageChange = (val: number) => {
}
const handleMaterialDropdownVisible = (visible: boolean) => {
if (visible && materialOptions.value.length === 0) {
if (!visible) return
// 防御性拦截:竞态条件守卫
// 如果当前已经有搜索关键字例如用户刚刚粘贴了内容、remote-method 已经设置了 searchKeyword
// 绝对不要去请求默认列表,否则会清空 searchKeyword、覆盖正确结果。
if (searchKeyword.value || materialOptions.value.length > 0) return
handleSearchMaterial('')
}
}
const handleSearchMaterial = async (query: string) => {
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
const rawQuery = String(query || '')
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
searchKeyword.value = safeQuery
searchLoading.value = true
try {
const res = await searchMaterialBase(query)
const res = await searchMaterialBase(safeQuery)
if (res.code === 200) {
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
materialOptions.value = apiResults