fix: improve material search selectors

This commit is contained in:
文睿 张 2026-09-17 09:48:04 +08:00
parent bb195610fd
commit 368c0bfb99
7 changed files with 203 additions and 28 deletions

View File

@ -4,6 +4,7 @@ import { materialsApi } from '../api/materials'
export const useMaterialsStore = defineStore('materials', () => { export const useMaterialsStore = defineStore('materials', () => {
const materials = ref([]) const materials = ref([])
const materialOptions = ref([])
const loading = ref(false) const loading = ref(false)
const pagination = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 }) const pagination = ref({ currentPage: 1, pageSize: 20, total: 0, totalPages: 0 })
@ -39,6 +40,23 @@ export const useMaterialsStore = defineStore('materials', () => {
} }
} }
async function fetchMaterialOptions() {
const pageSize = 100
let offset = 0
let total = 0
const options = []
do {
const res = await materialsApi.getAll({ limit: pageSize, offset })
const items = res.data?.items || res.data || []
options.push(...items)
total = res.data?.total ?? options.length
offset += items.length
} while (offset < total)
materialOptions.value = options
}
async function createMaterial(data) { async function createMaterial(data) {
const res = await materialsApi.create(data) const res = await materialsApi.create(data)
await fetchMaterials(1) await fetchMaterials(1)
@ -64,7 +82,8 @@ export const useMaterialsStore = defineStore('materials', () => {
} }
return { return {
materials, loading, pagination, categoryMap, materials, materialOptions, loading, pagination, categoryMap,
getCategoryDisplay, fetchMaterials, createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial getCategoryDisplay, fetchMaterials, fetchMaterialOptions,
createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial
} }
}) })

View File

@ -0,0 +1,54 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { materialsApi } from '../api/materials'
import { useMaterialsStore } from './materials'
vi.mock('../api/materials', () => ({
materialsApi: {
getAll: vi.fn()
}
}))
describe('materials store selection options', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('loads every page so a selector can find materials outside the first page', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({
id: 120 - index,
material_code: String(120 - index).padStart(3, '0'),
name: `物料 ${120 - index}`
}))
materialsApi.getAll
.mockResolvedValueOnce({
data: {
items: firstPage,
limit: 100,
offset: 0,
total: 101,
has_more: true
}
})
.mockResolvedValueOnce({
data: {
items: [{ id: 1, material_code: '001', name: '早期物料' }],
limit: 100,
offset: 100,
total: 101,
has_more: false
}
})
const store = useMaterialsStore()
await store.fetchMaterialOptions()
expect(store.materialOptions).toHaveLength(101)
expect(store.materialOptions.at(-1)).toEqual({
id: 1,
material_code: '001',
name: '早期物料'
})
})
})

View File

@ -0,0 +1,36 @@
function getFieldRank(value, keyword, fieldIndex) {
const normalizedValue = String(value || '').toLowerCase()
if (normalizedValue === keyword) return [0, 0, fieldIndex]
if (normalizedValue.startsWith(keyword)) return [1, 0, fieldIndex]
const matchIndex = normalizedValue.indexOf(keyword)
if (matchIndex !== -1) return [2, matchIndex, fieldIndex]
return null
}
function compareRank(left, right) {
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return left[index] - right[index]
}
return 0
}
export function searchMaterialOptions(materials, searchText, limit = 10) {
const keyword = String(searchText || '').trim().toLowerCase()
if (!keyword) return []
return materials
.map((material, originalIndex) => {
const ranks = [
getFieldRank(material.material_code, keyword, 0),
getFieldRank(material.name, keyword, 1)
].filter(Boolean)
if (ranks.length === 0) return null
ranks.sort(compareRank)
return { material, rank: ranks[0], originalIndex }
})
.filter(Boolean)
.sort((left, right) => compareRank(left.rank, right.rank) || left.originalIndex - right.originalIndex)
.slice(0, limit)
.map(result => result.material)
}

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { searchMaterialOptions } from './materialSearch'
const materials = [
{ id: 1, material_code: 'MAT-120', name: '备用电机' },
{ id: 2, material_code: 'MAT-012', name: '电机组件' },
{ id: 3, material_code: '012', name: '精密电机' },
{ id: 4, material_code: 'A-012-X', name: '连接件' }
]
describe('material option search', () => {
it('shows no options until a keyword is entered', () => {
expect(searchMaterialOptions(materials, '')).toEqual([])
expect(searchMaterialOptions(materials, ' ')).toEqual([])
})
it('ranks exact, prefix, and substring matches by relevance', () => {
expect(searchMaterialOptions(materials, '012').map(item => item.id)).toEqual([3, 4, 2])
})
it('matches code and name case-insensitively and returns at most ten options', () => {
const manyMaterials = Array.from({ length: 12 }, (_, index) => ({
id: index + 1,
material_code: `part-${String(index + 1).padStart(2, '0')}`,
name: index === 11 ? 'PART' : `零件 ${index + 1}`
}))
const results = searchMaterialOptions(manyMaterials, 'PART')
expect(results).toHaveLength(10)
expect(results[0].id).toBe(12)
})
})

View File

@ -15,6 +15,7 @@
placeholder="输入物料编码或名称搜索" placeholder="输入物料编码或名称搜索"
filterable filterable
:filter-method="(val) => materialFilter(val, item)" :filter-method="(val) => materialFilter(val, item)"
:no-data-text="item.materialSearchText.trim() ? '未找到匹配物料,请尝试其他关键词' : '请先输入物料编码或名称'"
style="width: 280px" style="width: 280px"
@change="(id) => onMaterialChange(item, id)" @change="(id) => onMaterialChange(item, id)"
> >
@ -111,6 +112,7 @@ import { useStockStore } from '../stores/stock'
import BaseTable from '../components/business/BaseTable.vue' import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue' import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { searchMaterialOptions } from '../utils/materialSearch'
const applicationStore = useApplicationStore() const applicationStore = useApplicationStore()
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
@ -129,7 +131,7 @@ const applyForm = reactive({
const itemStockMap = reactive(new Map()) const itemStockMap = reactive(new Map())
onMounted(() => { onMounted(() => {
materialsStore.fetchMaterials() materialsStore.fetchMaterialOptions()
stockStore.fetchStock() stockStore.fetchStock()
applicationStore.fetchMyApplications() applicationStore.fetchMyApplications()
}) })
@ -155,12 +157,7 @@ function materialFilter(val, item) {
} }
function getFilteredMaterials(item) { function getFilteredMaterials(item) {
const keyword = (item.materialSearchText || '').toLowerCase() return searchMaterialOptions(materialsStore.materialOptions, item.materialSearchText)
if (!keyword) return materialsStore.materials
return materialsStore.materials.filter(m =>
m.material_code.toLowerCase().includes(keyword) ||
m.name.toLowerCase().includes(keyword)
)
} }
async function onMaterialChange(item, id) { async function onMaterialChange(item, id) {
@ -193,7 +190,7 @@ async function handleSubmit() {
for (const item of validItems) { for (const item of validItems) {
const stock = itemStockMap.get(item) const stock = itemStockMap.get(item)
if (stock !== undefined && stock > 0 && item.quantity > stock) { if (stock !== undefined && stock > 0 && item.quantity > stock) {
const material = materialsStore.materials.find(m => m.id === item.material_id) const material = materialsStore.materialOptions.find(m => m.id === item.material_id)
insufficient.push(`${material?.name || item.material_id}(申请 ${item.quantity} 件,库存 ${stock} 件)`) insufficient.push(`${material?.name || item.material_id}(申请 ${item.quantity} 件,库存 ${stock} 件)`)
} }
} }

View File

@ -6,13 +6,22 @@
</div> </div>
<div class="search-bar"> <div class="search-bar">
<input <el-select
v-model="searchKeyword" v-model="searchKeyword"
type="text" placeholder="输入物料编码或名称搜索"
class="search-native-input" filterable
placeholder="搜索物料编码或名称" :filter-method="materialFilter"
@keyup.enter="handleSearch" :no-data-text="materialSearchText.trim() ? '未找到匹配物料,请尝试其他关键词' : '请先输入物料编码或名称'"
clearable
style="width: 260px"
>
<el-option
v-for="m in getFilteredMaterials()"
:key="m.id"
:label="`${m.material_code} - ${m.name}`"
:value="m.material_code"
/> />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" @click="handleSearch">搜索</el-button>
</div> </div>
@ -141,11 +150,13 @@ import { ElMessage } from 'element-plus'
import { createMaterialForm, toMaterialPayload } from './materialForm' import { createMaterialForm, toMaterialPayload } from './materialForm'
import { materialCategoriesApi, unitsApi } from '../api/masterData' import { materialCategoriesApi, unitsApi } from '../api/masterData'
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories' import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
import { searchMaterialOptions } from '../utils/materialSearch'
const materialsStore = useMaterialsStore() const materialsStore = useMaterialsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const searchKeyword = ref('') const searchKeyword = ref('')
const materialSearchText = ref('')
const dialogVisible = ref(false) const dialogVisible = ref(false)
const detailVisible = ref(false) const detailVisible = ref(false)
const deleteDialogVisible = ref(false) const deleteDialogVisible = ref(false)
@ -174,6 +185,7 @@ const form = reactive(createMaterialForm())
onMounted(async () => { onMounted(async () => {
materialsStore.fetchMaterials() materialsStore.fetchMaterials()
materialsStore.fetchMaterialOptions()
const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()]) const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()])
categories.value = (categoryResponse.data || []).filter(item => item.status === 'active') categories.value = (categoryResponse.data || []).filter(item => item.status === 'active')
units.value = (unitResponse.data || []).filter(item => item.status === 'active') units.value = (unitResponse.data || []).filter(item => item.status === 'active')
@ -206,6 +218,14 @@ function handleSearch() {
materialsStore.fetchMaterials(1, searchKeyword.value) materialsStore.fetchMaterials(1, searchKeyword.value)
} }
function materialFilter(val) {
materialSearchText.value = val
}
function getFilteredMaterials() {
return searchMaterialOptions(materialsStore.materialOptions, materialSearchText.value)
}
function handlePageChange(page) { function handlePageChange(page) {
materialsStore.fetchMaterials(page, searchKeyword.value) materialsStore.fetchMaterials(page, searchKeyword.value)
} }

View File

@ -7,12 +7,22 @@
<el-tabs v-model="activeTab"> <el-tabs v-model="activeTab">
<el-tab-pane label="库存查询" name="list"> <el-tab-pane label="库存查询" name="list">
<div class="search-bar"> <div class="search-bar">
<el-input <el-select
v-model="searchKeyword" v-model="searchKeyword"
placeholder="搜索物料编码或名称" placeholder="输入物料编码或名称搜索"
@keyup.enter="handleSearch" filterable
style="width: 220px" :filter-method="stockMaterialFilter"
:no-data-text="stockMaterialSearchText.trim() ? '未找到匹配物料,请尝试其他关键词' : '请先输入物料编码或名称'"
clearable
style="width: 260px"
>
<el-option
v-for="m in getFilteredMaterialsForStock()"
:key="m.id"
:label="`${m.material_code} - ${m.name}`"
:value="m.material_code"
/> />
</el-select>
<el-select v-model="categoryFilter" placeholder="全部分类" clearable style="width: 160px"> <el-select v-model="categoryFilter" placeholder="全部分类" clearable style="width: 160px">
<el-option v-for="cat in majorCategories" :key="cat.id" :label="`${cat.code} ${cat.name}`" :value="cat.code" /> <el-option v-for="cat in majorCategories" :key="cat.id" :label="`${cat.code} ${cat.name}`" :value="cat.code" />
</el-select> </el-select>
@ -94,6 +104,7 @@
placeholder="输入物料编码或名称搜索" placeholder="输入物料编码或名称搜索"
filterable filterable
:filter-method="(val) => logMaterialFilter(val)" :filter-method="(val) => logMaterialFilter(val)"
:no-data-text="logMaterialSearchText.trim() ? '未找到匹配物料,请尝试其他关键词' : '请先输入物料编码或名称'"
clearable clearable
style="width: 260px" style="width: 260px"
> >
@ -199,6 +210,7 @@ import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import WarehouseSettings from './inventory/WarehouseSettings.vue' import WarehouseSettings from './inventory/WarehouseSettings.vue'
import { isLowStock } from '../utils/quantity' import { isLowStock } from '../utils/quantity'
import { searchMaterialOptions } from '../utils/materialSearch'
import { materialCategoriesApi } from '../api/masterData' import { materialCategoriesApi } from '../api/masterData'
import { getMajorCategories } from '../utils/materialCategories' import { getMajorCategories } from '../utils/materialCategories'
@ -222,6 +234,7 @@ watch(activeTab, (newTab) => {
} }
}) })
const logMaterialSearchText = ref('') const logMaterialSearchText = ref('')
const stockMaterialSearchText = ref('')
const searchKeyword = ref('') const searchKeyword = ref('')
const categoryFilter = ref('') const categoryFilter = ref('')
const warningDialogVisible = ref(false) const warningDialogVisible = ref(false)
@ -232,7 +245,7 @@ const warningValue = ref(0)
onMounted(async () => { onMounted(async () => {
stockStore.fetchStock() stockStore.fetchStock()
stockStore.fetchStockWarning() stockStore.fetchStockWarning()
materialsStore.fetchMaterials() materialsStore.fetchMaterialOptions()
const catRes = await materialCategoriesApi.list() const catRes = await materialCategoriesApi.list()
categories.value = catRes.data || [] categories.value = catRes.data || []
}) })
@ -253,6 +266,14 @@ function handleSearch() {
stockStore.fetchStock(1, searchKeyword.value, categoryFilter.value) stockStore.fetchStock(1, searchKeyword.value, categoryFilter.value)
} }
function stockMaterialFilter(val) {
stockMaterialSearchText.value = val
}
function getFilteredMaterialsForStock() {
return searchMaterialOptions(materialsStore.materialOptions, stockMaterialSearchText.value)
}
function handlePageChange(page) { function handlePageChange(page) {
stockStore.fetchStock(page, searchKeyword.value, categoryFilter.value) stockStore.fetchStock(page, searchKeyword.value, categoryFilter.value)
} }
@ -293,12 +314,7 @@ function logMaterialFilter(val) {
} }
function getFilteredMaterialsForLog() { function getFilteredMaterialsForLog() {
const keyword = (logMaterialSearchText.value || '').toLowerCase() return searchMaterialOptions(materialsStore.materialOptions, logMaterialSearchText.value)
if (!keyword) return materialsStore.materials
return materialsStore.materials.filter(m =>
m.material_code.toLowerCase().includes(keyword) ||
m.name.toLowerCase().includes(keyword)
)
} }
function handleWarningPageChange(page) { function handleWarningPageChange(page) {