前端全局:<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:
@ -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
|
||||
|
||||
@ -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) {
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user