物料搜索:el-select 重构为 el-autocomplete Regression 修复(value-key 缺失 + parentNameInput 未声明 + onChildClear 不完整)
This commit is contained in:
@ -294,31 +294,21 @@
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
<el-autocomplete
|
||||
v-model="materialNameInput"
|
||||
:fetch-suggestions="fetchMaterialSuggestions"
|
||||
:value-key="'name'"
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterialDebounced"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
@select="onMaterialSelected"
|
||||
@clear="onMaterialClear"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="option-item">
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
@ -332,11 +322,8 @@
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" style="display: flex; align-items: center;">
|
||||
@ -846,11 +833,8 @@ const queryParams = reactive({
|
||||
advancedFilters: [] as any[]
|
||||
})
|
||||
|
||||
const materialNameInput = ref('')
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
|
||||
const printVisible = ref(false)
|
||||
const printLoading = ref(false)
|
||||
@ -1136,97 +1120,56 @@ const querySearchCurrency = (queryString: string, cb: any) => {
|
||||
cb(filtered)
|
||||
}
|
||||
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (!visible) return
|
||||
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||
if (form.base_id) return
|
||||
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||
// 同样不要重置、不要再请求默认列表
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
const rawQuery = String(query || '')
|
||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
// 防御性拦截:el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
|
||||
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
|
||||
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions)。
|
||||
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
|
||||
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
|
||||
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
|
||||
const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
|
||||
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
searchLoading.value = true
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
const res: any = await searchMaterialBase(safeQuery, 1)
|
||||
if (res.data) {
|
||||
const apiResults = (res.data || []).map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value = apiResults
|
||||
hasNextPage.value = res.has_next
|
||||
}
|
||||
} finally { searchLoading.value = false }
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.length > 0) {
|
||||
const newItems = res.data.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.has_next
|
||||
const res: any = await searchMaterialBase(safeQuery)
|
||||
if (res.code === 200 && res.data) {
|
||||
cb((res.data || []).map((i: any) => ({ ...i, isHistory: false })))
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
cb([])
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
cb([])
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMaterialSelected = async (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
// 保存强制质检标记
|
||||
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
|
||||
// 更新表单校验规则
|
||||
updateInspectionRules()
|
||||
checkHistoryAndSetMode(item.id)
|
||||
const onMaterialClear = () => {
|
||||
form.base_id = undefined
|
||||
form.company_name = ''
|
||||
form.material_name = ''
|
||||
form.spec_model = ''
|
||||
form.category = ''
|
||||
form.unit = ''
|
||||
form.material_type = ''
|
||||
isCurrentMaterialInspectionRequired.value = false
|
||||
updateInspectionRules()
|
||||
}
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: val } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
const onMaterialSelected = async (item: any) => {
|
||||
form.base_id = item.id
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
materialNameInput.value = item.name
|
||||
isCurrentMaterialInspectionRequired.value = item.isInspectionRequired || false
|
||||
updateInspectionRules()
|
||||
checkHistoryAndSetMode(item.id)
|
||||
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/buy/last-location', { params: { base_id: item.id } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1516,6 +1459,7 @@ const handleUpdate = (row: any) => {
|
||||
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
|
||||
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isInspectionRequired: row.isInspectionRequired }]
|
||||
materialNameInput.value = row.material_name
|
||||
// 设置强制质检标记
|
||||
isCurrentMaterialInspectionRequired.value = row.isInspectionRequired || false
|
||||
updateInspectionRules()
|
||||
@ -1810,8 +1754,7 @@ const confirmPrint = async () => {
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
|
||||
searchPage.value = 1; hasNextPage.value = true; searchKeyword.value = '';
|
||||
materialOptions.value = []; materialNameInput.value = ''; arrivalFileList.value = []; reportFileList.value = []; inspection_report_url.value = ''
|
||||
// 重置强制质检标记
|
||||
isCurrentMaterialInspectionRequired.value = false
|
||||
Object.assign(form, {
|
||||
|
||||
@ -283,24 +283,21 @@
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
<el-autocomplete
|
||||
v-model="materialNameInput"
|
||||
:fetch-suggestions="fetchMaterialSuggestions"
|
||||
:value-key="'name'"
|
||||
clearable
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
@select="onMaterialSelected"
|
||||
@clear="onMaterialClear"
|
||||
popper-class="product-dropdown"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
<el-option v-for="item in materialOptions" :key="item.id" :label="item.name" :value="item.id">
|
||||
<template #default="{ item }">
|
||||
<div class="option-item">
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
@ -314,11 +311,8 @@
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" style="display: flex; align-items: center;">
|
||||
@ -625,28 +619,6 @@ const debounce = (fn: Function, delay: number = 500) => {
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
// v-loadmore
|
||||
// ------------------------------------
|
||||
const vLoadmore = {
|
||||
mounted(el: any, binding: any) {
|
||||
const checkAndBind = () => {
|
||||
// 这里的 .product-dropdown 是唯一标识,防止和采购/半成品页面冲突
|
||||
const dropDownWrap = document.querySelector('.product-dropdown .el-select-dropdown__wrap')
|
||||
if (dropDownWrap && !dropDownWrap.getAttribute('data-loadmore-bound')) {
|
||||
dropDownWrap.setAttribute('data-loadmore-bound', 'true')
|
||||
dropDownWrap.addEventListener('scroll', function (this: any) {
|
||||
const condition = this.scrollHeight - this.scrollTop <= this.clientHeight + 1
|
||||
if (condition) {
|
||||
binding.value()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
setTimeout(checkAndBind, 500)
|
||||
el.addEventListener('click', () => setTimeout(checkAndBind, 300))
|
||||
}
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
@ -667,7 +639,6 @@ const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const visible = ref(false)
|
||||
const searchLoading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const dialogStatus = ref<'create' | 'update'>('create')
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
@ -738,11 +709,7 @@ const operatorOptions = ref([
|
||||
{ label: '大于等于', value: '>=' },
|
||||
{ label: '小于等于', value: '<=' },
|
||||
])
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
const materialNameInput = ref('')
|
||||
|
||||
// BOM 搜索相关
|
||||
const bomSearchLoading = ref(false)
|
||||
@ -1062,101 +1029,49 @@ const rules = {
|
||||
}
|
||||
|
||||
|
||||
// Material Search & Population Logic
|
||||
// ------------------------------------
|
||||
// Material Search & Population Logic (已修改)
|
||||
// ------------------------------------
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (!visible) return
|
||||
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||
if (form.base_id) return
|
||||
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||
// 同样不要重置、不要再请求默认列表
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSearchMaterial = async (query: string) => {
|
||||
// 防御性处理:粘贴场景常混入零宽字符 / 控制字符 / 不可见 Unicode
|
||||
// 1) 强制转字符串,防 ClipboardEvent 对象
|
||||
// 2) 深度净化:剔除所有控制字符、零宽字符、BOM
|
||||
// 3) 常规 trim
|
||||
const fetchMaterialSuggestions = (query: string, cb: (results: any[]) => void) => {
|
||||
const rawQuery = String(query || '')
|
||||
const safeQuery = rawQuery.replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
// 防御性拦截:el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
|
||||
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
|
||||
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions)。
|
||||
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
|
||||
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
|
||||
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
|
||||
searchLoading.value = true
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
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
|
||||
} finally { searchLoading.value = false }
|
||||
searchMaterialBase(safeQuery).then((res: any) => {
|
||||
cb((res.data?.items || []).map((i: any) => ({ ...i, isHistory: false })))
|
||||
}).catch(() => cb([])).finally(() => { searchLoading.value = false })
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.items && res.data.items.length > 0) {
|
||||
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.data.has_next
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
const onMaterialClear = () => {
|
||||
form.base_id = undefined
|
||||
form.company_name = ''
|
||||
form.material_name = ''
|
||||
form.spec_model = ''
|
||||
form.material_type = ''
|
||||
form.category = ''
|
||||
form.unit = ''
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
}
|
||||
|
||||
const onMaterialSelected = async (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
form.company_name = item.company_name // [新增]
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.material_type = item.type
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/product/last-location', { params: { base_id: val } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
const onMaterialSelected = (item: any) => {
|
||||
form.base_id = item.id
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.material_type = item.type
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
// 获取该物料历史入库库位
|
||||
request.get('/v1/inbound/product/last-location', { params: { base_id: item.id } }).then((res: any) => {
|
||||
if (res.code === 200 && res.data?.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ------------------------------------
|
||||
@ -1306,7 +1221,6 @@ const handleCreate = () => {
|
||||
resetForm()
|
||||
form.in_date = dayjs().format('YYYY-MM-DD')
|
||||
visible.value = true
|
||||
materialOptions.value = []
|
||||
}
|
||||
|
||||
const handleUpdate = (row: any) => {
|
||||
@ -1335,7 +1249,7 @@ const handleUpdate = (row: any) => {
|
||||
inspectionFileList.value = iReports.filter(r => !isExternalLink(r)).map(url => ({ name: url.split('/').pop(), url: getImageUrl(url) }))
|
||||
const iLinks = iReports.filter(r => isExternalLink(r))
|
||||
inspection_url.value = iLinks.length > 0 ? iLinks[0] : ''
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
|
||||
materialNameInput.value = row.material_name
|
||||
// 回显BOM
|
||||
if (form.bom_code) {
|
||||
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
|
||||
@ -1535,7 +1449,7 @@ const handlePrint = async (row: any) => {
|
||||
}
|
||||
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error('打印失败') } finally { printing.value = false } }
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
|
||||
materialNameInput.value = ''; bomOptions.value = []; productPhotoList.value = []; qualityFileList.value = []; inspectionFileList.value = []; quality_url.value = ''; inspection_url.value = ''
|
||||
Object.assign(form, { id: undefined, base_id: undefined, material_name: '', spec_model: '', material_type: '', category: '', unit: '', sku: '', barcode: '', serial_number: '', in_date: '', in_quantity: 1, stock_quantity: 1, available_quantity: 1, print_copies: 1, warehouse_location: '', status: '在库', quality_status: '合格', bom_code: '', bom_version: '', work_order_code: '', order_id: '', production_manager: '', production_time_range: [], raw_material_cost: undefined, unit_total_cost: undefined, total_price: undefined, sale_price: undefined, quality_report_link: [], inspection_report_link: [], product_photo: [], detail_link: '' })
|
||||
}
|
||||
const getStatusType = (s:string) => ({'在库':'success','出库':'info','借库':'warning','损耗':'danger'}[s]||'warning')
|
||||
|
||||
@ -318,29 +318,19 @@
|
||||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||||
<el-select
|
||||
v-model="form.base_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword="true"
|
||||
clearable
|
||||
<el-autocomplete
|
||||
v-model="materialNameInput"
|
||||
:fetch-suggestions="fetchMaterialSuggestions"
|
||||
:value-key="'name'"
|
||||
placeholder="请输入名称或规格进行检索..."
|
||||
:remote-method="handleSearchMaterial"
|
||||
@visible-change="handleMaterialDropdownVisible"
|
||||
:loading="searchLoading"
|
||||
:trigger-on-focus="true"
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="onMaterialSelected"
|
||||
default-first-option="true"
|
||||
v-loadmore="loadMoreMaterials"
|
||||
popper-class="long-dropdown"
|
||||
@select="onMaterialSelected"
|
||||
@clear="onMaterialClear"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
<el-option
|
||||
v-for="item in materialOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="option-item">
|
||||
<div class="opt-main">
|
||||
<span class="opt-name" :title="item.name">{{ item.name }}</span>
|
||||
@ -354,11 +344,8 @@
|
||||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
<div v-if="loadingMore" style="text-align: center; color: #999; font-size: 12px; padding: 8px; background: #f9f9f9;">
|
||||
<el-icon class="is-loading"><Refresh /></el-icon> 加载更多中...
|
||||
</div>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12" style="display: flex; align-items: center;">
|
||||
@ -791,11 +778,8 @@ const operatorOptions = ref([
|
||||
{ label: '大于等于', value: '>=' },
|
||||
{ label: '小于等于', value: '<=' },
|
||||
])
|
||||
const materialNameInput = ref('')
|
||||
const materialOptions = ref<any[]>([])
|
||||
const searchPage = ref(1)
|
||||
const searchKeyword = ref('')
|
||||
const hasNextPage = ref(true)
|
||||
let searchTimer: any = null
|
||||
|
||||
// BOM 搜索相关
|
||||
const bomSearchLoading = ref(false)
|
||||
@ -1059,98 +1043,58 @@ const handleManagerSelect = (item: any) => {
|
||||
// ------------------------------------
|
||||
// Material Search (Matches Buy.vue)
|
||||
// ------------------------------------
|
||||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||||
if (!visible) return
|
||||
// 防御性拦截 1:用户已选过物料(form.base_id 有值)
|
||||
// 此时下拉打开只是 el-select 切换到"输入模式",绝不能去请求默认列表。
|
||||
// 否则会清空 searchKeyword 和 materialOptions,破坏用户正在编辑的搜索结果。
|
||||
if (form.base_id) return
|
||||
// 防御性拦截 2:已经有搜索关键字或已经有下拉数据
|
||||
// 同样不要重置、不要再请求默认列表
|
||||
if (searchKeyword.value || materialOptions.value.length > 0) return
|
||||
// 打断正在排队的 debounce 定时器,避免与默认请求相互打架
|
||||
if (searchTimer) { clearTimeout(searchTimer); searchTimer = null }
|
||||
handleSearchMaterial('')
|
||||
}
|
||||
|
||||
const handleSearchMaterialDebounced = (query: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
handleSearchMaterial(query)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
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()
|
||||
// 防御性拦截:el-select 在 filterable + remote 模式下,用户点击已聚焦的 input 时
|
||||
// 会内部 emit query='' 触发 remote-method。这种"清空式 emit"是 el-select 切换到输入模式
|
||||
// 的固有行为,绝不能破坏已选物料对应的搜索结果(清空 searchKeyword + materialOptions)。
|
||||
// 只有当 form.base_id 已有值、当前查询为空、且下拉列表非空时,才拦截。
|
||||
// 真正"清空"的场景(用户点 X 按钮)会通过 clearable 把 form.base_id 置空,本拦截放行。
|
||||
if (!safeQuery && form.base_id && materialOptions.value.length > 0) return
|
||||
const fetchMaterialSuggestions = async (query: string, cb: (results: any[]) => void) => {
|
||||
const safeQuery = String(query || '').replace(/[\x00-\x1F\x7F-\x9F\u200B-\u200D\uFEFF]/g, '').trim()
|
||||
searchLoading.value = true
|
||||
searchKeyword.value = safeQuery
|
||||
searchPage.value = 1
|
||||
materialOptions.value = []
|
||||
|
||||
try {
|
||||
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
|
||||
} finally { searchLoading.value = false }
|
||||
}
|
||||
|
||||
const loadMoreMaterials = async () => {
|
||||
if (searchLoading.value || loadingMore.value || !hasNextPage.value) return
|
||||
loadingMore.value = true
|
||||
searchPage.value += 1
|
||||
try {
|
||||
const res: any = await searchMaterialBase(searchKeyword.value, searchPage.value)
|
||||
if (res.data && res.data.items && res.data.items.length > 0) {
|
||||
const newItems = res.data.items.map((i: any) => ({...i, isHistory: false}))
|
||||
materialOptions.value.push(...newItems)
|
||||
hasNextPage.value = res.data.has_next
|
||||
const res: any = await searchMaterialBase(safeQuery)
|
||||
if (res.code === 200 && res.data) {
|
||||
cb((res.data?.items || res.data || []).map((i: any) => ({ ...i, isHistory: false })))
|
||||
} else {
|
||||
hasNextPage.value = false
|
||||
cb([])
|
||||
}
|
||||
} catch (e) {
|
||||
searchPage.value -= 1
|
||||
cb([])
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onMaterialSelected = async (val: number) => {
|
||||
const item = materialOptions.value.find(i => i.id === val)
|
||||
if (item) {
|
||||
form.company_name = item.company_name // [新增]
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
// 切换物料时清空已选 BOM,防止脏数据
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
checkHistoryAndSetMode(item.id)
|
||||
const onMaterialClear = () => {
|
||||
form.base_id = undefined
|
||||
form.company_name = ''
|
||||
form.material_name = ''
|
||||
form.spec_model = ''
|
||||
form.category = ''
|
||||
form.unit = ''
|
||||
form.material_type = ''
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
}
|
||||
|
||||
// 获取该物料历史入库库位(新增独立接口)
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: val } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
const onMaterialSelected = async (item: any) => {
|
||||
form.base_id = item.id
|
||||
form.company_name = item.company_name
|
||||
form.material_name = item.name
|
||||
form.spec_model = item.spec
|
||||
form.category = item.category
|
||||
form.unit = item.unit
|
||||
form.material_type = item.type
|
||||
materialNameInput.value = item.name
|
||||
form.bom_code = ''
|
||||
form.bom_version = ''
|
||||
bomOptions.value = []
|
||||
checkHistoryAndSetMode(item.id)
|
||||
|
||||
try {
|
||||
const res = await request.get('/v1/inbound/semi/last-location', { params: { base_id: item.id } })
|
||||
if (res.code === 200 && res.data.location) {
|
||||
form.warehouse_location = res.data.location
|
||||
ElMessage.info(`已自动带入该物料历史库位:【${res.data.location}】,请核对。`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取历史库位失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1420,6 +1364,7 @@ const handleUpdate = (row: any) => {
|
||||
if (row.serial_number) { entryMode.value = 'serial'; form.serial_number = row.serial_number; form.batch_number = '' }
|
||||
else { entryMode.value = 'batch'; form.batch_number = row.batch_number; form.serial_number = '' }
|
||||
materialOptions.value = [{ id: row.base_id, name: row.material_name, spec: row.spec_model, category: row.category, company_name: row.company_name, isHistory: false }]
|
||||
materialNameInput.value = row.material_name
|
||||
// 回显BOM,如果存在
|
||||
if (form.bom_code) {
|
||||
bomOptions.value = [{ bom_no: form.bom_code, version: form.bom_version }]
|
||||
@ -1613,7 +1558,7 @@ const handlePrint = async (row: any) => {
|
||||
}
|
||||
const confirmPrint = async () => { printing.value = true; try { await executePrint({ ...currentPrintData.value, copies: printCopies.value }); ElMessage.success(`指令已发送 (x${printCopies.value})`); printVisible.value = false } catch (e: any) { ElMessage.error(e.msg || '打印失败') } finally { printing.value = false } }
|
||||
const resetForm = () => {
|
||||
materialOptions.value = []; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
|
||||
materialOptions.value = []; materialNameInput.value = ''; bomOptions.value = []; arrivalFileList.value = []; reportFileList.value = []; quality_report_url.value = ''
|
||||
Object.assign(form, {
|
||||
id: undefined, base_id: undefined,
|
||||
company_name: '', // [新增]
|
||||
|
||||
Reference in New Issue
Block a user