前端全局:<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>
|
</div>
|
||||||
</el-card>
|
</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-form :model="form" label-width="120px" ref="formRef" :rules="rules">
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@ -75,13 +75,14 @@
|
|||||||
placeholder="请搜索并选择父件"
|
placeholder="请搜索并选择父件"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
|
:remote-method="(q: string) => handleRemoteSearch(q, 'parent')"
|
||||||
:loading="selectLoading"
|
:loading="selectLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
:disabled="isReadOnlyMode || isEditMode"
|
:disabled="isReadOnlyMode || isEditMode"
|
||||||
class="beautified-select"
|
class="beautified-select"
|
||||||
popper-class="bom-loadmore-popper parent-popper"
|
popper-class="bom-loadmore-popper parent-popper"
|
||||||
|
default-first-option="true"
|
||||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'parent')"
|
||||||
@change="onParentChange"
|
@change="onParentChange"
|
||||||
>
|
>
|
||||||
@ -172,13 +173,14 @@
|
|||||||
placeholder="请搜索原料"
|
placeholder="请搜索原料"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
style="flex: 1;"
|
style="flex: 1;"
|
||||||
:remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
|
:remote-method="(q: string) => handleRemoteSearch(q, 'child', row.rowKey)"
|
||||||
:loading="selectLoading"
|
:loading="selectLoading"
|
||||||
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
:loading-text="`正在加载第 ${childQueryParams.page} 页...`"
|
||||||
:popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
|
:popper-class="`bom-loadmore-popper child-popper-${row.rowKey}`"
|
||||||
:disabled="isReadOnlyMode"
|
:disabled="isReadOnlyMode"
|
||||||
|
default-first-option="true"
|
||||||
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
|
@visible-change="(visible: boolean) => handleVisibleChange(visible, 'child', row.rowKey)"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@ -413,15 +415,18 @@ const handleRemoteSearch = (
|
|||||||
type: 'parent' | 'child',
|
type: 'parent' | 'child',
|
||||||
rowKey?: number
|
rowKey?: number
|
||||||
) => {
|
) => {
|
||||||
|
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||||
|
const rawQuery = String(query || '')
|
||||||
|
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||||
if (type === 'parent') {
|
if (type === 'parent') {
|
||||||
parentQueryParams.keyword = query
|
parentQueryParams.keyword = safeQuery
|
||||||
parentQueryParams.page = 1
|
parentQueryParams.page = 1
|
||||||
parentHasMore.value = true
|
parentHasMore.value = true
|
||||||
fetchMaterialOptions('parent')
|
fetchMaterialOptions('parent')
|
||||||
} else if (type === 'child' && rowKey !== undefined) {
|
} else if (type === 'child' && rowKey !== undefined) {
|
||||||
const state = childDropdownStates.value.get(rowKey)
|
const state = childDropdownStates.value.get(rowKey)
|
||||||
if (!state) return
|
if (!state) return
|
||||||
state.queryParams.keyword = query
|
state.queryParams.keyword = safeQuery
|
||||||
state.queryParams.page = 1
|
state.queryParams.page = 1
|
||||||
state.hasMore = true
|
state.hasMore = true
|
||||||
fetchMaterialOptions('child', rowKey)
|
fetchMaterialOptions('child', rowKey)
|
||||||
@ -435,6 +440,10 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
|
|||||||
if (!visible) return
|
if (!visible) return
|
||||||
|
|
||||||
if (type === 'parent') {
|
if (type === 'parent') {
|
||||||
|
// 防御性拦截:竞态条件守卫
|
||||||
|
// 如果当前已经有搜索关键字(例如用户刚刚粘贴了内容、remote-method 已经设置了 keyword),
|
||||||
|
// 绝对不要去请求默认列表,否则会清空 keyword、覆盖正确结果。
|
||||||
|
if (parentQueryParams.keyword || parentOptions.value.length > 0) return
|
||||||
parentQueryParams.page = 1
|
parentQueryParams.page = 1
|
||||||
parentQueryParams.keyword = ''
|
parentQueryParams.keyword = ''
|
||||||
parentHasMore.value = true
|
parentHasMore.value = true
|
||||||
@ -449,6 +458,8 @@ const handleVisibleChange = (visible: boolean, type: 'parent' | 'child', rowKey?
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const state = childDropdownStates.value.get(rowKey)!
|
const state = childDropdownStates.value.get(rowKey)!
|
||||||
|
// 防御性拦截:竞态条件守卫(同上)
|
||||||
|
if (state.queryParams.keyword || state.options.length > 0) return
|
||||||
state.queryParams.page = 1
|
state.queryParams.page = 1
|
||||||
state.queryParams.keyword = ''
|
state.queryParams.keyword = ''
|
||||||
state.hasMore = true
|
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-form ref="formRef" :model="form" label-width="110px">
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
@ -74,14 +74,14 @@
|
|||||||
v-model="materialBaseId"
|
v-model="materialBaseId"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="输入名称或规格搜索..."
|
placeholder="输入名称或规格搜索..."
|
||||||
:remote-method="handleSearchMaterialDebounced"
|
:remote-method="handleSearchMaterialDebounced"
|
||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
popper-class="long-dropdown"
|
popper-class="long-dropdown"
|
||||||
v-loadmore="handleLoadMoreMaterials"
|
v-loadmore="handleLoadMoreMaterials"
|
||||||
@visible-change="onMaterialDropdownVisibleChange"
|
@visible-change="onMaterialDropdownVisibleChange"
|
||||||
@ -171,7 +171,7 @@
|
|||||||
</el-dialog>
|
</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 :column="2" border>
|
||||||
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
|
<el-descriptions-item label="申请单号">{{ detail.request_no }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="状态">
|
<el-descriptions-item label="状态">
|
||||||
@ -215,7 +215,7 @@
|
|||||||
</el-dialog>
|
</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 label-width="80px">
|
||||||
<el-form-item label="申请单号">
|
<el-form-item label="申请单号">
|
||||||
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
<span style="font-weight: bold; color: #409EFF;">{{ currentRejectRow?.request_no }}</span>
|
||||||
@ -396,14 +396,17 @@ const handleSearchMaterialDebounced = (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (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
|
searchLoading.value = true
|
||||||
searchKeyword.value = query
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
hasNextPage.value = true
|
hasNextPage.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await searchMaterialPurchase(query, 1)
|
const res: any = await searchMaterialPurchase(safeQuery, 1)
|
||||||
materialOptions.value = res.data || []
|
materialOptions.value = res.data || []
|
||||||
hasNextPage.value = res.has_next !== false
|
hasNextPage.value = res.has_next !== false
|
||||||
} finally {
|
} finally {
|
||||||
@ -431,9 +434,14 @@ const handleLoadMoreMaterials = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onMaterialDropdownVisibleChange = (visible: boolean) => {
|
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('')
|
handleSearchMaterial('')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onMaterialSelected = (id: number | null) => {
|
const onMaterialSelected = (id: number | null) => {
|
||||||
|
|||||||
@ -298,7 +298,7 @@
|
|||||||
v-model="form.base_id"
|
v-model="form.base_id"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
clearable
|
clearable
|
||||||
placeholder="请输入名称或规格进行检索..."
|
placeholder="请输入名称或规格进行检索..."
|
||||||
:remote-method="handleSearchMaterialDebounced"
|
:remote-method="handleSearchMaterialDebounced"
|
||||||
@ -306,7 +306,7 @@
|
|||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
v-loadmore="loadMoreMaterials"
|
v-loadmore="loadMoreMaterials"
|
||||||
popper-class="long-dropdown"
|
popper-class="long-dropdown"
|
||||||
>
|
>
|
||||||
@ -651,8 +651,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</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="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">
|
<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
|
<WebRtcCamera
|
||||||
ref="cameraRef"
|
ref="cameraRef"
|
||||||
@photo-submit="handleCameraConfirm"
|
@photo-submit="handleCameraConfirm"
|
||||||
@ -660,7 +660,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-dialog>
|
</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 style="text-align: center;">
|
||||||
<div v-loading="printLoading" class="preview-box">
|
<div v-loading="printLoading" class="preview-box">
|
||||||
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
<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)
|
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) => {
|
const handleSearchMaterialDebounced = (query: string) => {
|
||||||
if (searchTimer) clearTimeout(searchTimer)
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
@ -1146,13 +1155,16 @@ const handleSearchMaterialDebounced = (query: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (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
|
searchLoading.value = true
|
||||||
searchKeyword.value = query
|
searchKeyword.value = safeQuery
|
||||||
searchPage.value = 1
|
searchPage.value = 1
|
||||||
materialOptions.value = []
|
materialOptions.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res: any = await searchMaterialBase(query, 1)
|
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
||||||
materialOptions.value = apiResults
|
materialOptions.value = apiResults
|
||||||
|
|||||||
@ -85,6 +85,8 @@
|
|||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
width="700px"
|
width="700px"
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
@close="resetDialog"
|
@close="resetDialog"
|
||||||
>
|
>
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
@ -103,14 +105,14 @@
|
|||||||
v-model="form.base_id"
|
v-model="form.base_id"
|
||||||
filterable
|
filterable
|
||||||
remote
|
remote
|
||||||
reserve-keyword
|
reserve-keyword="true"
|
||||||
placeholder="输入名称或规格..."
|
placeholder="输入名称或规格..."
|
||||||
:remote-method="handleSearchMaterial"
|
:remote-method="handleSearchMaterial"
|
||||||
@visible-change="handleMaterialDropdownVisible"
|
@visible-change="handleMaterialDropdownVisible"
|
||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
@change="onMaterialSelected"
|
@change="onMaterialSelected"
|
||||||
default-first-option
|
default-first-option="true"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in materialOptions"
|
v-for="item in materialOptions"
|
||||||
@ -269,6 +271,7 @@ const perPage = ref(20)
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
|
||||||
const materialOptions = ref<any[]>([])
|
const materialOptions = ref<any[]>([])
|
||||||
|
const searchKeyword = ref('')
|
||||||
const searchLoading = ref(false)
|
const searchLoading = ref(false)
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
@ -329,15 +332,22 @@ const handlePageChange = (val: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
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('')
|
handleSearchMaterial('')
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearchMaterial = async (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()
|
||||||
|
searchKeyword.value = safeQuery
|
||||||
searchLoading.value = true
|
searchLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await searchMaterialBase(query)
|
const res = await searchMaterialBase(safeQuery)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
||||||
materialOptions.value = apiResults
|
materialOptions.value = apiResults
|
||||||
|
|||||||
Reference in New Issue
Block a user