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', () => {
const materials = ref([])
const materialOptions = ref([])
const loading = ref(false)
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) {
const res = await materialsApi.create(data)
await fetchMaterials(1)
@ -64,7 +82,8 @@ export const useMaterialsStore = defineStore('materials', () => {
}
return {
materials, loading, pagination, categoryMap,
getCategoryDisplay, fetchMaterials, createMaterial, updateMaterial, obsoleteMaterial, restoreMaterial
materials, materialOptions, loading, pagination, categoryMap,
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="输入物料编码或名称搜索"
filterable
:filter-method="(val) => materialFilter(val, item)"
:no-data-text="item.materialSearchText.trim() ? '未找到匹配物料,请尝试其他关键词' : '请先输入物料编码或名称'"
style="width: 280px"
@change="(id) => onMaterialChange(item, id)"
>
@ -111,6 +112,7 @@ import { useStockStore } from '../stores/stock'
import BaseTable from '../components/business/BaseTable.vue'
import StatusBadge from '../components/business/StatusBadge.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { searchMaterialOptions } from '../utils/materialSearch'
const applicationStore = useApplicationStore()
const materialsStore = useMaterialsStore()
@ -129,7 +131,7 @@ const applyForm = reactive({
const itemStockMap = reactive(new Map())
onMounted(() => {
materialsStore.fetchMaterials()
materialsStore.fetchMaterialOptions()
stockStore.fetchStock()
applicationStore.fetchMyApplications()
})
@ -155,12 +157,7 @@ function materialFilter(val, item) {
}
function getFilteredMaterials(item) {
const keyword = (item.materialSearchText || '').toLowerCase()
if (!keyword) return materialsStore.materials
return materialsStore.materials.filter(m =>
m.material_code.toLowerCase().includes(keyword) ||
m.name.toLowerCase().includes(keyword)
)
return searchMaterialOptions(materialsStore.materialOptions, item.materialSearchText)
}
async function onMaterialChange(item, id) {
@ -193,7 +190,7 @@ async function handleSubmit() {
for (const item of validItems) {
const stock = itemStockMap.get(item)
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} 件)`)
}
}

View File

@ -6,13 +6,22 @@
</div>
<div class="search-bar">
<input
<el-select
v-model="searchKeyword"
type="text"
class="search-native-input"
placeholder="搜索物料编码或名称"
@keyup.enter="handleSearch"
/>
placeholder="输入物料编码或名称搜索"
filterable
:filter-method="materialFilter"
: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>
</div>
@ -141,11 +150,13 @@ import { ElMessage } from 'element-plus'
import { createMaterialForm, toMaterialPayload } from './materialForm'
import { materialCategoriesApi, unitsApi } from '../api/masterData'
import { getMajorCategories, getMinorCategories, resolveCategoryPath } from '../utils/materialCategories'
import { searchMaterialOptions } from '../utils/materialSearch'
const materialsStore = useMaterialsStore()
const authStore = useAuthStore()
const searchKeyword = ref('')
const materialSearchText = ref('')
const dialogVisible = ref(false)
const detailVisible = ref(false)
const deleteDialogVisible = ref(false)
@ -174,6 +185,7 @@ const form = reactive(createMaterialForm())
onMounted(async () => {
materialsStore.fetchMaterials()
materialsStore.fetchMaterialOptions()
const [categoryResponse, unitResponse] = await Promise.all([materialCategoriesApi.list(), unitsApi.list()])
categories.value = (categoryResponse.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)
}
function materialFilter(val) {
materialSearchText.value = val
}
function getFilteredMaterials() {
return searchMaterialOptions(materialsStore.materialOptions, materialSearchText.value)
}
function handlePageChange(page) {
materialsStore.fetchMaterials(page, searchKeyword.value)
}

View File

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