From 368c0bfb99548d8a185fe03a8a6e59b88b94c058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=96=87=E7=9D=BF=20=E5=BC=A0?= Date: Thu, 17 Sep 2026 09:48:04 +0800 Subject: [PATCH] fix: improve material search selectors --- frontend/src/stores/materials.js | 23 +++++++++- frontend/src/stores/materials.test.js | 54 +++++++++++++++++++++++ frontend/src/utils/materialSearch.js | 36 +++++++++++++++ frontend/src/utils/materialSearch.test.js | 33 ++++++++++++++ frontend/src/views/Application.vue | 13 +++--- frontend/src/views/Materials.vue | 32 +++++++++++--- frontend/src/views/Stock.vue | 40 ++++++++++++----- 7 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 frontend/src/stores/materials.test.js create mode 100644 frontend/src/utils/materialSearch.js create mode 100644 frontend/src/utils/materialSearch.test.js diff --git a/frontend/src/stores/materials.js b/frontend/src/stores/materials.js index 9c85dfdc..2dcd2674 100644 --- a/frontend/src/stores/materials.js +++ b/frontend/src/stores/materials.js @@ -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 } }) diff --git a/frontend/src/stores/materials.test.js b/frontend/src/stores/materials.test.js new file mode 100644 index 00000000..86c1ad81 --- /dev/null +++ b/frontend/src/stores/materials.test.js @@ -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: '早期物料' + }) + }) +}) diff --git a/frontend/src/utils/materialSearch.js b/frontend/src/utils/materialSearch.js new file mode 100644 index 00000000..4953e5a9 --- /dev/null +++ b/frontend/src/utils/materialSearch.js @@ -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) +} diff --git a/frontend/src/utils/materialSearch.test.js b/frontend/src/utils/materialSearch.test.js new file mode 100644 index 00000000..0611dd56 --- /dev/null +++ b/frontend/src/utils/materialSearch.test.js @@ -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) + }) +}) diff --git a/frontend/src/views/Application.vue b/frontend/src/views/Application.vue index 9c4db789..aef3914a 100644 --- a/frontend/src/views/Application.vue +++ b/frontend/src/views/Application.vue @@ -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} 件)`) } } diff --git a/frontend/src/views/Materials.vue b/frontend/src/views/Materials.vue index 9ca47863..82cf3245 100644 --- a/frontend/src/views/Materials.vue +++ b/frontend/src/views/Materials.vue @@ -6,13 +6,22 @@ @@ -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) } diff --git a/frontend/src/views/Stock.vue b/frontend/src/views/Stock.vue index 49e9e2ca..870aa053 100644 --- a/frontend/src/views/Stock.vue +++ b/frontend/src/views/Stock.vue @@ -7,12 +7,22 @@